first commit

This commit is contained in:
2026-07-21 16:50:58 +02:00
commit 256599626e
407 changed files with 30489 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { readAnalyticsConsent } from "./cookie-consent-storage";
type MatomoWindow = Window &
typeof globalThis & {
_paq?: unknown[][];
};
export const trackEvent = (category: string, action: string, name?: string): void => {
if (readAnalyticsConsent() !== "analytics-accepted") return;
const matomoWindow = window as MatomoWindow;
matomoWindow._paq = matomoWindow._paq ?? [];
matomoWindow._paq.push(["trackEvent", category, action, name ?? ""]);
};
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./cookie-consent-storage", () => ({
readAnalyticsConsent: vi.fn()
}));
import { readAnalyticsConsent } from "./cookie-consent-storage";
import { trackEvent } from "./analytics";
const readConsent = vi.mocked(readAnalyticsConsent);
describe("trackEvent (Matomo)", () => {
beforeEach(() => {
readConsent.mockReset();
readConsent.mockReturnValue("analytics-rejected");
vi.stubGlobal("window", { _paq: [] as unknown[][] });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("nenvoie rien sans consentement analytics", () => {
trackEvent("recipe", "share", "x");
expect(window._paq?.length ?? 0).toBe(0);
});
it("pousse un trackEvent Matomo lorsque le consentement est accepté", () => {
readConsent.mockReturnValue("analytics-accepted");
vi.stubGlobal("window", { _paq: [] as unknown[][] });
trackEvent("recipe", "share", "y");
expect(window._paq).toContainEqual(["trackEvent", "recipe", "share", "y"]);
});
});
@@ -0,0 +1,22 @@
import {
COOKIE_CONSENT_STORAGE_KEY,
normalizeConsentRaw,
type ConsentParseResult,
type StoredAnalyticsConsent
} from "@ben-to/privacy/cookie-consent";
export const readAnalyticsConsent = (): ConsentParseResult => {
try {
return normalizeConsentRaw(localStorage.getItem(COOKIE_CONSENT_STORAGE_KEY));
} catch {
return "unknown";
}
};
export const writeAnalyticsConsent = (value: StoredAnalyticsConsent): void => {
try {
localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, value);
} catch {
/* stockage indisponible (mode privé strict, etc.) */
}
};
+60
View File
@@ -0,0 +1,60 @@
import { readAnalyticsConsent } from "./cookie-consent-storage";
export type MatomoRuntimeConfig = Readonly<{
baseUrl: string;
siteId: string;
}>;
type MatomoWindow = Window &
typeof globalThis & {
_paq?: unknown[][];
};
let injectPromise: Promise<void> | null = null;
export const getMatomoRuntimeConfig = (): MatomoRuntimeConfig | null => {
const rawUrl = import.meta.env.VITE_MATOMO_URL;
const rawSite = import.meta.env.VITE_MATOMO_SITE_ID;
const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
const siteId = typeof rawSite === "string" ? rawSite.trim() : "";
if (!url || !siteId) return null;
const baseUrl = url.endsWith("/") ? url : `${url}/`;
return { baseUrl, siteId };
};
export const isMatomoConfigured = (): boolean => getMatomoRuntimeConfig() !== null;
export const injectMatomoTracker = (cfg: MatomoRuntimeConfig): Promise<void> => {
if (injectPromise) return injectPromise;
injectPromise = new Promise<void>((resolve, reject) => {
const w = window as MatomoWindow;
w._paq = w._paq ?? [];
w._paq.push(["setTrackerUrl", `${cfg.baseUrl}matomo.php`]);
w._paq.push(["setSiteId", cfg.siteId]);
const g = document.createElement("script");
g.async = true;
g.src = `${cfg.baseUrl}matomo.js`;
g.onload = () => {
const mw = window as MatomoWindow;
mw._paq?.push(["enableLinkTracking"]);
resolve();
};
g.onerror = () => reject(new Error("Failed to load matomo.js"));
const s = document.getElementsByTagName("script")[0];
if (s?.parentNode) {
s.parentNode.insertBefore(g, s);
} else {
document.head.appendChild(g);
}
});
return injectPromise;
};
export const trackMatomoPageView = (): void => {
if (readAnalyticsConsent() !== "analytics-accepted") return;
const w = window as MatomoWindow;
if (!w._paq) return;
w._paq.push(["setCustomUrl", window.location.href]);
w._paq.push(["setDocumentTitle", document.title]);
w._paq.push(["trackPageView"]);
};
+12
View File
@@ -0,0 +1,12 @@
import type { BentoSelection } from "@ben-to/builder/domain";
const key = "ben-to.selection";
export const saveSelection = (selection: BentoSelection): void => {
localStorage.setItem(key, JSON.stringify(selection));
};
export const loadSelection = (): BentoSelection | null => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as BentoSelection) : null;
};
@@ -0,0 +1,54 @@
const STORAGE_KEY = "ben-to.recipe-checklist";
export type RecipeChecklistStored = Readonly<{
ingredientIds: readonly string[];
stepIds: readonly string[];
}>;
type RecipeChecklistMap = Record<string, RecipeChecklistStored>;
const emptyEntry = (): RecipeChecklistStored =>
Object.freeze({ ingredientIds: Object.freeze([]), stepIds: Object.freeze([]) });
const loadMap = (): RecipeChecklistMap => {
if (typeof localStorage === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as RecipeChecklistMap;
} catch {
return {};
}
};
const saveMap = (map: RecipeChecklistMap): void => {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
/* quota / private mode */
}
};
export const loadRecipeChecklist = (recipeKey: string): RecipeChecklistStored => {
const map = loadMap();
const entry = map[recipeKey];
if (!entry || !Array.isArray(entry.ingredientIds) || !Array.isArray(entry.stepIds)) {
return emptyEntry();
}
return Object.freeze({
ingredientIds: Object.freeze([...entry.ingredientIds]),
stepIds: Object.freeze([...entry.stepIds])
});
};
export const saveRecipeChecklist = (recipeKey: string, data: RecipeChecklistStored): void => {
const map = loadMap();
map[recipeKey] = Object.freeze({
ingredientIds: Object.freeze([...data.ingredientIds]),
stepIds: Object.freeze([...data.stepIds])
});
saveMap(map);
};
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readWelcomeTourDismissed,
WELCOME_TOUR_SCHEMA_VERSION,
WELCOME_TOUR_STORAGE_KEY,
writeWelcomeTourDismissed
} from "./welcome-tour-storage";
const mockStorage = () => {
const map = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => void map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
}
} as Storage);
};
describe("welcome tour storage (Gherkin @int-welcome-tour-dismiss)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
/** @int-welcome-tour-dismiss */
it("enregistre la version courante lorsque le tour est fermé", () => {
mockStorage();
expect(readWelcomeTourDismissed()).toBe(false);
writeWelcomeTourDismissed();
expect(localStorage.getItem(WELCOME_TOUR_STORAGE_KEY)).toBe(WELCOME_TOUR_SCHEMA_VERSION);
expect(readWelcomeTourDismissed()).toBe(true);
});
});
@@ -0,0 +1,21 @@
/** Clé localStorage : fermeture du tour d'accueil pour une version de contenu donnée. */
export const WELCOME_TOUR_STORAGE_KEY = "ben-to.welcome-tour-dismissed";
/** Incrémenter pour réafficher le tour après refonte majeure du contenu. */
export const WELCOME_TOUR_SCHEMA_VERSION = "v1";
export const readWelcomeTourDismissed = (): boolean => {
try {
return localStorage.getItem(WELCOME_TOUR_STORAGE_KEY) === WELCOME_TOUR_SCHEMA_VERSION;
} catch {
return false;
}
};
export const writeWelcomeTourDismissed = (): void => {
try {
localStorage.setItem(WELCOME_TOUR_STORAGE_KEY, WELCOME_TOUR_SCHEMA_VERSION);
} catch {
/* stockage indisponible */
}
};
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readWelcomeTourDismissed,
WELCOME_TOUR_SCHEMA_VERSION,
WELCOME_TOUR_STORAGE_KEY,
writeWelcomeTourDismissed
} from "./welcome-tour-storage";
const mockStorage = () => {
const map = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => void map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
}
} as Storage);
};
describe("welcome-tour-storage", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("readWelcomeTourDismissed est faux sans valeur", () => {
mockStorage();
expect(readWelcomeTourDismissed()).toBe(false);
});
it("write puis read indique dismiss pour la version courante", () => {
mockStorage();
writeWelcomeTourDismissed();
expect(localStorage.getItem(WELCOME_TOUR_STORAGE_KEY)).toBe(WELCOME_TOUR_SCHEMA_VERSION);
expect(readWelcomeTourDismissed()).toBe(true);
});
it("une ancienne valeur ne compte pas comme dismiss", () => {
mockStorage();
localStorage.setItem(WELCOME_TOUR_STORAGE_KEY, "v0");
expect(readWelcomeTourDismissed()).toBe(false);
});
});