Compare commits
44 Commits
06891522d9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e66d9aee72 | |||
| 3ed12b274d | |||
| 0ff3396d6e | |||
| ecc3c6395e | |||
| 8bf2647a9e | |||
| 75591bb294 | |||
| 97c913a25c | |||
| 88876a24b7 | |||
| cacca3481c | |||
| 856b620891 | |||
| 0478f7c930 | |||
| 375a951be1 | |||
| 6dcc404dc1 | |||
| 6fe1955c9f | |||
| 653f94de86 | |||
| b1a93be974 | |||
| 260f816c83 | |||
| 9473329708 | |||
| 343a18393a | |||
| 5a47140b76 | |||
| ec0b64efe8 | |||
| 97b5baa367 | |||
| 9777c2ff1b | |||
| 8fe8900722 | |||
| 05c6cf9e04 | |||
| baa5f6447a | |||
| 305b6d1886 | |||
| 5ad6bf6f60 | |||
| 70021ebdf2 | |||
| c0a15bb1b5 | |||
| d098bf705e | |||
| c2a8aa8fa1 | |||
| c115c3cb4b | |||
| 8035093ae5 | |||
| bcd5f6d0db | |||
| 84a1606dd0 | |||
| 448a523d64 | |||
| 5f547708ae | |||
| 8352c3a0ce | |||
| 8f61ef1592 | |||
| f417902ac9 | |||
| 3b313135d4 | |||
| ed2e14f98e | |||
| 92b1ee54c6 |
+3
-1
@@ -44,8 +44,10 @@ AAMOS_MODE=mock
|
||||
AAMOS_API_URL=
|
||||
AAMOS_API_KEY=
|
||||
AAMOS_TIMEOUT_MS=60000
|
||||
# GEMINI_API_KEY lämnas tom lokalt – hämtas från AWS SSM (/cibello/prod/gemini-api-key) i prod,
|
||||
# inte inskriven för hand. Skriv aldrig over en .env som redan har riktiga varden.
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.5-flash
|
||||
GEMINI_MODEL=gemini-3.5-flash-lite
|
||||
GEMINI_TIMEOUT_MS=60000
|
||||
GEMINI_DAILY_BUDGET_USD=25
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ pnpm install
|
||||
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d
|
||||
|
||||
# 3. Miljövariabler
|
||||
cp .env.example .env
|
||||
cp -n .env.example .env # -n = skriv inte over en befintlig .env
|
||||
|
||||
# 4. Migrera + seeda databasen
|
||||
pnpm db:migrate
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"fastify": "^5.6.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"firebase-admin": "^14.2.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.13.0",
|
||||
|
||||
+11
-4
@@ -48,7 +48,7 @@ const configSchema = z.object({
|
||||
AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||||
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
|
||||
GEMINI_MODEL: z.string().default("gemini-3.5-flash-lite"),
|
||||
GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||||
GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0),
|
||||
|
||||
@@ -71,7 +71,9 @@ const configSchema = z.object({
|
||||
SMTP_USER: z.string().default(""),
|
||||
SMTP_PASS: z.string().default(""),
|
||||
MAIL_FROM: z.string().default(""),
|
||||
APPLE_BUNDLE_ID: z.string().default(""),
|
||||
/** Firebase Admin service-account (hela JSON:en base64-kodad). Tom = Firebase-session av. Lokalt via .env, prod via SSM. */
|
||||
FIREBASE_SERVICE_ACCOUNT_B64: z.string().optional(),
|
||||
APPLE_BUNDLE_ID: z.string().default(""),
|
||||
GOOGLE_PACKAGE_NAME: z.string().default(""),
|
||||
});
|
||||
|
||||
@@ -102,8 +104,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "gemini" && !cfg.GEMINI_API_KEY) {
|
||||
console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion.");
|
||||
if (
|
||||
cfg.AAMOS_MODE === "gemini" &&
|
||||
(!cfg.GEMINI_API_KEY || cfg.GEMINI_API_KEY.startsWith("ROTATE"))
|
||||
) {
|
||||
console.error(
|
||||
"SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver en riktig GEMINI_API_KEY (hämtas från SSM /cibello/prod/gemini-api-key), inte tom eller placeholder.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { initializeApp, cert, getApps, type App, type ServiceAccount } from "firebase-admin/app";
|
||||
import { getAuth } from "firebase-admin/auth";
|
||||
|
||||
/**
|
||||
* Leverantörsneutralt resultat av en verifierad ID-token.
|
||||
* App-koden/rutterna använder DETTA – aldrig firebase-admin direkt.
|
||||
*/
|
||||
export interface VerifiedIdentity {
|
||||
/** Stabil identitet från leverantören (Firebase-UID). */
|
||||
uid: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean;
|
||||
name: string | null;
|
||||
/** t.ex. "password" | "google.com" | "apple.com". */
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enda gränssnittet mot token-verifiering. En framtida ZitadelTokenVerifier
|
||||
* (introspektion/JWKS) implementerar samma interface – då byts bara denna fil.
|
||||
*/
|
||||
export interface TokenVerifier {
|
||||
verify(idToken: string): Promise<VerifiedIdentity>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firebase-implementation. ENDA serverfilen som importerar firebase-admin.
|
||||
* Admin-appen initieras lat (först vid första verify) så att build/typecheck
|
||||
* aldrig kräver tjänstekontot.
|
||||
*/
|
||||
export function createFirebaseTokenVerifier(serviceAccount: ServiceAccount): TokenVerifier {
|
||||
let app: App | undefined;
|
||||
const ensureApp = (): App => {
|
||||
if (app) return app;
|
||||
app = getApps()[0] ?? initializeApp({ credential: cert(serviceAccount) });
|
||||
return app;
|
||||
};
|
||||
return {
|
||||
async verify(idToken: string): Promise<VerifiedIdentity> {
|
||||
const decoded = await getAuth(ensureApp()).verifyIdToken(idToken);
|
||||
return {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email ?? null,
|
||||
emailVerified: decoded.email_verified ?? false,
|
||||
name: (decoded.name as string | undefined) ?? null,
|
||||
provider: decoded.firebase?.sign_in_provider ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+137
-1
@@ -20,6 +20,34 @@ import { errors, parse } from "../lib/errors.js";
|
||||
import { hashPassword, verifyPassword } from "../lib/passwords.js";
|
||||
import { verifyTotp } from "../lib/totp.js";
|
||||
import { audit, sha256 } from "../lib/helpers.js";
|
||||
import { z } from "zod";
|
||||
import { createFirebaseTokenVerifier, type TokenVerifier } from "../lib/token-verifier.js";
|
||||
|
||||
/** Body-schema för Firebase-sessionväxling. */
|
||||
const firebaseSessionInputSchema = z.object({
|
||||
idToken: z.string().min(1),
|
||||
displayName: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Lat-initierad Firebase-verifierare (byggd ur base64-env). Cache per process. */
|
||||
let firebaseVerifier: TokenVerifier | null = null;
|
||||
function getFirebaseVerifier(app: FastifyInstance): TokenVerifier {
|
||||
if (firebaseVerifier) return firebaseVerifier;
|
||||
const b64 = app.config.FIREBASE_SERVICE_ACCOUNT_B64;
|
||||
if (!b64) throw errors.internal("Firebase är inte konfigurerat (FIREBASE_SERVICE_ACCOUNT_B64 saknas).");
|
||||
const sa = JSON.parse(Buffer.from(b64, "base64").toString("utf8")) as {
|
||||
project_id: string;
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
};
|
||||
firebaseVerifier = createFirebaseTokenVerifier({
|
||||
projectId: sa.project_id,
|
||||
clientEmail: sa.client_email,
|
||||
privateKey: sa.private_key,
|
||||
});
|
||||
return firebaseVerifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth: registrering, inloggning, roterande refresh-tokens, utloggning.
|
||||
@@ -100,7 +128,115 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
|
||||
// Firebase-sessionväxling: verifiera Firebase-ID-token -> find-or-create user -> intern session.
|
||||
// Firebase äger kundinloggningen; detta är enda kopplingen till våra interna tokens.
|
||||
app.post("/v1/auth/session", strictLimit, async (req, reply) => {
|
||||
const input = parse(firebaseSessionInputSchema, req.body);
|
||||
|
||||
const identity = await getFirebaseVerifier(app)
|
||||
.verify(input.idToken)
|
||||
.catch(() => {
|
||||
throw errors.unauthorized("Ogiltig Firebase-token.");
|
||||
});
|
||||
|
||||
// 1) Redan kopplad via firebaseUid?
|
||||
let [user] = await app.db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(and(eq(schema.users.firebaseUid, identity.uid), isNull(schema.users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
// 2) Annars: länka befintligt konto på VERIFIERAD e-post (annars neka – undviker kontokapning).
|
||||
if (!user && identity.email) {
|
||||
const [byEmail] = await app.db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(and(eq(schema.users.email, identity.email), isNull(schema.users.deletedAt)))
|
||||
.limit(1);
|
||||
if (byEmail) {
|
||||
if (!identity.emailVerified) throw errors.forbidden("E-postadressen måste vara verifierad.");
|
||||
if (byEmail.firebaseUid && byEmail.firebaseUid !== identity.uid)
|
||||
throw errors.conflict("Kontot är redan kopplat till en annan inloggning.");
|
||||
[user] = await app.db
|
||||
.update(schema.users)
|
||||
.set({ firebaseUid: identity.uid, emailVerifiedAt: byEmail.emailVerifiedAt ?? new Date() })
|
||||
.where(eq(schema.users.id, byEmail.id))
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Skapa nytt konto (inget internt lösenord – Firebase äger inloggningen).
|
||||
if (!user) {
|
||||
if (!identity.email) throw errors.badRequest("Firebase-token saknar e-postadress.");
|
||||
const locale = input.locale ?? "sv-SE";
|
||||
const displayName = input.displayName ?? identity.name ?? identity.email.split("@")[0] ?? "Användare";
|
||||
[user] = await app.db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
email: identity.email,
|
||||
displayName,
|
||||
locale,
|
||||
firebaseUid: identity.uid,
|
||||
emailVerifiedAt: identity.emailVerified ? new Date() : null,
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw errors.internal();
|
||||
await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing();
|
||||
await app.db
|
||||
.insert(schema.userHealthProfiles)
|
||||
.values({ userId: user.id })
|
||||
.onConflictDoNothing();
|
||||
const consentNow = new Date();
|
||||
await app.db
|
||||
.insert(schema.userConsents)
|
||||
.values({
|
||||
userId: user.id,
|
||||
kind: "product_analytics",
|
||||
status: "granted",
|
||||
grantedAt: consentNow,
|
||||
updatedAt: consentNow,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
const regionFromLocale = locale.split("-")[1]?.toUpperCase();
|
||||
const localeDefaults = regionFromLocale
|
||||
? localeDefaultsForRegion(regionFromLocale)
|
||||
: DEFAULT_LOCALE_PREFERENCES;
|
||||
await app.db
|
||||
.insert(schema.userLocalePreferences)
|
||||
.values({
|
||||
userId: user.id,
|
||||
languageTag: locale,
|
||||
regionCode: localeDefaults.regionCode,
|
||||
timeZone: localeDefaults.timeZone,
|
||||
measurementSystem: localeDefaults.measurementSystem,
|
||||
temperatureUnit: localeDefaults.temperatureUnit,
|
||||
currencyCode: localeDefaults.currencyCode,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
await audit(app.db, {
|
||||
actorUserId: user.id,
|
||||
action: "auth.firebase_provision",
|
||||
ip: req.ip,
|
||||
correlationId: req.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) throw errors.internal();
|
||||
|
||||
await audit(app.db, {
|
||||
actorUserId: user.id,
|
||||
action: "auth.firebase_session",
|
||||
ip: req.ip,
|
||||
correlationId: req.correlationId,
|
||||
});
|
||||
const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip);
|
||||
return reply.send({
|
||||
user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role },
|
||||
...tokens,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/v1/auth/login", strictLimit, async (req, reply) => {
|
||||
const input = parse(loginInputSchema, req.body);
|
||||
|
||||
const [user] = await app.db
|
||||
|
||||
@@ -383,6 +383,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
||||
ratingCount: recipe.ratingCount,
|
||||
peakSeasons: recipe.peakSeasons,
|
||||
holidayTags: recipe.holidayTags,
|
||||
mealTypes: recipe.mealTypes,
|
||||
spiceLevel: recipe.spiceLevel,
|
||||
coverage,
|
||||
daysSinceLastCooked: lastDate
|
||||
|
||||
@@ -150,6 +150,43 @@ export async function scanRoutes(app: FastifyInstance) {
|
||||
if (!locationId)
|
||||
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
|
||||
|
||||
// Dedup (#1): finns varan redan aktiv i hushållet? Uppdatera + hoppa över,
|
||||
// så överlappande foton / omfotografering inte skapar dubletter.
|
||||
const dedupCond = item.canonicalIngredientId
|
||||
? and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
eq(schema.inventoryItems.canonicalIngredientId, item.canonicalIngredientId),
|
||||
gt(schema.inventoryItems.quantity, 0),
|
||||
)
|
||||
: and(
|
||||
eq(schema.inventoryItems.householdId, householdId),
|
||||
isNull(schema.inventoryItems.canonicalIngredientId),
|
||||
eq(schema.inventoryItems.displayName, item.displayName),
|
||||
gt(schema.inventoryItems.quantity, 0),
|
||||
);
|
||||
const [dupe] = await app.db
|
||||
.select({ id: schema.inventoryItems.id })
|
||||
.from(schema.inventoryItems)
|
||||
.where(dedupCond)
|
||||
.limit(1);
|
||||
if (dupe) {
|
||||
await app.db
|
||||
.update(schema.inventoryItems)
|
||||
.set({
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
brand: item.brand ?? null,
|
||||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||||
useByDate: item.useByDate ?? null,
|
||||
lastVerifiedAt: new Date(),
|
||||
verifiedByUser: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.inventoryItems.id, dupe.id));
|
||||
created.push(dupe.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [inv] = await app.db
|
||||
.insert(schema.inventoryItems)
|
||||
.values({
|
||||
|
||||
@@ -387,7 +387,7 @@ async function generateItemsFromPlan(
|
||||
const rounded = targetUnit === "COUNT" ? Math.ceil(qty) : Math.ceil(qty * 10) / 10;
|
||||
const estimatedPrice =
|
||||
info?.defaultPriceMinorPerKg != null
|
||||
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg * 10) / 10
|
||||
? Math.round((need.grams / 1000) * info.defaultPriceMinorPerKg)
|
||||
: null;
|
||||
|
||||
await app.db.insert(schema.shoppingListItems).values({
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("scan confirmation → ai_corrections", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"firebase": "^12.17.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Link } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { firebaseAuthProvider } from "@/lib/auth-provider/firebase";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
@@ -21,7 +21,7 @@ export default function ForgotPasswordScreen() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api("/v1/auth/forgot-password", { method: "POST", body: { email } });
|
||||
await firebaseAuthProvider.sendPasswordReset(email.trim()).catch(() => {});
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("common.error"));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Link, Redirect, router } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { firebaseAuthProvider } from "@/lib/auth-provider/firebase";
|
||||
import { useAuth, type AuthUser } from "@/lib/auth";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { Body, Button, Input, Screen, Spacer, Title } from "@/components/ui";
|
||||
@@ -27,9 +28,12 @@ export default function LoginScreen() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<LoginResponse>("/v1/auth/login", {
|
||||
await firebaseAuthProvider.signInWithEmail(email.trim(), password);
|
||||
const idToken = await firebaseAuthProvider.getIdToken();
|
||||
if (!idToken) throw new Error(t("common.error"));
|
||||
const result = await api<LoginResponse>("/v1/auth/session", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
body: { idToken },
|
||||
});
|
||||
await setSession(
|
||||
{ accessToken: result.accessToken, refreshToken: result.refreshToken },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Link, router } from "expo-router";
|
||||
import { api } from "@/lib/api";
|
||||
import { firebaseAuthProvider } from "@/lib/auth-provider/firebase";
|
||||
import { persistLanguageTag, useAuth, type AuthUser } from "@/lib/auth";
|
||||
import { detectDeviceLanguageTag, t } from "@/lib/i18n";
|
||||
import { localeDefaultsForRegion } from "@app/shared-types";
|
||||
@@ -21,20 +22,31 @@ export default function RegisterScreen() {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (password.length < 8) {
|
||||
setError("Lösenordet måste vara minst 8 tecken.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("Lösenorden matchar inte.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<RegisterResponse>("/v1/auth/register", {
|
||||
await firebaseAuthProvider.signUpWithEmail(email.trim(), password);
|
||||
const idToken = await firebaseAuthProvider.getIdToken();
|
||||
if (!idToken) throw new Error(t("common.error"));
|
||||
const result = await api<RegisterResponse>("/v1/auth/session", {
|
||||
method: "POST",
|
||||
body: {
|
||||
idToken,
|
||||
displayName,
|
||||
email,
|
||||
password,
|
||||
// D-031: enhetens språk – backend skapar locale-preferenser före välkomstmejlet.
|
||||
// D-031: enhetens språk – backend skapar locale-preferenser.
|
||||
locale: detectDeviceLanguageTag() ?? "sv-SE",
|
||||
},
|
||||
});
|
||||
@@ -94,11 +106,17 @@ export default function RegisterScreen() {
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<Input
|
||||
placeholder={`${t("auth.password")} (minst 10 tecken)`}
|
||||
placeholder={`${t("auth.password")} (minst 8 tecken)`}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Upprepa lösenord"
|
||||
secureTextEntry
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
/>
|
||||
{error && <Text style={{ color: colors.danger }}>{error}</Text>}
|
||||
<Button label={t("auth.register")} onPress={() => void submit()} loading={busy} />
|
||||
<Spacer size={spacing.sm} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { t } from "@/lib/i18n";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { formatMinor } from "@/lib/money";
|
||||
import {
|
||||
Body,
|
||||
@@ -113,6 +114,13 @@ export default function HomeScreen() {
|
||||
<QuickLink label={t("profile.title")} glyph="⚙️" onPress={() => router.push("/profile")} />
|
||||
</Row>
|
||||
|
||||
<Card onPress={() => router.push("/saved-recipes")}>
|
||||
<Body>⭐ Dina sparade recept</Body>
|
||||
</Card>
|
||||
<Card onPress={() => router.push("/kitchen")}>
|
||||
<Body>🧺 Ditt kök</Body>
|
||||
</Card>
|
||||
|
||||
{budget.data && (
|
||||
<Card>
|
||||
<Heading>{t("home.budget")}</Heading>
|
||||
@@ -179,9 +187,17 @@ function QuickLink({
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card onPress={onPress} style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}>
|
||||
<Card
|
||||
onPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}
|
||||
>
|
||||
<Body>{glyph}</Body>
|
||||
<Small>{label}</Small>
|
||||
<Small numberOfLines={1} adjustsFontSizeToFit style={{ textAlign: "center" }}>
|
||||
{label}
|
||||
</Small>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ interface Recommendation {
|
||||
missingIngredients: string[];
|
||||
usesExpiring: Array<{ nameSv: string; daysLeft: number | null }>;
|
||||
coveragePercent: number;
|
||||
ratingAverage: number | null;
|
||||
ratingCount: number;
|
||||
}
|
||||
interface MealBoxSuggestion {
|
||||
mealBoxId: string;
|
||||
@@ -54,11 +56,14 @@ export default function WhatToEatScreen() {
|
||||
const [page, setPage] = useState(0);
|
||||
const { track } = useAnalytics();
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const currentMeal = hour < 10 ? "breakfast" : hour < 14 ? "lunch" : "dinner";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["what-to-eat", submittedCraving],
|
||||
queryKey: ["what-to-eat", submittedCraving, currentMeal],
|
||||
queryFn: () =>
|
||||
api<WhatToEatResponse>(
|
||||
`/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
|
||||
`/v1/recommendations/what-to-eat?limit=15&view=pantry&mealType=${currentMeal}${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -145,13 +150,14 @@ export default function WhatToEatScreen() {
|
||||
|
||||
{shownRecs.map((rec, index) => (
|
||||
<Card key={rec.recipeId} onPress={() => router.push(`/recipe/${rec.recipeId}`)}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Heading>
|
||||
{index === 0 ? "⭐ " : ""}
|
||||
{rec.titleSv}
|
||||
</Heading>
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Heading>{rec.titleSv}</Heading>
|
||||
{index === 0 && <Tag label="Toppval" tone="accent" />}
|
||||
</Row>
|
||||
<Row>
|
||||
{rec.ratingCount > 0 && rec.ratingAverage != null && (
|
||||
<Tag label={`★ ${rec.ratingAverage.toFixed(1).replace(".", ",")} · ${rec.ratingCount}`} tone="accent" />
|
||||
)}
|
||||
<Tag
|
||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
||||
|
||||
@@ -59,13 +59,6 @@ function addDays(d: Date, n: number): Date {
|
||||
copy.setDate(copy.getDate() + n);
|
||||
return copy;
|
||||
}
|
||||
function startOfWeekMonday(d: Date): Date {
|
||||
const copy = new Date(d);
|
||||
const dow = (copy.getDay() + 6) % 7;
|
||||
copy.setDate(copy.getDate() - dow);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy;
|
||||
}
|
||||
function cap(s: string): string {
|
||||
return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
@@ -78,9 +71,24 @@ export default function PlanScreen() {
|
||||
const todayKey = toKey(new Date());
|
||||
|
||||
const selected = useMemo(() => new Date(`${selectedKey}T00:00:00`), [selectedKey]);
|
||||
const monday = useMemo(() => startOfWeekMonday(selected), [selected]);
|
||||
// Veckoplanen startar alltid idag och går 7 dagar framåt (aldrig bakåt i tiden).
|
||||
const monday = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
const mondayKey = toKey(monday);
|
||||
const weekDays = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(monday, i)), [monday]);
|
||||
// Vardag: middag. Helg (lör/sön): lunch + middag. Realistisk förifylld default.
|
||||
const [mealSlots, setMealSlots] = useState<string[][]>(() =>
|
||||
weekDays.map((d) => (d.getDay() === 0 || d.getDay() === 6 ? ["lunch", "dinner"] : ["dinner"])),
|
||||
);
|
||||
const toggleSlot = (dayIdx: number, meal: string) =>
|
||||
setMealSlots((prev) =>
|
||||
prev.map((meals, i) =>
|
||||
i !== dayIdx ? meals : meals.includes(meal) ? meals.filter((m) => m !== meal) : [...meals, meal],
|
||||
),
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["week-plan", mondayKey],
|
||||
@@ -127,7 +135,7 @@ export default function PlanScreen() {
|
||||
mutationFn: () =>
|
||||
api("/v1/week-plans/generate", {
|
||||
method: "POST",
|
||||
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"] },
|
||||
body: { weekStartDate: mondayKey, mealTypes: ["lunch", "dinner"], mealSlots },
|
||||
}),
|
||||
onSuccess: () => setGenerating(true),
|
||||
onError: onMutationError,
|
||||
@@ -170,6 +178,11 @@ export default function PlanScreen() {
|
||||
text: t("common.skip"),
|
||||
onPress: () => patchEntry.mutate({ planId, entryId: entry.id, status: "skipped" }),
|
||||
},
|
||||
{
|
||||
text: "Byt rätt",
|
||||
onPress: () =>
|
||||
router.push(`/swap-meal/${entry.id}?planId=${planId}&mealType=${entry.mealType}`),
|
||||
},
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
]);
|
||||
};
|
||||
@@ -310,11 +323,39 @@ export default function PlanScreen() {
|
||||
<>
|
||||
<EmptyState text={t("plan.emptyDay")} />
|
||||
{(plan === null || !hasEntries) && (
|
||||
<Button
|
||||
label={t("plan.generate")}
|
||||
onPress={() => generate.mutate()}
|
||||
loading={generate.isPending}
|
||||
/>
|
||||
<>
|
||||
<Small>{t("plan.chooseMeals")}</Small>
|
||||
{weekDays.map((d, i) => (
|
||||
<Row
|
||||
key={toKey(d)}
|
||||
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: 2 }}
|
||||
>
|
||||
<Small>
|
||||
{cap(new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" }).format(d))}
|
||||
</Small>
|
||||
<Row>
|
||||
{(["breakfast", "lunch", "dinner"] as const).map((meal) => (
|
||||
<Pressable
|
||||
key={meal}
|
||||
onPress={() => toggleSlot(i, meal)}
|
||||
style={{ marginLeft: spacing.xs }}
|
||||
>
|
||||
<Tag
|
||||
label={mealTypeLabel(meal)}
|
||||
tone={mealSlots[i]?.includes(meal) ? "accent" : "neutral"}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</Row>
|
||||
</Row>
|
||||
))}
|
||||
<Spacer size={spacing.xs} />
|
||||
<Button
|
||||
label={t("plan.generate")}
|
||||
onPress={() => generate.mutate()}
|
||||
loading={generate.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, uploadImage } from "@/lib/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, uploadImage, ApiError } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
@@ -48,7 +48,8 @@ export default function ScanScreen() {
|
||||
const [busyType, setBusyType] = useState<string | null>(null);
|
||||
const coach = useFirstScanCoach();
|
||||
const { track } = useAnalytics();
|
||||
const entitlements = useQuery({
|
||||
const queryClient = useQueryClient();
|
||||
const entitlements = useQuery({
|
||||
queryKey: ["entitlements"],
|
||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||
});
|
||||
@@ -82,7 +83,9 @@ export default function ScanScreen() {
|
||||
body: { scanType, imageCount: 1, contentType: "image/jpeg" },
|
||||
});
|
||||
|
||||
// 3. Ladda upp + starta analysen
|
||||
queryClient.invalidateQueries({ queryKey: ["entitlements"] });
|
||||
|
||||
// 3. Ladda upp + starta analysen
|
||||
const upload = created.uploads[0];
|
||||
if (!upload) throw new Error(t("common.error"));
|
||||
await uploadImage(upload, asset.uri);
|
||||
@@ -91,7 +94,11 @@ export default function ScanScreen() {
|
||||
// 4. Vidare till granskningsvyn som pollar tills resultatet kommer
|
||||
router.push(`${scanType === "plate" ? "/meal-review" : "/scan-review"}/${created.scan.id}`);
|
||||
} catch (err) {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") {
|
||||
router.push("/paywall");
|
||||
} else {
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
}
|
||||
} finally {
|
||||
setBusyType(null);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function RootLayout() {
|
||||
headerShadowVisible: false,
|
||||
headerBackTitle: t("common.back"),
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
fullScreenGestureEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
@@ -75,15 +76,18 @@ export default function RootLayout() {
|
||||
name="cooking/[id]"
|
||||
options={{ title: "", presentation: "fullScreenModal" }}
|
||||
/>
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
|
||||
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="swap-meal/[entryId]" options={{ presentation: "modal", title: "Byt ut" }} />
|
||||
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="reconciliation" options={{ title: t("reconciliation.title") }} />
|
||||
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title") }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title") }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title") }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household") }} />
|
||||
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
|
||||
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} />
|
||||
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title") }} />
|
||||
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
options={{ title: t("paywall.title"), presentation: "modal" }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import * as Haptics from "expo-haptics";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Spacer,
|
||||
} from "@/components/ui";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/**
|
||||
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
|
||||
@@ -38,6 +39,13 @@ interface RecipeForCooking {
|
||||
temperatureC: number | null;
|
||||
tip: string | null;
|
||||
}>;
|
||||
ingredients: Array<{
|
||||
id: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function CookingScreen() {
|
||||
@@ -45,6 +53,7 @@ export default function CookingScreen() {
|
||||
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [showIngredients, setShowIngredients] = useState(false);
|
||||
const [timerLeft, setTimerLeft] = useState<number | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
@@ -265,6 +274,40 @@ export default function CookingScreen() {
|
||||
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
|
||||
</Text>
|
||||
{step?.tip && <Small>💡 {step.tip}</Small>}
|
||||
<Pressable
|
||||
onPress={() => setShowIngredients((v) => !v)}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
|
||||
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
|
||||
</Small>
|
||||
</Pressable>
|
||||
{showIngredients && (
|
||||
<View
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ScrollView>
|
||||
{recipe.ingredients.map((ing) => {
|
||||
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
|
||||
return (
|
||||
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{ing.displayNameSv}
|
||||
{ing.optional ? " (valfritt)" : ""}
|
||||
</Body>
|
||||
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{step?.timerSeconds != null && (
|
||||
<Pressable
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
} from "@/components/ui";
|
||||
|
||||
/** Ditt kök: se och ta bort varor i lagret. Radering = mjuk (kvantitet → 0, historik bevaras). */
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
|
||||
}
|
||||
|
||||
export default function KitchenScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["inventory", "all"],
|
||||
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["inventory", "all"] });
|
||||
const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]);
|
||||
queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) =>
|
||||
old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old,
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _id, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["inventory"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{items.length === 0 && (
|
||||
<EmptyState text="Ditt kök är tomt än. Skanna eller lägg till varor." />
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Body>
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<Button
|
||||
label="Ta bort"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
Alert.alert("Ta bort", `Ta bort ${item.displayName} ur ditt kök?`, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
|
||||
])
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
{item.expiry.pastBestBefore && <Small>⚠︎ bäst före har passerat</Small>}
|
||||
</Card>
|
||||
))}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Alert } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { firebaseAuthProvider } from "@/lib/auth-provider/firebase";
|
||||
import { persistLanguageTag, useAuth } from "@/lib/auth";
|
||||
import { SUPPORTED_LANGUAGES, t } from "@/lib/i18n";
|
||||
import {
|
||||
@@ -59,7 +60,11 @@ const RELIGIOUS = ["none", "halal", "kosher", "hindu_no_beef", "buddhist_vegetar
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const logout = useAuth((s) => s.logout);
|
||||
const rawLogout = useAuth((s) => s.logout);
|
||||
const logout = async () => {
|
||||
await firebaseAuthProvider.signOut().catch(() => {});
|
||||
await rawLogout();
|
||||
};
|
||||
|
||||
const me = useQuery({ queryKey: ["me"], queryFn: () => api<Me>("/v1/me") });
|
||||
const entitlements = useQuery({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -86,17 +86,37 @@ export default function RecipeScreen() {
|
||||
const favorite = useMutation({
|
||||
mutationFn: (isFavorite: boolean) =>
|
||||
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
onMutate: async (isFavorite: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
|
||||
const prev = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
|
||||
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
|
||||
old ? { ...old, isFavorite: !isFavorite } : old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
if (ctx?.prev) queryClient.setQueryData(["recipe", id], ctx.prev);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
const rate = useMutation({
|
||||
mutationFn: (stars: number) =>
|
||||
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
onMutate: async (stars: number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
|
||||
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
|
||||
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
|
||||
old ? { ...old, myRating: { stars } } : old,
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (err, _stars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
|
||||
},
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
@@ -214,14 +234,24 @@ export default function RecipeScreen() {
|
||||
<Card>
|
||||
<Heading>{t("cooked.rate")}</Heading>
|
||||
<Row>
|
||||
{[1, 2, 3, 4, 5].map((stars) => (
|
||||
<Button
|
||||
key={stars}
|
||||
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
|
||||
variant="ghost"
|
||||
onPress={() => rate.mutate(stars)}
|
||||
/>
|
||||
))}
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = (recipe.myRating?.stars ?? 0) >= star;
|
||||
return (
|
||||
<Pressable
|
||||
key={star}
|
||||
onPress={() => rate.mutate(star)}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
})}
|
||||
>
|
||||
<Small style={{ fontSize: 34, color: filled ? colors.accent : colors.border }}>
|
||||
{filled ? "★" : "☆"}
|
||||
</Small>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
{recipe.ratingAverage != null && (
|
||||
<Small>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, EmptyState, ErrorView, Heading, Input, LoadingView, Screen, Small } from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
|
||||
/** Dina sparade recept (favoriter) – sökbar lista. Backend: GET /v1/recipes/favorites/mine. */
|
||||
|
||||
interface SavedRecipe {
|
||||
id: string;
|
||||
titleSv: string;
|
||||
totalTimeMinutes: number | null;
|
||||
nutritionPerPortion: { kcal?: number } | null;
|
||||
}
|
||||
interface FavoritesResponse {
|
||||
recipes: SavedRecipe[];
|
||||
}
|
||||
|
||||
export default function SavedRecipesScreen() {
|
||||
const [query, setQuery] = useState("");
|
||||
const favs = useQuery<FavoritesResponse>({
|
||||
queryKey: ["favorites-mine"],
|
||||
queryFn: () => api<FavoritesResponse>("/v1/recipes/favorites/mine"),
|
||||
});
|
||||
|
||||
if (favs.isLoading) return <LoadingView />;
|
||||
if (favs.isError) return <ErrorView onRetry={() => void favs.refetch()} />;
|
||||
|
||||
const all = favs.data?.recipes ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? all.filter((r) => r.titleSv.toLowerCase().includes(q)) : all;
|
||||
|
||||
return (
|
||||
<Screen style={{ gap: spacing.md }}>
|
||||
<Input
|
||||
placeholder="Sök bland dina sparade recept"
|
||||
autoCapitalize="none"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
/>
|
||||
{all.length === 0 ? (
|
||||
<EmptyState text="Du har inga sparade recept än. Tryck på ☆ Spara på ett recept så hamnar det här." />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState text="Inga sparade recept matchar din sökning." />
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<Card key={r.id} onPress={() => router.push(`/recipe/${r.id}`)} style={{ gap: spacing.xs }}>
|
||||
<Heading>{r.titleSv}</Heading>
|
||||
<Small>
|
||||
{[
|
||||
r.totalTimeMinutes != null ? `${r.totalTimeMinutes} min` : null,
|
||||
r.nutritionPerPortion?.kcal != null
|
||||
? `${Math.round(r.nutritionPerPortion.kcal)} kcal`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Small>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,14 @@ export default function ScanReviewScreen() {
|
||||
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
|
||||
return (
|
||||
<Screen scroll={false}>
|
||||
<LoadingView />
|
||||
<LoadingView
|
||||
messages={[
|
||||
t("scan.progress.analyzing"),
|
||||
t("scan.progress.finding"),
|
||||
t("scan.progress.matching"),
|
||||
t("scan.progress.almost"),
|
||||
]}
|
||||
/>
|
||||
<Body muted>{t("scan.analyzing")}</Body>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { Alert } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import {
|
||||
Body,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
|
||||
interface Rec {
|
||||
recipeId: string;
|
||||
titleSv: string;
|
||||
coveragePercent: number;
|
||||
}
|
||||
interface WhatToEatResponse {
|
||||
recommendations: Rec[];
|
||||
}
|
||||
|
||||
export default function SwapMealScreen() {
|
||||
const params = useLocalSearchParams<{ entryId: string; planId: string; mealType: string }>();
|
||||
const entryId = String(params.entryId);
|
||||
const planId = String(params.planId);
|
||||
const mealType = String(params.mealType || "dinner");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["swap-options", mealType],
|
||||
queryFn: () =>
|
||||
api<WhatToEatResponse>(`/v1/recommendations/what-to-eat?limit=10&mealType=${mealType}`),
|
||||
});
|
||||
|
||||
const swap = useMutation({
|
||||
mutationFn: (recipeId: string) =>
|
||||
api(`/v1/week-plans/${planId}/entries/${entryId}`, { method: "PATCH", body: { recipeId } }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["week-plan"] });
|
||||
router.back();
|
||||
},
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
});
|
||||
|
||||
const recs = query.data?.recommendations ?? [];
|
||||
const mealLabel = mealType === "lunch" ? "lunchen" : mealType === "breakfast" ? "frukosten" : "middagen";
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<Title>Byt ut måltiden</Title>
|
||||
<Small>Välj en annan rätt för {mealLabel}:</Small>
|
||||
{query.isLoading && <LoadingView />}
|
||||
{query.isError && <ErrorView onRetry={() => void query.refetch()} />}
|
||||
{query.data && recs.length === 0 && <EmptyState text={t("wte.empty")} />}
|
||||
{recs.map((rec) => (
|
||||
<Card key={rec.recipeId} onPress={() => swap.mutate(rec.recipeId)}>
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Body>{rec.titleSv}</Body>
|
||||
<Tag
|
||||
label={t("wte.coverage", { pct: rec.coveragePercent })}
|
||||
tone={rec.coveragePercent >= 80 ? "success" : "neutral"}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
@@ -17,6 +18,15 @@ import {
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { colors, radius, spacing, typography } from "@/lib/theme";
|
||||
import { t } from "@/lib/i18n";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
/** Lätt haptik + handlern — gör tryck taktila i hela appen. */
|
||||
function withHaptic(fn: () => void): () => void {
|
||||
return () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
fn();
|
||||
};
|
||||
}
|
||||
|
||||
export function Screen({
|
||||
children,
|
||||
@@ -55,8 +65,26 @@ export function Heading({ children }: { children: ReactNode }) {
|
||||
export function Body({ children, muted = false }: { children: ReactNode; muted?: boolean }) {
|
||||
return <Text style={[typography.body, muted && { color: colors.textMuted }]}>{children}</Text>;
|
||||
}
|
||||
export function Small({ children, style }: { children: ReactNode; style?: StyleProp<TextStyle> }) {
|
||||
return <Text style={[typography.small, style]}>{children}</Text>;
|
||||
export function Small({
|
||||
children,
|
||||
style,
|
||||
numberOfLines,
|
||||
adjustsFontSizeToFit,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<TextStyle>;
|
||||
numberOfLines?: number;
|
||||
adjustsFontSizeToFit?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Text
|
||||
style={[typography.small, style]}
|
||||
numberOfLines={numberOfLines}
|
||||
adjustsFontSizeToFit={adjustsFontSizeToFit}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
@@ -67,9 +95,8 @@ export function Card({
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.card, style, pressed && { opacity: 0.85 }]}
|
||||
>
|
||||
onPress={withHaptic(onPress)}
|
||||
style={({ pressed }) => [styles.card, style, pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] }]}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -102,11 +129,11 @@ export function Button({
|
||||
variant === "primary" ? "#fff" : variant === "danger" ? colors.danger : colors.primaryDark;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPress={withHaptic(onPress)}
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{ backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.85 : 1 },
|
||||
{ backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.85 : 1, transform: [{ scale: pressed ? 0.97 : 1 }] },
|
||||
variant === "ghost" && { borderWidth: 1, borderColor: colors.border },
|
||||
]}
|
||||
>
|
||||
@@ -178,11 +205,20 @@ export function ProgressBar({
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingView() {
|
||||
export function LoadingView({ messages }: { messages?: string[] } = {}) {
|
||||
const [i, setI] = useState(0);
|
||||
const list = messages ?? [];
|
||||
useEffect(() => {
|
||||
setI(0);
|
||||
if (list.length <= 1) return;
|
||||
const id = setInterval(() => setI((v) => (v + 1 < list.length ? v + 1 : v)), 1800);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [list.length]);
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Small>{t("common.loading")}</Small>
|
||||
<Small>{list.length ? list[i] : t("common.loading")}</Small>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { initializeApp, getApps, getApp } from "firebase/app";
|
||||
import {
|
||||
initializeAuth,
|
||||
getAuth,
|
||||
onAuthStateChanged as fbOnAuthStateChanged,
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut as fbSignOut,
|
||||
sendPasswordResetEmail,
|
||||
type Auth,
|
||||
type User,
|
||||
type Persistence,
|
||||
} from "firebase/auth";
|
||||
import * as fbAuth from "firebase/auth";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AuthProvider, AuthUser, Unsubscribe } from "./types";
|
||||
|
||||
/**
|
||||
* ENDA mobilfilen som importerar Firebase. App-koden använder bara
|
||||
* AuthProvider-gränssnittet. Byt till Zitadel => byt bara denna fil.
|
||||
* Publik klient-config (identifierare, ingen hemlighet).
|
||||
*/
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAH2F-aZRvZXQtGD7X8cF9Zp3yXUhEl3sM",
|
||||
authDomain: "cibello-c2ff3.firebaseapp.com",
|
||||
projectId: "cibello-c2ff3",
|
||||
storageBucket: "cibello-c2ff3.firebasestorage.app",
|
||||
messagingSenderId: "1034102905039",
|
||||
appId: "1:1034102905039:web:12e31120989655f34affba",
|
||||
};
|
||||
|
||||
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
|
||||
|
||||
// getReactNativePersistence finns i runtime men saknas ibland i typerna
|
||||
// (firebase-js-sdk#9316) – hämtas därför via en typsäker modul-cast.
|
||||
const getReactNativePersistence = (
|
||||
fbAuth as unknown as { getReactNativePersistence: (storage: unknown) => Persistence }
|
||||
).getReactNativePersistence;
|
||||
|
||||
// AsyncStorage-persistens så inloggningen överlever app-omstart.
|
||||
let auth: Auth;
|
||||
try {
|
||||
auth = initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) });
|
||||
} catch {
|
||||
// initializeAuth kastar om den redan körts (Fast Refresh) – återanvänd instansen.
|
||||
auth = getAuth(app);
|
||||
}
|
||||
|
||||
function toAuthUser(u: User | null): AuthUser | null {
|
||||
if (!u) return null;
|
||||
return {
|
||||
uid: u.uid,
|
||||
email: u.email,
|
||||
emailVerified: u.emailVerified,
|
||||
displayName: u.displayName,
|
||||
providerId: u.providerData[0]?.providerId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const firebaseAuthProvider: AuthProvider = {
|
||||
async signUpWithEmail(email, password) {
|
||||
const cred = await createUserWithEmailAndPassword(auth, email, password);
|
||||
return toAuthUser(cred.user)!;
|
||||
},
|
||||
async signInWithEmail(email, password) {
|
||||
const cred = await signInWithEmailAndPassword(auth, email, password);
|
||||
return toAuthUser(cred.user)!;
|
||||
},
|
||||
async signInWithGoogle(): Promise<AuthUser> {
|
||||
throw new Error("Google-inloggning läggs till i AUTH-6.");
|
||||
},
|
||||
async signInWithApple(): Promise<AuthUser> {
|
||||
throw new Error("Apple-inloggning läggs till i AUTH-6.");
|
||||
},
|
||||
async signOut() {
|
||||
await fbSignOut(auth);
|
||||
},
|
||||
async sendPasswordReset(email) {
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
},
|
||||
currentUser() {
|
||||
return toAuthUser(auth.currentUser);
|
||||
},
|
||||
onAuthStateChanged(callback): Unsubscribe {
|
||||
return fbOnAuthStateChanged(auth, (u) => callback(toAuthUser(u)));
|
||||
},
|
||||
async getIdToken(forceRefresh = false) {
|
||||
const u = auth.currentUser;
|
||||
if (!u) return null;
|
||||
return u.getIdToken(forceRefresh);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Portabelt autentiseringsgränssnitt.
|
||||
*
|
||||
* App-koden (vyer, affärslogik) anropar ENDAST detta gränssnitt – aldrig
|
||||
* Firebase direkt. Firebase blir en implementation (FirebaseAuthProvider); en
|
||||
* framtida ZitadelAuthProvider ska kunna ersätta den utan att någon vy eller
|
||||
* affärslogik ändras. ALL leverantörsspecifik import (firebase/*) hålls
|
||||
* isolerad till implementationsfilen – aldrig här, aldrig i en vy.
|
||||
*/
|
||||
|
||||
/** Minimal, leverantörsneutral användarrepresentation. */
|
||||
export interface AuthUser {
|
||||
/** Stabil unik identitet från identitetsleverantören (Firebase-UID i dag). */
|
||||
uid: string;
|
||||
email: string | null;
|
||||
emailVerified: boolean;
|
||||
displayName: string | null;
|
||||
/** t.ex. "password" | "google.com" | "apple.com". Neutral sträng. */
|
||||
providerId: string | null;
|
||||
}
|
||||
|
||||
/** Avregistreringsfunktion som returneras av onAuthStateChanged. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* Enda gränssnittet mot autentisering. Implementeras av FirebaseAuthProvider.
|
||||
* Alla nätverksmetoder är async och kastar vid fel (fångas i UI-lagret).
|
||||
*/
|
||||
export interface AuthProvider {
|
||||
signUpWithEmail(email: string, password: string): Promise<AuthUser>;
|
||||
signInWithEmail(email: string, password: string): Promise<AuthUser>;
|
||||
signInWithGoogle(): Promise<AuthUser>;
|
||||
signInWithApple(): Promise<AuthUser>;
|
||||
signOut(): Promise<void>;
|
||||
/** Skickar återställningsmejl för lösenord (leverantören sköter mejlet). */
|
||||
sendPasswordReset(email: string): Promise<void>;
|
||||
/** Nuvarande inloggade användare, eller null. Synkront ögonblicksvärde. */
|
||||
currentUser(): AuthUser | null;
|
||||
/** Prenumererar på inloggningsändringar. Returnerar avregistrering. */
|
||||
onAuthStateChanged(callback: (user: AuthUser | null) => void): Unsubscribe;
|
||||
/**
|
||||
* Hämtar aktuell ID-token för backend-verifiering. forceRefresh tvingar
|
||||
* förnyelse mot leverantören. null om ingen är inloggad.
|
||||
*/
|
||||
getIdToken(forceRefresh?: boolean): Promise<string | null>;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ interface I18nState {
|
||||
const useI18nStore = create<I18nState>((set) => ({
|
||||
version: 0,
|
||||
languageTag: "sv-SE",
|
||||
bump: (languageTag) => set((s) => ({ version: s.version + 1, languageTag })),
|
||||
bump: (languageTag) => set((s) => (s.languageTag === languageTag ? s : { version: s.version + 1, languageTag })),
|
||||
}));
|
||||
|
||||
/** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Skift adgangskode",
|
||||
"auth.resetTitle": "Vælg en ny adgangskode",
|
||||
"auth.resetTokenPlaceholder": "Indsæt koden fra e-mailen",
|
||||
"auth.trialNote": "7 dages fuld adgang – uden kort.",
|
||||
"auth.trialNote": "14 dages fuld adgang – uden kort.",
|
||||
"auth.verifyDoneBody": "Tak! Din konto er nu verificeret.",
|
||||
"auth.verifyDoneTitle": "E-mail bekræftet",
|
||||
"auth.verifyFailedBody": "Linket er ugyldigt eller udløbet. Anmod om et nyt fra profilen.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Vælg",
|
||||
"paywall.fairUse": "Fair use-kvote for AI-scanninger inkluderet.",
|
||||
"paywall.family": "Family · op til 6 personer",
|
||||
"paywall.freeNote": "Gratis: 10 AI-scanninger/md., manuelt lager, gemte opskrifter og enkel registrering.",
|
||||
"paywall.freeNote": "Gratis: 5 AI-scanninger/md., manuelt lager, gemte opskrifter og enkel registrering.",
|
||||
"paywall.household": "Household · op til 3 personer",
|
||||
"paywall.large": "Large Household · op til 12 personer",
|
||||
"paywall.perMonth": "{price}/md.",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Estimerede kalorier",
|
||||
"scan.meal.namePlaceholder": "Hvad spiste du? (valgfrit)",
|
||||
"scan.meal.components": "Genkendt på tallerkenen",
|
||||
"scan.meal.noEstimate": "Kunne ikke estimere kalorierne ud fra fotoet."
|
||||
"scan.meal.noEstimate": "Kunne ikke estimere kalorierne ud fra fotoet.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Passwort ändern",
|
||||
"auth.resetTitle": "Neues Passwort wählen",
|
||||
"auth.resetTokenPlaceholder": "Code aus der E-Mail einfügen",
|
||||
"auth.trialNote": "7 Tage voller Zugriff – ohne Karte.",
|
||||
"auth.trialNote": "14 Tage voller Zugriff – ohne Karte.",
|
||||
"auth.verifyDoneBody": "Danke! Dein Konto ist jetzt verifiziert.",
|
||||
"auth.verifyDoneTitle": "E-Mail bestätigt",
|
||||
"auth.verifyFailedBody": "Der Link ist ungültig oder abgelaufen. Fordere im Profil einen neuen an.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Wählen",
|
||||
"paywall.fairUse": "Fair-Use-Kontingent für KI-Scans inklusive.",
|
||||
"paywall.family": "Family · bis zu 6 Personen",
|
||||
"paywall.freeNote": "Gratis: 10 KI-Scans/Monat, manueller Vorrat, gespeicherte Rezepte und einfaches Tracking.",
|
||||
"paywall.freeNote": "Gratis: 5 KI-Scans/Monat, manueller Vorrat, gespeicherte Rezepte und einfaches Tracking.",
|
||||
"paywall.household": "Household · bis zu 3 Personen",
|
||||
"paywall.large": "Large Household · bis zu 12 Personen",
|
||||
"paywall.perMonth": "{price}/Monat",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Geschätzte Kalorien",
|
||||
"scan.meal.namePlaceholder": "Was hast du gegessen? (optional)",
|
||||
"scan.meal.components": "Auf dem Teller erkannt",
|
||||
"scan.meal.noEstimate": "Kalorien konnten nicht aus dem Foto geschätzt werden."
|
||||
"scan.meal.noEstimate": "Kalorien konnten nicht aus dem Foto geschätzt werden.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Change password",
|
||||
"auth.resetTitle": "Choose a new password",
|
||||
"auth.resetTokenPlaceholder": "Paste the code from the email",
|
||||
"auth.trialNote": "7 days of full access – no card required.",
|
||||
"auth.trialNote": "14 days of full access – no card required.",
|
||||
"auth.verifyDoneBody": "Thanks! Your account is now verified.",
|
||||
"auth.verifyDoneTitle": "Email confirmed",
|
||||
"auth.verifyFailedBody": "The link is invalid or has expired. Request a new one from your profile.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Choose",
|
||||
"paywall.fairUse": "Fair-use quota for AI scans included.",
|
||||
"paywall.family": "Family · up to 6 people",
|
||||
"paywall.freeNote": "Free: 10 AI scans/month, manual inventory, saved recipes and simple logging.",
|
||||
"paywall.freeNote": "Free: 5 AI scans/month, manual inventory, saved recipes and simple logging.",
|
||||
"paywall.household": "Household · up to 3 people",
|
||||
"paywall.large": "Large Household · up to 12 people",
|
||||
"paywall.perMonth": "{price}/month",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Estimated calories",
|
||||
"scan.meal.namePlaceholder": "What did you eat? (optional)",
|
||||
"scan.meal.components": "Recognised on the plate",
|
||||
"scan.meal.noEstimate": "Couldn't estimate calories from the photo."
|
||||
"scan.meal.noEstimate": "Couldn't estimate calories from the photo.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Cambiar contraseña",
|
||||
"auth.resetTitle": "Elige una nueva contraseña",
|
||||
"auth.resetTokenPlaceholder": "Pega el código del correo",
|
||||
"auth.trialNote": "7 días de acceso completo – sin tarjeta.",
|
||||
"auth.trialNote": "14 días de acceso completo – sin tarjeta.",
|
||||
"auth.verifyDoneBody": "¡Gracias! Tu cuenta está verificada.",
|
||||
"auth.verifyDoneTitle": "Correo confirmado",
|
||||
"auth.verifyFailedBody": "El enlace no es válido o ha caducado. Solicita uno nuevo desde tu perfil.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Elegir",
|
||||
"paywall.fairUse": "Cuota de uso razonable de escaneos de IA incluida.",
|
||||
"paywall.family": "Family · hasta 6 personas",
|
||||
"paywall.freeNote": "Gratis: 10 escaneos de IA/mes, inventario manual, recetas guardadas y registro sencillo.",
|
||||
"paywall.freeNote": "Gratis: 5 escaneos de IA/mes, inventario manual, recetas guardadas y registro sencillo.",
|
||||
"paywall.household": "Household · hasta 3 personas",
|
||||
"paywall.large": "Large Household · hasta 12 personas",
|
||||
"paywall.perMonth": "{price}/mes",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Calorías estimadas",
|
||||
"scan.meal.namePlaceholder": "¿Qué comiste? (opcional)",
|
||||
"scan.meal.components": "Reconocido en el plato",
|
||||
"scan.meal.noEstimate": "No se pudieron estimar las calorías de la foto."
|
||||
"scan.meal.noEstimate": "No se pudieron estimar las calorías de la foto.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Vaihda salasana",
|
||||
"auth.resetTitle": "Valitse uusi salasana",
|
||||
"auth.resetTokenPlaceholder": "Liitä koodi sähköpostista",
|
||||
"auth.trialNote": "7 päivää täyttä käyttöä – ilman korttia.",
|
||||
"auth.trialNote": "14 päivää täyttä käyttöä – ilman korttia.",
|
||||
"auth.verifyDoneBody": "Kiitos! Tilisi on nyt vahvistettu.",
|
||||
"auth.verifyDoneTitle": "Sähköposti vahvistettu",
|
||||
"auth.verifyFailedBody": "Linkki on virheellinen tai vanhentunut. Pyydä uusi profiilista.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Valitse",
|
||||
"paywall.fairUse": "Kohtuukäytön AI-skannauskiintiö sisältyy.",
|
||||
"paywall.family": "Family · enintään 6 henkilöä",
|
||||
"paywall.freeNote": "Ilmainen: 10 AI-skannausta/kk, manuaalinen varasto, tallennetut reseptit ja helppo kirjaus.",
|
||||
"paywall.freeNote": "Ilmainen: 5 AI-skannausta/kk, manuaalinen varasto, tallennetut reseptit ja helppo kirjaus.",
|
||||
"paywall.household": "Household · enintään 3 henkilöä",
|
||||
"paywall.large": "Large Household · enintään 12 henkilöä",
|
||||
"paywall.perMonth": "{price}/kk",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Arvioidut kalorit",
|
||||
"scan.meal.namePlaceholder": "Mitä söit? (valinnainen)",
|
||||
"scan.meal.components": "Tunnistettu lautaselta",
|
||||
"scan.meal.noEstimate": "Kaloreita ei voitu arvioida kuvasta."
|
||||
"scan.meal.noEstimate": "Kaloreita ei voitu arvioida kuvasta.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Changer le mot de passe",
|
||||
"auth.resetTitle": "Choisissez un nouveau mot de passe",
|
||||
"auth.resetTokenPlaceholder": "Collez le code reçu par e-mail",
|
||||
"auth.trialNote": "7 jours d'accès complet – sans carte.",
|
||||
"auth.trialNote": "14 jours d'accès complet – sans carte.",
|
||||
"auth.verifyDoneBody": "Merci ! Votre compte est vérifié.",
|
||||
"auth.verifyDoneTitle": "E-mail confirmé",
|
||||
"auth.verifyFailedBody": "Le lien est invalide ou expiré. Demandez-en un nouveau depuis votre profil.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Choisir",
|
||||
"paywall.fairUse": "Quota d'usage raisonnable de scans IA inclus.",
|
||||
"paywall.family": "Family · jusqu'à 6 personnes",
|
||||
"paywall.freeNote": "Gratuit : 10 scans IA/mois, stock manuel, recettes enregistrées et suivi simple.",
|
||||
"paywall.freeNote": "Gratuit : 5 scans IA/mois, stock manuel, recettes enregistrées et suivi simple.",
|
||||
"paywall.household": "Household · jusqu'à 3 personnes",
|
||||
"paywall.large": "Large Household · jusqu'à 12 personnes",
|
||||
"paywall.perMonth": "{price}/mois",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Calories estimées",
|
||||
"scan.meal.namePlaceholder": "Qu'as-tu mangé ? (facultatif)",
|
||||
"scan.meal.components": "Reconnu dans l'assiette",
|
||||
"scan.meal.noEstimate": "Impossible d'estimer les calories à partir de la photo."
|
||||
"scan.meal.noEstimate": "Impossible d'estimer les calories à partir de la photo.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Cambia password",
|
||||
"auth.resetTitle": "Scegli una nuova password",
|
||||
"auth.resetTokenPlaceholder": "Incolla il codice dall'email",
|
||||
"auth.trialNote": "7 giorni di accesso completo – senza carta.",
|
||||
"auth.trialNote": "14 giorni di accesso completo – senza carta.",
|
||||
"auth.verifyDoneBody": "Grazie! Il tuo account è verificato.",
|
||||
"auth.verifyDoneTitle": "Email confermata",
|
||||
"auth.verifyFailedBody": "Il link non è valido o è scaduto. Richiedine uno nuovo dal profilo.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Scegli",
|
||||
"paywall.fairUse": "Quota fair use di scansioni IA inclusa.",
|
||||
"paywall.family": "Family · fino a 6 persone",
|
||||
"paywall.freeNote": "Gratis: 10 scansioni IA/mese, dispensa manuale, ricette salvate e registrazione semplice.",
|
||||
"paywall.freeNote": "Gratis: 5 scansioni IA/mese, dispensa manuale, ricette salvate e registrazione semplice.",
|
||||
"paywall.household": "Household · fino a 3 persone",
|
||||
"paywall.large": "Large Household · fino a 12 persone",
|
||||
"paywall.perMonth": "{price}/mese",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Calorie stimate",
|
||||
"scan.meal.namePlaceholder": "Cosa hai mangiato? (facoltativo)",
|
||||
"scan.meal.components": "Riconosciuto nel piatto",
|
||||
"scan.meal.noEstimate": "Impossibile stimare le calorie dalla foto."
|
||||
"scan.meal.noEstimate": "Impossibile stimare le calorie dalla foto.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Bytt passord",
|
||||
"auth.resetTitle": "Velg et nytt passord",
|
||||
"auth.resetTokenPlaceholder": "Lim inn koden fra e-posten",
|
||||
"auth.trialNote": "7 dager full tilgang – uten kort.",
|
||||
"auth.trialNote": "14 dager full tilgang – uten kort.",
|
||||
"auth.verifyDoneBody": "Takk! Kontoen din er verifisert.",
|
||||
"auth.verifyDoneTitle": "E-post bekreftet",
|
||||
"auth.verifyFailedBody": "Lenken er ugyldig eller utløpt. Be om en ny fra profilen.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Velg",
|
||||
"paywall.fairUse": "Fair use-kvote for AI-skanninger inkludert.",
|
||||
"paywall.family": "Family · opptil 6 personer",
|
||||
"paywall.freeNote": "Gratis: 10 AI-skanninger/mnd., manuelt lager, lagrede oppskrifter og enkel loggføring.",
|
||||
"paywall.freeNote": "Gratis: 5 AI-skanninger/mnd., manuelt lager, lagrede oppskrifter og enkel loggføring.",
|
||||
"paywall.household": "Household · opptil 3 personer",
|
||||
"paywall.large": "Large Household · opptil 12 personer",
|
||||
"paywall.perMonth": "{price}/mnd.",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Estimerte kalorier",
|
||||
"scan.meal.namePlaceholder": "Hva spiste du? (valgfritt)",
|
||||
"scan.meal.components": "Gjenkjent på tallerkenen",
|
||||
"scan.meal.noEstimate": "Kunne ikke estimere kaloriene fra bildet."
|
||||
"scan.meal.noEstimate": "Kunne ikke estimere kaloriene fra bildet.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Wachtwoord wijzigen",
|
||||
"auth.resetTitle": "Kies een nieuw wachtwoord",
|
||||
"auth.resetTokenPlaceholder": "Plak de code uit de e-mail",
|
||||
"auth.trialNote": "7 dagen volledige toegang – zonder kaart.",
|
||||
"auth.trialNote": "14 dagen volledige toegang – zonder kaart.",
|
||||
"auth.verifyDoneBody": "Bedankt! Je account is geverifieerd.",
|
||||
"auth.verifyDoneTitle": "E-mail bevestigd",
|
||||
"auth.verifyFailedBody": "De link is ongeldig of verlopen. Vraag een nieuwe aan via je profiel.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Kies",
|
||||
"paywall.fairUse": "Fair-use quotum voor AI-scans inbegrepen.",
|
||||
"paywall.family": "Family · tot 6 personen",
|
||||
"paywall.freeNote": "Gratis: 10 AI-scans/maand, handmatige voorraad, opgeslagen recepten en eenvoudig registreren.",
|
||||
"paywall.freeNote": "Gratis: 5 AI-scans/maand, handmatige voorraad, opgeslagen recepten en eenvoudig registreren.",
|
||||
"paywall.household": "Household · tot 3 personen",
|
||||
"paywall.large": "Large Household · tot 12 personen",
|
||||
"paywall.perMonth": "{price}/maand",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Geschatte calorieën",
|
||||
"scan.meal.namePlaceholder": "Wat heb je gegeten? (optioneel)",
|
||||
"scan.meal.components": "Herkend op het bord",
|
||||
"scan.meal.noEstimate": "Kon de calorieën niet schatten op basis van de foto."
|
||||
"scan.meal.noEstimate": "Kon de calorieën niet schatten op basis van de foto.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Zmień hasło",
|
||||
"auth.resetTitle": "Wybierz nowe hasło",
|
||||
"auth.resetTokenPlaceholder": "Wklej kod z e-maila",
|
||||
"auth.trialNote": "7 dni pełnego dostępu – bez karty.",
|
||||
"auth.trialNote": "14 dni pełnego dostępu – bez karty.",
|
||||
"auth.verifyDoneBody": "Dziękujemy! Twoje konto jest zweryfikowane.",
|
||||
"auth.verifyDoneTitle": "E-mail potwierdzony",
|
||||
"auth.verifyFailedBody": "Link jest nieprawidłowy lub wygasł. Poproś o nowy w profilu.",
|
||||
@@ -216,7 +216,7 @@
|
||||
"paywall.choose": "Wybierz",
|
||||
"paywall.fairUse": "Limit fair use skanów AI w cenie.",
|
||||
"paywall.family": "Family · do 6 osób",
|
||||
"paywall.freeNote": "Za darmo: 10 skanów AI/mies., ręczna spiżarnia, zapisane przepisy i proste rejestrowanie.",
|
||||
"paywall.freeNote": "Za darmo: 5 skanów AI/mies., ręczna spiżarnia, zapisane przepisy i proste rejestrowanie.",
|
||||
"paywall.household": "Household · do 3 osób",
|
||||
"paywall.large": "Large Household · do 12 osób",
|
||||
"paywall.perMonth": "{price}/mies.",
|
||||
@@ -425,5 +425,37 @@
|
||||
"scan.meal.calories": "Szacowane kalorie",
|
||||
"scan.meal.namePlaceholder": "Co zjadłeś(-aś)? (opcjonalnie)",
|
||||
"scan.meal.components": "Rozpoznano na talerzu",
|
||||
"scan.meal.noEstimate": "Nie udało się oszacować kalorii ze zdjęcia."
|
||||
"scan.meal.noEstimate": "Nie udało się oszacować kalorii ze zdjęcia.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Alterar palavra-passe",
|
||||
"auth.resetTitle": "Escolha uma nova palavra-passe",
|
||||
"auth.resetTokenPlaceholder": "Cole o código do e-mail",
|
||||
"auth.trialNote": "7 dias de acesso completo – sem cartão.",
|
||||
"auth.trialNote": "14 dias de acesso completo – sem cartão.",
|
||||
"auth.verifyDoneBody": "Obrigado! A sua conta está verificada.",
|
||||
"auth.verifyDoneTitle": "E-mail confirmado",
|
||||
"auth.verifyFailedBody": "O link é inválido ou expirou. Peça um novo no perfil.",
|
||||
@@ -210,7 +210,7 @@
|
||||
"paywall.choose": "Escolher",
|
||||
"paywall.fairUse": "Quota de utilização justa de análises de IA incluída.",
|
||||
"paywall.family": "Family · até 6 pessoas",
|
||||
"paywall.freeNote": "Grátis: 10 análises de IA/mês, despensa manual, receitas guardadas e registo simples.",
|
||||
"paywall.freeNote": "Grátis: 5 análises de IA/mês, despensa manual, receitas guardadas e registo simples.",
|
||||
"paywall.household": "Household · até 3 pessoas",
|
||||
"paywall.large": "Large Household · até 12 pessoas",
|
||||
"paywall.perMonth": "{price}/mês",
|
||||
@@ -411,5 +411,37 @@
|
||||
"scan.meal.calories": "Calorias estimadas",
|
||||
"scan.meal.namePlaceholder": "O que comeste? (opcional)",
|
||||
"scan.meal.components": "Reconhecido no prato",
|
||||
"scan.meal.noEstimate": "Não foi possível estimar as calorias a partir da foto."
|
||||
"scan.meal.noEstimate": "Não foi possível estimar as calorias a partir da foto.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…",
|
||||
"home.completeProfile.title": "Gör appen smartare",
|
||||
"home.completeProfile.body": "Berätta om kost och allergier så blir förslagen mer personliga (ca 30 sek).",
|
||||
"onboarding.religious": "Religiös kosthållning",
|
||||
"onboarding.religious.none": "Ingen",
|
||||
"onboarding.religious.halal": "Halal (inget fläsk)",
|
||||
"onboarding.religious.kosher": "Kosher",
|
||||
"onboarding.religious.hindu_no_beef": "Inget nötkött",
|
||||
"onboarding.religious.buddhist_vegetarian": "Buddhistisk vegetarisk",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"auth.resetSubmit": "Byt lösenord",
|
||||
"auth.resetTitle": "Välj nytt lösenord",
|
||||
"auth.resetTokenPlaceholder": "Klistra in koden från mejlet",
|
||||
"auth.trialNote": "7 dagars full tillgång – inget kort behövs.",
|
||||
"auth.trialNote": "14 dagars full tillgång – inget kort behövs.",
|
||||
"auth.verifyDoneBody": "Tack! Ditt konto är nu verifierat.",
|
||||
"auth.verifyDoneTitle": "E-postadressen är bekräftad",
|
||||
"auth.verifyFailedBody": "Länken är ogiltig eller har gått ut. Begär en ny från profilen.",
|
||||
@@ -218,7 +218,7 @@
|
||||
"paywall.choose": "Välj",
|
||||
"paywall.fairUse": "Fair use-kvot för AI-skanningar ingår.",
|
||||
"paywall.family": "Family · upp till 6 personer",
|
||||
"paywall.freeNote": "Gratis: 10 AI-skanningar/mån, manuellt lager, sparade recept och enkel loggning.",
|
||||
"paywall.freeNote": "Gratis: 5 AI-skanningar/mån, manuellt lager, sparade recept och enkel loggning.",
|
||||
"paywall.household": "Household · upp till 3 personer",
|
||||
"paywall.large": "Large Household · upp till 12 personer",
|
||||
"paywall.perMonth": "{price}/mån",
|
||||
@@ -375,8 +375,8 @@
|
||||
"home.trustStatus.uncertain": "Lagret är osäkert – gör en snabbkoll",
|
||||
"reconciliation.title": "Snabbkoll av lagret",
|
||||
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
|
||||
"consent.title": "Innan du börjar",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna våra villkor. Bilderna du tar i appen (kylskåp, förpackningar, maträtter) används för att träna och förbättra Cibellos AI – det ingår i villkoren.",
|
||||
"consent.title": "Välkommen till Cibello",
|
||||
"consent.body": "För att använda Cibello behöver du godkänna Användarvillkoren och Integritetspolicyn. I nästa steg väljer du själv hur personlig du vill att appen ska vara – helt frivilligt, och du kan ändra det när som helst.",
|
||||
"consent.termsLink": "Läs Användarvillkoren",
|
||||
"consent.privacyLink": "Läs Integritetspolicyn",
|
||||
"consent.checkbox": "Jag har läst och godkänner Användarvillkoren och Integritetspolicyn.",
|
||||
@@ -419,5 +419,29 @@
|
||||
"scan.meal.calories": "Uppskattade kalorier",
|
||||
"scan.meal.namePlaceholder": "Vad åt du? (valfritt)",
|
||||
"scan.meal.components": "Igenkänt på tallriken",
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot."
|
||||
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot.",
|
||||
"onboarding.consent.personalization.title": "Gör Cibello till din",
|
||||
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det – förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
|
||||
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
|
||||
"onboarding.consent.skip": "Inte nu",
|
||||
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
|
||||
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre – för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
|
||||
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
|
||||
"onboarding.consent.image_training.no": "Nej tack",
|
||||
"onboarding.consent.dataPrivacy.title": "Data & integritet",
|
||||
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
|
||||
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik – aldrig dina bilder eller något som pekar ut dig – hjälper oss göra förslagen bättre.",
|
||||
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
|
||||
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
|
||||
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
|
||||
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret – värmande när det är kallt, grillat när solen är framme.",
|
||||
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga.",
|
||||
"swap.title": "Byt ut måltiden",
|
||||
"swap.subtitle": "Välj en annan rätt:",
|
||||
"plan.swapMeal": "Byt rätt",
|
||||
"plan.chooseMeals": "Planera veckan – välj måltider per dag:",
|
||||
"scan.progress.analyzing": "Analyserar bilden…",
|
||||
"scan.progress.finding": "Hittar ingredienser…",
|
||||
"scan.progress.matching": "Matchar mot ditt kök…",
|
||||
"scan.progress.almost": "Snart klar…"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ async function main() {
|
||||
],
|
||||
imageQualityIssues: [],
|
||||
},
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
latencyMs: 1234,
|
||||
costUsd: 0.0001,
|
||||
|
||||
@@ -4,6 +4,7 @@ import IORedis from "ioredis";
|
||||
import { createServer } from "node:http";
|
||||
import { createContext } from "./context.js";
|
||||
import { processScanJob } from "./processors/scans.js";
|
||||
import { refundAiScanForFailedJob } from "./processors/quota-refund.js";
|
||||
import { processModerateRecipe } from "./processors/moderation.js";
|
||||
import { processTranslateRecipe } from "./processors/translation.js";
|
||||
import { processGenerateWeekPlan } from "./processors/weekplan.js";
|
||||
@@ -186,6 +187,10 @@ worker.on("failed", async (job, err) => {
|
||||
console.error(`✗ ${jobType} (${job?.id}): ${err.message} (attempt ${attemptsMade}/${attempts})`);
|
||||
|
||||
if (job && attemptsMade >= attempts) {
|
||||
const _sjid = (job.data as { scanJobId?: unknown })?.scanJobId;
|
||||
if (_sjid) {
|
||||
try { await refundAiScanForFailedJob(ctx, String(_sjid)); } catch (e) { console.error("Kvot-refund misslyckades:", e); }
|
||||
}
|
||||
try {
|
||||
await deadLetterQueue.add(
|
||||
job.name,
|
||||
@@ -197,7 +202,7 @@ worker.on("failed", async (job, err) => {
|
||||
_failedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
jobId: `${instanceId}:${job.id}`,
|
||||
jobId: `${instanceId}__${job.id}`.replaceAll(":", "_"),
|
||||
removeOnComplete: { age: 30 * 24 * 60 * 60 }, // 30 dagar
|
||||
removeOnFail: { age: 90 * 24 * 60 * 60 }, // 90 dagar
|
||||
},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import type { WorkerContext } from "../context.js";
|
||||
|
||||
/**
|
||||
* Återbetalar AI-skanningskvot när ett skann-jobb dör definitivt.
|
||||
* Speglar consumeAiScan (api): kvot debiteras på household-ägaren (billingUser).
|
||||
* Barcode-skanningar drar ingen kvot och hoppas över. Golvat på 0.
|
||||
*/
|
||||
export async function refundAiScanForFailedJob(ctx: WorkerContext, scanJobId: string): Promise<void> {
|
||||
const [job] = await ctx.db
|
||||
.select({ userId: schema.scanJobs.userId, scanType: schema.scanJobs.scanType })
|
||||
.from(schema.scanJobs)
|
||||
.where(eq(schema.scanJobs.id, scanJobId))
|
||||
.limit(1);
|
||||
if (!job || job.scanType === "barcode") return;
|
||||
|
||||
let billingUserId = job.userId;
|
||||
const [member] = await ctx.db
|
||||
.select({ householdId: schema.householdMembers.householdId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, job.userId))
|
||||
.orderBy(schema.householdMembers.joinedAt)
|
||||
.limit(1);
|
||||
if (member?.householdId) {
|
||||
const [owner] = await ctx.db
|
||||
.select({ userId: schema.householdMembers.userId })
|
||||
.from(schema.householdMembers)
|
||||
.where(and(eq(schema.householdMembers.householdId, member.householdId), eq(schema.householdMembers.role, "owner")))
|
||||
.limit(1);
|
||||
if (owner?.userId) billingUserId = owner.userId;
|
||||
}
|
||||
|
||||
const month = new Date().toISOString().slice(0, 7);
|
||||
await ctx.db
|
||||
.update(schema.aiUsageCounters)
|
||||
.set({ aiScans: sql`GREATEST(${schema.aiUsageCounters.aiScans} - 1, 0)` })
|
||||
.where(and(eq(schema.aiUsageCounters.userId, billingUserId), eq(schema.aiUsageCounters.month, month)));
|
||||
}
|
||||
@@ -301,19 +301,32 @@ function findBestCanonicalMatch(
|
||||
return best.item;
|
||||
}
|
||||
|
||||
function fold(s: string): string {
|
||||
// Vik bort accenter (crème -> creme) OCH å/ä/ö -> a/a/o så att plural-vokalskifte
|
||||
// (morot <-> morötter) och lånord (crème fraîche) matchar konsekvent.
|
||||
return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
function scoreMatch(query: string, item: CanonicalIndex): number {
|
||||
const candidates = [
|
||||
item.nameSv.toLowerCase(),
|
||||
item.nameEn.toLowerCase(),
|
||||
...item.aliases.map((a) => a.toLowerCase()),
|
||||
];
|
||||
const q = fold(query);
|
||||
const candidates = [item.nameSv, item.nameEn, ...item.aliases].map(fold);
|
||||
|
||||
let max = 0;
|
||||
const queryTokens = tokenize(query);
|
||||
const queryTokens = tokenize(q);
|
||||
|
||||
for (const cand of candidates) {
|
||||
if (cand === query) return 1;
|
||||
if (cand.includes(query) || query.includes(cand)) max = Math.max(max, 0.85);
|
||||
if (cand === q) return 1;
|
||||
if (cand.includes(q) || q.includes(cand)) {
|
||||
max = Math.max(max, 0.85);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delad prefix (vindruva <-> vindruvor): stark signal utan token-exakthet.
|
||||
const shorter = q.length <= cand.length ? q : cand;
|
||||
const longer = q.length <= cand.length ? cand : q;
|
||||
let p = 0;
|
||||
while (p < shorter.length && shorter[p] === longer[p]) p++;
|
||||
if (p >= 5 && p / shorter.length >= 0.7) max = Math.max(max, 0.7);
|
||||
|
||||
const candTokens = tokenize(cand);
|
||||
const intersection = queryTokens.filter((t) => candTokens.includes(t));
|
||||
|
||||
@@ -18,6 +18,7 @@ interface WeekPlanJobInput {
|
||||
weekStartDate: string;
|
||||
daysToPlann?: number;
|
||||
mealTypes: MealType[];
|
||||
mealSlots?: MealType[][];
|
||||
portionsPerMeal?: number;
|
||||
budgetMinorTotal?: number;
|
||||
preferLeftoversFirst: boolean;
|
||||
@@ -228,7 +229,8 @@ export async function processGenerateWeekPlan(
|
||||
const date = new Date(Date.parse(data.input.weekStartDate) + day * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const mealType of mealTypes) {
|
||||
const dayMeals = data.input.mealSlots ? (data.input.mealSlots[day] ?? []) : mealTypes;
|
||||
for (const mealType of dayMeals) {
|
||||
// Matlåda om det finns och portionerna räcker
|
||||
const box = mealBoxes[boxIndex];
|
||||
if (box && box.portionsRemaining >= Math.min(portions, 2)) {
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("BUILD_TRAINING_SAMPLE banks locally", () => {
|
||||
corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" },
|
||||
},
|
||||
imageS3Key: "fridge-scans/train.jpg",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
modelVersion: "gemini-3.5-flash-lite",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
consentSnapshot: { anonymized_improvement: "granted", image_training: "granted" },
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ och tolkar men hittar aldrig på – vilket gör produkten trovärdig nog för a
|
||||
## Affärsvärde
|
||||
|
||||
Prenumeration via App Store/Google Play: **Household 79 kr**, **Family 129 kr**,
|
||||
**Large Household 169 kr** per månad, 7 dagars fri provperiod utan kort, användbar
|
||||
gratisnivå (10 AI-skanningar/mån) som behåller användare i tratten.
|
||||
**Large Household 169 kr** per månad, 14 dagars fri provperiod utan kort, användbar
|
||||
gratisnivå (5 AI-skanningar/mån) som behåller användare i tratten.
|
||||
|
||||
Ett genomsnittligt svenskt barnhushåll slänger mat för tusenlappar per år och lägger
|
||||
10 000+ kr/månad på mat – en app som synligt sänker svinnet och svarar på vardagens
|
||||
|
||||
@@ -49,7 +49,7 @@ systemet är medvetet flaggat AV vid lansering – det är rätt beslut och ska
|
||||
värden: tid (planering + "vad ska vi äta?" löst), pengar (svinnvärde i kronor visas i
|
||||
appen – "du har räddat mat för 214 kr i månaden") och hälsa (mål utan loggningsjobb).
|
||||
Kritisk sanning: gratisnivån måste vara bra nog att bygga vanan men inte så bra att
|
||||
uppgraderingen känns onödig – 10 AI-skanningar/mån + manuellt lager + full receptbank är
|
||||
uppgraderingen känns onödig – 5 AI-skanningar/mån + manuellt lager + full receptbank är
|
||||
den balansen. Priset testas i beta med prisexperiment innan låsning.
|
||||
|
||||
## Retention
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ tryck bort. Svenska först. Stora tryckytor (min 48 pt). Grön/varm palett (`src
|
||||
5 steg, allt frivilligt, progressindikator: (1) primärt mål → (2) kosthållning +
|
||||
allergier (med löftet "rätter med dina allergener visas aldrig") → (3) kroppsdata för
|
||||
kalorimål, med icke-medicinsk-disclaimer → (4) hushåll: skapa/gå med via kod/hoppa över →
|
||||
(5) enkelt vs exakt läge (§5), kombinerbart. Registrering startar 7-dagars trial utan kort.
|
||||
(5) enkelt vs exakt läge (§5), kombinerbart. Registrering startar 14-dagars trial utan kort.
|
||||
|
||||
## Flikarna
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Fel: `{ "error": { code, message, details?, correlationId } }`. Rate limit 300/m
|
||||
|
||||
| Metod | Path | Beskrivning |
|
||||
| ----- | ------------------------ | --------------------------------------------------------------- |
|
||||
| POST | /v1/auth/register | Skapa konto; startar 7-dagars trial; returnerar tokens |
|
||||
| POST | /v1/auth/register | Skapa konto; startar 14-dagars trial; returnerar tokens |
|
||||
| POST | /v1/auth/login | Logga in |
|
||||
| POST | /v1/auth/refresh | Rotera refresh-token (återanvändning → hela familjen revokeras) |
|
||||
| POST | /v1/auth/logout | Revokera alla sessioner |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Uppdaterad 2026-08-08 efter scouting: AAMOS Core v1 är autentiseringsmässigt
|
||||
> nåbar men saknar konfigurerad matmodell (`/v1/detect` faller tillbaka på YuNet
|
||||
> ansiktsdetektering). Tills vidare används **Gemini 2.5 Flash** som
|
||||
> ansiktsdetektering). Tills vidare används **Gemini 3.5 Flash Lite** som
|
||||
> lärar-tier för kylskåpsskanning, bakom samma `AamosClient`-gränssnitt.
|
||||
> AAMOS/prod rörs inte.
|
||||
|
||||
@@ -24,7 +24,7 @@ bytas fritt bakom kontraktet ("Food API ska få stabila kontrakt oavsett modell"
|
||||
┌──────────────────┬──────────┴──────────┐
|
||||
│ AAMOS_MODE=http │ AAMOS_MODE=gemini │
|
||||
│ HttpAamosClient │ GeminiAamosClient │
|
||||
│ → AAMOS Core v1 │ → Gemini 2.5 Flash │
|
||||
│ → AAMOS Core v1 │ → Gemini 3.5 Flash │
|
||||
└──────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ Gemini; API:et når aldrig nyckeln.
|
||||
|
||||
Skiva 2 kommer lägga premium-eskaleringsstier (Gemini Pro / GPT-4o / Claude)
|
||||
internt i adaptern vid låg konfidens, med eval/guldset som beslutar billig vs
|
||||
dyr modell. Fram till dess: Gemini 2.5 Flash för allt i Skiva 1.
|
||||
dyr modell. Fram till dess: Gemini 3.5 Flash Lite för allt i Skiva 1.
|
||||
|
||||
## Feedback → träning (Skiva 3)
|
||||
|
||||
@@ -126,4 +126,4 @@ separat `image_training`-samtycke. Personligt minne är aldrig träningsdata (§
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| `mock` | dev/test utan nätverk. Produkten vägrar starta i mock. |
|
||||
| `http` | Riktig AAMOS Core när matmodell finns. |
|
||||
| `gemini` | Gemini 2.5 Flash som lärar-tier för kylskåpsskanning (Skiva 1). |
|
||||
| `gemini` | Gemini 3.5 Flash Lite som lärar-tier för kylskåpsskanning (Skiva 1). |
|
||||
|
||||
+67
-33
@@ -3,54 +3,58 @@
|
||||
Priser verifierade 2026-08-02 (BenchLM, uppdaterad 2026-08-01, samt korsläst mot
|
||||
publika prislistor). **Kontrollera mot Anthropics officiella prissida innan avtal.**
|
||||
|
||||
> **Faktisk modell (kontrollerad mot koden):** All bildanalys, OCR och receptgenerering körs på
|
||||
> **Gemini 3.5 Flash Lite** (`GEMINI_MODEL`-default i `apps/api/src/config.ts` +
|
||||
> `packages/ai-contracts/src/gemini.ts`). Priserna nedan är verifierade mot Google (2026) och
|
||||
> speglar `estimateCostUsd` i koden.
|
||||
|
||||
## Aktuella modeller och listpriser (USD per miljon tokens, in/ut)
|
||||
|
||||
| Modell | Input | Output | Roll i appen |
|
||||
| ---------------- | ----- | ----------------------- | ----------------------------------------------------------- |
|
||||
| Claude Haiku 4.5 | $1 | $5 | Arbetshäst: bildanalys, OCR, normalisering, craving-parsing |
|
||||
| Claude Sonnet 5 | $2 | $10 (t.o.m. 2026-08-31) | Fallback vid låg confidence; receptstrukturering; veckoplan |
|
||||
| Claude Opus 5 | $5 | $25 | Endast evals/svåraste moderering – inte i volymflöden |
|
||||
| Claude Fable 5 | $10 | $50 | Används ej i volymflöden |
|
||||
| Modell | Input | Output | Roll i appen |
|
||||
| ------------------------------------- | ----- | ------ | -------------------------------------------------------------- |
|
||||
| Gemini 3.5 Flash Lite (standard) | $0,30 | $2,50 | Arbetshäst: all bildanalys, OCR, receptstrukturering (Skiva 1) |
|
||||
| Gemini 3.5 Flash Lite (batch) | $0,15 | $1,25 | Asynkrona nattjobb: minnesuppdatering, batch-generering |
|
||||
| Premium-eskalering (Skiva 2, framtid) | – | – | Större modell vid låg confidence (spec §33) – ej i volym ännu |
|
||||
|
||||
Hävstänger: cache-träffar 10 % av inputpris; Batch API −50 % (perfekt för nattliga
|
||||
minnes-/träningsjobb). AAMOS specialistmodeller ersätter över tid externa anrop för
|
||||
de vanligaste bilduppgifterna (spec §33) → kostnaden nedan är ett tak, inte ett golv.
|
||||
Hävstänger: batch-tier −50 %; context caching sänker upprepad input; AAMOS-specialistmodeller
|
||||
ersätter över tid externa anrop för de vanligaste bilduppgifterna (spec §33) → kostnaden nedan
|
||||
är ett tak, inte ett golv.
|
||||
|
||||
## Kostnad per uppgift (ANTAGANDEN markerade)
|
||||
## Kostnad per uppgift
|
||||
|
||||
Antaget: bild ≈ 1 500 tokens (A1), systemprompt+kontext 1 000 in / strukturerad output
|
||||
800 ut (A2), 80 % av anropen klaras av Haiku, 20 % eskalerar till Sonnet (A3),
|
||||
växelkurs 10,50 SEK/USD (A4).
|
||||
Antaget: bild ≈ 1 000 tokens (Gemini-tiling, A1), systemprompt+kontext ≈ 500–1 000 in,
|
||||
strukturerad JSON-output 400–1 200 ut (A2), växelkurs 10,50 SEK/USD (A4). All bildanalys på
|
||||
Gemini 3.5 Flash Lite standard-tier – ingen modell-eskalering i Skiva 1.
|
||||
|
||||
| Uppgift | Tokens (in/ut) | Haiku | Sonnet | Blandad (80/20) |
|
||||
| ------------------------------- | -------------- | --------------- | ------ | -------------------- |
|
||||
| Kyl-/skafferibild (2 bilder) | 4 000 / 800 | $0,008 | $0,016 | **$0,010 ≈ 0,10 kr** |
|
||||
| Kvitto | 3 000 / 1 000 | $0,008 | $0,016 | $0,010 |
|
||||
| Tallriksfoto | 2 500 / 500 | $0,005 | $0,010 | $0,006 |
|
||||
| Näringsetikett/datum | 2 000 / 400 | $0,004 | $0,008 | $0,005 |
|
||||
| Receptstrukturering (text) | 2 500 / 1 200 | – | $0,017 | $0,017 |
|
||||
| Veckoplan | 3 000 / 800 | – | $0,014 | $0,014 |
|
||||
| Minnesuppdatering (batch, natt) | 4 000 / 600 | $0,0035 (batch) | – | $0,0035 |
|
||||
| Uppgift | Tokens (in/ut) | Kostnad (Gemini 3.5 Flash Lite) |
|
||||
| ------------------------------- | -------------- | ------------------------------- |
|
||||
| Kyl-/skafferibild (2 bilder) | 4 000 / 800 | **$0,003 ≈ 0,03 kr** |
|
||||
| Kvitto | 3 000 / 1 000 | $0,003 |
|
||||
| Tallriksfoto | 2 500 / 500 | $0,002 |
|
||||
| Näringsetikett/datum | 2 000 / 400 | $0,002 |
|
||||
| Receptstrukturering (text) | 2 500 / 1 200 | $0,004 |
|
||||
| Veckoplan | 3 000 / 800 | $0,003 |
|
||||
| Minnesuppdatering (batch, natt) | 4 000 / 600 | $0,0014 (batch-tier) |
|
||||
|
||||
**Tumregel: ≈ $0,01 ≈ 0,11 kr per AI-skanning.** Deterministiska flöden
|
||||
(rekommendationer, nutrition, streckkod, FEFO) kostar $0.
|
||||
**Tumregel: ≈ $0,003 ≈ 0,03 kr per AI-skanning.** Deterministiska flöden (rekommendationer,
|
||||
nutrition, streckkod, FEFO) kostar $0.
|
||||
|
||||
## Kostnad per användartyp och månad
|
||||
|
||||
| Profil | Antagande om användning | AI-kostnad/mån |
|
||||
| --------------------------- | ----------------------------------------- | ----------------------------------------------- |
|
||||
| Gratis | 10 skanningar (kvottak) | $0,10 ≈ 1,1 kr |
|
||||
| Household (aktivt) | 60 skanningar + 4 planer + nattligt minne | $0,76 ≈ 8 kr |
|
||||
| Family (aktivt) | 100 skanningar + 4 planer + minne | $1,17 ≈ 12 kr |
|
||||
| Family (maxar fair use 600) | värsta fallet | $6,66 ≈ 70 kr — fortfarande under 129 kr-priset |
|
||||
| Gratis | 5 skanningar (kvottak) | $0,015 ≈ 0,16 kr |
|
||||
| Household (aktivt) | 60 skanningar + 4 planer + nattligt minne | $0,21 ≈ 2,2 kr |
|
||||
| Family (aktivt) | 100 skanningar + 4 planer + minne | $0,33 ≈ 3,5 kr |
|
||||
| Family (maxar fair use 200) | värsta fallet | $0,60 ≈ 6,3 kr — fortfarande under 129 kr-priset |
|
||||
|
||||
Fair use-taken (300/600/1000, gratis 10) gör att ingen enskild användare kan bli
|
||||
Fair use-taken (100/200/400, gratis 5) gör att ingen enskild användare kan bli
|
||||
förlustaffär ens i värsta fallet.
|
||||
|
||||
## Total kostnad per skala
|
||||
|
||||
Antaget (A5): 60 % av registrerade är månadsaktiva; mix 80 % gratis / 20 % betalande;
|
||||
betalande mix 60/30/10 (Household/Family/Large); aktiva gratisanvändare gör 6
|
||||
betalande mix 60/30/10 (Household/Family/Large); aktiva gratisanvändare gör 5
|
||||
skanningar, betalande enligt tabellen ovan.
|
||||
|
||||
| Registrerade | Aktiva | AI-kostnad/mån (USD) | ≈ SEK/mån | Intäkt/mån (SEK) | AI-andel av intäkt |
|
||||
@@ -63,8 +67,38 @@ Intäktsantagande (A6): 20 % betalande × viktat pris ≈ 99 kr. Skalan är linj
|
||||
specialistmodeller (spec §33) sänker styckkostnaden – rimligt mål är att halvera
|
||||
bildkostnaden vid >10 000 användare genom AAMOS-specialister + cachade prompts + batch.
|
||||
|
||||
**Med Gemini 3.5 Flash Lite (~$0,003/skanning) blir AI-kostnaden ~3,3× lägre än tabellen ovan:**
|
||||
≈ $42 / 440 kr (1k reg), ≈ $420 / 4 400 kr (10k), ≈ $4 200 / 44 000 kr (100k). AI-andelen av
|
||||
intäkt sjunker från ~12 % till ~4 %. Firebase Auth tillkommer först vid 60 000 aktiva:
|
||||
10 000 × $0,0055 = $55/mån ≈ 580 kr.
|
||||
|
||||
## Känslighet
|
||||
|
||||
Dubbla bildstorleken (A1) → +60 % på skanningskostnad. Halverad Haiku-andel (A3) →
|
||||
+50 %. Bevaka därför: tokens/bild i verklig trafik, eskaleringsandel, kostnad per
|
||||
uppgiftstyp (finns i scan_jobs.cost_usd → adminpanelen) med larm på dagsbudget (§58).
|
||||
Dubbla bildstorleken (A1) → +~30 % (fler bild-tokens). Fler upptäckta varor per bild → mer,
|
||||
eftersom output ($2,50/1M) dominerar över input ($0,30/1M). Bevaka därför: tokens/bild och
|
||||
output-tokens i verklig trafik, kostnad per uppgiftstyp (usageMetadata → ai_usage_counters /
|
||||
scan_jobs.cost_usd → adminpanelen) med larm på dagsbudget (§58).
|
||||
|
||||
## Scenario: 100 000 trial-användare dag 1 (onboarding-toppen)
|
||||
|
||||
Antagande: 100 000 registrerar sig dag 1, alla kör 14-dagars trial (25 skanningar) och använder
|
||||
upp alla 25 på bilder i **medelkvalitet** via **Gemini 3.5 Flash Lite** (~$0,003/skanning:
|
||||
~1 500 input-tokens à $0,30/1M + ~700 output-tokens à $2,50/1M).
|
||||
|
||||
| Post | Beräkning | Kostnad |
|
||||
| ----------------------------- | -------------------------- | ---------------------------------- |
|
||||
| AI – trial-skanningar | 100 000 × 25 × $0,003 | **$7 500 ≈ 79 000 kr** (på 14 dgr) |
|
||||
| Firebase Auth (100k MAU) | 50k gratis + 50k × $0,0055 | $275/mån ≈ 2 900 kr/mån |
|
||||
| **Totalt onboarding-månaden** | | **≈ $7 775 ≈ 82 000 kr** |
|
||||
|
||||
AI står för ~97 %, men hela toppen är nu ~3,3× lägre än den tidigare felräkningen
|
||||
(263 000 kr → 79 000 kr) eftersom Flash Lite är långt billigare per token.
|
||||
|
||||
Efter trial – om alla faller till gratis (5 skanningar/mån): 100 000 × 5 × $0,003 + Firebase
|
||||
≈ **19 000 kr/mån** utan intäkt. Break-even på den bördan: ~190 betalande (0,19 %), och
|
||||
trial-toppen (79 000 kr) tjänas in långt inom första månaden om ~2 % konverterar.
|
||||
|
||||
Slutsats: med rätt modell är onboarding-toppen ~79 000 kr på 14 dagar vid 100k dag-1 – en
|
||||
hanterbar post och strukturellt ofarlig. Batch-tier (Flash Lite $0,15/$1,25) halverar dessutom
|
||||
nattliga minnesjobb. Bevaka faktisk token-förbrukning per skanning (usageMetadata →
|
||||
ai_usage_counters) och konvertering – inte Firebase.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Del 13 – Subscriptions
|
||||
|
||||
Planer (spec §45): Household 79 kr (3 pers), Family 129 kr (6), Large Household 169 kr (12).
|
||||
Fair use-kvoter för AI: 300/600/1000 skanningar/mån. Gratis: 10. Trial: 7 dagar full
|
||||
Fair use-kvoter för AI: 100/200/400 skanningar/mån. Gratis: 5. Trial: 14 dagar full
|
||||
tillgång (Family-nivå) utan kort, startar vid registrering (spec §46).
|
||||
|
||||
## Arkitekturprincip
|
||||
|
||||
@@ -26,8 +26,8 @@ utifrån det ni faktiskt har hemma, vad som bör användas snart och vad ni gill
|
||||
• Kalorier och näring beräknas alltid deterministiskt – AI:n gissar aldrig siffror
|
||||
• Full insyn: "Vad {NAMN} vet om mig" går att rätta, pausa och radera
|
||||
|
||||
Gratis: 10 AI-skanningar/månad, manuellt lager, sparade recept och enkel loggning.
|
||||
Premium (7 dagar gratis, inget kort): obegränsat lager, veckoplan, delning och
|
||||
Gratis: 5 AI-skanningar/månad, manuellt lager, sparade recept och enkel loggning.
|
||||
Premium (14 dagar gratis, inget kort): obegränsat lager, veckoplan, delning och
|
||||
fler AI-skanningar.
|
||||
|
||||
**Nyckelord (100, App Store):**
|
||||
@@ -57,8 +57,8 @@ what you like.
|
||||
• Calories and nutrition are always computed deterministically – AI never invents numbers
|
||||
• Full transparency: "What {NAME} knows about me" can be corrected, paused and deleted
|
||||
|
||||
Free: 10 AI scans/month, manual inventory, saved recipes and simple logging.
|
||||
Premium (7-day free trial, no card): unlimited inventory, week planning, sharing
|
||||
Free: 5 AI scans/month, manual inventory, saved recipes and simple logging.
|
||||
Premium (14-day free trial, no card): unlimited inventory, week planning, sharing
|
||||
and more AI scans.
|
||||
|
||||
**Keywords:**
|
||||
|
||||
@@ -110,7 +110,7 @@ personuppgiftsbiträdesavtal. Inga uppgifter säljs eller delas för marknadsfö
|
||||
5. **Användarinnehåll**: recept du publicerar modereras. Du garanterar att du
|
||||
har rätt till innehåll du laddar upp; upphovsrättsintrång tas bort.
|
||||
6. **Prenumerationer**: köp och förnyelser hanteras av App Store/Google Play
|
||||
enligt deras villkor; provperioden är 7 dagar utan kort.
|
||||
enligt deras villkor; provperioden är 14 dagar utan kort.
|
||||
7. **Ansvarsbegränsning**: tjänsten tillhandahålls "i befintligt skick";
|
||||
ansvar begränsas till vad tvingande lag medger.
|
||||
8. **Ändringar**: väsentliga villkorsändringar meddelas i appen minst 30 dagar
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ADD COLUMN "firebase_uid" text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_firebase_uid_unique" ON "users" USING btree ("firebase_uid");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
||||
"when": 1786719342931,
|
||||
"tag": "0027_nostalgic_forgotten_one",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1786797105109,
|
||||
"tag": "0028_unknown_wrecking_crew",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -115,10 +115,10 @@ function requestOnce(
|
||||
}
|
||||
|
||||
/** Estimated max cost per image call in USD (pessimistic). */
|
||||
const COST_ESTIMATE_PER_IMAGE_USD = 0.0015;
|
||||
const COST_ESTIMATE_PER_IMAGE_USD = 0.004;
|
||||
|
||||
/** Estimated max cost per simple OCR image call in USD. */
|
||||
const COST_ESTIMATE_PER_OCR_IMAGE_USD = 0.001;
|
||||
const COST_ESTIMATE_PER_OCR_IMAGE_USD = 0.002;
|
||||
|
||||
export interface BudgetStore {
|
||||
/** Return current daily spend in USD. */
|
||||
@@ -182,13 +182,30 @@ const geminiFridgeResponseSchema = z.object({
|
||||
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
|
||||
});
|
||||
|
||||
function parseDate(value: string | null | undefined): string | null {
|
||||
export function parseDate(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return null;
|
||||
// Accept ISO dates (YYYY-MM-DD) only.
|
||||
// Accept ISO dates (YYYY-MM-DD).
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
|
||||
// Try common Swedish/EU formats.
|
||||
// Kompakta stämplar utan separator (vanligt på svensk förpackning): YYYYMMDD / YYMMDD.
|
||||
if (/^\d{8}$/.test(normalized)) {
|
||||
const y = +normalized.slice(0, 4),
|
||||
m = +normalized.slice(4, 6),
|
||||
d = +normalized.slice(6, 8);
|
||||
if (y > 2000 && m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
return null;
|
||||
}
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
const y = 2000 + +normalized.slice(0, 2),
|
||||
m = +normalized.slice(2, 4),
|
||||
d = +normalized.slice(4, 6);
|
||||
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
return null;
|
||||
}
|
||||
// Separatorformat (- / .).
|
||||
const parts = normalized.split(/[-/.]/);
|
||||
if (parts.length === 3) {
|
||||
const [aStr, bStr, cStr] = parts;
|
||||
@@ -203,6 +220,9 @@ function parseDate(value: string | null | undefined): string | null {
|
||||
// DD-MM-YYYY
|
||||
if (c > 2000 && b <= 12 && a <= 31)
|
||||
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
||||
// YY-MM-DD (2-siffrigt år först — vanligast på svensk bäst-före-stämpel).
|
||||
if (a <= 99 && b >= 1 && b <= 12 && c >= 1 && c <= 31)
|
||||
return `${2000 + a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -266,7 +286,7 @@ export class GeminiAamosClient implements AamosClient {
|
||||
constructor(config: GeminiAamosClientConfig) {
|
||||
this.cfg = {
|
||||
apiKey: config.apiKey,
|
||||
model: config.model ?? "gemini-2.5-flash",
|
||||
model: config.model ?? "gemini-3.5-flash-lite",
|
||||
timeoutMs: config.timeoutMs ?? 60_000,
|
||||
dailyBudgetUsd: config.dailyBudgetUsd ?? 0,
|
||||
promptVersion: config.promptVersion ?? "gemini-fridge-v1",
|
||||
@@ -856,6 +876,11 @@ Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med:
|
||||
- sistaForbruk: sista förbrukningsdatum om det syns (YYYY-MM-DD), annars null
|
||||
- konfidens: 0.0–1.0
|
||||
|
||||
Viktigt:
|
||||
- Kvantitet: uppskatta den FAKTISKA synliga/kvarvarande mängden, inte tryckta förpackningsantal. Ett tomt eller öppnat paket ska ha låg kvantitet (nära 0), även om lådan säger t.ex. "24-pack". Är du osäker, gissa lågt och sänk konfidensen.
|
||||
- Svenska mjölk- och mejerifärger: röd förpackning = standardmjölk 3%, grön = mellanmjölk 1,5%, blå = lättmjölk 0,5%. Använd färgen för att avgöra fetthalt.
|
||||
- Hellre lägre konfidens än en exakt gissning när du är osäker.
|
||||
|
||||
Svara ENDAST med giltig JSON i exakt detta format:
|
||||
{
|
||||
"items": [...],
|
||||
@@ -1008,8 +1033,8 @@ Språk: ${lang}.`;
|
||||
}
|
||||
|
||||
private estimateCostUsd(inputTokens: number, outputTokens: number): number {
|
||||
// Gemini 2.5 Flash pricing (Aug 2025): ~$0.075/1M input, $0.30/1M output.
|
||||
return inputTokens * 0.075e-6 + outputTokens * 0.3e-6;
|
||||
// Gemini 3.5 Flash Lite standard-tier pricing (2026): $0.30/1M input, $2.50/1M output.
|
||||
return inputTokens * 0.3e-6 + outputTokens * 2.5e-6;
|
||||
}
|
||||
|
||||
private async isOverBudget(estimateUsd: number): Promise<boolean> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GeminiAamosClient, MemoryBudgetStore } from "../src/gemini.js";
|
||||
import { GeminiAamosClient, MemoryBudgetStore, parseDate } from "../src/gemini.js";
|
||||
import type { AamosRequestEnvelope } from "../src/tasks.js";
|
||||
|
||||
const FIXTURE_RESPONSE = {
|
||||
@@ -67,7 +67,7 @@ describe("GeminiAamosClient", () => {
|
||||
const budget = new MemoryBudgetStore();
|
||||
const client = new GeminiAamosClient({
|
||||
apiKey: "test-key",
|
||||
model: "gemini-2.5-flash",
|
||||
model: "gemini-3.5-flash-lite",
|
||||
fetchImpl: makeFetch(JPEG_BYTES),
|
||||
budgetStore: budget,
|
||||
dailyBudgetUsd: 10,
|
||||
@@ -82,7 +82,7 @@ describe("GeminiAamosClient", () => {
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.output).not.toBeNull();
|
||||
expect(result.modelVersion).toBe("gemini-2.5-flash");
|
||||
expect(result.modelVersion).toBe("gemini-3.5-flash-lite");
|
||||
expect(result.costUsd).toBeGreaterThan(0);
|
||||
expect(result.inputTokens).toBe(392);
|
||||
expect(result.outputTokens).toBe(121);
|
||||
@@ -159,3 +159,14 @@ describe("GeminiAamosClient", () => {
|
||||
expect(result.error).toContain("Kontraktsfel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDate (svenska bäst-före-stämplar)", () => {
|
||||
it("läser 2-siffrigt år och kompakta format", () => {
|
||||
expect(parseDate("25-08-30")).toBe("2025-08-30"); // YY-MM-DD
|
||||
expect(parseDate("250830")).toBe("2025-08-30"); // YYMMDD
|
||||
expect(parseDate("20250830")).toBe("2025-08-30"); // YYYYMMDD
|
||||
expect(parseDate("2025-08-30")).toBe("2025-08-30"); // ISO
|
||||
expect(parseDate("30-08-2025")).toBe("2025-08-30"); // DD-MM-YYYY
|
||||
expect(parseDate("skräp")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ export const users = pgTable(
|
||||
email: text("email").notNull(),
|
||||
/** null = e-posten ej verifierad ännu (icke-blockerande – appen fungerar ändå). */
|
||||
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
|
||||
/** Firebase-UID (kundidentitet). null = admin/personal eller konto skapat före Firebase. Unik. */
|
||||
firebaseUid: text("firebase_uid"),
|
||||
displayName: text("display_name").notNull(),
|
||||
role: userRoleEnum("role").notNull().default("user"),
|
||||
locale: text("locale").notNull().default("sv-SE"),
|
||||
@@ -45,7 +47,10 @@ export const users = pgTable(
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("users_email_unique").on(t.email)],
|
||||
(t) => [
|
||||
uniqueIndex("users_email_unique").on(t.email),
|
||||
uniqueIndex("users_firebase_uid_unique").on(t.firebaseUid),
|
||||
],
|
||||
);
|
||||
|
||||
/** Autentiseringsuppgifter separerade från kontot (byts utan att röra users). */
|
||||
|
||||
@@ -131,7 +131,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
n: { kcal: 60, p: 3.4, k: 4.7, f: 3, mf: 1.9, s: 4.7, salt: 0.1 },
|
||||
shelfLifeGuidance: { fridge: 7 },
|
||||
defaultPriceMinorPerKg: 1600,
|
||||
aliases: ["mjölk", "helmjölk"],
|
||||
aliases: ["mjölk 3", "mjolk 3", "standardmjölk", "standardmjolk", "mjölk", "helmjölk"],
|
||||
}),
|
||||
ing("milk_1_5", "Mellanmjölk 1,5 %", "Semi-skimmed milk", "mejeri", {
|
||||
...vgt,
|
||||
@@ -174,7 +174,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
shelfLifeGuidance: { fridge: 7 },
|
||||
defaultPriceMinorPerKg: 5500,
|
||||
}),
|
||||
ing("creme_fraiche", "Crème fraiche", "Crème fraîche", "mejeri", {
|
||||
ing("creme_fraiche", "Crème fraiche", "Crème fraîche", "mejeri", { aliases: ["creme fraiche", "crème fraiche 32%", "creme fraiche 32%", "crème fraiche 32 %", "creme fraiche 32 %"],
|
||||
...vgt,
|
||||
defaultUnit: "DECILITER",
|
||||
densityGPerMl: 1,
|
||||
@@ -193,7 +193,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
defaultPriceMinorPerKg: 4500,
|
||||
aliases: ["kesella"],
|
||||
}),
|
||||
ing("yoghurt_natural", "Naturell yoghurt 3 %", "Plain yogurt", "mejeri", {
|
||||
ing("yoghurt_natural", "Naturell yoghurt 3 %", "Plain yogurt", "mejeri", { aliases: ["yoghurt", "yogurt", "naturell yoghurt", "grekisk yoghurt", "greek yogurt", "grekisk style yoghurt", "greek style yogurt"],
|
||||
...vgt,
|
||||
defaultUnit: "DECILITER",
|
||||
densityGPerMl: 1.03,
|
||||
@@ -392,7 +392,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
shelfLifeGuidance: { pantry: 60 },
|
||||
defaultPriceMinorPerKg: 9000,
|
||||
}),
|
||||
ing("carrot", "Morot", "Carrot", "gronsaker", {
|
||||
ing("carrot", "Morot", "Carrot", "gronsaker", { aliases: ["morötter", "morotter", "carrots"],
|
||||
...veg,
|
||||
defaultUnit: "COUNT",
|
||||
gramsPerPiece: 125,
|
||||
@@ -471,6 +471,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
}),
|
||||
ing("dill", "Dill (färsk)", "Dill", "gronsaker", {
|
||||
...veg,
|
||||
gramsPerPiece: 25,
|
||||
densityGPerMl: 0.12,
|
||||
n: { kcal: 40, p: 3.5, k: 5, f: 0.8, fib: 2.5 },
|
||||
shelfLifeGuidance: { fridge: 5 },
|
||||
@@ -479,6 +480,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
}),
|
||||
ing("parsley", "Persilja (färsk)", "Parsley", "gronsaker", {
|
||||
...veg,
|
||||
gramsPerPiece: 25,
|
||||
densityGPerMl: 0.12,
|
||||
n: { kcal: 45, p: 3.7, k: 6, f: 0.9, fib: 3.5 },
|
||||
shelfLifeGuidance: { fridge: 5 },
|
||||
@@ -638,7 +640,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
shelfLifeGuidance: { pantry: 365 },
|
||||
defaultPriceMinorPerKg: 3000,
|
||||
}),
|
||||
ing("bread_sourdough", "Surdegsbröd", "Sourdough bread", "brod", {
|
||||
ing("bread_sourdough", "Surdegsbröd", "Sourdough bread", "brod", { gramsPerPiece: 40,
|
||||
...veg,
|
||||
containsGluten: true,
|
||||
allergens: ["gluten"],
|
||||
@@ -972,12 +974,20 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
||||
ing("yeast_fresh", "Jäst", "Fresh yeast", "skafferi", { n: { kcal: 105, p: 9, k: 19, f: 2 }, isVegan: true, isVegetarian: true }),
|
||||
ing("cooked_ham", "Kokt skinka (julskinka)", "Cooked ham", "chark", { n: { kcal: 145, p: 18, k: 1, f: 8, mf: 3, salt: 2 }, isPork: true }),
|
||||
ing("crayfish", "Kräftor", "Crayfish", "skaldjur", { n: { kcal: 77, p: 16, k: 0, f: 1 }, allergens: ["crustaceans"] }),
|
||||
ing("cardamom", "Kardemumma", "Cardamom", "krydda", { n: { kcal: 311, p: 11, k: 68, f: 7 }, isVegan: true, isVegetarian: true }),
|
||||
ing("cardamom", "Kardemumma", "Cardamom", "krydda", { densityGPerMl: 0.45, n: { kcal: 311, p: 11, k: 68, f: 7 }, isVegan: true, isVegetarian: true }),
|
||||
ing("almond_paste", "Mandelmassa", "Almond paste", "bakning", { n: { kcal: 430, p: 9, k: 45, f: 24, s: 40 }, allergens: ["tree_nuts"], isVegetarian: true }),
|
||||
ing("red_cabbage", "Rödkål", "Red cabbage", "gronsaker", { n: { kcal: 31, p: 1.4, k: 7, f: 0.2, fib: 2 }, isVegan: true, isVegetarian: true }),
|
||||
ing("lingonberry_jam", "Lingonsylt", "Lingonberry jam", "skafferi", { n: { kcal: 200, p: 0.3, k: 49, f: 0.2, s: 45 }, isVegan: true, isVegetarian: true }),
|
||||
ing("prinskorv", "Prinskorv", "Cocktail sausage", "chark", { n: { kcal: 280, p: 11, k: 3, f: 25, mf: 9, salt: 2 }, isPork: true }),
|
||||
ing("rye_flour", "Rågmjöl", "Rye flour", "skafferi", { n: { kcal: 325, p: 9, k: 60, f: 2, fib: 14 }, containsGluten: true, allergens: ["gluten"], isVegan: true, isVegetarian: true }),
|
||||
ing("grape", "Vindruvor", "Grapes", "frukt", { n: { kcal: 69, p: 0.7, k: 18, f: 0.2, fib: 0.9, s: 15 }, isVegan: true, isVegetarian: true, aliases: ["druvor", "vindruva", "roda druvor", "röda druvor", "grona druvor", "gröna druvor", "drue druvor", "drue", "grapes"] }),
|
||||
ing("beetroot", "Rödbetor", "Beetroot", "gronsaker", { n: { kcal: 43, p: 1.6, k: 10, f: 0.2, fib: 2.8, s: 7 }, isVegan: true, isVegetarian: true, aliases: ["rödbeta", "rodbeta", "rödbetor", "rodbetor", "inlagda rödbetor", "inlagda rodbetor", "skivad rödbeta", "skivade rödbetor", "beetroot", "beets", "red beets"] }),
|
||||
ing("pea_shoots", "Ärtskott", "Pea shoots", "gronsaker", { n: { kcal: 42, p: 4, k: 4, f: 0.5, fib: 2 }, isVegan: true, isVegetarian: true, aliases: ["ärtskott", "artskott", "ertskott", "artiskott", "pea shoots"] }),
|
||||
ing("balsamic_vinegar", "Balsamvinäger", "Balsamic vinegar", "skafferi", { n: { kcal: 88, p: 0.5, k: 17, f: 0, s: 15 }, isVegan: true, isVegetarian: true, aliases: ["balsamvinäger", "balsamvinager", "balsamico", "balsamic vinegar", "vinäger"] }),
|
||||
ing("strawberry_jam", "Jordgubbssylt", "Strawberry jam", "skafferi", { n: { kcal: 200, p: 0.3, k: 49, f: 0.2, s: 45 }, isVegan: true, isVegetarian: true, aliases: ["jordgubbssylt", "jordgubbsylt", "strawberry jam"] }),
|
||||
ing("fish_stock", "Fiskfond", "Fish stock", "skafferi", { n: { kcal: 15, p: 1.5, k: 1, f: 0.5, salt: 6 }, allergens: ["fish"], aliases: ["fiskfond", "fisk fond", "fish stock"] }),
|
||||
ing("lobster_stock", "Hummerfond", "Lobster stock", "skafferi", { n: { kcal: 20, p: 2, k: 1.5, f: 0.7, salt: 6 }, allergens: ["crustaceans"], aliases: ["hummerfond", "hummer fond", "lobster stock"] }),
|
||||
ing("cornichons", "Cornichoner", "Cornichons", "skafferi", { n: { kcal: 11, p: 0.5, k: 2, f: 0.1, fib: 1, salt: 2 }, isVegan: true, isVegetarian: true, aliases: ["cornichons", "cornicher", "cornichoner", "ättiksgurka", "inlagd gurka", "små gurkor", "gherkins"] }),
|
||||
];
|
||||
|
||||
export const SEED_INGREDIENT_IDS = new Set(SEED_INGREDIENTS.map((i) => i.id));
|
||||
|
||||
@@ -572,7 +572,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklingpasta-med-spenat",
|
||||
titleSv: "Krämig kycklingpasta med spenat",
|
||||
titleSv: "Len kycklingpasta med spenat",
|
||||
descriptionSv:
|
||||
"Snabb vardagsfavorit: saftig kyckling, vitlök och spenat i krämig sås som vänds ner i nykokt pasta.",
|
||||
cuisine: "italian",
|
||||
@@ -2026,7 +2026,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-lax-med-dill-och-potatis",
|
||||
titleSv: "Krämig lax med dill och potatis",
|
||||
titleSv: "Delikat lax med dill och potatis",
|
||||
descriptionSv:
|
||||
"En enkel och snabb rätt med ugnsbakad laxfilé i en krämig dillsås, serverad med kokt potatis.",
|
||||
cuisine: "swedish",
|
||||
@@ -2591,7 +2591,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-linssoppa-med-mjolk-och-curry",
|
||||
titleSv: "Krämig linssoppa med mjölk och curry",
|
||||
titleSv: "Värmande linssoppa med mjölk och curry",
|
||||
descriptionSv:
|
||||
"En värmande och mättande vegetarisk linssoppa med en mild currysmak och krämighet från mjölk.",
|
||||
cuisine: "swedish",
|
||||
@@ -2718,7 +2718,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-svamppasta",
|
||||
titleSv: "Krämig svamppasta",
|
||||
titleSv: "Len svamppasta",
|
||||
descriptionSv:
|
||||
"En enkel och snabb vegetarisk pastarätt med champinjoner i en fyllig gräddsås, perfekt för vardagen.",
|
||||
cuisine: "swedish",
|
||||
@@ -2838,7 +2838,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-pasta-med-bacon-och-champinjoner",
|
||||
titleSv: "Krämig pasta med bacon och champinjoner",
|
||||
titleSv: "Rustik pasta med bacon och champinjoner",
|
||||
descriptionSv:
|
||||
"En snabb och enkel pastarätt med knaprigt bacon, champinjoner och en fyllig gräddsås.",
|
||||
cuisine: "swedish",
|
||||
@@ -3457,7 +3457,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tomatsoppa-med-basilika",
|
||||
titleSv: "Krämig Tomatsoppa med Basilika",
|
||||
titleSv: "Solmogen Tomatsoppa med Basilika",
|
||||
descriptionSv:
|
||||
"En värmande och fyllig tomatsoppa med en touch av mjölk för extra krämighet. Perfekt för en enkel vardagsmiddag, serveras gärna med en bit surdegsbröd.",
|
||||
cuisine: "swedish",
|
||||
@@ -3582,7 +3582,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-spenat-och-fetaostgratang",
|
||||
titleSv: "Krämig Spenat- och Fetaostgratäng",
|
||||
titleSv: "Gyllene Spenat- och Fetaostgratäng",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vegetarisk gratäng med spenat, fetaost och en krämig gräddbas. Perfekt som lättare middag eller som ett gott tillbehör.",
|
||||
cuisine: "swedish",
|
||||
@@ -3806,7 +3806,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-paprikagryta",
|
||||
titleSv: "Krämig Kyckling- och Paprikagryta",
|
||||
titleSv: "Mustig Kyckling- och Paprikagryta",
|
||||
descriptionSv:
|
||||
"En smakrik och krämig kycklinggryta med paprika och crème fraiche. Naturligt glutenfri och perfekt att servera med ris för en mättande vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -3935,7 +3935,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklingfile-med-tomatsas-och-pasta",
|
||||
titleSv: "Krämig kycklingfilé med tomatsås och pasta",
|
||||
titleSv: "Solmogen kycklingfilé med tomatsås och pasta",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vardagsrätt med saftig kycklingfilé i en krämig tomatsås, serverad med pasta. Perfekt när tiden är knapp men du vill äta gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -5005,7 +5005,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-svampsas-med-ris",
|
||||
titleSv: "Krämig Kyckling- och Svampsås med Ris",
|
||||
titleSv: "Fyllig Kyckling- och Svampsås med Ris",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vardagsrätt med saftig kyckling och champinjoner i en fyllig gräddsås. Perfekt att servera med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -5143,7 +5143,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-falukorv-och-pastagratang",
|
||||
titleSv: "Krämig Falukorv- och Pastagratäng",
|
||||
titleSv: "Matig Falukorv- och Pastagratäng",
|
||||
descriptionSv:
|
||||
"En enkel och älskad familjefavorit! Falukorv och pasta i en krämig tomatsås, toppad med ost och gratinerad i ugnen. Perfekt för en stressig vardag.",
|
||||
cuisine: "swedish",
|
||||
@@ -5512,7 +5512,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramiga-kottbullar-med-potatis",
|
||||
titleSv: "Krämiga köttbullar med potatis",
|
||||
titleSv: "Saftiga köttbullar med potatis",
|
||||
descriptionSv:
|
||||
"En klassisk svensk vardagsrätt med färdiga köttbullar i en len gräddsås, serverad med kokt potatis. Enkel att laga och älskad av många.",
|
||||
cuisine: "swedish",
|
||||
@@ -5775,7 +5775,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklingpasta-med-spenat-och-paprika",
|
||||
titleSv: "Krämig Kycklingpasta med Spenat och Paprika",
|
||||
titleSv: "Silkeslen Kycklingpasta med Spenat och Paprika",
|
||||
descriptionSv:
|
||||
"En snabb och krämig kycklingpasta som passar perfekt när tiden är knapp. Saftig kycklingfilé, färsk spenat och paprika i en len gräddsås.",
|
||||
cuisine: "swedish",
|
||||
@@ -6338,7 +6338,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-fiskgratang-med-dill-och-potatis",
|
||||
titleSv: "Krämig Fiskgratäng med dill och potatis",
|
||||
titleSv: "Gyllene Fiskgratäng med dill och potatis",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik fiskgratäng med torsk, potatis och en härlig dillsås. Perfekt för en snabb och god middag som uppskattas av hela familjen.",
|
||||
cuisine: "swedish",
|
||||
@@ -7005,7 +7005,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-svamp-och-ris",
|
||||
titleSv: "Krämig Kycklinggryta med Svamp och Ris",
|
||||
titleSv: "Rustik Kycklinggryta med Svamp och Ris",
|
||||
descriptionSv:
|
||||
"En snabb och enkel kycklinggryta med champinjoner i en fyllig gräddsås, perfekt för en vardagskväll när du vill ha något värmande och mättande. Serveras med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -7407,7 +7407,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kottfars-och-potatisgratang",
|
||||
titleSv: "Krämig Köttfärs- och Potatisgratäng",
|
||||
titleSv: "Matig Köttfärs- och Potatisgratäng",
|
||||
descriptionSv:
|
||||
"En värmande och mättande gratäng som kombinerar köttfärs och potatis i en krämig sås, toppad med ost. Perfekt för kyligare dagar och en uppskattad familjemiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -7710,7 +7710,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-spenat-och-tomatpasta",
|
||||
titleSv: "Krämig spenat- och tomatpasta",
|
||||
titleSv: "Fyllig spenat- och tomatpasta",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vegetarisk pastarätt med en krämig sås baserad på krossade tomater, färsk spenat och mjölk. Toppas med riven hårdost.",
|
||||
cuisine: "swedish",
|
||||
@@ -7843,7 +7843,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklingcurry-med-ris",
|
||||
titleSv: "Krämig kycklingcurry med ris",
|
||||
titleSv: "Mild kycklingcurry med ris",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik kycklingcurry med kokosmjölk och ris, perfekt för en vardagskväll. Full av härliga smaker och enkel att laga.",
|
||||
cuisine: "international",
|
||||
@@ -7987,7 +7987,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-broccolisoppa-med-mjolk",
|
||||
titleSv: "Krämig broccolisoppa med mjölk",
|
||||
titleSv: "Len broccolisoppa med mjölk",
|
||||
descriptionSv:
|
||||
"En enkel och värmande vegetarisk soppa med broccoli och morot, gjord extra krämig med standardmjölk. Perfekt som en lättare middag eller lunch.",
|
||||
cuisine: "swedish",
|
||||
@@ -8533,7 +8533,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-svamp-och-spenatpasta",
|
||||
titleSv: "Krämig svamp- och spenatpasta",
|
||||
titleSv: "Rustik svamp- och spenatpasta",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vegetarisk pastarätt med champinjoner, spenat och en härlig gräddsås. Perfekt för en vardag när tiden är knapp men du vill äta gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -8679,7 +8679,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-potatis-och-broccoligratang",
|
||||
titleSv: "Krämig potatis- och broccoligratäng",
|
||||
titleSv: "Gyllene potatis- och broccoligratäng",
|
||||
descriptionSv:
|
||||
"En klassisk och omtyckt gratäng som passar perfekt som vegetarisk huvudrätt eller som tillbehör. Krämig grädde, mjuk potatis och fräsch broccoli i en härlig kombination.",
|
||||
cuisine: "swedish",
|
||||
@@ -8960,7 +8960,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-paprika-och-ris",
|
||||
titleSv: "Krämig kycklinggryta med paprika och ris",
|
||||
titleSv: "Mustig kycklinggryta med paprika och ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med krämig sås och färgglada grönsaker. Perfekt för en glutenfri vardagsmiddag som uppskattas av hela familjen.",
|
||||
cuisine: "swedish",
|
||||
@@ -9116,7 +9116,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tofucurry-med-kokos-och-gronsaker",
|
||||
titleSv: "Krämig Tofucurry med Kokos och Grönsaker",
|
||||
titleSv: "Mild Tofucurry med Kokos och Grönsaker",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vegansk curry med tofu, kokosmjölk och massor av grönsaker, serverad med fluffigt ris. Perfekt för en vardagskväll!",
|
||||
cuisine: "swedish",
|
||||
@@ -9587,7 +9587,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-curry-och-kokosmjolk",
|
||||
titleSv: "Krämig kycklinggryta med curry och kokosmjölk",
|
||||
titleSv: "Aromatisk kycklinggryta med curry och kokosmjölk",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med krämig kokosmjölk och mild curry. Perfekt för en snabb vardagsmiddag som uppskattas av hela familjen. Servera med fluffigt ris och gärna en fräsch sallad.",
|
||||
cuisine: "swedish",
|
||||
@@ -9875,7 +9875,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinglarfile-med-ris-och-broccoli",
|
||||
titleSv: "Krämig kycklinglårfilé med ris och broccoli",
|
||||
titleSv: "Saftig kycklinglårfilé med ris och broccoli",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vardagsrätt med saftig kycklinglårfilé i en krämig sås, serverad med fluffigt ris och krispig broccoli. Perfekt för en snabb middag mitt i veckan som hela familjen uppskattar.",
|
||||
cuisine: "swedish",
|
||||
@@ -10023,7 +10023,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kottfarsgryta-med-potatis-laktosfri",
|
||||
titleSv: "Krämig Köttfärsgryta med Potatis (Laktosfri)",
|
||||
titleSv: "Mustig Köttfärsgryta med Potatis (Laktosfri)",
|
||||
descriptionSv:
|
||||
"En värmande och krämig köttfärsgryta med potatis, smaksatt med paprika och timjan. Havredryck ger en fin laktosfri krämighet.",
|
||||
cuisine: "swedish",
|
||||
@@ -11168,7 +11168,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-potatis-och-linsgryta-med-kokosmjolk",
|
||||
titleSv: "Krämig Potatis- och Linsgryta med Kokosmjölk",
|
||||
titleSv: "Fyllig Potatis- och Linsgryta med Kokosmjölk",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegansk gryta med potatis, röda linser och krämig kokosmjölk. Perfekt för en enkel vardagsmiddag som mättar gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -11467,7 +11467,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-potatis-och-purjolokssoppa",
|
||||
titleSv: "Krämig potatis- och purjolökssoppa",
|
||||
titleSv: "Len potatis- och purjolökssoppa",
|
||||
descriptionSv:
|
||||
"En värmande och mättande soppa med mild smak av potatis och purjolök. Perfekt för kyliga dagar eller som en lättare middag.",
|
||||
cuisine: "swedish",
|
||||
@@ -11734,7 +11734,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-rakpasta-med-vitlok-och-persilja",
|
||||
titleSv: "Krämig räkpasta med vitlök och persilja",
|
||||
titleSv: "Silkeslen räkpasta med vitlök och persilja",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik pastarätt med räkor i en krämig tomatsås, perfekt för en vardagskväll när tiden är knapp men suget efter något gott är stort. Toppas med färsk persilja och en skvätt citron.",
|
||||
cuisine: "swedish",
|
||||
@@ -12168,7 +12168,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-potatis-och-svampfras-med-spenat",
|
||||
titleSv: "Krämig Potatis- och Svampfräs med Spenat",
|
||||
titleSv: "Rustik Potatis- och Svampfräs med Spenat",
|
||||
descriptionSv:
|
||||
"En snabb och krämig vegansk rätt med potatis, champinjoner och spenat, smaksatt med havredryck och timjan. Perfekt när tiden är knapp.",
|
||||
cuisine: "swedish",
|
||||
@@ -12309,7 +12309,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-kokos-och-ris",
|
||||
titleSv: "Krämig kycklinggryta med kokos och ris",
|
||||
titleSv: "Fyllig kycklinggryta med kokos och ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med krämig kokosmjölk och milda kryddor, perfekt för en snabb vardagsmiddag. Serveras med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -12815,7 +12815,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tofucurry-med-kokosmjolk-och-ris",
|
||||
titleSv: "Krämig Tofucurry med Kokosmjölk och Ris",
|
||||
titleSv: "Aromatisk Tofucurry med Kokosmjölk och Ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vegansk curry med tofu, kokosmjölk och ris. Perfekt för en snabb vardagsmiddag med asiatiska smaker.",
|
||||
cuisine: "swedish",
|
||||
@@ -13447,7 +13447,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tofucurry-med-spenat-och-ris",
|
||||
titleSv: "Krämig tofucurry med spenat och ris",
|
||||
titleSv: "Mild tofucurry med spenat och ris",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegansk curry med tofu, spenat och kokosmjölk. Serveras med fluffigt ris för en komplett vardagsmåltid.",
|
||||
cuisine: "swedish",
|
||||
@@ -14238,7 +14238,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-linsgryta-med-kokos-och-curry",
|
||||
titleSv: "Krämig Linsgryta med Kokos och Curry",
|
||||
titleSv: "Doftande Linsgryta med Kokos och Curry",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik linsgryta med krämig kokosmjölk och mild curry. Perfekt för en enkel vardagsmiddag som mättar och värmer. Servera med fluffigt ris och kanske lite färsk spenat för extra grönt.",
|
||||
cuisine: "swedish",
|
||||
@@ -14393,7 +14393,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-vegansk-tomatpasta-med-basilika",
|
||||
titleSv: "Krämig vegansk tomatpasta med basilika",
|
||||
titleSv: "Solmogen vegansk tomatpasta med basilika",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vegansk pastarätt med en fyllig tomatsås, havredryck för krämighet och färsk basilika. Perfekt när tiden är knapp!",
|
||||
cuisine: "swedish",
|
||||
@@ -14516,7 +14516,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-rod-linssoppa-med-morot-och-ingefara",
|
||||
titleSv: "Krämig röd linssoppa med morot och ingefära",
|
||||
titleSv: "Värmande röd linssoppa med morot och ingefära",
|
||||
descriptionSv:
|
||||
"En värmande och krämig linssoppa som är perfekt för en snabb vardagsmiddag. Morötter och ingefära ger en härlig smak, och kokosmjölken bidrar med en fin fyllighet.",
|
||||
cuisine: "swedish",
|
||||
@@ -15255,7 +15255,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kikartscurry-med-spenat-och-kokos",
|
||||
titleSv: "Krämig kikärtscurry med spenat och kokos",
|
||||
titleSv: "Mild kikärtscurry med spenat och kokos",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vegansk curry med kikärtor, spenat och kokosmjölk. Perfekt för en vardagskväll när du vill ha något värmande och gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -15407,7 +15407,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-tomat-och-paprika",
|
||||
titleSv: "Krämig kycklinggryta med tomat och paprika",
|
||||
titleSv: "Solmogen kycklinggryta med tomat och paprika",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med tomat och paprika som passar perfekt till vardags. Servera med ris för en komplett måltid.",
|
||||
cuisine: "swedish",
|
||||
@@ -15688,7 +15688,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tomatpasta-med-spenat-och-vitlok-vegetarisk",
|
||||
titleSv: "Krämig tomatpasta med spenat och vitlök (Vegetarisk)",
|
||||
titleSv: "Fyllig tomatpasta med spenat och vitlök (Vegetarisk)",
|
||||
descriptionSv:
|
||||
"En snabb och krämig vegetarisk pasta med en fyllig tomatsås, färsk spenat och vitlök. Perfekt för en smakrik vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -15963,7 +15963,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-vegansk-tomat-och-linsgryta",
|
||||
titleSv: "Krämig vegansk tomat- och linsgryta",
|
||||
titleSv: "Mustig vegansk tomat- och linsgryta",
|
||||
descriptionSv:
|
||||
"En värmande och mättande vegansk gryta med röda linser, krossade tomater och morötter. Perfekt för en enkel vardagsmiddag som värmer gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -16389,7 +16389,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-paprika-och-linsgryta-med-ris",
|
||||
titleSv: "Krämig Paprika- och Linsgryta med Ris",
|
||||
titleSv: "Mustig Paprika- och Linsgryta med Ris",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegansk gryta med paprika och röda linser, perfekt för en enkel vardagsmiddag. Serveras med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -16717,7 +16717,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-zucchinipanna-med-ris",
|
||||
titleSv: "Krämig kyckling- och zucchinipanna med ris",
|
||||
titleSv: "Rustik kyckling- och zucchinipanna med ris",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik kycklingrätt med zucchini i en krämig sås, serverad med fluffigt ris. En enkel och uppskattad vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -17009,7 +17009,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-morotsgryta-med-curry",
|
||||
titleSv: "Krämig Kyckling- och Morotsgryta med Curry",
|
||||
titleSv: "Mild Kyckling- och Morotsgryta med Curry",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med morötter och en mild currysmak. Perfekt att servera med ris för en komplett vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -17157,7 +17157,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-morotssoppa-med-vitlok-och-surdegsbrod",
|
||||
titleSv: "Krämig morotssoppa med vitlök och surdegsbröd",
|
||||
titleSv: "Söt och len morotssoppa med vitlök och surdegsbröd",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik morotssoppa med en härlig krämighet från matlagningsgrädde. Perfekt för en kylig kväll och enkel att laga. Servera med en bit gott surdegsbröd.",
|
||||
cuisine: "swedish",
|
||||
@@ -17514,7 +17514,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-spenat-och-linsgryta-med-kokos",
|
||||
titleSv: "Krämig Spenat- och Linsgryta med Kokos",
|
||||
titleSv: "Matig Spenat- och Linsgryta med Kokos",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegansk gryta med röda linser, färsk spenat och krämig kokosmjölk. Perfekt för en enkel vardagsmiddag som mättar och värmer.",
|
||||
cuisine: "swedish",
|
||||
@@ -17850,7 +17850,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-paprikagryta-med-ris",
|
||||
titleSv: "Krämig kyckling- och paprikagryta med ris",
|
||||
titleSv: "Mustig kyckling- och paprikagryta med ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik kycklinggryta med krämig sås och färgglad paprika, perfekt för en snabb vardagsmiddag. Servera med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -19382,7 +19382,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kottfarssas-med-pasta",
|
||||
titleSv: "Krämig Köttfärssås med Pasta",
|
||||
titleSv: "Fyllig Köttfärssås med Pasta",
|
||||
descriptionSv:
|
||||
"En klassisk och krämig köttfärssås som passar perfekt till vardags. Serveras med valfri pasta och är en favorit hos många.",
|
||||
cuisine: "swedish",
|
||||
@@ -19529,7 +19529,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-paprika-och-linsgryta",
|
||||
titleSv: "Krämig Paprika- och Linsgryta",
|
||||
titleSv: "Matig Paprika- och Linsgryta",
|
||||
descriptionSv:
|
||||
"En värmande och mättande vegetarisk gryta med paprika och röda linser, perfekt för en enkel vardagsmiddag. Serveras gärna med ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -19981,7 +19981,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-blandfarssas-med-glutenfri-pasta",
|
||||
titleSv: "Krämig blandfärssås med glutenfri pasta",
|
||||
titleSv: "Fyllig blandfärssås med glutenfri pasta",
|
||||
descriptionSv:
|
||||
"En enkel och krämig pastarätt med smakrik blandfärssås som passar hela familjen. Serveras med glutenfri pasta för en helt glutenfri måltid.",
|
||||
cuisine: "swedish",
|
||||
@@ -20654,7 +20654,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-rakpasta-med-spenat-och-citron",
|
||||
titleSv: "Krämig räkpasta med spenat och citron",
|
||||
titleSv: "Frisk räkpasta med spenat och citron",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vardagsrätt med räkor, krämig sås, spenat och en frisk smak av citron. Perfekt när tiden är knapp!",
|
||||
cuisine: "swedish",
|
||||
@@ -21476,7 +21476,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-svart-bonchili-med-ris",
|
||||
titleSv: "Krämig svart bönchili med ris",
|
||||
titleSv: "Rökig svart bönchili med ris",
|
||||
descriptionSv:
|
||||
"En värmande och mättande vegansk chili med svarta bönor, serverad med fluffigt ris. Perfekt för kyliga dagar!",
|
||||
cuisine: "swedish",
|
||||
@@ -21979,7 +21979,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-glutenfri-rakpasta-med-spenat-och-citron",
|
||||
titleSv: "Krämig glutenfri räkpasta med spenat och citron",
|
||||
titleSv: "Frisk glutenfri räkpasta med spenat och citron",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vardagsrätt med räkor, krämig sås och fräsch spenat. Perfekt när du vill ha något gott som går snabbt att laga.",
|
||||
cuisine: "swedish",
|
||||
@@ -23725,7 +23725,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-fiskgratang-med-torsk-och-potatis",
|
||||
titleSv: "Krämig fiskgratäng med torsk och potatis",
|
||||
titleSv: "Gyllene fiskgratäng med torsk och potatis",
|
||||
descriptionSv:
|
||||
"En klassisk och omtyckt fiskgratäng med torsk, krämig sås och potatis. En perfekt vardagsrätt som uppskattas av hela familjen.",
|
||||
cuisine: "swedish",
|
||||
@@ -23990,7 +23990,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tomatpasta-med-spenat-och-fetaost",
|
||||
titleSv: "Krämig tomatpasta med spenat och fetaost",
|
||||
titleSv: "Solmogen tomatpasta med spenat och fetaost",
|
||||
descriptionSv:
|
||||
"En snabb och krämig pastarätt med solmogna tomater, färsk spenat och salt fetaost. Perfekt för en vardagskväll när tiden är knapp men smaklökarna kräver något gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -24351,7 +24351,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-tomat-och-kokosmjolk",
|
||||
titleSv: "Krämig Kycklinggryta med Tomat och Kokosmjölk",
|
||||
titleSv: "Solmogen Kycklinggryta med Tomat och Kokosmjölk",
|
||||
descriptionSv:
|
||||
"En smakrik och enkel kycklinggryta med krämig kokosmjölk och syrliga tomater. Perfekt att servera med ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -25070,7 +25070,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-zucchini-och-linsgryta-med-kokosmjolk",
|
||||
titleSv: "Krämig zucchini- och linsgryta med kokosmjölk",
|
||||
titleSv: "Fyllig zucchini- och linsgryta med kokosmjölk",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegansk gryta med röda linser, zucchini och krämig kokosmjölk, perfekt med ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -25225,7 +25225,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-zucchinipasta-med-basilika",
|
||||
titleSv: "Krämig Zucchinipasta med Basilika",
|
||||
titleSv: "Silkeslen Zucchinipasta med Basilika",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vegetarisk pastarätt där zucchini får spela huvudrollen i en krämig sås smaksatt med vitlök och färsk basilika. Perfekt för en vardagskväll!",
|
||||
cuisine: "swedish",
|
||||
@@ -25506,7 +25506,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-zucchinicurry-med-kikartor",
|
||||
titleSv: "Krämig Zucchinicurry med Kikärtor",
|
||||
titleSv: "Kryddig Zucchinicurry med Kikärtor",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik vegetarisk curry med zucchini, kikärtor och spenat i en krämig kokosmjölksås. Serveras med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -25675,7 +25675,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-zucchinigryta-med-ris",
|
||||
titleSv: "Krämig kyckling- och zucchinigryta med ris",
|
||||
titleSv: "Fyllig kyckling- och zucchinigryta med ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vardagsrätt där kyckling och zucchini får puttra i en krämig sås, serverad med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -26845,7 +26845,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-morots-och-linssoppa",
|
||||
titleSv: "Krämig Morots- och Linssoppa",
|
||||
titleSv: "Len Morots- och Linssoppa",
|
||||
descriptionSv:
|
||||
"En värmande och mättande soppa med söta morötter och proteinrika röda linser, perfekt för en enkel vardagsmiddag. Servera gärna med en bit gott bröd.",
|
||||
cuisine: "swedish",
|
||||
@@ -27289,7 +27289,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-spenatpasta",
|
||||
titleSv: "Krämig kyckling- och spenatpasta",
|
||||
titleSv: "Silkeslen kyckling- och spenatpasta",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vardagsrätt med krämig sås, kyckling och färsk spenat. Perfekt när tiden är knapp!",
|
||||
cuisine: "swedish",
|
||||
@@ -27415,7 +27415,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-linsgryta-med-morotter-och-kokosmjolk",
|
||||
titleSv: "Krämig Linsgryta med Morötter och Kokosmjölk",
|
||||
titleSv: "Söt och len Linsgryta med Morötter och Kokosmjölk",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik linsgryta med morötter och krämig kokosmjölk. Enkel att laga och perfekt för en näringsrik vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -27566,7 +27566,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-spenatpasta-med-hardost",
|
||||
titleSv: "Krämig spenatpasta med hårdost",
|
||||
titleSv: "Silkeslen spenatpasta med hårdost",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vegetarisk pastarätt med krämig spenatsås och riven hårdost. Perfekt för en vardag när tiden är knapp men du ändå vill äta gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -28431,7 +28431,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-falukorv-i-ugn-med-potatis",
|
||||
titleSv: "Krämig Falukorv i Ugn med Potatis",
|
||||
titleSv: "Gyllene Falukorv i Ugn med Potatis",
|
||||
descriptionSv:
|
||||
"En enkel och älskad vardagsrätt där falukorv och potatis samsas i en krämig sås i ugnen. Perfekt för en snabb middag som sköter sig själv!",
|
||||
cuisine: "swedish",
|
||||
@@ -28783,7 +28783,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-rakcurry-med-kokosmjolk-och-ris",
|
||||
titleSv: "Krämig räkcurry med kokosmjölk och ris",
|
||||
titleSv: "Mild räkcurry med kokosmjölk och ris",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik räkcurry med krämig kokosmjölk, fräscha grönsaker och en mild hetta från röd currypasta. En perfekt vardagsrätt som är enkel att laga och full av goda smaker.",
|
||||
cuisine: "international",
|
||||
@@ -29212,7 +29212,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklinggryta-med-curry",
|
||||
titleSv: "Krämig kycklinggryta med curry",
|
||||
titleSv: "Mild kycklinggryta med curry",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik kycklinggryta med krämig gräddsås och mild currysmak, perfekt till ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -29700,7 +29700,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-lax-med-dill-och-ugnsrostad-potatis",
|
||||
titleSv: "Krämig Lax med Dill och Ugnsrostad Potatis",
|
||||
titleSv: "Delikat Lax med Dill och Ugnsrostad Potatis",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vardagsrätt där ugnsbakad lax får sällskap av en krämig dillsås och gyllene ugnsrostad potatis. Perfekt när du vill ha något gott men inte lägga för mycket tid i köket.",
|
||||
cuisine: "swedish",
|
||||
@@ -29922,7 +29922,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-pasta-med-spenat-och-champinjoner",
|
||||
titleSv: "Krämig pasta med spenat och champinjoner",
|
||||
titleSv: "Fyllig pasta med spenat och champinjoner",
|
||||
descriptionSv:
|
||||
"En snabb och krämig vegetarisk pastarätt fylld med färsk spenat och champinjoner. Perfekt för en vardag när tiden är knapp men smaklökarna längtar efter något gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -30352,7 +30352,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-laxcurry-med-kokosmjolk-och-ris",
|
||||
titleSv: "Krämig laxcurry med kokosmjölk och ris",
|
||||
titleSv: "Aromatisk laxcurry med kokosmjölk och ris",
|
||||
descriptionSv:
|
||||
"En smakrik och värmande curry med lax, kokosmjölk och färska grönsaker, serverad med fluffigt ris. En snabb och enkel rätt som passar utmärkt till vardags.",
|
||||
cuisine: "swedish",
|
||||
@@ -30915,7 +30915,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-zucchinipasta",
|
||||
titleSv: "Krämig Zucchinipasta",
|
||||
titleSv: "Len Zucchinipasta",
|
||||
descriptionSv:
|
||||
"En snabb och enkel vegansk pastarätt med krämig sås baserad på havredryck och zucchini.",
|
||||
cuisine: "swedish",
|
||||
@@ -31040,7 +31040,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kycklingcurry-med-kokosmjolk",
|
||||
titleSv: "Krämig kycklingcurry med kokosmjölk",
|
||||
titleSv: "Doftande kycklingcurry med kokosmjölk",
|
||||
descriptionSv:
|
||||
"En smakrik och krämig kycklingcurry med kokosmjölk, grönsaker och ris. Perfekt för en enkel vardagsmiddag som är naturligt laktosfri.",
|
||||
cuisine: "swedish",
|
||||
@@ -31741,7 +31741,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-zucchinipasta-med-spenat",
|
||||
titleSv: "Krämig zucchinipasta med spenat",
|
||||
titleSv: "Len zucchinipasta med spenat",
|
||||
descriptionSv:
|
||||
"En snabb och krämig vegansk pasta med zucchini och spenat, perfekt för en enkel vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -32027,7 +32027,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-tofucurry-med-kokos-och-broccoli",
|
||||
titleSv: "Krämig Tofucurry med Kokos och Broccoli",
|
||||
titleSv: "Doftande Tofucurry med Kokos och Broccoli",
|
||||
descriptionSv:
|
||||
"En snabb och smakrik vegansk curry med tofu, kokosmjölk och krispig broccoli. Perfekt för en vardag när tiden är knapp men suget efter något gott är stort. Servera med fluffigt ris.",
|
||||
cuisine: "swedish",
|
||||
@@ -32176,7 +32176,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kottfarssas-med-potatis",
|
||||
titleSv: "Krämig köttfärssås med potatis",
|
||||
titleSv: "Mustig köttfärssås med potatis",
|
||||
descriptionSv:
|
||||
"En mättande och smakrik köttfärssås med krämig konsistens, perfekt att servera med kokt potatis för en glutenfri vardagsmiddag som hela familjen gillar.",
|
||||
cuisine: "swedish",
|
||||
@@ -32316,7 +32316,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-och-svampgryta-med-ris",
|
||||
titleSv: "Krämig Kyckling- och Svampgryta med Ris",
|
||||
titleSv: "Rustik Kyckling- och Svampgryta med Ris",
|
||||
descriptionSv:
|
||||
"En smakrik och krämig kycklinggryta med champinjoner, perfekt att servera med fluffigt ris för en mättande vardagsmiddag.",
|
||||
cuisine: "swedish",
|
||||
@@ -33040,7 +33040,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-svart-bongryta-med-ris",
|
||||
titleSv: "Krämig svart böngryta med ris",
|
||||
titleSv: "Matig svart böngryta med ris",
|
||||
descriptionSv:
|
||||
"En värmande och smakrik gryta med svarta bönor och krossade tomater, krämig av kokosmjölk och serverad med fluffigt ris. En perfekt och mättande vardagsrätt som är enkel att laga.",
|
||||
cuisine: "swedish",
|
||||
@@ -33323,7 +33323,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-morotssoppa-med-ingefara",
|
||||
titleSv: "Krämig Morotssoppa med Ingefära",
|
||||
titleSv: "Söt Morotssoppa med Ingefära",
|
||||
descriptionSv:
|
||||
"En värmande och len morotssoppa med en frisk ton av ingefära. Perfekt för en enkel vardagsmiddag som värmer gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -33435,7 +33435,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-flaskytterfile-med-svamp-och-potatis",
|
||||
titleSv: "Krämig fläskytterfilé med svamp och potatis",
|
||||
titleSv: "Saftig fläskytterfilé med svamp och potatis",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vardagsrätt med mör fläskytterfilé i en krämig svampsås, serverad med kokt potatis. Perfekt när tiden är knapp men du vill äta gott.",
|
||||
cuisine: "swedish",
|
||||
@@ -34170,7 +34170,7 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
{
|
||||
slug: "kramig-kyckling-med-jordnotssas-och-ris",
|
||||
titleSv: "Krämig Kyckling med Jordnötssås och Ris",
|
||||
titleSv: "Nötig Kyckling med Jordnötssås och Ris",
|
||||
descriptionSv:
|
||||
"En enkel och smakrik vardagsrätt med saftig kyckling i en krämig jordnötssås, serverad med fluffigt ris och krispiga grönsaker. Perfekt för en snabb middag som hela familjen gillar.",
|
||||
cuisine: "swedish",
|
||||
@@ -34836,4 +34836,497 @@ export const VERIFIED_RECIPES: SeedRecipe[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "gravad-lax",
|
||||
titleSv: "Gravad lax",
|
||||
descriptionSv:
|
||||
"Klassisk gravad lax med dill, socker och salt. Serveras tunt skivad på julbordet, till påsk, midsommar och nyår – gärna med hovmästarsås och bröd.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["starter", "buffet"],
|
||||
tags: ["high_protein", "luxury"],
|
||||
methods: ["no_cook"],
|
||||
equipment: [],
|
||||
difficulty: "easy",
|
||||
prepMin: 20,
|
||||
cookMin: 0,
|
||||
portions: 8,
|
||||
spiceLevel: 0,
|
||||
mealPrepFriendly: true,
|
||||
freezerFriendly: false,
|
||||
peakSeasons: ["winter", "summer"],
|
||||
holidayTags: ["jul", "påsk", "midsommar", "nyår"],
|
||||
variantType: "standard",
|
||||
dnaProtein: "salmon",
|
||||
dnaVegetables: [],
|
||||
dnaFlavor: ["salty", "fresh", "herby"],
|
||||
verificationStatus: "editorial",
|
||||
ingredients: [
|
||||
{
|
||||
ing: "salmon",
|
||||
nameSv: "Laxfilé (mittbit, med skinn)",
|
||||
qty: 800,
|
||||
unit: "GRAM",
|
||||
note: "sushikvalitet eller tidigare fryst i minst 3 dygn (−18 °C)",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "sugar",
|
||||
nameSv: "Strösocker",
|
||||
qty: 1,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "salt",
|
||||
nameSv: "Grovt salt",
|
||||
qty: 0.5,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "black_pepper",
|
||||
nameSv: "Krossad peppar",
|
||||
qty: 2,
|
||||
unit: "TABLESPOON",
|
||||
note: "helst vitpeppar",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "dill",
|
||||
nameSv: "Dill (stora knippen)",
|
||||
qty: 2,
|
||||
unit: "COUNT",
|
||||
note: "grovhackad",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
text: "Torka av laxen och dra ut eventuella ben med en pincett. Använd tidigare fryst eller sushiklassad lax – rå fisk måste ha varit fryst i minst 3 dygn för att vara säker att äta ogräddad.",
|
||||
tip: "Frys laxen själv i minst 72 timmar om du är osäker på ursprunget.",
|
||||
},
|
||||
{
|
||||
text: "Blanda socker, salt och krossad peppar. Gnid in blandningen ordentligt på laxens köttsida.",
|
||||
},
|
||||
{
|
||||
text: "Lägg hälften av dillen i en form, lägg laxen med skinnsidan nedåt och täck med resten av dillen.",
|
||||
},
|
||||
{
|
||||
text: "Täck med plastfolie, lägg en lätt tyngd ovanpå och ställ kallt i 48 timmar. Vänd laxen ett par gånger under tiden.",
|
||||
timerSeconds: 172800,
|
||||
},
|
||||
{
|
||||
text: "Skrapa bort kryddblandningen. Skiva laxen tunt snett från skinnet och servera med hovmästarsås, bröd eller kokt potatis.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "toast-skagen",
|
||||
titleSv: "Toast Skagen",
|
||||
descriptionSv:
|
||||
"Krämig räkröra med dill och citron på stekt bröd. En elegant förrätt som hör nyår och jul till.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["starter"],
|
||||
tags: ["luxury", "quick"],
|
||||
methods: ["stovetop"],
|
||||
equipment: ["stove"],
|
||||
difficulty: "easy",
|
||||
prepMin: 15,
|
||||
cookMin: 5,
|
||||
portions: 4,
|
||||
spiceLevel: 0,
|
||||
mealPrepFriendly: false,
|
||||
freezerFriendly: false,
|
||||
peakSeasons: ["winter"],
|
||||
holidayTags: ["nyår", "jul"],
|
||||
variantType: "standard",
|
||||
dnaProtein: "shrimp",
|
||||
dnaCarb: "bread_sourdough",
|
||||
dnaVegetables: [],
|
||||
dnaFlavor: ["creamy", "fresh", "herby"],
|
||||
verificationStatus: "editorial",
|
||||
ingredients: [
|
||||
{
|
||||
ing: "shrimp",
|
||||
nameSv: "Handskalade räkor",
|
||||
qty: 400,
|
||||
unit: "GRAM",
|
||||
note: "väl avrunna",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "mayonnaise",
|
||||
nameSv: "Majonnäs",
|
||||
qty: 1,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "creme_fraiche",
|
||||
nameSv: "Crème fraiche",
|
||||
qty: 1,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "dill",
|
||||
nameSv: "Dill, finhackad",
|
||||
qty: 2,
|
||||
unit: "TABLESPOON",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "lemon",
|
||||
nameSv: "Citron (saften)",
|
||||
qty: 0.5,
|
||||
unit: "COUNT",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "bread_sourdough",
|
||||
nameSv: "Bröd, till rostning",
|
||||
qty: 4,
|
||||
unit: "SLICE",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "butter",
|
||||
nameSv: "Smör, till stekning",
|
||||
qty: 25,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
text: "Blanda majonnäs, crème fraiche, hackad dill och citronsaft. Vänd försiktigt ner de väl avrunna räkorna. Smaka av med salt och peppar.",
|
||||
},
|
||||
{
|
||||
text: "Stek brödskivorna gyllene i smör i en stekpanna på medelvärme.",
|
||||
},
|
||||
{
|
||||
text: "Lägg upp brödet och toppa med Skagenröran. Garnera med en dillkvist och gärna en klick löjrom. Servera genast.",
|
||||
tip: "Låt räkröran vila kallt en stund innan servering så smakerna sätter sig.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "julskinka",
|
||||
titleSv: "Julskinka med senapstäcke",
|
||||
descriptionSv:
|
||||
"Färdigkokt julskinka penslad med senap, ägg och ströbröd och gratinerad gyllenbrun i ugn. Julbordets mittpunkt.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["dinner", "buffet"],
|
||||
tags: ["high_protein", "meal_prep"],
|
||||
methods: ["oven"],
|
||||
equipment: ["oven", "thermometer"],
|
||||
difficulty: "medium",
|
||||
prepMin: 10,
|
||||
cookMin: 15,
|
||||
portions: 10,
|
||||
spiceLevel: 0,
|
||||
mealPrepFriendly: true,
|
||||
freezerFriendly: true,
|
||||
peakSeasons: ["winter"],
|
||||
holidayTags: ["jul", "nyår"],
|
||||
variantType: "standard",
|
||||
dnaProtein: "cooked_ham",
|
||||
dnaVegetables: [],
|
||||
dnaFlavor: ["salty", "savory", "rich"],
|
||||
verificationStatus: "editorial",
|
||||
ingredients: [
|
||||
{
|
||||
ing: "cooked_ham",
|
||||
nameSv: "Färdigkokt julskinka",
|
||||
qty: 1.5,
|
||||
unit: "KILOGRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "mustard",
|
||||
nameSv: "Slät senap",
|
||||
qty: 3,
|
||||
unit: "TABLESPOON",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "egg",
|
||||
nameSv: "Ägg, vispat",
|
||||
qty: 1,
|
||||
unit: "COUNT",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "breadcrumbs",
|
||||
nameSv: "Ströbröd",
|
||||
qty: 1,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "sugar",
|
||||
nameSv: "Strösocker",
|
||||
qty: 1,
|
||||
unit: "TABLESPOON",
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
text: "Sätt ugnen på 225 °C. Torka av skinkan.",
|
||||
temperatureC: 225,
|
||||
},
|
||||
{
|
||||
text: "Bred senap (blandad med eventuellt socker) över skinkans ovansida. Pensla med vispat ägg och strö över ströbröd i ett jämnt lager.",
|
||||
tip: "Utgår du från en rå skinka: koka den först till 75 °C innertemperatur innan senapstäcket.",
|
||||
},
|
||||
{
|
||||
text: "Gratinera mitt i ugnen tills täcket är gyllenbrunt, cirka 10–15 minuter. Håll koll så det inte bränns.",
|
||||
timerSeconds: 900,
|
||||
temperatureC: 225,
|
||||
},
|
||||
{
|
||||
text: "Låt vila några minuter, skiva tunt och servera på julbordet.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "lussekatter",
|
||||
titleSv: "Lussekatter",
|
||||
descriptionSv:
|
||||
"Saftiga saffransbullar formade som S, med russin som ögon. Hör Lucia och advent till.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["dessert", "breakfast"],
|
||||
tags: ["vegetarian", "kid_friendly"],
|
||||
methods: ["oven"],
|
||||
equipment: ["oven"],
|
||||
difficulty: "medium",
|
||||
prepMin: 40,
|
||||
cookMin: 10,
|
||||
portions: 24,
|
||||
spiceLevel: 0,
|
||||
mealPrepFriendly: true,
|
||||
freezerFriendly: true,
|
||||
peakSeasons: ["winter"],
|
||||
holidayTags: ["lucia", "jul"],
|
||||
variantType: "standard",
|
||||
dnaCarb: "flour_wheat",
|
||||
dnaVegetables: [],
|
||||
dnaFlavor: ["sweet", "buttery", "saffron"],
|
||||
verificationStatus: "editorial",
|
||||
ingredients: [
|
||||
{
|
||||
ing: "milk_3",
|
||||
nameSv: "Mjölk",
|
||||
qty: 5,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "yeast_fresh",
|
||||
nameSv: "Färsk jäst",
|
||||
qty: 50,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "butter",
|
||||
nameSv: "Smör",
|
||||
qty: 150,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "saffron",
|
||||
nameSv: "Saffran",
|
||||
qty: 1,
|
||||
unit: "GRAM",
|
||||
note: "1–2 kuvert",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "sugar",
|
||||
nameSv: "Strösocker",
|
||||
qty: 1.5,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "salt",
|
||||
nameSv: "Salt",
|
||||
qty: 1,
|
||||
unit: "TEASPOON",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "egg",
|
||||
nameSv: "Ägg",
|
||||
qty: 1,
|
||||
unit: "COUNT",
|
||||
note: "+ 1 till pensling",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "flour_wheat",
|
||||
nameSv: "Vetemjöl",
|
||||
qty: 14,
|
||||
unit: "DECILITER",
|
||||
note: "cirka, tills degen släpper",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "quark",
|
||||
nameSv: "Kvarg",
|
||||
qty: 1,
|
||||
unit: "DECILITER",
|
||||
note: "ger saftigare bullar (valfritt)",
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
text: "Smält smöret, häll i mjölken och värm till 37 °C. Smula ner jästen och rör tills den lösts upp.",
|
||||
temperatureC: 37,
|
||||
},
|
||||
{
|
||||
text: "Tillsätt saffran, socker, salt, ägg (och kvarg) samt det mesta av mjölet. Arbeta degen smidig, cirka 10 minuter. Låt jäsa under duk cirka 40 minuter.",
|
||||
timerSeconds: 2400,
|
||||
},
|
||||
{
|
||||
text: "Dela degen och rulla längder som formas till S. Lägg på plåt med bakplåtspapper och tryck in russin i varje snurr. Jäs cirka 30 minuter.",
|
||||
timerSeconds: 1800,
|
||||
},
|
||||
{
|
||||
text: "Pensla med uppvispat ägg. Grädda mitt i ugnen på 225 °C i 8–10 minuter tills gyllene.",
|
||||
temperatureC: 225,
|
||||
timerSeconds: 600,
|
||||
},
|
||||
{
|
||||
text: "Låt svalna under duk så de håller sig saftiga.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "saffransbullar-med-mandelmassa",
|
||||
titleSv: "Saffransbullar med mandelmassa",
|
||||
descriptionSv:
|
||||
"Saftiga saffrans- och kardemummabullar fyllda med mandelmassa. Ett festligt bakverk till Lucia och advent.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["dessert", "breakfast"],
|
||||
tags: ["vegetarian", "kid_friendly"],
|
||||
methods: ["oven"],
|
||||
equipment: ["oven"],
|
||||
difficulty: "medium",
|
||||
prepMin: 45,
|
||||
cookMin: 12,
|
||||
portions: 20,
|
||||
spiceLevel: 0,
|
||||
mealPrepFriendly: true,
|
||||
freezerFriendly: true,
|
||||
peakSeasons: ["winter"],
|
||||
holidayTags: ["lucia", "jul"],
|
||||
variantType: "standard",
|
||||
dnaCarb: "flour_wheat",
|
||||
dnaVegetables: [],
|
||||
dnaFlavor: ["sweet", "saffron", "almond"],
|
||||
verificationStatus: "editorial",
|
||||
ingredients: [
|
||||
{
|
||||
ing: "milk_3",
|
||||
nameSv: "Mjölk",
|
||||
qty: 5,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "yeast_fresh",
|
||||
nameSv: "Färsk jäst",
|
||||
qty: 50,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "butter",
|
||||
nameSv: "Smör",
|
||||
qty: 150,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "saffron",
|
||||
nameSv: "Saffran",
|
||||
qty: 1,
|
||||
unit: "GRAM",
|
||||
note: "1–2 kuvert",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "cardamom",
|
||||
nameSv: "Malen kardemumma",
|
||||
qty: 1,
|
||||
unit: "TEASPOON",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "sugar",
|
||||
nameSv: "Strösocker",
|
||||
qty: 1.5,
|
||||
unit: "DECILITER",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "salt",
|
||||
nameSv: "Salt",
|
||||
qty: 1,
|
||||
unit: "TEASPOON",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "egg",
|
||||
nameSv: "Ägg",
|
||||
qty: 1,
|
||||
unit: "COUNT",
|
||||
note: "+ 1 till pensling",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "flour_wheat",
|
||||
nameSv: "Vetemjöl",
|
||||
qty: 14,
|
||||
unit: "DECILITER",
|
||||
note: "cirka, tills degen släpper",
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
ing: "almond_paste",
|
||||
nameSv: "Mandelmassa",
|
||||
qty: 200,
|
||||
unit: "GRAM",
|
||||
note: "grovriven, till fyllning",
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
text: "Smält smöret, häll i mjölken och värm till 37 °C. Smula ner jästen och rör tills den lösts upp.",
|
||||
temperatureC: 37,
|
||||
},
|
||||
{
|
||||
text: "Tillsätt saffran, kardemumma, socker, salt, ägg och det mesta av mjölet. Arbeta degen smidig, cirka 10 minuter. Låt jäsa under duk cirka 40 minuter.",
|
||||
timerSeconds: 2400,
|
||||
},
|
||||
{
|
||||
text: "Kavla ut degen, strö över grovriven mandelmassa, rulla ihop och skär i bitar. Lägg på plåt med bakplåtspapper och jäs cirka 30 minuter.",
|
||||
timerSeconds: 1800,
|
||||
},
|
||||
{
|
||||
text: "Pensla med uppvispat ägg. Grädda mitt i ugnen på 225 °C i 10–12 minuter tills gyllene.",
|
||||
temperatureC: 225,
|
||||
timerSeconds: 720,
|
||||
},
|
||||
{
|
||||
text: "Låt svalna under duk.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -79,15 +79,11 @@ export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): Ex
|
||||
factor: "opened_guidance",
|
||||
date: new Date(opened.getTime() + guidanceDays * DAY_MS),
|
||||
});
|
||||
} else if (!useBy && !bestBefore && guidanceDays != null) {
|
||||
const purchased = parse(input.purchasedAt);
|
||||
if (purchased) {
|
||||
candidates.push({
|
||||
factor: "purchased_guidance",
|
||||
date: new Date(purchased.getTime() + guidanceDays * DAY_MS),
|
||||
});
|
||||
}
|
||||
}
|
||||
// shelfLifeGuidance = hållbarhet EFTER öppning (entities.ts). Vi gissar ALDRIG
|
||||
// utgång från inköpsdatum på en oöppnad vara — utan riktigt tryckt datum blir
|
||||
// status "unknown" (spec: "ingen data → unknown, aldrig gissning"). Den gamla
|
||||
// purchased_guidance-grenen gav falska "går ut snart"-varningar på förseglade varor.
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
|
||||
|
||||
@@ -54,6 +54,16 @@ describe("bäst före-klassning (spec §13)", () => {
|
||||
it("ingen data → unknown, aldrig gissning", () => {
|
||||
expect(classifyExpiry({}, TODAY).status).toBe("unknown");
|
||||
});
|
||||
it("förseglad vara: inköpsdatum + riktvärde men INGET tryckt datum → unknown", () => {
|
||||
// Regressionsvakt: gamla purchased_guidance-grenen gissade utgång från inköp
|
||||
// på oöppnade varor → falska "går ut snart"-varningar (Bug B).
|
||||
const result = classifyExpiry(
|
||||
{ purchasedAt: "2026-08-01", storageLocationType: "fridge", shelfLifeGuidance: { fridge: 7 } },
|
||||
TODAY,
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.limitingFactor).toBe("none");
|
||||
});
|
||||
it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => {
|
||||
// Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte
|
||||
// automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig
|
||||
|
||||
@@ -108,6 +108,12 @@ export function scoreCandidate(
|
||||
parts.cookingAssumptionFit = 0;
|
||||
}
|
||||
|
||||
// 16. Måltids-lämplighet: nedvikta lätta övergångsrätter (frukost/dessert) vid huvudmåltid.
|
||||
const mainMeal = ctx.mealType === "dinner" || ctx.mealType === "lunch";
|
||||
const crossover =
|
||||
(candidate.mealTypes ?? []).includes("breakfast") || (candidate.mealTypes ?? []).includes("dessert");
|
||||
parts.mealFit = mainMeal && crossover ? 0.15 : 1;
|
||||
|
||||
const score = weightedSum(parts, weights);
|
||||
|
||||
return {
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface RecommendationCandidate {
|
||||
ratingCount: number;
|
||||
peakSeasons: Season[];
|
||||
holidayTags: string[];
|
||||
mealTypes?: MealType[];
|
||||
spiceLevel: number;
|
||||
coverage: CoverageResult;
|
||||
/** Dagar sedan hushållet senast lagade receptet (null = aldrig). */
|
||||
@@ -104,6 +105,7 @@ export interface ScoringWeights {
|
||||
variety: number;
|
||||
weather: number;
|
||||
craving: number;
|
||||
mealFit: number;
|
||||
/** Nya S1-vikter; 0 om personalisering är av. */
|
||||
memoryFit: number;
|
||||
tasteFit: number;
|
||||
@@ -123,6 +125,7 @@ export const DEFAULT_WEIGHTS: ScoringWeights = {
|
||||
variety: 6,
|
||||
weather: 3,
|
||||
craving: 15,
|
||||
mealFit: 12,
|
||||
memoryFit: 6,
|
||||
tasteFit: 6,
|
||||
cookingAssumptionFit: 4,
|
||||
@@ -155,6 +158,7 @@ export const TASTE_VIEW_WEIGHTS: ScoringWeights = {
|
||||
variety: 3,
|
||||
weather: 1,
|
||||
craving: 25,
|
||||
mealFit: 12,
|
||||
memoryFit: 12,
|
||||
tasteFit: 12,
|
||||
cookingAssumptionFit: 6,
|
||||
@@ -174,6 +178,7 @@ export const HEALTH_VIEW_WEIGHTS: ScoringWeights = {
|
||||
variety: 4,
|
||||
weather: 2,
|
||||
craving: 5,
|
||||
mealFit: 12,
|
||||
memoryFit: 3,
|
||||
tasteFit: 3,
|
||||
cookingAssumptionFit: 4,
|
||||
@@ -193,6 +198,7 @@ export const PANTRY_VIEW_WEIGHTS: ScoringWeights = {
|
||||
variety: 2,
|
||||
weather: 1,
|
||||
craving: 5,
|
||||
mealFit: 12,
|
||||
memoryFit: 4,
|
||||
tasteFit: 4,
|
||||
cookingAssumptionFit: 8,
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface PlanDefinition {
|
||||
googleProductId: string;
|
||||
}
|
||||
|
||||
export const TRIAL_DAYS = 7;
|
||||
export const TRIAL_DAYS = 14;
|
||||
export const TRIAL_SCANS = 25;
|
||||
|
||||
export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
free: {
|
||||
@@ -33,7 +34,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 0,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 1,
|
||||
aiScansPerMonth: 10,
|
||||
aiScansPerMonth: 5,
|
||||
weekPlanning: false,
|
||||
advancedNutrition: false,
|
||||
communityPublish: false,
|
||||
@@ -46,7 +47,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 7900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 3,
|
||||
aiScansPerMonth: 300,
|
||||
aiScansPerMonth: 100,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
@@ -59,7 +60,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 12900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 6,
|
||||
aiScansPerMonth: 600,
|
||||
aiScansPerMonth: 200,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
@@ -72,7 +73,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
|
||||
priceMinorPerMonth: 16900,
|
||||
currency: "SEK",
|
||||
maxHouseholdMembers: 12,
|
||||
aiScansPerMonth: 1000,
|
||||
aiScansPerMonth: 400,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
communityPublish: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
PLAN_DEFINITIONS,
|
||||
TRIAL_DAYS,
|
||||
TRIAL_SCANS,
|
||||
type Entitlements,
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionStatus,
|
||||
@@ -60,14 +61,14 @@ export function computeEntitlements(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Trial: 7 dagar full tillgång utan kort (spec §46). Family-nivå under trial.
|
||||
// 2. Trial: 14 dagar full tillgång utan kort (spec §46). Family-nivå under trial.
|
||||
if (trial && trial.endsAt > now) {
|
||||
const def = PLAN_DEFINITIONS.family;
|
||||
return {
|
||||
plan: "family",
|
||||
status: "trial",
|
||||
maxHouseholdMembers: def.maxHouseholdMembers,
|
||||
aiScansPerMonth: def.aiScansPerMonth,
|
||||
aiScansPerMonth: TRIAL_SCANS,
|
||||
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
|
||||
weekPlanning: true,
|
||||
advancedNutrition: true,
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("entitlements (spec §45–47)", () => {
|
||||
);
|
||||
expect(ent.plan).toBe("free");
|
||||
});
|
||||
it("trial ger full tillgång i 7 dagar (spec §46)", () => {
|
||||
it("trial ger full tillgång i 14 dagar (spec §46)", () => {
|
||||
const start = new Date("2026-08-01T00:00:00Z");
|
||||
const ent = computeEntitlements(
|
||||
null,
|
||||
@@ -59,12 +59,12 @@ describe("entitlements (spec §45–47)", () => {
|
||||
expect(ent.status).toBe("trial");
|
||||
expect(ent.weekPlanning).toBe(true);
|
||||
});
|
||||
it("free tier: 10 skanningar och användbar grund (spec §46)", () => {
|
||||
const ent = computeEntitlements(null, null, { aiScansUsedThisMonth: 9 }, NOW);
|
||||
it("free tier: 5 skanningar och användbar grund (spec §46)", () => {
|
||||
const ent = computeEntitlements(null, null, { aiScansUsedThisMonth: 4 }, NOW);
|
||||
expect(ent.plan).toBe("free");
|
||||
expect(ent.aiScansPerMonth).toBe(10);
|
||||
expect(ent.aiScansPerMonth).toBe(5);
|
||||
expect(canUseAiScan(ent)).toBe(true);
|
||||
expect(canUseAiScan({ ...ent, aiScansUsedThisMonth: 10 })).toBe(false);
|
||||
expect(canUseAiScan({ ...ent, aiScansUsedThisMonth: 5 })).toBe(false);
|
||||
});
|
||||
it("mappar productId → plan (härlett ur brand-konfig, aldrig hårdkodat)", () => {
|
||||
expect(resolvePlanFromProductId(PLAN_DEFINITIONS.family.appleProductId)).toBe("family");
|
||||
|
||||
@@ -7,6 +7,7 @@ export const generateWeekPlanInputSchema = z.object({
|
||||
weekStartDate: dateStringSchema,
|
||||
daysToPlann: z.number().int().min(1).max(14).optional(),
|
||||
mealTypes: z.array(z.enum(MEAL_TYPES)).min(1).default(["dinner"]),
|
||||
mealSlots: z.array(z.array(z.enum(MEAL_TYPES))).optional(),
|
||||
portionsPerMeal: z.number().int().min(1).max(20).optional(),
|
||||
budgetMinorTotal: z.number().int().min(0).max(5_000_000).optional(),
|
||||
/** T.ex. "Sju middagar för fyra personer under 1 000 kr" (spec §26). */
|
||||
|
||||
Generated
+1722
-9
@@ -28,7 +28,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||
|
||||
apps/admin:
|
||||
dependencies:
|
||||
@@ -125,13 +125,16 @@ importers:
|
||||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.0
|
||||
version: 0.45.2(@types/pg@8.20.3)(pg@8.22.0)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0)
|
||||
fastify:
|
||||
specifier: ^5.6.0
|
||||
version: 5.11.0
|
||||
fastify-plugin:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.0
|
||||
firebase-admin:
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.0(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -220,6 +223,9 @@ importers:
|
||||
expo-system-ui:
|
||||
specifier: ~6.0.9
|
||||
version: 6.0.9(expo@54.0.36)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0))
|
||||
firebase:
|
||||
specifier: ^12.17.1
|
||||
version: 12.17.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))
|
||||
i18next:
|
||||
specifier: ^26.3.6
|
||||
version: 26.3.6(typescript@5.9.3)
|
||||
@@ -313,7 +319,7 @@ importers:
|
||||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.0
|
||||
version: 0.45.2(@types/pg@8.20.3)(pg@8.22.0)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0)
|
||||
ioredis:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -368,7 +374,7 @@ importers:
|
||||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.0
|
||||
version: 0.45.2(@types/pg@8.20.3)(pg@8.22.0)
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0)
|
||||
pg:
|
||||
specifier: ^8.13.0
|
||||
version: 8.22.0
|
||||
@@ -446,7 +452,7 @@ importers:
|
||||
version: 16.6.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0)
|
||||
version: 0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0)
|
||||
zod:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.3
|
||||
@@ -459,7 +465,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||
version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||
|
||||
packages/recommendation-engine:
|
||||
dependencies:
|
||||
@@ -1910,6 +1916,9 @@ packages:
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/busboy@3.2.1':
|
||||
resolution: {integrity: sha512-tgK4O+57iz5ycYNGXE5ZWj1ES03lD2XnnBYWSbU/3wYZRMQzCUq7Ycds/RdyBZQeL5MU4fxBt6lzbIWf/Bickw==}
|
||||
|
||||
'@fastify/cors@11.3.0':
|
||||
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
||||
|
||||
@@ -1934,9 +1943,279 @@ packages:
|
||||
'@fastify/rate-limit@10.3.0':
|
||||
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
||||
|
||||
'@firebase/ai@2.14.0':
|
||||
resolution: {integrity: sha512-TYEQqCQUTyVHuG/HVi9vau6F9kvEaS49o/hmdn/yUuN6ZXQkwIml2nNJTIBfjNl/r9LOxwUNILgcOY16nxObug==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-types': 0.x
|
||||
|
||||
'@firebase/analytics-compat@0.2.29':
|
||||
resolution: {integrity: sha512-allztvCvCUlItZzD97TiRAtGoFJzR1FQFmLxbaLc6PvgscqD9cl5NdKPTtka6keShVYXvCZJpzWcRoH4TME8rw==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/analytics-types@0.8.4':
|
||||
resolution: {integrity: sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==}
|
||||
|
||||
'@firebase/analytics@0.10.23':
|
||||
resolution: {integrity: sha512-34ALWXzWA6PTRUA5hipZmsm1RKzeecw5J1+qTCXsiMzwLqONC+GuTIQSdmm91MmTAEA+wG1Q5t0IFahcYQOqAA==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/app-check-compat@0.4.6':
|
||||
resolution: {integrity: sha512-2pzNEZEkX84jSqy6TH6FI1HSLA1lc7kakRUybBbKjg9YhIttPlW/XX3N9CDtChji2PTTPWVPZiWhB10exHfA+A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/app-check-interop-types@0.3.4':
|
||||
resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==}
|
||||
|
||||
'@firebase/app-check-types@0.5.4':
|
||||
resolution: {integrity: sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==}
|
||||
|
||||
'@firebase/app-check@0.13.0':
|
||||
resolution: {integrity: sha512-AbMttBKazQvGVXBZhQdVAdPzRhwHyJAY3Ghu5y2C7IZKIDIppzNYz0shTZ1mP4FBJa+28BuC4t+5h1Q6pT3Asg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/app-compat@0.5.16':
|
||||
resolution: {integrity: sha512-shQq37O8qELDzvsVwYPlDXwD1zlcrZ0m2bpBF5ov2HSbY8x+AHsnL5TtJ2e1JAfkQN05qHao1AfabS69PN6GiA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/app-types@0.9.5':
|
||||
resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==}
|
||||
|
||||
'@firebase/app@0.16.0':
|
||||
resolution: {integrity: sha512-G+ZGEyVP8YTb3ay6A+XpcYgFH3sTESHcnHU/EyTktodqhz2BHkLq+QEP7IVwjiMX0cxYwpVKip0/wC0KZcn9vQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/auth-compat@0.6.9':
|
||||
resolution: {integrity: sha512-/hHeTBmQ61+N5J1RECls+WfskZTY78JXr7aO5EMOfUpqJvDqvoS+568k0rp6Ss/4UWwBjadILs+H+SGy1zCS3A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/auth-interop-types@0.2.5':
|
||||
resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==}
|
||||
|
||||
'@firebase/auth-types@0.13.1':
|
||||
resolution: {integrity: sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==}
|
||||
peerDependencies:
|
||||
'@firebase/app-types': 0.x
|
||||
'@firebase/util': 1.x
|
||||
|
||||
'@firebase/auth@1.13.4':
|
||||
resolution: {integrity: sha512-s+NS1aV0DDyyfoIMeSz53HXnVTv7ufJjJfrP63XyaWHweJ5vOoxKWrTm5tO7S7PDqvyOa/Wi3oP0dgAo6JTMMA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@react-native-async-storage/async-storage': ^2.2.0 || ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@react-native-async-storage/async-storage':
|
||||
optional: true
|
||||
|
||||
'@firebase/component@0.7.4':
|
||||
resolution: {integrity: sha512-tLpOaaCol9ugUIYp2R3CbWPPA8Ajg/papX/XHEy8U52b/QXH3BbX8tTJX9aShDCjp+9sMAxMLD94i7lresdugQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/data-connect@0.7.3':
|
||||
resolution: {integrity: sha512-nHBFk3Ntl+NZCRIUG2d5j7I69P0otjyQ/duhVKLbw4+5cNke/F6RK1pdE5Jnf831/QOTs2Bd00LlxlZ+jNsb9w==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/database-compat@2.1.6':
|
||||
resolution: {integrity: sha512-mu7S/75UIajB1A5M9Vfojk69LttW55uABp9nHEtWrV/mIaSEwvoaIe9GySsEzS2EKFK5/3f5okcAuUbihhYeJg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
peerDependenciesMeta:
|
||||
'@firebase/app':
|
||||
optional: true
|
||||
'@firebase/app-compat':
|
||||
optional: true
|
||||
|
||||
'@firebase/database-types@1.0.21':
|
||||
resolution: {integrity: sha512-SX1jUqhttKgg/m9dYRTvqU9QvucBooziWfA986r4cpsbi4zlsvewe424j3Vpduwd6DG1MSAMfBVT2VqA61FnkA==}
|
||||
|
||||
'@firebase/database@1.1.4':
|
||||
resolution: {integrity: sha512-D+j4+8uhGtNd1tVD+X+c8JrC4ppStGJKyujSQt2NPwdN26QcCk0BeIxue+UqspHkHiFHyQOimwlzjLewGq6S+A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/firestore-compat@0.4.12':
|
||||
resolution: {integrity: sha512-k2uX81Ao/S0jnFcWGPOQpKK1cPlJHvD9WIqh/RE1XBDP2yg5zhE4rHhSg1rtB11k39q3nKon9XLNDDrPjGclag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/firestore-types@3.0.4':
|
||||
resolution: {integrity: sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==}
|
||||
peerDependencies:
|
||||
'@firebase/app-types': 0.x
|
||||
'@firebase/util': 1.x
|
||||
|
||||
'@firebase/firestore@4.17.0':
|
||||
resolution: {integrity: sha512-P9tof6pyO1bnLlMWbux+5O7WFJqlb7OTPMKxxOiXKYiQl7mxykAvxr1BFCgWeEXUU7DZxQncyJ040B0IhFVZCg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/functions-compat@0.4.6':
|
||||
resolution: {integrity: sha512-dj9sOet+FIU91jeU4A3vGJoXHty7NqkSfjRLCwLgJXPDk1m72KFuxD3nlFgw/yXx/Fr7UjqzbxZ0LrIOdpx7+w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/functions-types@0.6.4':
|
||||
resolution: {integrity: sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==}
|
||||
|
||||
'@firebase/functions@0.13.6':
|
||||
resolution: {integrity: sha512-9obLnzeQUivK5lmtGFOU2ucQ38BjTp+jpPtbfFp/mDsdVCvEpRqdWNvMMQ6aQwR4vcVc/utsvngm5BRkXbc7ZA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/installations-compat@0.2.23':
|
||||
resolution: {integrity: sha512-isaXmjb9roM83eVeXAe+ZRNKYNsSo2s0aNM+cy04AAGEyVL/d8Aa11GwEXovRFeYjl9+1yRAOxRDTOukZRwTxA==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/installations-types@0.5.4':
|
||||
resolution: {integrity: sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==}
|
||||
peerDependencies:
|
||||
'@firebase/app-types': 0.x
|
||||
|
||||
'@firebase/installations@0.6.23':
|
||||
resolution: {integrity: sha512-MBkbcQfd+3qHjW+slsH4s7jH5qTdGlYpwqmxEZ7QcIpgDxu1SKyU0f+mCZhCt1BCacLNiOWF5L0R06N0LtlfMg==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/logger@0.5.1':
|
||||
resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/messaging-compat@0.2.28':
|
||||
resolution: {integrity: sha512-/AmMqHRnSQhPsdeED3ocs+s30/tpFvZDiiwIYY2uXFRvLujo1fnbPOeCFoe4Y+dRy1LCSjpvJf+dy5ZTsxi1yg==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/messaging-interop-types@0.2.5':
|
||||
resolution: {integrity: sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==}
|
||||
|
||||
'@firebase/messaging@0.13.1':
|
||||
resolution: {integrity: sha512-kL8fdjbNBI7hprlXJrUjktDWosrpT4JtfwXtVVevImPF/rBRAsC+LS/jIs+kgQVuotnvMhaBCgAFipBoY9YU9g==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/performance-compat@0.2.26':
|
||||
resolution: {integrity: sha512-jgoocXLN6ao26xWQ8pzosmzQ33uLzGBJQPNK0NTbVy1XvIHr5pfgBf9hWLOxsWe+R7sJq5bjD+8ybXprmt61mA==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/performance-types@0.2.4':
|
||||
resolution: {integrity: sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==}
|
||||
|
||||
'@firebase/performance@0.7.13':
|
||||
resolution: {integrity: sha512-1u6fuXP9cj0s+lkTFAspr/ttfPebPbEdpx+5Wdr4mPZbp8qH2KCMxOddEAR1ZMRa5GI0E7hDYSnolEmbqOFOAg==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/remote-config-compat@0.2.28':
|
||||
resolution: {integrity: sha512-kEO9Gn6fbmVj7eNUtZ6d59mLgUDUD0qo7aCicGOWNfuRWTaUv3CF9DMYychO61zaEQ3cfA+CEny4V1E8A1gRGA==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/remote-config-types@0.5.1':
|
||||
resolution: {integrity: sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==}
|
||||
|
||||
'@firebase/remote-config@0.9.1':
|
||||
resolution: {integrity: sha512-nzQUSJnk1zAZEl2Q5O3I7Z61cYLK5JI4H6wyyOiHkVZ+bmgy1YXNNMptNbVjixMQ/eCzgA6nZRaC+1eBcJGUFA==}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/storage-compat@0.4.4':
|
||||
resolution: {integrity: sha512-qSRgCB9f2R/nCp8t/8OC101cIFBFeUazlRInOMdzbnLzvrQBzEfx19SrR4pvdj/0+M+P/y8AK/a2s+3EB+B1Pw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
'@firebase/app-compat': 0.x
|
||||
|
||||
'@firebase/storage-types@0.8.4':
|
||||
resolution: {integrity: sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==}
|
||||
peerDependencies:
|
||||
'@firebase/app-types': 0.x
|
||||
'@firebase/util': 1.x
|
||||
|
||||
'@firebase/storage@0.14.4':
|
||||
resolution: {integrity: sha512-jfzEWZb3Fpsq3FwAB2ifoc8mcSh935qXdDou3TpyjDWa45hhNcZUv8/w28/10njByhfK7snbakKN30nwnzQ3/w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@firebase/app': 0.x
|
||||
|
||||
'@firebase/util@1.15.2':
|
||||
resolution: {integrity: sha512-974pWIZVLDMc5GW5YAsj8y0XxULxIy/sPUy7tsxmWbF93KRIyh9xpuHlh0zDL+shUcf5nHDjFOg9YLiQ763eiA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@firebase/webchannel-wrapper@1.0.6':
|
||||
resolution: {integrity: sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==}
|
||||
|
||||
'@google-cloud/firestore@8.7.1':
|
||||
resolution: {integrity: sha512-Hp/WI8sH569ANitsko6RNvCUkzTCYudHoINOkQjiJq2FBG6kKnofBAW7PtS823cu6RMxHqK2RxRcspdjrRpUFA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@google-cloud/paginator@5.0.2':
|
||||
resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@google-cloud/projectify@4.0.0':
|
||||
resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@google-cloud/promisify@4.0.0':
|
||||
resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@google-cloud/storage@7.22.0':
|
||||
resolution: {integrity: sha512-W98gTQOAntEeEQ7/pZxSQXxfUO5CiQcXJRsxBpUa6UU25n8YN/VtogPBi0H+MAmUrn/6bY/sBhFansoWEsr2/g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@grpc/grpc-js@1.14.4':
|
||||
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
|
||||
engines: {node: '>=12.10.0'}
|
||||
|
||||
'@grpc/grpc-js@1.9.16':
|
||||
resolution: {integrity: sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
|
||||
'@grpc/proto-loader@0.7.15':
|
||||
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@grpc/proto-loader@0.8.1':
|
||||
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@ioredis/commands@2.0.0':
|
||||
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -1996,6 +2275,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2039,9 +2321,47 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@nodable/entities@3.0.0':
|
||||
resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1':
|
||||
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.2':
|
||||
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
||||
|
||||
'@radix-ui/primitive@1.1.7':
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
|
||||
@@ -2621,6 +2941,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tootallnate/once@2.0.1':
|
||||
resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@turbo/darwin-64@2.10.8':
|
||||
resolution: {integrity: sha512-po+7rfJfUnFXjWlcoN2RwhErgzCdRtBc1T26vYPcywHlggmCQiQe1uWaE4j+BibI2uY9/2pDoFzMN0rmSaPFOw==}
|
||||
cpu: [x64]
|
||||
@@ -2666,6 +2990,9 @@ packages:
|
||||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
'@types/caseless@0.12.5':
|
||||
resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@@ -2693,9 +3020,15 @@ packages:
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.20.1':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
@@ -2716,12 +3049,18 @@ packages:
|
||||
'@types/react@19.2.18':
|
||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||
|
||||
'@types/request@2.48.13':
|
||||
resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
|
||||
|
||||
'@types/responselike@1.0.3':
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
|
||||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
@@ -2802,6 +3141,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -2832,6 +3175,10 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.3.0:
|
||||
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@3.2.1:
|
||||
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2844,6 +3191,10 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -2851,6 +3202,9 @@ packages:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
anynum@1.0.1:
|
||||
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
@@ -2864,6 +3218,10 @@ packages:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
arrify@2.0.1:
|
||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
@@ -2877,6 +3235,9 @@ packages:
|
||||
async-limiter@1.0.1:
|
||||
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
|
||||
|
||||
async-retry@1.3.3:
|
||||
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -2981,6 +3342,9 @@ packages:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
bn.js@4.12.5:
|
||||
resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==}
|
||||
|
||||
@@ -3020,6 +3384,9 @@ packages:
|
||||
bser@2.1.1:
|
||||
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3245,6 +3612,10 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
@@ -3525,6 +3896,12 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplexify@4.1.3:
|
||||
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
@@ -3537,6 +3914,9 @@ packages:
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
encodeurl@1.0.2:
|
||||
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3808,6 +4188,9 @@ packages:
|
||||
exponential-backoff@3.1.3:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
@@ -3833,6 +4216,13 @@ packages:
|
||||
fast-uri@4.1.2:
|
||||
resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==}
|
||||
|
||||
fast-xml-builder@1.3.1:
|
||||
resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
|
||||
|
||||
fast-xml-parser@5.10.1:
|
||||
resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==}
|
||||
hasBin: true
|
||||
|
||||
fastfall@1.5.1:
|
||||
resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3855,6 +4245,10 @@ packages:
|
||||
fastseries@1.7.2:
|
||||
resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==}
|
||||
|
||||
faye-websocket@0.11.4:
|
||||
resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
||||
|
||||
@@ -3873,6 +4267,10 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3893,6 +4291,13 @@ packages:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
firebase-admin@14.2.0:
|
||||
resolution: {integrity: sha512-zfs5PdccEgjX479bbMmz95Rqc7ttQ4LV3qkGFlPiqOaOu/suBZc3fG52Qzn2hQjiSHkBM4NP2AZV5r2NvAt0TQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
firebase@12.17.1:
|
||||
resolution: {integrity: sha512-dhp41ye9jMQvhx5FwjMkf/hjDHJApl7gXmvzOZGvP0M7c/GZGUnQ4qvsvlOBkF0Pa7wAwHMdcpL0ON2pXCQ4Sw==}
|
||||
|
||||
fix-dts-default-cjs-exports@1.0.1:
|
||||
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
|
||||
|
||||
@@ -3902,10 +4307,22 @@ packages:
|
||||
fontfaceobserver@2.3.0:
|
||||
resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@2.5.6:
|
||||
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
|
||||
engines: {node: '>= 0.12'}
|
||||
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
freeport-async@2.0.0:
|
||||
resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3925,6 +4342,33 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
functional-red-black-tree@1.0.1:
|
||||
resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
|
||||
|
||||
gaxios@6.7.1:
|
||||
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
gaxios@7.1.3:
|
||||
resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gaxios@7.3.1:
|
||||
resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gcp-metadata@6.1.1:
|
||||
resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
gcp-metadata@8.1.2:
|
||||
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gcp-metadata@8.1.4:
|
||||
resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3960,6 +4404,11 @@ packages:
|
||||
resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.6:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -3968,6 +4417,30 @@ packages:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
google-auth-library@10.5.0:
|
||||
resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-auth-library@10.9.1:
|
||||
resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-auth-library@9.15.1:
|
||||
resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
google-gax@5.0.8:
|
||||
resolution: {integrity: sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-logging-utils@0.0.2:
|
||||
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
google-logging-utils@1.1.3:
|
||||
resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3979,6 +4452,14 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
gtoken@7.1.0:
|
||||
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
gtoken@8.0.0:
|
||||
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
has-async-hooks@1.0.0:
|
||||
resolution: {integrity: sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw==}
|
||||
|
||||
@@ -4040,6 +4521,9 @@ packages:
|
||||
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
||||
html-entities@2.6.0:
|
||||
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -4050,10 +4534,22 @@ packages:
|
||||
http-parser-js@0.5.10:
|
||||
resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
http2-wrapper@1.0.3:
|
||||
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
|
||||
engines: {node: '>=10.19.0'}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -4072,6 +4568,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
@@ -4136,6 +4635,13 @@ packages:
|
||||
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-unsafe@2.0.0:
|
||||
resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4151,6 +4657,9 @@ packages:
|
||||
resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jest-environment-node@29.7.0:
|
||||
resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -4190,6 +4699,9 @@ packages:
|
||||
jimp-compact@0.16.1:
|
||||
resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==}
|
||||
|
||||
jose@6.2.9:
|
||||
resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4213,6 +4725,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@@ -4227,6 +4742,20 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jwks-rsa@4.1.0:
|
||||
resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -4322,6 +4851,9 @@ packages:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
limiter@1.1.5:
|
||||
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -4333,6 +4865,9 @@ packages:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash.camelcase@4.3.0:
|
||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||
|
||||
lodash.chunk@4.2.0:
|
||||
resolution: {integrity: sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==}
|
||||
|
||||
@@ -4345,6 +4880,27 @@ packages:
|
||||
lodash.flatten@4.4.0:
|
||||
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
@@ -4352,6 +4908,9 @@ packages:
|
||||
resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
@@ -4370,6 +4929,9 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lru-memoizer@3.0.0:
|
||||
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4544,6 +5106,11 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@1.2.0:
|
||||
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4631,6 +5198,11 @@ packages:
|
||||
node-abort-controller@3.1.1:
|
||||
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -4640,6 +5212,10 @@ packages:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-forge@1.4.0:
|
||||
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
@@ -4686,6 +5262,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
@@ -4752,6 +5332,9 @@ packages:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
@@ -4767,6 +5350,10 @@ packages:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-expression-matcher@1.6.2:
|
||||
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4778,6 +5365,10 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
path-scurry@2.0.2:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -4937,6 +5528,14 @@ packages:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
proto3-json-serializer@3.0.4:
|
||||
resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
@@ -4970,6 +5569,10 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
re2js@2.8.6:
|
||||
resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
react-devtools-core@6.1.5:
|
||||
resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
|
||||
|
||||
@@ -5119,6 +5722,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -5209,6 +5816,18 @@ packages:
|
||||
retimer@3.0.0:
|
||||
resolution: {integrity: sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==}
|
||||
|
||||
retry-request@7.0.2:
|
||||
resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
retry-request@8.0.4:
|
||||
resolution: {integrity: sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
retry@0.13.1:
|
||||
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
@@ -5221,6 +5840,10 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rimraf@5.0.10:
|
||||
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
|
||||
hasBin: true
|
||||
|
||||
rollup@4.62.4:
|
||||
resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -5324,6 +5947,10 @@ packages:
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-plist@1.3.1:
|
||||
resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==}
|
||||
|
||||
@@ -5414,6 +6041,12 @@ packages:
|
||||
resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
stream-events@1.0.5:
|
||||
resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==}
|
||||
|
||||
stream-shift@1.0.3:
|
||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||
|
||||
strict-uri-encode@2.0.0:
|
||||
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -5422,6 +6055,13 @@ packages:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@5.2.0:
|
||||
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5430,13 +6070,23 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strnum@2.4.2:
|
||||
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
|
||||
|
||||
structured-headers@0.4.1:
|
||||
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
|
||||
|
||||
stubs@3.0.0:
|
||||
resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
|
||||
|
||||
styleq@0.1.3:
|
||||
resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==}
|
||||
|
||||
@@ -5469,6 +6119,14 @@ packages:
|
||||
resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
teeny-request@10.1.4:
|
||||
resolution: {integrity: sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
teeny-request@9.0.0:
|
||||
resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
terminal-link@2.1.1:
|
||||
resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5661,6 +6319,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -5683,6 +6344,11 @@ packages:
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-name@5.0.1:
|
||||
resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -5790,6 +6456,13 @@ packages:
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-vitals@4.2.4:
|
||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -5797,6 +6470,14 @@ packages:
|
||||
resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
websocket-driver@0.7.5:
|
||||
resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
websocket-extensions@0.1.4:
|
||||
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
whatwg-fetch@3.6.20:
|
||||
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
|
||||
|
||||
@@ -5824,6 +6505,10 @@ packages:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -5870,6 +6555,10 @@ packages:
|
||||
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
xml-naming@0.3.0:
|
||||
resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
xml2js@0.6.0:
|
||||
resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -7435,6 +8124,8 @@ snapshots:
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 3.1.5
|
||||
|
||||
'@fastify/busboy@3.2.1': {}
|
||||
|
||||
'@fastify/cors@11.3.0':
|
||||
dependencies:
|
||||
fastify-plugin: 6.0.0
|
||||
@@ -7471,8 +8162,408 @@ snapshots:
|
||||
fastify-plugin: 5.1.0
|
||||
toad-cache: 3.7.4
|
||||
|
||||
'@firebase/ai@2.14.0(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-check-interop-types': 0.3.4
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/analytics-compat@0.2.29(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.23(@firebase/app@0.16.0)
|
||||
'@firebase/analytics-types': 0.8.4
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/analytics-types@0.8.4': {}
|
||||
|
||||
'@firebase/analytics@0.10.23(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/app-check-compat@0.4.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-check': 0.13.0(@firebase/app@0.16.0)
|
||||
'@firebase/app-check-types': 0.5.4
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/app-check-interop-types@0.3.4': {}
|
||||
|
||||
'@firebase/app-check-types@0.5.4': {}
|
||||
|
||||
'@firebase/app-check@0.13.0(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/app-compat@0.5.16':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/app-types@0.9.5':
|
||||
dependencies:
|
||||
'@firebase/logger': 0.5.1
|
||||
|
||||
'@firebase/app@0.16.0':
|
||||
dependencies:
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
idb: 7.1.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/auth-compat@0.6.9(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/auth': 1.13.4(@firebase/app@0.16.0)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))
|
||||
'@firebase/auth-types': 0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@firebase/app-types'
|
||||
- '@react-native-async-storage/async-storage'
|
||||
|
||||
'@firebase/auth-interop-types@0.2.5': {}
|
||||
|
||||
'@firebase/auth-types@0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)':
|
||||
dependencies:
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/util': 1.15.2
|
||||
|
||||
'@firebase/auth@1.13.4(@firebase/app@0.16.0)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@react-native-async-storage/async-storage': 2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0))
|
||||
|
||||
'@firebase/component@0.7.4':
|
||||
dependencies:
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/data-connect@0.7.3(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/auth-interop-types': 0.2.5
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/database-compat@2.1.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/database': 1.1.4
|
||||
'@firebase/database-types': 1.0.21
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
|
||||
'@firebase/database-types@1.0.21':
|
||||
dependencies:
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/util': 1.15.2
|
||||
|
||||
'@firebase/database@1.1.4':
|
||||
dependencies:
|
||||
'@firebase/app-check-interop-types': 0.3.4
|
||||
'@firebase/auth-interop-types': 0.2.5
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
faye-websocket: 0.11.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/firestore-compat@0.4.12(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/firestore': 4.17.0(@firebase/app@0.16.0)
|
||||
'@firebase/firestore-types': 3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@firebase/app-types'
|
||||
|
||||
'@firebase/firestore-types@3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)':
|
||||
dependencies:
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/util': 1.15.2
|
||||
|
||||
'@firebase/firestore@4.17.0(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
'@firebase/webchannel-wrapper': 1.0.6
|
||||
'@grpc/grpc-js': 1.9.16
|
||||
'@grpc/proto-loader': 0.7.15
|
||||
re2js: 2.8.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/functions-compat@0.4.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/functions': 0.13.6(@firebase/app@0.16.0)
|
||||
'@firebase/functions-types': 0.6.4
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/functions-types@0.6.4': {}
|
||||
|
||||
'@firebase/functions@0.13.6(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-check-interop-types': 0.3.4
|
||||
'@firebase/auth-interop-types': 0.2.5
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/messaging-interop-types': 0.2.5
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/installations-compat@0.2.23(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/installations-types': 0.5.4(@firebase/app-types@0.9.5)
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@firebase/app-types'
|
||||
|
||||
'@firebase/installations-types@0.5.4(@firebase/app-types@0.9.5)':
|
||||
dependencies:
|
||||
'@firebase/app-types': 0.9.5
|
||||
|
||||
'@firebase/installations@0.6.23(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/util': 1.15.2
|
||||
idb: 7.1.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/logger@0.5.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/messaging-compat@0.2.28(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/messaging': 0.13.1(@firebase/app@0.16.0)
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/messaging-interop-types@0.2.5': {}
|
||||
|
||||
'@firebase/messaging@0.13.1(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/messaging-interop-types': 0.2.5
|
||||
'@firebase/util': 1.15.2
|
||||
idb: 7.1.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/performance-compat@0.2.26(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/performance': 0.7.13(@firebase/app@0.16.0)
|
||||
'@firebase/performance-types': 0.2.4
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/performance-types@0.2.4': {}
|
||||
|
||||
'@firebase/performance@0.7.13(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
web-vitals: 4.2.4
|
||||
|
||||
'@firebase/remote-config-compat@0.2.28(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/remote-config': 0.9.1(@firebase/app@0.16.0)
|
||||
'@firebase/remote-config-types': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/remote-config-types@0.5.1': {}
|
||||
|
||||
'@firebase/remote-config@0.9.1(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/logger': 0.5.1
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/storage-compat@0.4.4(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/storage': 0.14.4(@firebase/app@0.16.0)
|
||||
'@firebase/storage-types': 0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- '@firebase/app-types'
|
||||
|
||||
'@firebase/storage-types@0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.2)':
|
||||
dependencies:
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/util': 1.15.2
|
||||
|
||||
'@firebase/storage@0.14.4(@firebase/app@0.16.0)':
|
||||
dependencies:
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/component': 0.7.4
|
||||
'@firebase/util': 1.15.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/util@1.15.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@firebase/webchannel-wrapper@1.0.6': {}
|
||||
|
||||
'@google-cloud/firestore@8.7.1':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
fast-deep-equal: 3.1.3
|
||||
functional-red-black-tree: 1.0.1
|
||||
google-gax: 5.0.8
|
||||
protobufjs: 7.6.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@google-cloud/paginator@5.0.2':
|
||||
dependencies:
|
||||
arrify: 2.0.1
|
||||
extend: 3.0.2
|
||||
optional: true
|
||||
|
||||
'@google-cloud/projectify@4.0.0':
|
||||
optional: true
|
||||
|
||||
'@google-cloud/promisify@4.0.0':
|
||||
optional: true
|
||||
|
||||
'@google-cloud/storage@7.22.0':
|
||||
dependencies:
|
||||
'@google-cloud/paginator': 5.0.2
|
||||
'@google-cloud/projectify': 4.0.0
|
||||
'@google-cloud/promisify': 4.0.0
|
||||
abort-controller: 3.0.0
|
||||
async-retry: 1.3.3
|
||||
duplexify: 4.1.3
|
||||
fast-xml-parser: 5.10.1
|
||||
gaxios: 6.7.1
|
||||
google-auth-library: 9.15.1
|
||||
html-entities: 2.6.0
|
||||
mime: 3.0.0
|
||||
p-limit: 3.1.0
|
||||
retry-request: 7.0.2
|
||||
teeny-request: 9.0.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@grpc/grpc-js@1.14.4':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.8.1
|
||||
'@js-sdsl/ordered-map': 4.4.2
|
||||
optional: true
|
||||
|
||||
'@grpc/grpc-js@1.9.16':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.15
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@grpc/proto-loader@0.7.15':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
|
||||
'@grpc/proto-loader@0.8.1':
|
||||
dependencies:
|
||||
lodash.camelcase: 4.3.0
|
||||
long: 5.3.2
|
||||
protobufjs: 7.6.5
|
||||
yargs: 17.7.3
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@2.0.0': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.2.0
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
optional: true
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
@@ -7566,6 +8657,9 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
optional: true
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@minimistjs/subarg@1.0.0':
|
||||
@@ -7593,8 +8687,37 @@ snapshots:
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
optional: true
|
||||
|
||||
'@nodable/entities@3.0.0':
|
||||
optional: true
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
optional: true
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.2': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.7': {}
|
||||
|
||||
'@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
@@ -8239,6 +9362,9 @@ snapshots:
|
||||
'@tanstack/query-core': 5.101.4
|
||||
react: 19.1.0
|
||||
|
||||
'@tootallnate/once@2.0.1':
|
||||
optional: true
|
||||
|
||||
'@turbo/darwin-64@2.10.8':
|
||||
optional: true
|
||||
|
||||
@@ -8285,6 +9411,9 @@ snapshots:
|
||||
'@types/node': 22.20.1
|
||||
'@types/responselike': 1.0.3
|
||||
|
||||
'@types/caseless@0.12.5':
|
||||
optional: true
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@@ -8312,10 +9441,17 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@types/keyv@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.20.1':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -8347,12 +9483,23 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/request@2.48.13':
|
||||
dependencies:
|
||||
'@types/caseless': 0.12.5
|
||||
'@types/node': 22.20.1
|
||||
'@types/tough-cookie': 4.0.5
|
||||
form-data: 2.5.6
|
||||
optional: true
|
||||
|
||||
'@types/responselike@1.0.3':
|
||||
dependencies:
|
||||
'@types/node': 22.20.1
|
||||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
optional: true
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.35':
|
||||
@@ -8449,6 +9596,13 @@ snapshots:
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
@@ -8472,6 +9626,9 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.3.0:
|
||||
optional: true
|
||||
|
||||
ansi-styles@3.2.1:
|
||||
dependencies:
|
||||
color-convert: 1.9.3
|
||||
@@ -8482,6 +9639,9 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
optional: true
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -8489,6 +9649,9 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.2
|
||||
|
||||
anynum@1.0.1:
|
||||
optional: true
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
@@ -8501,6 +9664,9 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
arrify@2.0.1:
|
||||
optional: true
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
@@ -8514,6 +9680,11 @@ snapshots:
|
||||
|
||||
async-limiter@1.0.1: {}
|
||||
|
||||
async-retry@1.3.3:
|
||||
dependencies:
|
||||
retry: 0.13.1
|
||||
optional: true
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
@@ -8695,6 +9866,8 @@ snapshots:
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
bn.js@4.12.5: {}
|
||||
|
||||
bowser@2.14.1: {}
|
||||
@@ -8740,6 +9913,8 @@ snapshots:
|
||||
dependencies:
|
||||
node-int64: 0.4.0
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -8962,6 +10137,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
@@ -9021,13 +10198,15 @@ snapshots:
|
||||
esbuild: 0.25.12
|
||||
tsx: 4.23.4
|
||||
|
||||
drizzle-orm@0.41.0(@types/pg@8.20.3)(pg@8.22.0):
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.20.3
|
||||
pg: 8.22.0
|
||||
|
||||
drizzle-orm@0.45.2(@types/pg@8.20.3)(pg@8.22.0):
|
||||
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.3)(pg@8.22.0):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/pg': 8.20.3
|
||||
pg: 8.22.0
|
||||
|
||||
@@ -9037,6 +10216,17 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplexify@4.1.3:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
stream-shift: 1.0.3
|
||||
optional: true
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
optional: true
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -9047,6 +10237,9 @@ snapshots:
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
optional: true
|
||||
|
||||
encodeurl@1.0.2: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
@@ -9422,6 +10615,8 @@ snapshots:
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -9453,6 +10648,22 @@ snapshots:
|
||||
|
||||
fast-uri@4.1.2: {}
|
||||
|
||||
fast-xml-builder@1.3.1:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.6.2
|
||||
xml-naming: 0.3.0
|
||||
optional: true
|
||||
|
||||
fast-xml-parser@5.10.1:
|
||||
dependencies:
|
||||
'@nodable/entities': 3.0.0
|
||||
fast-xml-builder: 1.3.1
|
||||
is-unsafe: 2.0.0
|
||||
path-expression-matcher: 1.6.2
|
||||
strnum: 2.4.2
|
||||
xml-naming: 0.3.0
|
||||
optional: true
|
||||
|
||||
fastfall@1.5.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -9493,6 +10704,10 @@ snapshots:
|
||||
reusify: 1.1.0
|
||||
xtend: 4.0.2
|
||||
|
||||
faye-websocket@0.11.4:
|
||||
dependencies:
|
||||
websocket-driver: 0.7.5
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
dependencies:
|
||||
bser: 2.1.1
|
||||
@@ -9515,6 +10730,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -9544,6 +10764,57 @@ snapshots:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
firebase-admin@14.2.0(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0):
|
||||
dependencies:
|
||||
'@fastify/busboy': 3.2.1
|
||||
'@firebase/database-compat': 2.1.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/database-types': 1.0.21
|
||||
fast-deep-equal: 3.1.3
|
||||
google-auth-library: 10.9.1
|
||||
jsonwebtoken: 9.0.3
|
||||
jwks-rsa: 4.1.0
|
||||
optionalDependencies:
|
||||
'@google-cloud/firestore': 8.7.1
|
||||
'@google-cloud/storage': 7.22.0
|
||||
transitivePeerDependencies:
|
||||
- '@firebase/app'
|
||||
- '@firebase/app-compat'
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
firebase@12.17.1(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0))):
|
||||
dependencies:
|
||||
'@firebase/ai': 2.14.0(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)
|
||||
'@firebase/analytics': 0.10.23(@firebase/app@0.16.0)
|
||||
'@firebase/analytics-compat': 0.2.29(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/app': 0.16.0
|
||||
'@firebase/app-check': 0.13.0(@firebase/app@0.16.0)
|
||||
'@firebase/app-check-compat': 0.4.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/app-compat': 0.5.16
|
||||
'@firebase/app-types': 0.9.5
|
||||
'@firebase/auth': 1.13.4(@firebase/app@0.16.0)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))
|
||||
'@firebase/auth-compat': 0.6.9(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)(@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.1.17)(react@19.1.0)))
|
||||
'@firebase/data-connect': 0.7.3(@firebase/app@0.16.0)
|
||||
'@firebase/database': 1.1.4
|
||||
'@firebase/database-compat': 2.1.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/firestore': 4.17.0(@firebase/app@0.16.0)
|
||||
'@firebase/firestore-compat': 0.4.12(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)
|
||||
'@firebase/functions': 0.13.6(@firebase/app@0.16.0)
|
||||
'@firebase/functions-compat': 0.4.6(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/installations': 0.6.23(@firebase/app@0.16.0)
|
||||
'@firebase/installations-compat': 0.2.23(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)
|
||||
'@firebase/messaging': 0.13.1(@firebase/app@0.16.0)
|
||||
'@firebase/messaging-compat': 0.2.28(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/performance': 0.7.13(@firebase/app@0.16.0)
|
||||
'@firebase/performance-compat': 0.2.26(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/remote-config': 0.9.1(@firebase/app@0.16.0)
|
||||
'@firebase/remote-config-compat': 0.2.28(@firebase/app-compat@0.5.16)(@firebase/app@0.16.0)
|
||||
'@firebase/storage': 0.14.4(@firebase/app@0.16.0)
|
||||
'@firebase/storage-compat': 0.4.4(@firebase/app-compat@0.5.16)(@firebase/app-types@0.9.5)(@firebase/app@0.16.0)
|
||||
'@firebase/util': 1.15.2
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-async-storage/async-storage'
|
||||
|
||||
fix-dts-default-cjs-exports@1.0.1:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
@@ -9554,6 +10825,22 @@ snapshots:
|
||||
|
||||
fontfaceobserver@2.3.0: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
optional: true
|
||||
|
||||
form-data@2.5.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
safe-buffer: 5.2.1
|
||||
optional: true
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@@ -9562,6 +10849,10 @@ snapshots:
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
freeport-async@2.0.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@@ -9573,6 +10864,66 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
functional-red-black-tree@1.0.1:
|
||||
optional: true
|
||||
|
||||
gaxios@6.7.1:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
is-stream: 2.0.1
|
||||
node-fetch: 2.7.0
|
||||
uuid: 9.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
gaxios@7.1.3:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
node-fetch: 3.3.2
|
||||
rimraf: 5.0.10
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
gaxios@7.3.1:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
node-fetch: 3.3.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gcp-metadata@6.1.1:
|
||||
dependencies:
|
||||
gaxios: 6.7.1
|
||||
google-logging-utils: 0.0.2
|
||||
json-bigint: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
gcp-metadata@8.1.2:
|
||||
dependencies:
|
||||
gaxios: 7.3.1
|
||||
google-logging-utils: 1.1.3
|
||||
json-bigint: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gcp-metadata@8.1.4:
|
||||
dependencies:
|
||||
gaxios: 7.1.3
|
||||
google-logging-utils: 1.1.3
|
||||
json-bigint: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
@@ -9609,6 +10960,16 @@ snapshots:
|
||||
|
||||
getenv@2.0.0: {}
|
||||
|
||||
glob@10.5.0:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.9
|
||||
minipass: 7.1.3
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
optional: true
|
||||
|
||||
glob@13.0.6:
|
||||
dependencies:
|
||||
minimatch: 10.2.6
|
||||
@@ -9624,6 +10985,65 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
google-auth-library@10.5.0:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
gaxios: 7.3.1
|
||||
gcp-metadata: 8.1.4
|
||||
google-logging-utils: 1.1.3
|
||||
gtoken: 8.0.0
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
google-auth-library@10.9.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
gaxios: 7.3.1
|
||||
gcp-metadata: 8.1.2
|
||||
google-logging-utils: 1.1.3
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
google-auth-library@9.15.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
gaxios: 6.7.1
|
||||
gcp-metadata: 6.1.1
|
||||
gtoken: 7.1.0
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
google-gax@5.0.8:
|
||||
dependencies:
|
||||
'@grpc/grpc-js': 1.14.4
|
||||
'@grpc/proto-loader': 0.8.1
|
||||
duplexify: 4.1.3
|
||||
google-auth-library: 10.5.0
|
||||
google-logging-utils: 1.1.3
|
||||
node-fetch: 3.3.2
|
||||
object-hash: 3.0.0
|
||||
proto3-json-serializer: 3.0.4
|
||||
protobufjs: 7.6.5
|
||||
retry-request: 8.0.4
|
||||
rimraf: 5.0.10
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
google-logging-utils@0.0.2:
|
||||
optional: true
|
||||
|
||||
google-logging-utils@1.1.3: {}
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
got@11.8.6:
|
||||
@@ -9642,6 +11062,23 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gtoken@7.1.0:
|
||||
dependencies:
|
||||
gaxios: 6.7.1
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
gtoken@8.0.0:
|
||||
dependencies:
|
||||
gaxios: 7.3.1
|
||||
jws: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
has-async-hooks@1.0.0: {}
|
||||
|
||||
has-flag@3.0.0: {}
|
||||
@@ -9702,6 +11139,9 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
|
||||
html-entities@2.6.0:
|
||||
optional: true
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
@@ -9714,11 +11154,36 @@ snapshots:
|
||||
|
||||
http-parser-js@0.5.10: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
dependencies:
|
||||
'@tootallnate/once': 2.0.1
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
http2-wrapper@1.0.3:
|
||||
dependencies:
|
||||
quick-lru: 5.1.1
|
||||
resolve-alpn: 1.2.1
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -9738,6 +11203,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
@@ -9792,6 +11259,12 @@ snapshots:
|
||||
|
||||
is-plain-obj@2.1.0: {}
|
||||
|
||||
is-stream@2.0.1:
|
||||
optional: true
|
||||
|
||||
is-unsafe@2.0.0:
|
||||
optional: true
|
||||
|
||||
is-wsl@2.2.0:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
@@ -9810,6 +11283,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
optional: true
|
||||
|
||||
jest-environment-node@29.7.0:
|
||||
dependencies:
|
||||
'@jest/environment': 29.7.0
|
||||
@@ -9884,6 +11364,8 @@ snapshots:
|
||||
|
||||
jimp-compact@0.16.1: {}
|
||||
|
||||
jose@6.2.9: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -9901,6 +11383,10 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
@@ -9911,6 +11397,41 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.8.5
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jwks-rsa@4.1.0:
|
||||
dependencies:
|
||||
'@types/jsonwebtoken': 9.0.10
|
||||
debug: 4.4.3
|
||||
jose: 6.2.9
|
||||
limiter: 1.1.5
|
||||
lru-cache: 11.5.2
|
||||
lru-memoizer: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -9985,6 +11506,8 @@ snapshots:
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
limiter@1.1.5: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
@@ -9993,6 +11516,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash.camelcase@4.3.0: {}
|
||||
|
||||
lodash.chunk@4.2.0: {}
|
||||
|
||||
lodash.clonedeep@4.5.0: {}
|
||||
@@ -10001,12 +11526,28 @@ snapshots:
|
||||
|
||||
lodash.flatten@4.4.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
log-symbols@2.2.0:
|
||||
dependencies:
|
||||
chalk: 2.4.2
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
@@ -10021,6 +11562,11 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru-memoizer@3.0.0:
|
||||
dependencies:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 11.5.2
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -10430,6 +11976,9 @@ snapshots:
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@3.0.0:
|
||||
optional: true
|
||||
|
||||
mimic-fn@1.2.0: {}
|
||||
|
||||
mimic-response@1.0.1: {}
|
||||
@@ -10510,10 +12059,18 @@ snapshots:
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-forge@1.4.0: {}
|
||||
|
||||
node-gyp-build-optional-packages@5.2.2:
|
||||
@@ -10551,6 +12108,9 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0:
|
||||
optional: true
|
||||
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
@@ -10613,6 +12173,9 @@ snapshots:
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
optional: true
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parse-png@2.1.0:
|
||||
@@ -10623,12 +12186,21 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-expression-matcher@1.6.2:
|
||||
optional: true
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
optional: true
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.5.2
|
||||
@@ -10776,6 +12348,25 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
proto3-json-serializer@3.0.4:
|
||||
dependencies:
|
||||
protobufjs: 7.6.5
|
||||
optional: true
|
||||
|
||||
protobufjs@7.6.5:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.1
|
||||
'@protobufjs/fetch': 1.1.1
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.2
|
||||
'@types/node': 22.20.1
|
||||
long: 5.3.2
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
@@ -10809,6 +12400,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
re2js@2.8.6: {}
|
||||
|
||||
react-devtools-core@6.1.5:
|
||||
dependencies:
|
||||
shell-quote: 1.10.0
|
||||
@@ -11003,6 +12596,13 @@ snapshots:
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
optional: true
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
@@ -11080,6 +12680,27 @@ snapshots:
|
||||
|
||||
retimer@3.0.0: {}
|
||||
|
||||
retry-request@7.0.2:
|
||||
dependencies:
|
||||
'@types/request': 2.48.13
|
||||
extend: 3.0.2
|
||||
teeny-request: 9.0.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
retry-request@8.0.4:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
teeny-request: 10.1.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
retry@0.13.1:
|
||||
optional: true
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
@@ -11088,6 +12709,11 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rimraf@5.0.10:
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
optional: true
|
||||
|
||||
rollup@4.62.4:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -11201,6 +12827,9 @@ snapshots:
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
optional: true
|
||||
|
||||
simple-plist@1.3.1:
|
||||
dependencies:
|
||||
bplist-creator: 0.1.0
|
||||
@@ -11274,6 +12903,14 @@ snapshots:
|
||||
|
||||
stream-buffers@2.2.0: {}
|
||||
|
||||
stream-events@1.0.5:
|
||||
dependencies:
|
||||
stubs: 3.0.0
|
||||
optional: true
|
||||
|
||||
stream-shift@1.0.3:
|
||||
optional: true
|
||||
|
||||
strict-uri-encode@2.0.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
@@ -11282,6 +12919,18 @@ snapshots:
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@5.1.2:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.2.0
|
||||
optional: true
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
optional: true
|
||||
|
||||
strip-ansi@5.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 4.1.1
|
||||
@@ -11290,10 +12939,23 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.3.0
|
||||
optional: true
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strnum@2.4.2:
|
||||
dependencies:
|
||||
anynum: 1.0.1
|
||||
optional: true
|
||||
|
||||
structured-headers@0.4.1: {}
|
||||
|
||||
stubs@3.0.0:
|
||||
optional: true
|
||||
|
||||
styleq@0.1.3: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
@@ -11333,6 +12995,28 @@ snapshots:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
teeny-request@10.1.4:
|
||||
dependencies:
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
node-fetch: 3.3.2
|
||||
stream-events: 1.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
teeny-request@9.0.0:
|
||||
dependencies:
|
||||
http-proxy-agent: 5.0.0
|
||||
https-proxy-agent: 5.0.1
|
||||
node-fetch: 2.7.0
|
||||
stream-events: 1.0.5
|
||||
uuid: 9.0.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
terminal-link@2.1.1:
|
||||
dependencies:
|
||||
ansi-escapes: 4.3.2
|
||||
@@ -11499,6 +13183,9 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
optional: true
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid-parse@1.1.0: {}
|
||||
@@ -11509,6 +13196,9 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
uuid@9.0.1:
|
||||
optional: true
|
||||
|
||||
validate-npm-package-name@5.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
@@ -11538,7 +13228,7 @@ snapshots:
|
||||
tsx: 4.23.4
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.10(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||
@@ -11561,6 +13251,7 @@ snapshots:
|
||||
vite: 6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/node': 22.20.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
@@ -11577,10 +13268,22 @@ snapshots:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@5.0.0: {}
|
||||
|
||||
websocket-driver@0.7.5:
|
||||
dependencies:
|
||||
http-parser-js: 0.5.10
|
||||
safe-buffer: 5.2.1
|
||||
websocket-extensions: 0.1.4
|
||||
|
||||
websocket-extensions@0.1.4: {}
|
||||
|
||||
whatwg-fetch@3.6.20: {}
|
||||
|
||||
whatwg-url-without-unicode@8.0.0-3:
|
||||
@@ -11611,6 +13314,13 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.2.0
|
||||
optional: true
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
write-file-atomic@4.0.2:
|
||||
@@ -11631,6 +13341,9 @@ snapshots:
|
||||
simple-plist: 1.3.1
|
||||
uuid: 7.0.3
|
||||
|
||||
xml-naming@0.3.0:
|
||||
optional: true
|
||||
|
||||
xml2js@0.6.0:
|
||||
dependencies:
|
||||
sax: 1.6.1
|
||||
|
||||
Reference in New Issue
Block a user