first commit
This commit is contained in:
@@ -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 ?? ""]);
|
||||
};
|
||||
@@ -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("n’envoie 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.) */
|
||||
}
|
||||
};
|
||||
@@ -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"]);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user