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);
});
});
+5
View File
@@ -0,0 +1,5 @@
import { render } from "solid-js/web";
import { App } from "./ui/App";
import "./ui/app.css";
render(() => <App />, document.getElementById("root")!);
+18
View File
@@ -0,0 +1,18 @@
import { Route, Router } from "@solidjs/router";
import { BuilderFlow } from "./BuilderFlow";
import { RootLayout } from "./RootLayout";
import { StaticInfoPage } from "./pages/StaticInfoPage";
export function App() {
return (
<Router root={RootLayout}>
<Route path="/" component={BuilderFlow} />
<Route path="/r/:baseId/:variantId" component={BuilderFlow} />
<Route path="/a-propos" component={() => <StaticInfoPage kind="about" />} />
<Route path="/cgu" component={() => <StaticInfoPage kind="terms" />} />
<Route path="/mentions-legales" component={() => <StaticInfoPage kind="legal" />} />
<Route path="/confidentialite" component={() => <StaticInfoPage kind="privacy" />} />
<Route path="*" component={() => <StaticInfoPage kind="notFound" />} />
</Router>
);
}
+126
View File
@@ -0,0 +1,126 @@
import type { RouteSectionProps } from "@solidjs/router";
import type { BentoSelection } from "@ben-to/builder/domain";
import { createInitialSelection } from "@ben-to/builder/domain";
import { fetchRecipesIndex } from "@ben-to/recipes/catalog";
import { getDictionary } from "@ben-to/i18n/dictionary";
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js";
import { clearBuilderResetBus, readBuilderResetNonce } from "./builder-reset-bus";
import { useAppLocale } from "./app-locale-context";
import { BuilderBaseStep } from "./builder-flow/BuilderBaseStep";
import { BuilderCtaDock } from "./builder-flow/BuilderCtaDock";
import { BuilderStepsNav } from "./builder-flow/BuilderStepsNav";
import { BuilderVariantStep } from "./builder-flow/BuilderVariantStep";
import { RecipeSheetSection } from "./builder-flow/RecipeSheetSection";
import { useBuilderCatalog } from "./builder-flow/use-builder-catalog";
import { useBuilderSteps } from "./builder-flow/use-builder-steps";
import { useRecipeSheet } from "./builder-flow/use-recipe-sheet";
export function BuilderFlow(_props: RouteSectionProps) {
const { locale } = useAppLocale();
const [selection, setSelection] = createSignal<BentoSelection>(createInitialSelection());
const [recipesIndex] = createResource(async () => fetchRecipesIndex());
const t = createMemo(() => getDictionary(locale()));
const steps = useBuilderSteps({
selection,
setSelection,
recipesIndex
});
const catalog = useBuilderCatalog({
recipesIndex,
selection,
flavorFilter: steps.flavorFilter,
locale
});
const recipe = useRecipeSheet({
selection,
currentStep: steps.currentStep,
mergedRecipe: catalog.mergedRecipe,
recipeDisplayTitle: catalog.recipeDisplayTitle,
locale,
t
});
createEffect(() => {
const n = readBuilderResetNonce();
if (n === 0) return;
steps.goHomeWizard();
recipe.resetServingsForGoHome();
clearBuilderResetBus();
});
return (
<main
classList={{
"app-shell": true,
"app-shell--with-dock": steps.canContinue(),
"app-shell--internal-scroll": true,
"app-shell--recipe-scroll": steps.currentStep() === "recipe"
}}
>
<BuilderStepsNav
t={t}
progressStepIndex={steps.progressStepIndex}
progressPercent={steps.progressPercent}
canNavigateToStep={steps.canNavigateToStep}
goToBuilderStep={steps.goToBuilderStep}
currentStep={steps.currentStep}
recipeProgressCompact={recipe.recipeProgressCompact}
/>
<Show when={steps.currentStep() === "base"}>
<div class="builder-scroll">
<BuilderBaseStep
t={t}
locale={locale}
recipesIndex={recipesIndex}
filteredBases={catalog.filteredBases}
flavorFilter={steps.flavorFilter}
setFlavorFilter={steps.setFlavorFilter}
selection={selection}
setSelection={setSelection}
advanceAfterBasePickDouble={steps.advanceAfterBasePickDouble}
onPickCardTouchEndForDouble={steps.onPickCardTouchEndForDouble}
/>
</div>
</Show>
<Show when={steps.currentStep() === "variant"}>
<div class="builder-scroll">
<BuilderVariantStep
t={t}
locale={locale}
currentBase={catalog.currentBase}
availableVariants={catalog.availableVariants}
selection={selection}
setSelection={setSelection}
advanceAfterVariantPickDouble={steps.advanceAfterVariantPickDouble}
onPickCardTouchEndForDouble={steps.onPickCardTouchEndForDouble}
/>
</div>
</Show>
<RecipeSheetSection
currentStep={steps.currentStep}
locale={locale}
t={t}
recipeCoverImageUrl={catalog.recipeCoverImageUrl}
recipeDisplayTitle={catalog.recipeDisplayTitle}
mergedRecipe={catalog.mergedRecipe}
recipe={recipe}
/>
<BuilderCtaDock
canContinue={steps.canContinue}
currentStep={steps.currentStep}
currentBase={catalog.currentBase}
currentVariant={catalog.currentVariant}
locale={locale}
t={t}
nextStep={steps.nextStep}
/>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { A } from "@solidjs/router";
import type { Accessor } from "solid-js";
import type { Dictionary } from "@ben-to/i18n/dictionary";
export function CookieConsentBanner(props: {
readonly t: Accessor<Dictionary>;
readonly onAccept: () => void;
readonly onReject: () => void;
}) {
return (
<div class="cookie-consent" role="region" aria-labelledby="cookie-consent-heading">
<div class="cookie-consent__inner">
<h2 id="cookie-consent-heading" class="cookie-consent__title">
{props.t().cookieConsentTitle}
</h2>
<p class="cookie-consent__text">{props.t().cookieConsentDescription}</p>
<p class="cookie-consent__privacy">
<A href="/confidentialite" class="cookie-consent__link">
{props.t().navPrivacy}
</A>
</p>
<div class="cookie-consent__actions">
<button
type="button"
class="cookie-consent__btn cookie-consent__btn--primary"
onClick={() => props.onAccept()}
>
{props.t().cookieConsentAccept}
</button>
<button
type="button"
class="cookie-consent__btn cookie-consent__btn--ghost"
onClick={() => props.onReject()}
>
{props.t().cookieConsentReject}
</button>
</div>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { A, useLocation } from "@solidjs/router";
import type { RouteSectionProps } from "@solidjs/router";
import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { getDictionary, type LocaleCode } from "@ben-to/i18n/dictionary";
import { readAnalyticsConsent, writeAnalyticsConsent } from "../infra/cookie-consent-storage";
import { readWelcomeTourDismissed, writeWelcomeTourDismissed } from "../infra/welcome-tour-storage";
import { getMatomoRuntimeConfig, injectMatomoTracker, trackMatomoPageView } from "../infra/matomo";
import { bumpBuilderResetBus } from "./builder-reset-bus";
import { AppLocaleProvider, useAppLocale } from "./app-locale-context";
import { CookieConsentBanner } from "./CookieConsentBanner";
import { WelcomeTourModal } from "./WelcomeTourModal";
import { publicAssetHref } from "./builder-flow/public-asset";
const localeOptions: LocaleCode[] = ["fr", "en", "ko", "zh"];
const localeNativeNames: Record<LocaleCode, string> = {
fr: "Français",
en: "English",
ko: "한국어",
zh: "中文"
};
const localeFlags: Record<LocaleCode, string> = {
fr: "🇫🇷",
en: "🇬🇧",
ko: "🇰🇷",
zh: "🇨🇳"
};
function RootLayoutInner(props: RouteSectionProps) {
const { locale, setLocale } = useAppLocale();
const location = useLocation();
const [menuOpen, setMenuOpen] = createSignal(false);
const t = () => getDictionary(locale());
const matomoCfg = getMatomoRuntimeConfig();
const [matomoReady, setMatomoReady] = createSignal(false);
const [consentBannerOpen, setConsentBannerOpen] = createSignal(readAnalyticsConsent() === "unknown");
const [welcomeTourOpen, setWelcomeTourOpen] = createSignal(false);
createEffect(() => {
location.pathname;
setMenuOpen(false);
});
createEffect(() => {
location.pathname;
location.search;
if (!matomoReady()) return;
trackMatomoPageView();
});
const acceptAnalyticsCookies = () => {
writeAnalyticsConsent("analytics-accepted");
setConsentBannerOpen(false);
if (matomoCfg) {
void injectMatomoTracker(matomoCfg).then(() => setMatomoReady(true));
}
};
const rejectAnalyticsCookies = () => {
writeAnalyticsConsent("analytics-rejected");
setConsentBannerOpen(false);
};
const dismissWelcomeTour = () => {
writeWelcomeTourDismissed();
setWelcomeTourOpen(false);
};
createEffect(() => {
location.pathname;
consentBannerOpen();
if (location.pathname !== "/" || consentBannerOpen()) {
setWelcomeTourOpen(false);
return;
}
if (readWelcomeTourDismissed()) {
setWelcomeTourOpen(false);
return;
}
setWelcomeTourOpen(true);
});
onMount(() => {
const consent = readAnalyticsConsent();
if (consent === "unknown") setConsentBannerOpen(true);
if (consent === "analytics-accepted" && matomoCfg) {
void injectMatomoTracker(matomoCfg).then(() => setMatomoReady(true));
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (welcomeTourOpen()) {
dismissWelcomeTour();
return;
}
setMenuOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
});
return (
<>
<header class="topbar">
<button
type="button"
class="topbar__burger"
aria-expanded={menuOpen()}
aria-controls="site-nav-drawer"
aria-label={t().menu}
onClick={() => setMenuOpen((open) => !open)}
>
<span class="topbar__burger-line" aria-hidden="true" />
<span class="topbar__burger-line" aria-hidden="true" />
<span class="topbar__burger-line" aria-hidden="true" />
</button>
<h1 class="topbar__title">
<A
href="/"
class="brand"
aria-label={t().home}
end
onClick={(event) => {
if (location.pathname === "/") {
event.preventDefault();
bumpBuilderResetBus();
}
}}
>
<img class="brand__logo" src={publicAssetHref("/branding/onigiri-logo.png")} width="40" height="40" alt="" />
<span class="brand__wordmark" aria-hidden="true">
<span class="brand__ben">Ben</span>
<span class="brand__to">to</span>
</span>
</A>
</h1>
<div class="topbar__locale" role="radiogroup" aria-label={t().language}>
<For each={localeOptions}>
{(code) => (
<button
type="button"
class="topbar__locale-btn"
classList={{ "topbar__locale-btn--active": locale() === code }}
role="radio"
aria-checked={locale() === code}
title={localeNativeNames[code]}
onClick={() => setLocale(code)}
>
<span class="visually-hidden">{localeNativeNames[code]}</span>
<span class="topbar__locale-flag" aria-hidden="true">
{localeFlags[code]}
</span>
</button>
)}
</For>
</div>
</header>
{props.children}
<Show when={menuOpen()}>
<div
class="nav-drawer__backdrop"
role="presentation"
onClick={() => setMenuOpen(false)}
/>
<nav
id="site-nav-drawer"
class="nav-drawer"
aria-label={t().menu}
role="dialog"
aria-modal="true"
>
<div class="nav-drawer__header">
<span class="nav-drawer__title">{t().menu}</span>
<button type="button" class="nav-drawer__close" onClick={() => setMenuOpen(false)}>
{t().menuClose}
</button>
</div>
<ul class="nav-drawer__list">
<li>
<A href="/a-propos" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navAbout}
</A>
</li>
<li>
<A href="/cgu" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navTerms}
</A>
</li>
<li>
<A href="/mentions-legales" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navLegal}
</A>
</li>
<li>
<A href="/confidentialite" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navPrivacy}
</A>
</li>
</ul>
</nav>
</Show>
<Show when={consentBannerOpen()}>
<CookieConsentBanner t={t} onAccept={acceptAnalyticsCookies} onReject={rejectAnalyticsCookies} />
</Show>
<WelcomeTourModal t={t} open={welcomeTourOpen} onDismiss={dismissWelcomeTour} />
</>
);
}
/** Enveloppe : locale partagée + barre + menu latéral. */
export function RootLayout(props: RouteSectionProps) {
return (
<AppLocaleProvider>
<RootLayoutInner {...props} />
</AppLocaleProvider>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Accessor } from "solid-js";
import { For, Show, createEffect } from "solid-js";
import type { Dictionary } from "@ben-to/i18n/dictionary";
import { publicAssetHref } from "./builder-flow/public-asset";
export function WelcomeTourModal(props: {
readonly t: Accessor<Dictionary>;
readonly open: Accessor<boolean>;
readonly onDismiss: () => void;
}) {
let primaryButton: HTMLButtonElement | undefined;
createEffect(() => {
if (!props.open()) return;
queueMicrotask(() => primaryButton?.focus());
});
const bullets = () =>
[
props.t().welcomeTourBulletSteps,
props.t().welcomeTourBulletFilters,
props.t().welcomeTourBulletServings,
props.t().welcomeTourBulletShare
] as const;
return (
<Show when={props.open()}>
<div
class="welcome-tour__backdrop"
role="presentation"
aria-hidden="true"
onClick={() => props.onDismiss()}
/>
<div
id="welcome-tour-dialog"
class="welcome-tour"
role="dialog"
aria-modal="true"
aria-labelledby="welcome-tour-heading"
aria-describedby="welcome-tour-intro"
>
<div class="welcome-tour__inner">
<h2 id="welcome-tour-heading" class="welcome-tour__title">
{props.t().welcomeTourTitle}
</h2>
<p id="welcome-tour-intro" class="welcome-tour__intro">
{props.t().welcomeTourIntro}
</p>
<ul class="welcome-tour__bullets">
<For each={bullets()}>{(text) => <li>{text}</li>}</For>
</ul>
<div class="welcome-tour__gifs">
<figure class="welcome-tour__figure">
<img
class="welcome-tour__gif"
src={publicAssetHref("/tour/flow-builder.gif")}
alt={props.t().welcomeTourGifFlowAlt}
width={360}
height={202}
loading="lazy"
decoding="async"
/>
</figure>
<figure class="welcome-tour__figure">
<img
class="welcome-tour__gif"
src={publicAssetHref("/tour/recipe-sheet.gif")}
alt={props.t().welcomeTourGifRecipeAlt}
width={360}
height={202}
loading="lazy"
decoding="async"
/>
</figure>
</div>
<div class="welcome-tour__actions">
<button
type="button"
class="welcome-tour__btn welcome-tour__btn--primary"
ref={(el) => (primaryButton = el)}
onClick={() => props.onDismiss()}
>
{props.t().welcomeTourCtaStart}
</button>
<button type="button" class="welcome-tour__btn welcome-tour__btn--ghost" onClick={() => props.onDismiss()}>
{props.t().welcomeTourCtaClose}
</button>
</div>
</div>
</div>
</Show>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { createContext, createSignal, useContext, type ParentProps } from "solid-js";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
export type AppLocaleValue = Readonly<{
locale: () => LocaleCode;
setLocale: (code: LocaleCode) => void;
}>;
const AppLocaleContext = createContext<AppLocaleValue>();
export function AppLocaleProvider(props: ParentProps) {
const [locale, setLocale] = createSignal<LocaleCode>("fr");
const value: AppLocaleValue = {
locale,
setLocale
};
return <AppLocaleContext.Provider value={value}>{props.children}</AppLocaleContext.Provider>;
}
export function useAppLocale(): AppLocaleValue {
const ctx = useContext(AppLocaleContext);
if (!ctx) {
throw new Error("useAppLocale doit être utilisé sous AppLocaleProvider");
}
return ctx;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import type { Accessor, Resource, Setter } from "solid-js";
import { For, Show } from "solid-js";
import type { BaseOption, BentoSelection } from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import type { FlavorFilter } from "./builder-flow-types";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderBaseStep(props: {
readonly t: Accessor<DictionaryView>;
readonly locale: Accessor<LocaleCode>;
readonly recipesIndex: Resource<RecipesIndex | undefined>;
readonly filteredBases: Accessor<BaseOption[]>;
readonly flavorFilter: Accessor<FlavorFilter>;
readonly setFlavorFilter: Setter<FlavorFilter>;
readonly selection: Accessor<BentoSelection>;
readonly setSelection: Setter<BentoSelection>;
readonly advanceAfterBasePickDouble: () => void;
readonly onPickCardTouchEndForDouble: (
cardKey: string,
advance: () => void
) => (e: TouchEvent) => void;
}) {
return (
<section aria-label={props.t().selectBase}>
<div class="filters">
<button
type="button"
class={props.flavorFilter() === "all" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("all")}
>
{props.t().filterAll}
</button>
<button
type="button"
class={props.flavorFilter() === "savory" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("savory")}
>
{props.t().filterSavory}
</button>
<button
type="button"
class={props.flavorFilter() === "sweet" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("sweet")}
>
{props.t().filterSweet}
</button>
</div>
<Show when={props.recipesIndex.loading}>
<p>{props.t().loading}</p>
</Show>
<Show when={props.recipesIndex.error}>
<p role="alert">{props.t().loadError}</p>
</Show>
<div class="cards">
<For each={props.filteredBases()}>
{(base) => (
<button
type="button"
class={`card card--pick ${props.selection().baseId === base.id ? "selected" : ""}`}
onClick={() => props.setSelection((prev) => ({ ...prev, baseId: base.id, variantId: null }))}
onDblClick={() => props.advanceAfterBasePickDouble()}
onTouchEnd={props.onPickCardTouchEndForDouble(`base:${base.id}`, props.advanceAfterBasePickDouble)}
>
<Show when={base.coverImageUrl}>
<img class="card__pick-icon" src={publicAssetHref(base.coverImageUrl)} alt="" />
</Show>
<Show when={!base.coverImageUrl && base.coverSprite}>
<SpriteTile
sprite={base.coverSprite!}
size={32}
class="card__sprite card__pick-sprite"
/>
</Show>
<span class="card__pick-name">{base.name[props.locale()]}</span>
</button>
)}
</For>
</div>
</section>
);
}
@@ -0,0 +1,122 @@
import type { Accessor } from "solid-js";
import { createEffect, onCleanup, Show } from "solid-js";
import type { BaseOption, StepKind, VariantOption } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderCtaDock(props: {
readonly canContinue: Accessor<boolean>;
readonly currentStep: Accessor<StepKind>;
readonly currentBase: Accessor<BaseOption | undefined>;
readonly currentVariant: Accessor<VariantOption | undefined>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
readonly nextStep: () => void;
}) {
let dockEl: HTMLDivElement | undefined;
let dockObserver: ResizeObserver | undefined;
const updateDockHeight = () => {
document.documentElement.style.setProperty(
"--cta-dock-height",
props.canContinue() && dockEl ? `${Math.ceil(dockEl.getBoundingClientRect().height)}px` : "0px"
);
};
const observeDock = () => {
dockObserver?.disconnect();
if (!props.canContinue() || !dockEl) return;
dockObserver = new ResizeObserver(updateDockHeight);
dockObserver.observe(dockEl);
updateDockHeight();
};
createEffect(() => {
if (!props.canContinue()) {
dockObserver?.disconnect();
dockObserver = undefined;
document.documentElement.style.setProperty("--cta-dock-height", "0px");
return;
}
observeDock();
});
onCleanup(() => {
dockObserver?.disconnect();
document.documentElement.style.removeProperty("--cta-dock-height");
});
return (
<Show when={props.canContinue()}>
<div
class="cta-dock"
role="presentation"
ref={(el) => {
dockEl = el;
observeDock();
}}
>
<div class="cta-dock__inner">
<Show when={props.currentStep() === "base" && props.currentBase()} keyed>
{(base: BaseOption) => {
const hasMedia = Boolean(base.coverImageUrl || base.coverSprite);
return (
<div class="cta-context" classList={{ "cta-context--with-media": hasMedia }}>
{base.coverImageUrl ? (
<div class="cta-context__media">
<img
class="cta-context__icon"
src={publicAssetHref(base.coverImageUrl)}
alt=""
width="32"
height="32"
/>
</div>
) : base.coverSprite ? (
<div class="cta-context__media">
<SpriteTile sprite={base.coverSprite} size={32} class="cta-context__sprite" />
</div>
) : null}
<p class="cta-context__title">{base.name[props.locale()]}</p>
<p class="cta-context__subtitle">{base.history[props.locale()]}</p>
</div>
);
}}
</Show>
<Show when={props.currentStep() === "variant" && props.currentVariant()} keyed>
{(variant: VariantOption) => {
const hasMedia = Boolean(variant.coverImageUrl || variant.coverSprite);
return (
<div class="cta-context" classList={{ "cta-context--with-media": hasMedia }}>
{variant.coverImageUrl ? (
<div class="cta-context__media">
<img
class="cta-context__icon"
src={publicAssetHref(variant.coverImageUrl)}
alt=""
width="32"
height="32"
/>
</div>
) : variant.coverSprite ? (
<div class="cta-context__media">
<SpriteTile sprite={variant.coverSprite} size={32} class="cta-context__sprite" />
</div>
) : null}
<p class="cta-context__title">{variant.name[props.locale()]}</p>
<p class="cta-context__subtitle">{variant.story[props.locale()]}</p>
</div>
);
}}
</Show>
<button type="button" class="cta" onClick={props.nextStep}>
{props.t().continue}
</button>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,93 @@
import type { Accessor } from "solid-js";
import type { StepKind } from "@ben-to/builder/domain";
import type { DictionaryView } from "./dictionary-view";
export function BuilderStepsNav(props: {
readonly t: Accessor<DictionaryView>;
readonly progressStepIndex: Accessor<number>;
readonly progressPercent: Accessor<number>;
readonly canNavigateToStep: (step: StepKind) => boolean;
readonly goToBuilderStep: (step: StepKind) => void;
readonly currentStep: Accessor<StepKind>;
readonly recipeProgressCompact: Accessor<boolean>;
}) {
return (
<nav
class="builder-steps"
classList={{
"builder-steps--compact": props.currentStep() === "recipe" && props.recipeProgressCompact()
}}
aria-label={props.t().progress}
>
<ol class="builder-steps__list">
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 0,
"builder-steps__step--current": props.progressStepIndex() === 0
}}
aria-current={props.progressStepIndex() === 0 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
aria-label={props.t().builderNavToBase}
onClick={() => props.goToBuilderStep("base")}
>
1
</button>
<span class="builder-steps__label">{props.t().builderStepBase}</span>
</li>
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 1,
"builder-steps__step--current": props.progressStepIndex() === 1
}}
aria-current={props.progressStepIndex() === 1 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
disabled={!props.canNavigateToStep("variant")}
aria-label={
props.canNavigateToStep("variant")
? props.t().builderNavToVariant
: props.t().builderNavVariantDisabled
}
onClick={() => props.goToBuilderStep("variant")}
>
2
</button>
<span class="builder-steps__label">{props.t().builderStepVariant}</span>
</li>
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 2,
"builder-steps__step--current": props.progressStepIndex() === 2
}}
aria-current={props.progressStepIndex() === 2 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
disabled={!props.canNavigateToStep("recipe")}
aria-label={
props.canNavigateToStep("recipe")
? props.t().builderNavToRecipe
: props.t().builderNavRecipeDisabled
}
onClick={() => props.goToBuilderStep("recipe")}
>
3
</button>
<span class="builder-steps__label">{props.t().builderStepRecipe}</span>
</li>
</ol>
<div class="builder-steps__track" aria-hidden="true">
<div class="builder-steps__fill" style={{ width: `${props.progressPercent()}%` }} />
</div>
</nav>
);
}
@@ -0,0 +1,89 @@
import { For, Show } from "solid-js";
import type { Accessor, Setter } from "solid-js";
import type { BaseOption, BentoSelection, VariantOption } from "@ben-to/builder/domain";
import { primaryBaseIdForVariant } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderVariantStep(props: {
readonly t: Accessor<DictionaryView>;
readonly locale: Accessor<LocaleCode>;
readonly currentBase: Accessor<BaseOption | undefined>;
readonly availableVariants: Accessor<VariantOption[]>;
readonly selection: () => BentoSelection;
readonly setSelection: Setter<BentoSelection>;
readonly advanceAfterVariantPickDouble: () => void;
readonly onPickCardTouchEndForDouble: (
cardKey: string,
advance: () => void
) => (e: TouchEvent) => void;
}) {
return (
<section aria-label={props.t().selectVariant}>
<Show when={props.currentBase()}>
{(base) => (
<p class="builder-variant-base-reminder" role="status">
<span class="builder-variant-base-reminder__media" aria-hidden="true">
<Show when={base().coverImageUrl}>
<img
class="builder-variant-base-reminder__icon"
src={publicAssetHref(base().coverImageUrl)}
width="28"
height="28"
alt=""
decoding="async"
/>
</Show>
<Show when={!base().coverImageUrl && base().coverSprite}>
<SpriteTile
sprite={base().coverSprite!}
size={28}
class="builder-variant-base-reminder__sprite"
/>
</Show>
</span>
<span class="builder-variant-base-reminder__text">
{props.t().builderVariantBaseReminderTemplate.replace("{base}", base().name[props.locale()])}
</span>
</p>
)}
</Show>
<div class="cards">
<For each={props.availableVariants()}>
{(variant) => (
<button
type="button"
class={`card card--pick ${props.selection().variantId === variant.id ? "selected" : ""}`}
onClick={() =>
props.setSelection((prev) => ({
...prev,
variantId: variant.id,
baseId: primaryBaseIdForVariant(variant, prev.baseId)
}))
}
onDblClick={() => props.advanceAfterVariantPickDouble()}
onTouchEnd={props.onPickCardTouchEndForDouble(
`variant:${variant.id}`,
props.advanceAfterVariantPickDouble
)}
>
<Show when={variant.coverImageUrl}>
<img class="card__pick-icon" src={publicAssetHref(variant.coverImageUrl)} alt="" />
</Show>
<Show when={!variant.coverImageUrl && variant.coverSprite}>
<SpriteTile
sprite={variant.coverSprite!}
size={32}
class="card__sprite card__pick-sprite"
/>
</Show>
<span class="card__pick-name">{variant.name[props.locale()]}</span>
</button>
)}
</For>
</div>
</section>
);
}
@@ -0,0 +1,190 @@
import type { Accessor } from "solid-js";
import { For, Show } from "solid-js";
import type { StepKind } from "@ben-to/builder/domain";
import { computeRecipeSteps } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { fillRecipeTemplate } from "./recipe-template";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
import type { RecipeSheetHandle } from "./use-recipe-sheet";
type MergedRecipeView = ReturnType<typeof computeRecipeSteps>;
export function RecipeSheetSection(props: {
readonly currentStep: Accessor<StepKind>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
readonly recipeCoverImageUrl: Accessor<string | null>;
readonly recipeDisplayTitle: Accessor<string>;
readonly mergedRecipe: Accessor<MergedRecipeView>;
readonly recipe: RecipeSheetHandle;
}) {
const r = props.recipe;
return (
<Show when={props.currentStep() === "recipe"}>
<div
class="recipe-scroll"
ref={(el) => {
r.bindRecipeScrollHost(el);
}}
onScroll={r.onRecipeScroll}
>
<section class="recipe-sheet" aria-labelledby="recipe-sheet-title">
<Show when={props.recipeCoverImageUrl()}>
<div class="recipe-sheet__hero">
<div class="recipe-sheet__hero-media">
<img
class="recipe-sheet__photo"
src={publicAssetHref(props.recipeCoverImageUrl())}
alt={props.recipeDisplayTitle() || props.t().recipe}
width="1200"
height="675"
decoding="async"
fetchpriority="high"
/>
</div>
</div>
</Show>
<h2 class="recipe-sheet__title" id="recipe-sheet-title">
{props.recipeDisplayTitle() || props.t().recipe}
</h2>
<div class="recipe-sheet__actions">
<button type="button" class="recipe-sheet__action" onClick={() => void r.shareRecipe()}>
{props.t().shareRecipe}
</button>
<button type="button" class="recipe-sheet__action" onClick={r.printRecipe}>
{props.t().printRecipe}
</button>
</div>
<Show when={r.shareFeedback()}>
<p class="recipe-sheet__share-feedback" role="status" aria-live="polite">
{r.shareFeedback()}
</p>
</Show>
<div class="servings-stepper" role="group" aria-label={props.t().servings}>
<button
type="button"
class="servings-stepper__btn"
aria-label={props.t().decreaseServings}
disabled={r.servings() <= 1}
onClick={() => r.bumpServings(-1)}
>
</button>
<label class="servings-stepper__field">
<span class="visually-hidden">{props.t().servings}</span>
<input
class="servings-stepper__input"
type="text"
inputMode="numeric"
autocomplete="off"
maxLength={2}
value={r.servingsFieldValue()}
onFocus={r.onServingsFocus}
onInput={r.onServingsInput}
onBlur={r.onServingsBlur}
/>
</label>
<button
type="button"
class="servings-stepper__btn"
aria-label={props.t().increaseServings}
disabled={r.servings() >= 99}
onClick={() => r.bumpServings(1)}
>
+
</button>
</div>
<p class="recipe-sheet__print-servings" aria-hidden="true">
{props.t().servings}: {r.servings()}
</p>
<h3 class="recipe-sheet__subtitle">{props.t().ingredients}</h3>
<ul class="recipe-sheet__ingredient-list">
<For each={r.displayedIngredients()}>
{(ingredient) => (
<li class="recipe-sheet__ingredient-item">
<button
type="button"
class="recipe-sheet__ingredient-row"
classList={{
"recipe-sheet__ingredient-row--done": r.checkedIngredientIds().has(ingredient.id)
}}
aria-pressed={r.checkedIngredientIds().has(ingredient.id)}
title={[
fillRecipeTemplate(props.t().recipeIngredientCheckboxLabel, {
name: ingredient.name[props.locale()]
}),
String(ingredient.info[props.locale()] ?? "").trim()
]
.filter(Boolean)
.join(" — ")}
onClick={() => r.toggleIngredientChecklist(ingredient.id)}
>
<span class="recipe-sheet__ingredient-media" aria-hidden="true">
{ingredient.coverSprite ? (
<SpriteTile
sprite={ingredient.coverSprite}
size={32}
class="recipe-sheet__ingredient-sprite"
/>
) : (
<img
class="recipe-sheet__ingredient-fallback"
src={publicAssetHref("/ingredient-unknown.png")}
width="32"
height="32"
alt=""
decoding="async"
/>
)}
</span>
<span class="recipe-sheet__ingredient-text">
<strong>
{ingredient.quantity}
{ingredient.unit ? ` ${ingredient.unit}` : ""}
</strong>{" "}
{ingredient.name[props.locale()]}
</span>
<span class="recipe-sheet__ingredient-row-check" aria-hidden="true">
<span class="recipe-sheet__ingredient-mark__glyph"></span>
</span>
</button>
</li>
)}
</For>
</ul>
<h3 class="recipe-sheet__subtitle">{props.t().steps}</h3>
<ol class="recipe-sheet__steps-list">
<For each={props.mergedRecipe().steps}>
{(step, index) => (
<li class="recipe-sheet__step-item">
<button
type="button"
class="recipe-sheet__step-btn"
classList={{
"recipe-sheet__step-btn--done": r.doneStepIds().has(step.id)
}}
aria-pressed={r.doneStepIds().has(step.id)}
title={fillRecipeTemplate(props.t().recipeStepToggleLabel, {
n: index() + 1
})}
onClick={() => r.toggleStepChecklist(step.id)}
>
{step.text[props.locale()]}
</button>
</li>
)}
</For>
</ol>
</section>
</div>
</Show>
);
}
@@ -0,0 +1,46 @@
import type { CoverSprite } from "@ben-to/builder/domain";
import { publicAssetHref } from "./public-asset";
/**
* Tuile dune planche de sprites via `<img>` + décalage (plus fiable que `background-image` avec le runtime Solid).
*/
export const SpriteTile = (props: Readonly<{ sprite: CoverSprite; size?: number; class?: string }>) => {
const size = () => props.size ?? 32;
const scale = () => size() / props.sprite.cell;
const imgW = () => Math.round(props.sprite.sheetWidth * scale());
const imgH = () => Math.round(props.sprite.sheetHeight * scale());
const offsetLeft = () => Math.round(-props.sprite.col * size());
const offsetTop = () => Math.round(-props.sprite.row * size());
return (
<span
class={props.class}
aria-hidden="true"
style={{
display: "block",
width: `${size()}px`,
height: `${size()}px`,
overflow: "hidden",
position: "relative",
"flex-shrink": "0"
}}
>
<img
src={publicAssetHref(props.sprite.sheet)}
alt=""
width={imgW()}
height={imgH()}
draggable={false}
decoding="async"
style={{
position: "absolute",
left: `${offsetLeft()}px`,
top: `${offsetTop()}px`,
"max-width": "none",
width: `${imgW()}px`,
height: `${imgH()}px`,
"image-rendering": "pixelated"
}}
/>
</span>
);
};
@@ -0,0 +1 @@
export type FlavorFilter = "all" | "savory" | "sweet";
@@ -0,0 +1,7 @@
import type { StepKind } from "@ben-to/builder/domain";
/** Ordre du wizard builder (base → variante → recette). */
export const BUILDER_STEP_ORDER: StepKind[] = ["base", "variant", "recipe"];
export const PICK_DOUBLE_TAP_MS = 380;
export const PICK_DOUBLE_TAP_PX = 48;
@@ -0,0 +1,4 @@
import { getDictionary } from "@ben-to/i18n/dictionary";
/** Type des chaînes UI du builder (évite de dupliquer `ReturnType` dans chaque sous-composant). */
export type DictionaryView = ReturnType<typeof getDictionary>;
@@ -0,0 +1,8 @@
export const safeDecodePathSegment = (value: string | undefined): string => {
if (!value) return "";
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { safeDecodePathSegment } from "./path-utils";
describe("safeDecodePathSegment", () => {
it("retourne une chaîne vide si absence de valeur", () => {
expect(safeDecodePathSegment(undefined)).toBe("");
});
it("décode un segment dURL correctement encodé", () => {
expect(safeDecodePathSegment("caf%C3%A9")).toBe("café");
});
it("retourne la valeur brute si le décodage échoue", () => {
expect(safeDecodePathSegment("%E0%A4%A")).toBe("%E0%A4%A");
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { publicAssetHref } from "./public-asset";
/**
* Test d'intégration aligné sur le scénario Gherkin `@int-public-asset-base-path` :
* vérifie que tous les chemins d'assets publics référencés en dur dans l'app
* (logo, tour d'accueil, fallback ingrédient, images catalogue) sont résolus
* en respectant `import.meta.env.BASE_URL` (Vite `base`).
*/
const withBaseUrl = (baseUrl: string | undefined) => {
// @ts-expect-error: Vitest laisse `import.meta.env` mutable pour les tests.
import.meta.env.BASE_URL = baseUrl;
};
describe("publicAssetHref (Gherkin @int-public-asset-base-path)", () => {
/** @int-public-asset-base-path */
it("préfixe tous les chemins absolus par BASE_URL quand Vite expose un base path", () => {
const previous = import.meta.env.BASE_URL;
withBaseUrl("/ben-to/");
try {
// Logo (RootLayout)
expect(publicAssetHref("/branding/onigiri-logo.png")).toBe("/ben-to/branding/onigiri-logo.png");
// Tour d'accueil (WelcomeTourModal)
expect(publicAssetHref("/tour/flow-builder.gif")).toBe("/ben-to/tour/flow-builder.gif");
expect(publicAssetHref("/tour/recipe-sheet.gif")).toBe("/ben-to/tour/recipe-sheet.gif");
// Fallback ingrédient (RecipeSheetSection)
expect(publicAssetHref("/ingredient-unknown.png")).toBe("/ben-to/ingredient-unknown.png");
// Images issues du catalogue (cover base / variante + recipeCover)
expect(publicAssetHref("/recipe-covers/onigiri.jpg")).toBe("/ben-to/recipe-covers/onigiri.jpg");
// Chemins déjà relatifs : on les promeut puis on applique le base
expect(publicAssetHref("recipe-covers/onigiri.jpg")).toBe("/ben-to/recipe-covers/onigiri.jpg");
} finally {
withBaseUrl(previous);
}
});
/** @int-public-asset-base-path */
it("laisse les chemins intacts quand BASE_URL est la racine", () => {
const previous = import.meta.env.BASE_URL;
withBaseUrl("/");
try {
expect(publicAssetHref("/branding/onigiri-logo.png")).toBe("/branding/onigiri-logo.png");
expect(publicAssetHref("/ingredient-unknown.png")).toBe("/ingredient-unknown.png");
} finally {
withBaseUrl(previous);
}
});
});
@@ -0,0 +1,9 @@
/** Préfixe Vite `base` pour les fichiers dans `public/`. */
export const publicAssetHref = (path: string | null | undefined): string => {
if (!path) return "";
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const base = import.meta.env.BASE_URL ?? "/";
const normalized = path.startsWith("/") ? path : `/${path}`;
if (base === "/" || base === "") return normalized;
return `${base.replace(/\/$/, "")}${normalized}`;
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { publicAssetHref } from "./public-asset";
describe("publicAssetHref", () => {
it("laisse inchangées les URLs absolues http(s)", () => {
expect(publicAssetHref("https://exemple.test/a.png")).toBe("https://exemple.test/a.png");
expect(publicAssetHref("http://localhost/a.png")).toBe("http://localhost/a.png");
});
it("ajoute un slash initial aux chemins relatifs", () => {
expect(publicAssetHref("sprite.png")).toMatch(/^\/?sprite\.png$/);
});
it("conserve les chemins déjà absolus côté site", () => {
expect(publicAssetHref("/icons/a.png")).toMatch(/^\/icons\/a\.png$/);
});
});
@@ -0,0 +1,93 @@
import { Capacitor } from "@capacitor/core";
import { Share } from "@capacitor/share";
import { trackEvent } from "../../infra/analytics";
export type RecipeShareDeps = Readonly<{
title: string;
body: string;
url: string;
variantIdForTrack: string;
strings: { shareCopied: string; shareCopyFailed: string };
setShareFeedback: (msg: string | null) => void;
clearShareFeedbackTimer: () => void;
flashShareFeedback: (message: string) => void;
}>;
/**
* Partage recette : Share natif (Capacitor / Web Share API) avec repli presse-papiers.
* Effets de bord analytics conservés ici pour garder lUI découplée du détail des plateformes.
*/
export async function shareRecipeWithFallback(deps: RecipeShareDeps): Promise<void> {
const trackShareSuccess = () => {
trackEvent("recipe", "share", deps.variantIdForTrack);
};
if (Capacitor.isNativePlatform()) {
try {
await Share.share({
title: deps.title,
text: deps.body,
url: deps.url || undefined,
dialogTitle: deps.title
});
deps.clearShareFeedbackTimer();
deps.setShareFeedback(null);
trackShareSuccess();
return;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
const msg =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
if (/cancel/i.test(msg) || msg.toLowerCase().includes("user did not share")) return;
}
}
const shareWithNative = async () => {
const basePayload: ShareData = { title: deps.title, text: deps.body };
if (deps.url) {
const withUrl: ShareData = { ...basePayload, url: deps.url };
if (typeof navigator.canShare !== "function" || navigator.canShare(withUrl)) {
await navigator.share(withUrl);
return;
}
}
await navigator.share(basePayload);
};
if (typeof navigator.share === "function") {
try {
await shareWithNative();
deps.clearShareFeedbackTimer();
deps.setShareFeedback(null);
trackShareSuccess();
return;
} catch (error: unknown) {
const name =
error instanceof Error || error instanceof DOMException
? error.name
: typeof error === "object" &&
error !== null &&
"name" in error &&
typeof (error as { name: unknown }).name === "string"
? (error as { name: string }).name
: "";
if (name === "AbortError") return;
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(deps.body);
deps.flashShareFeedback(deps.strings.shareCopied);
trackShareSuccess();
} else {
deps.flashShareFeedback(deps.strings.shareCopyFailed);
}
} catch {
deps.flashShareFeedback(deps.strings.shareCopyFailed);
}
}
@@ -0,0 +1,7 @@
export const fillRecipeTemplate = (
template: string,
vars: { name?: string; n?: number }
): string =>
template
.replace(/\{name\}/g, vars.name ?? "")
.replace(/\{n\}/g, vars.n !== undefined ? String(vars.n) : "");
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { fillRecipeTemplate } from "./recipe-template";
describe("fillRecipeTemplate", () => {
it("remplace name et n", () => {
expect(fillRecipeTemplate("{name} — #{n}", { name: "Riz", n: 2 })).toBe("Riz — #2");
});
it("tolère des variables absentes", () => {
expect(fillRecipeTemplate("{name}", {})).toBe("");
});
});
@@ -0,0 +1,83 @@
import type { Accessor } from "solid-js";
import { createMemo } from "solid-js";
import type { BaseOption, BentoSelection } from "@ben-to/builder/domain";
import {
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps
} from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { FlavorFilter } from "./builder-flow-types";
export function useBuilderCatalog(props: {
readonly recipesIndex: Accessor<RecipesIndex | undefined>;
readonly selection: Accessor<BentoSelection>;
readonly flavorFilter: Accessor<FlavorFilter>;
readonly locale: Accessor<LocaleCode>;
}) {
const filteredBases = createMemo(() => {
const index = props.recipesIndex();
if (!index) return [];
if (props.flavorFilter() !== "all") {
return index.bases.filter((base) => base.flavor === props.flavorFilter());
}
const seen = new Set<string>();
const out: BaseOption[] = [];
for (const base of index.bases) {
const key = baseFlavorGroupKey(base);
if (seen.has(key)) continue;
seen.add(key);
out.push(base);
}
return out;
});
const currentBase = createMemo(() =>
props.recipesIndex()?.bases.find((base) => base.id === props.selection().baseId)
);
const availableVariants = createMemo(() => {
const index = props.recipesIndex();
if (!index) return [];
const baseId = props.selection().baseId;
if (!baseId) {
return [...(index.variants ?? [])];
}
const groupIds = baseIdsInSameFlavorGroup(index.bases, baseId);
return (index.variants ?? []).filter((variant) => variant.baseIds.some((id) => groupIds.includes(id)));
});
const currentVariant = createMemo(() =>
(props.recipesIndex()?.variants ?? []).find((variant) => variant.id === props.selection().variantId)
);
const mergedRecipe = createMemo(() =>
computeRecipeSteps({
base: currentBase() ?? null,
variant: currentVariant() ?? null
})
);
const recipeCoverImageUrl = createMemo(() => currentVariant()?.recipeCoverImageUrl ?? null);
const recipeDisplayTitle = createMemo(() => {
const base = currentBase();
const variant = currentVariant();
const loc = props.locale();
if (base && variant) return `${base.name[loc]}${variant.name[loc]}`;
if (variant) return variant.name[loc];
if (base) return base.name[loc];
return "";
});
return {
filteredBases,
currentBase,
availableVariants,
currentVariant,
mergedRecipe,
recipeCoverImageUrl,
recipeDisplayTitle
};
}
@@ -0,0 +1,185 @@
import { useLocation, useNavigate, useParams } from "@solidjs/router";
import type { Accessor, Setter } from "solid-js";
import { createEffect, createMemo, createSignal, onMount } from "solid-js";
import type { BentoSelection, StepKind } from "@ben-to/builder/domain";
import { createInitialSelection } from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import { loadSelection, saveSelection } from "../../infra/persistence";
import { safeDecodePathSegment } from "./path-utils";
import { BUILDER_STEP_ORDER, PICK_DOUBLE_TAP_MS, PICK_DOUBLE_TAP_PX } from "./constants";
import type { FlavorFilter } from "./builder-flow-types";
export function useBuilderSteps(props: {
readonly selection: Accessor<BentoSelection>;
readonly setSelection: Setter<BentoSelection>;
readonly recipesIndex: Accessor<RecipesIndex | undefined>;
}) {
const params = useParams<{ baseId?: string; variantId?: string }>();
const navigate = useNavigate();
const location = useLocation();
const [currentStep, setCurrentStep] = createSignal<StepKind>("base");
const [flavorFilter, setFlavorFilter] = createSignal<FlavorFilter>("all");
const canContinue = createMemo(() => {
if (currentStep() === "base") return Boolean(props.selection().baseId);
if (currentStep() === "variant") return Boolean(props.selection().variantId);
return false;
});
const nextStep = () => {
const index = BUILDER_STEP_ORDER.indexOf(currentStep());
const next = BUILDER_STEP_ORDER[index + 1];
if (next) setCurrentStep(next);
};
const advanceAfterBasePickDouble = () => {
if (currentStep() !== "base") return;
if (!props.selection().baseId) return;
setCurrentStep("variant");
};
const advanceAfterVariantPickDouble = () => {
if (currentStep() !== "variant") return;
if (!props.selection().variantId) return;
setCurrentStep("recipe");
};
let lastPickTouch: { key: string; t: number; x: number; y: number } | null = null;
const onPickCardTouchEndForDouble =
(cardKey: string, advance: () => void) => (e: TouchEvent) => {
if (e.changedTouches.length !== 1) return;
const touch = e.changedTouches[0];
const now = Date.now();
const x = touch.clientX;
const y = touch.clientY;
const prev = lastPickTouch;
if (
prev &&
prev.key === cardKey &&
now - prev.t <= PICK_DOUBLE_TAP_MS &&
Math.hypot(x - prev.x, y - prev.y) <= PICK_DOUBLE_TAP_PX
) {
lastPickTouch = null;
advance();
return;
}
lastPickTouch = { key: cardKey, t: now, x, y };
};
const progressStepIndex = createMemo(() => BUILDER_STEP_ORDER.indexOf(currentStep()));
const progressPercent = createMemo(() => ((progressStepIndex() + 1) / BUILDER_STEP_ORDER.length) * 100);
const canNavigateToStep = (step: StepKind) => {
if (step === "base") return true;
if (step === "variant") return Boolean(props.selection().baseId);
return Boolean(props.selection().baseId && props.selection().variantId);
};
const goToBuilderStep = (step: StepKind) => {
if (!canNavigateToStep(step)) return;
setCurrentStep(step);
};
const goHomeWizard = () => {
setCurrentStep("base");
props.setSelection(createInitialSelection());
setFlavorFilter("all");
};
onMount(() => {
if (location.pathname.startsWith("/r/")) return;
const stored = loadSelection();
if (stored) props.setSelection(stored);
});
createEffect(() => {
const rawBase = params.baseId;
const rawVariant = params.variantId;
if (!rawBase || !rawVariant) return;
const index = props.recipesIndex();
if (!index) return;
const baseId = safeDecodePathSegment(rawBase);
const variantId = safeDecodePathSegment(rawVariant);
if (!baseId || !variantId) {
navigate("/", { replace: true });
return;
}
const base = index.bases.find((item) => item.id === baseId);
const variant = index.variants.find((item) => item.id === variantId);
if (!base || !variant || !variant.baseIds.includes(base.id)) {
navigate("/", { replace: true });
return;
}
const sel = props.selection();
if (sel.baseId === baseId && sel.variantId === variantId && currentStep() === "recipe") return;
setFlavorFilter("all");
props.setSelection({ baseId, variantId });
setCurrentStep("recipe");
});
createEffect(() => {
const index = props.recipesIndex();
if (!index) return;
const step = currentStep();
const path = location.pathname;
const { baseId, variantId } = props.selection();
if (step === "recipe" && baseId && variantId) {
const target = `/r/${encodeURIComponent(baseId)}/${encodeURIComponent(variantId)}`;
if (path !== target) {
navigate(target, { replace: false });
}
return;
}
if (step !== "recipe" && path.startsWith("/r/")) {
navigate("/", { replace: true });
}
});
createEffect(() => {
saveSelection(props.selection());
});
createEffect(() => {
const index = props.recipesIndex();
if (!index) return;
const baseId = props.selection().baseId;
if (!baseId) return;
const base = index.bases.find((item) => item.id === baseId);
if (!base) {
props.setSelection((prev) => ({ ...prev, baseId: null, variantId: null }));
return;
}
if (flavorFilter() !== "all" && base.flavor !== flavorFilter()) {
props.setSelection((prev) => ({ ...prev, baseId: null, variantId: null }));
}
});
return {
params,
navigate,
location,
currentStep,
setCurrentStep,
flavorFilter,
setFlavorFilter,
canContinue,
nextStep,
advanceAfterBasePickDouble,
advanceAfterVariantPickDouble,
onPickCardTouchEndForDouble,
progressStepIndex,
progressPercent,
canNavigateToStep,
goToBuilderStep,
goHomeWizard
};
}
@@ -0,0 +1,240 @@
import type { Accessor } from "solid-js";
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type { BentoSelection, StepKind } from "@ben-to/builder/domain";
import { computeRecipeSteps } from "@ben-to/builder/domain";
import { scaleQuantities } from "@ben-to/recipes/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { loadRecipeChecklist, saveRecipeChecklist } from "../../infra/recipe-checklist-persistence";
import { trackEvent } from "../../infra/analytics";
import { shareRecipeWithFallback } from "./recipe-share";
type MergedRecipeView = ReturnType<typeof computeRecipeSteps>;
export function useRecipeSheet(props: {
readonly selection: Accessor<BentoSelection>;
readonly currentStep: Accessor<StepKind>;
readonly mergedRecipe: Accessor<MergedRecipeView>;
readonly recipeDisplayTitle: Accessor<string>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
}) {
const [servings, setServings] = createSignal(1);
const [servingsDraft, setServingsDraft] = createSignal<string | null>(null);
const [recipeProgressCompact, setRecipeProgressCompact] = createSignal(false);
const [shareFeedback, setShareFeedback] = createSignal<string | null>(null);
const [checkedIngredientIds, setCheckedIngredientIds] = createSignal(new Set<string>());
const [doneStepIds, setDoneStepIds] = createSignal(new Set<string>());
const baseServings = createMemo(() => Math.max(1, props.mergedRecipe().servings));
const displayedIngredients = createMemo(() => {
const quantityFactor = servings() / baseServings();
return props.mergedRecipe().ingredients.map((ingredient) => ({
...ingredient,
quantity: scaleQuantities(ingredient.quantity, quantityFactor)
}));
});
let recipeScrollHost: HTMLDivElement | undefined;
let shareFeedbackTimer: ReturnType<typeof setTimeout> | undefined;
const clearShareFeedbackTimer = () => {
if (shareFeedbackTimer !== undefined) clearTimeout(shareFeedbackTimer);
};
onCleanup(() => {
clearShareFeedbackTimer();
});
let lastRecipeKey = "";
createEffect(() => {
const selection = props.selection();
const recipeKey = `${selection.baseId ?? ""}:${selection.variantId ?? ""}:${baseServings()}`;
if (recipeKey !== lastRecipeKey) {
lastRecipeKey = recipeKey;
setServings(baseServings());
setServingsDraft(null);
}
});
createEffect(() => {
const variantId = props.selection().variantId;
const recipe = props.mergedRecipe();
const validIngredientIds = new Set(recipe.ingredients.map((item) => item.id));
const validStepIds = new Set(recipe.steps.map((item) => item.id));
if (!variantId) {
setCheckedIngredientIds(new Set<string>());
setDoneStepIds(new Set<string>());
return;
}
const stored = loadRecipeChecklist(variantId);
const ingredients = new Set(stored.ingredientIds.filter((id) => validIngredientIds.has(id)));
const steps = new Set(stored.stepIds.filter((id) => validStepIds.has(id)));
setCheckedIngredientIds(ingredients);
setDoneStepIds(steps);
});
const toggleIngredientChecklist = (id: string) => {
const variantId = props.selection().variantId;
if (!variantId) return;
setCheckedIngredientIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
saveRecipeChecklist(variantId, {
ingredientIds: [...next],
stepIds: [...doneStepIds()]
});
return next;
});
};
const toggleStepChecklist = (id: string) => {
const variantId = props.selection().variantId;
if (!variantId) return;
setDoneStepIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
saveRecipeChecklist(variantId, {
ingredientIds: [...checkedIngredientIds()],
stepIds: [...next]
});
return next;
});
};
createEffect(() => {
if (props.currentStep() !== "recipe") {
setRecipeProgressCompact(false);
return;
}
queueMicrotask(() => {
recipeScrollHost?.scrollTo(0, 0);
setRecipeProgressCompact(false);
});
});
const onRecipeScroll: EventListener = (event) => {
const el = event.currentTarget as HTMLDivElement;
setRecipeProgressCompact(el.scrollTop > 14);
};
const servingsFieldValue = createMemo(() => servingsDraft() ?? String(servings()));
const bumpServings = (delta: number) => {
setServingsDraft(null);
setServings((n) => Math.min(99, Math.max(1, n + delta)));
};
const onServingsFocus = () => {
setServingsDraft(String(servings()));
};
const onServingsInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
const digits = event.currentTarget.value.replace(/\D/g, "").slice(0, 2);
setServingsDraft(digits);
};
const onServingsBlur = () => {
const raw = servingsDraft();
setServingsDraft(null);
if (raw === null || raw === "") {
setServings(baseServings());
return;
}
const parsed = Number.parseInt(raw, 10);
setServings(Number.isFinite(parsed) ? Math.min(99, Math.max(1, parsed)) : baseServings());
};
const getRecipeShareUrl = () => {
if (typeof window === "undefined") return "";
const { baseId, variantId } = props.selection();
if (!baseId || !variantId) return "";
const path = `/r/${encodeURIComponent(baseId)}/${encodeURIComponent(variantId)}`;
return new URL(path, window.location.origin).href;
};
const buildRecipeShareText = () => {
const loc = props.locale();
const dict = props.t();
const title = props.recipeDisplayTitle() || dict.recipe;
const lines: string[] = [title, "", `${dict.servings}: ${servings()}`, "", dict.ingredients];
for (const ing of displayedIngredients()) {
lines.push(`${ing.name[loc]}${ing.quantity} ${ing.unit}`);
}
lines.push("", dict.steps);
props.mergedRecipe().steps.forEach((step, index) => {
lines.push(`${index + 1}. ${step.text[loc]}`);
});
const url = getRecipeShareUrl();
if (url) {
lines.push("", dict.recipeShareUrlIntro, url);
}
return lines.join("\n");
};
const flashShareFeedback = (message: string) => {
clearShareFeedbackTimer();
setShareFeedback(message);
shareFeedbackTimer = setTimeout(() => {
setShareFeedback(null);
shareFeedbackTimer = undefined;
}, 4500);
};
const shareRecipe = async () => {
const dict = props.t();
await shareRecipeWithFallback({
title: props.recipeDisplayTitle() || dict.recipe,
body: buildRecipeShareText(),
url: getRecipeShareUrl(),
variantIdForTrack: props.selection().variantId ?? "none",
strings: { shareCopied: dict.shareCopied, shareCopyFailed: dict.shareCopyFailed },
setShareFeedback,
clearShareFeedbackTimer,
flashShareFeedback
});
};
const printRecipe = () => {
window.print();
trackEvent("recipe", "print", props.selection().variantId ?? "none");
};
const resetServingsForGoHome = () => {
setServings(1);
setServingsDraft(null);
};
return {
servings,
servingsDraft,
servingsFieldValue,
bumpServings,
onServingsFocus,
onServingsInput,
onServingsBlur,
checkedIngredientIds,
doneStepIds,
toggleIngredientChecklist,
toggleStepChecklist,
recipeProgressCompact,
shareFeedback,
onRecipeScroll,
shareRecipe,
printRecipe,
resetServingsForGoHome,
/** Callback ref Solid : assigner au conteneur scroll de la feuille recette. */
bindRecipeScrollHost: (el: HTMLDivElement) => {
recipeScrollHost = el;
},
displayedIngredients
};
}
export type RecipeSheetHandle = ReturnType<typeof useRecipeSheet>;
+10
View File
@@ -0,0 +1,10 @@
import { createSignal } from "solid-js";
const [builderResetNonce, setBuilderResetNonce] = createSignal(0);
/** Clic sur le logo alors que lURL est déjà « / » : réinitialiser le parcours sans recharger. */
export const bumpBuilderResetBus = () => setBuilderResetNonce((n) => n + 1);
export const readBuilderResetNonce = () => builderResetNonce();
export const clearBuilderResetBus = () => setBuilderResetNonce(0);
+46
View File
@@ -0,0 +1,46 @@
import { A } from "@solidjs/router";
import { For, createMemo } from "solid-js";
import { getDictionary } from "@ben-to/i18n/dictionary";
import { useAppLocale } from "../app-locale-context";
export type StaticInfoKind = "about" | "terms" | "legal" | "privacy" | "notFound";
type StaticDocContent = Readonly<{
title: string;
paragraphs: readonly string[];
}>;
export function StaticInfoPage(props: { kind: StaticInfoKind }) {
const { locale } = useAppLocale();
const t = createMemo(() => getDictionary(locale()));
const doc = createMemo((): StaticDocContent => {
const d = t();
switch (props.kind) {
case "about":
return { title: d.aboutTitle, paragraphs: d.aboutParagraphs };
case "terms":
return { title: d.termsTitle, paragraphs: d.termsParagraphs };
case "legal":
return { title: d.legalTitle, paragraphs: d.legalParagraphs };
case "privacy":
return { title: d.privacyTitle, paragraphs: d.privacyParagraphs };
default:
return { title: d.notFoundTitle, paragraphs: d.notFoundParagraphs };
}
});
return (
<main class="app-shell static-doc">
<nav class="static-doc__nav" aria-label={t().backToBuilder}>
<A href="/" class="static-doc__back">
{t().backToBuilder}
</A>
</nav>
<article class="static-doc__article">
<h1 class="static-doc__heading">{doc().title}</h1>
<For each={[...doc().paragraphs]}>{(paragraph) => <p class="static-doc__p">{paragraph}</p>}</For>
</article>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL de base Matomo (répertoire contenant matomo.js), ex. https://analytics.example.com/ */
readonly VITE_MATOMO_URL?: string;
/** Identifiant du site Matomo */
readonly VITE_MATOMO_SITE_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** File dattente Matomo avant chargement de matomo.js */
interface Window {
_paq?: unknown[][];
}