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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { App } from "./ui/App";
|
||||
import "./ui/app.css";
|
||||
|
||||
render(() => <App />, document.getElementById("root")!);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 d’une 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 d’URL 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 l’UI 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>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
const [builderResetNonce, setBuilderResetNonce] = createSignal(0);
|
||||
|
||||
/** Clic sur le logo alors que l’URL 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+17
@@ -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 d’attente Matomo avant chargement de matomo.js */
|
||||
interface Window {
|
||||
_paq?: unknown[][];
|
||||
}
|
||||
Reference in New Issue
Block a user