first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Optionnel — Matomo (mesure d’audience). Les deux variables sont requises pour activer
|
||||
# le tracker et le bandeau de consentement cookies.
|
||||
# VITE_MATOMO_URL=https://analytics.example.com/
|
||||
# VITE_MATOMO_SITE_ID=1
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description"
|
||||
content="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
|
||||
<meta name="theme-color" content="#f1e9de" />
|
||||
<link rel="canonical" href="https://ben-to.fr/" />
|
||||
<meta property="og:site_name" content="Ben-to" />
|
||||
<meta property="og:url" content="https://ben-to.fr/" />
|
||||
<meta property="og:title" content="Bento — Ben-to" />
|
||||
<meta property="og:description"
|
||||
content="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="ko_KR" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
<meta property="og:image" content="https://ben-to.fr/branding/og.jpg" />
|
||||
<meta property="og:image:alt" content="Logo onigiri pixel-art — Ben-to" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Bento — Ben-to" />
|
||||
<meta name="twitter:description"
|
||||
content="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
|
||||
<meta name="twitter:image" content="https://ben-to.fr/branding/og.jpg" />
|
||||
<meta name="twitter:image:alt" content="Logo onigiri pixel-art — Ben-to" />
|
||||
<link rel="icon" type="image/png" href="/branding/onigiri-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/branding/onigiri-logo.png" />
|
||||
<title>Bento</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@ben-to/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node ../../scripts/build-recipes.mjs",
|
||||
"dev": "vite",
|
||||
"prebuild": "node ../../scripts/build-recipes.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ben-to/builder": "0.1.0",
|
||||
"@ben-to/i18n": "0.1.0",
|
||||
"@ben-to/privacy": "0.1.0",
|
||||
"@ben-to/recipes": "0.1.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@solidjs/router": "^0.16.1",
|
||||
"solid-js": "^1.9.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.4",
|
||||
"vite-plugin-solid": "^2.11.8"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2025 The Cause Project Authors (https://github.com/xconsau/Cause)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) YoonDesign Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Klee Project Authors (https://github.com/fontworks-fonts/Klee)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
,assiette,fouet,baguettes,carnet,,,,,,,,,,,,,,,,,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,
|
||||
eau|vinaigre de riz|Vinaigre blanc|mirin,huile|jus de citron,jus d'orange,jus de citron vert,lait|crême fraîche liquide entière|Crème fraîche épaisse,chocolat fondu|sauce soja|aromate pistache,purée de sésame noir,moutarde forte,Chapelure panko|Chapelure,,,,,,,,,,,,,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,
|
||||
onigiri,empenadas,gimbap,radis mariné,kimchi,ramen,tofu,udon,dan bing|Omelette roulée,mandu,muffin,madelaine|madeleine,naan,bao,mandu shiitake,mandu aubergine,onigiri kimchi,blinis,cheese naan fromage,empanada-intensesalty,empanadas-butternut,empanadas-citron,madeleine-chocolat-blanc,guacamole
|
||||
gateau de savoie,tranche cake,banana cake,carrot cake,cake au citron,cake à l'orange,flan,cheesecake-speculoos,speculoos,financier,financier lin,muffin au sésame noir,muffin aux pépites de chocolat,muffin chocolat blanc,muffin pistache et chocolat blanc,muffin pomme cannelle,muffin menthe et pépites de chocolat,gua bao,,,,,,
|
||||
citron,orange,pomme rouge|Pomme,pomme verte,poire,,,,,,,,,,,,,,,,,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,
|
||||
betterave,carotte| carottes râpées,feuille de nori,cornichon|concombre,poivron rouge|poivron,poivron orange,poivron vert,poivron jaune,gingembre,navet,danmuji|radis jaune,aubergine|aubergines,champignon|champignons|shiitake,ail|aïl|ail frais|,radis|radis râpé,tomate|tomates,melon|melons,,,,,,,
|
||||
haricots noirs,flageolets,haricots rouges,mais,epinard,courge,oignon,piment rouge,ciboulette,ciboule,piment vert,banane|bananes,Avocat|avocats|Avocados,Butternut|butternuts,Feuilles de coriandre|coriandre,Échalote|echalote,menthe|mint,,,,,,,
|
||||
oeuf|oeufs|œufs|œuf,jaune d'oeuf|jaune d'œufs|jaune d'œuf,blanc d'oeuf|blanc d'œufs|blanc d'œuf,,,,,,,,,,,,,,,,,,,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,
|
||||
sel|bicarbonate|fleur de sel,poivre,poudre verte,poudre beige|poudre d'amande|poudre d'amandes|canelle en poudre|cannelle|cassonade|sucre brun|Levure boulangère sèche,gingembre en poudre,poudre blanche|sucre en poudre|sucre|fécule|fécule de maïs|levure chimique,poudre rouge|piment de cayenne,poudre orange,,,,,,,,,,,,,,,,
|
||||
vanille,noisette|noisettes,noix,Muscade|muscade,Clous de girofle |clous de girofle|girofle,,,,,,,,,,,,,,,,,,,
|
||||
pepite de chocolat au lait,pepites de chocolat noir,pepite de chocolat blanc|pepite chocolat blanc,,,,,,,,,,,,,,,,,,,,,
|
||||
tablette chocolat|chocolat,tablette chocolat blanc|chocolat blanc,,,,,,,,,,,,,,,,,,,,,,
|
||||
emmental,beurre|beurre doux,mozzarella,comté|gruyère,cheddar,fromage frais|yaourt grec,fromage râpé,,,,,,,,,,,,,,,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,
|
||||
farine,sucre de canne,,,,,,,,,,,,,,,,,,,,,,
|
||||
riz,,,,,,,,,,,,,,,,,,,,,,,
|
||||
graines de sésame|graines de lin,,,,,,,,,,,,,,,,,,,,,,,
|
||||
arachide|---,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 378 B |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://ben-to.fr/sitemap.xml
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://ben-to.fr/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ben-to.fr/a-propos</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ben-to.fr/cgu</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ben-to.fr/mentions-legales</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ben-to.fr/confidentialite</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
@@ -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[][];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCspHeader, buildServeJson, injectCspMetaIntoHtml, toOrigin } from "./vite-plugin-csp";
|
||||
|
||||
/** Petite fabrique de HTML représentative de l'index de l'app. */
|
||||
const sampleIndexHtml = `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bento</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
describe("CSP HTML injection (Gherkin @int-csp-html-injection)", () => {
|
||||
it("le HTML de production est instrumenté avec une meta CSP durcie", () => {
|
||||
const csp = buildCspHeader("production", null);
|
||||
const out = injectCspMetaIntoHtml(sampleIndexHtml, csp);
|
||||
|
||||
// 1) Présence de la balise meta (regex non gourmande sur la valeur).
|
||||
expect(out).toMatch(/<meta\s+http-equiv="Content-Security-Policy"\s+content="[^"]+"/);
|
||||
// 2) Vérification du contenu de la CSP via les directives clés.
|
||||
const cspValue = out.match(/content="([^"]+)"/)?.[1] ?? "";
|
||||
expect(cspValue).toContain("default-src 'self'");
|
||||
expect(cspValue).toContain("frame-ancestors 'none'");
|
||||
expect(cspValue).toContain("object-src 'none'");
|
||||
// En production, aucune directive n'autorise unsafe-eval (HMR Vite ne sert qu'en dev).
|
||||
expect(out).not.toContain("unsafe-eval");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSP adapts to Matomo origin (Gherkin @int-csp-matomo-origin)", () => {
|
||||
it("avec VITE_MATOMO_URL=https://analytics.example.com/, l'origine est ajoutée aux directives externes", () => {
|
||||
const origin = toOrigin("https://analytics.example.com/");
|
||||
expect(origin).toBe("https://analytics.example.com");
|
||||
const csp = buildCspHeader("production", origin);
|
||||
|
||||
expect(csp).toMatch(/script-src 'self' 'https:\/\/analytics\.example\.com'/);
|
||||
expect(csp).toMatch(/connect-src 'self' 'https:\/\/analytics\.example\.com'/);
|
||||
expect(csp).toMatch(/img-src 'self' 'https:\/\/analytics\.example\.com'/);
|
||||
// default-src ne s'élargit pas avec Matomo.
|
||||
expect(csp).toMatch(/default-src 'self'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve.json headers (Gherkin @int-csp-serve-json)", () => {
|
||||
it("déclare la CSP sur les HTML et les en-têtes statiques sur tout le contenu", () => {
|
||||
const sj = buildServeJson(null);
|
||||
const htmlRule = sj.headers.find((r) => r.source === "**/*.html");
|
||||
expect(htmlRule).toBeDefined();
|
||||
const byKey = (key: string) => htmlRule!.headers.find((h) => h.key === key);
|
||||
|
||||
expect(byKey("Content-Security-Policy")?.value).toBeDefined();
|
||||
expect(byKey("X-Content-Type-Options")?.value).toBe("nosniff");
|
||||
expect(byKey("X-Frame-Options")?.value).toBe("DENY");
|
||||
expect(byKey("Referrer-Policy")?.value).toBe("strict-origin-when-cross-origin");
|
||||
|
||||
const pp = byKey("Permissions-Policy")?.value ?? "";
|
||||
expect(pp).toContain("camera=()");
|
||||
expect(pp).toContain("microphone=()");
|
||||
expect(pp).toContain("geolocation=()");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Connect } from "vite";
|
||||
import type { Plugin, ResolvedConfig } from "vite";
|
||||
|
||||
/** Mode effectif de la construction Vite (sert à choisir la politique CSP). */
|
||||
export type CspMode = "development" | "production";
|
||||
|
||||
/** Options publiques du plugin. */
|
||||
export type CspPluginOptions = Readonly<{
|
||||
/**
|
||||
* Origine Matomo à autoriser (sans slash final). `null` = aucune origine externe
|
||||
* (Matomo non configuré via `VITE_MATOMO_URL`).
|
||||
*/
|
||||
readonly matomoOrigin: string | null;
|
||||
}>;
|
||||
|
||||
/** Une règle CSP (directive + sources). */
|
||||
export type CspDirective = Readonly<{
|
||||
readonly name: string;
|
||||
readonly sources: readonly string[];
|
||||
}>;
|
||||
|
||||
/** Helpers exportés (testés unitairement) : construction de la chaîne CSP. */
|
||||
|
||||
/**
|
||||
* Source `'self'` et tokens spéciaux. Les sources `none`, `self`, `unsafe-inline`,
|
||||
* `unsafe-eval`, `strict-dynamic` etc. doivent être quotés **uniquement** quand
|
||||
* ils sont entre quotes, ce qui est notre cas.
|
||||
*/
|
||||
const sourceNeedsQuoting = true;
|
||||
|
||||
const quote = (s: string): string => (sourceNeedsQuoting ? `'${s}'` : s);
|
||||
|
||||
/** Joint deux sources CSP avec un espace. */
|
||||
const joinSources = (sources: readonly string[]): string => sources.map(quote).join(" ");
|
||||
|
||||
/**
|
||||
* Sérialise une liste de directives CSP en une chaîne `Content-Security-Policy`.
|
||||
* Chaque directive devient `name source1 source2 …` séparée par `; `.
|
||||
*/
|
||||
export const serializeCsp = (directives: readonly CspDirective[]): string =>
|
||||
directives.map((d) => `${d.name} ${joinSources(d.sources)}`).join("; ");
|
||||
|
||||
/** Enlève un slash final d'une origine. */
|
||||
const stripTrailingSlash = (s: string): string => s.replace(/\/+$/, "");
|
||||
|
||||
/** Normalise une URL en origine (`https://host[:port]`). Renvoie `null` si invalide. */
|
||||
export const toOrigin = (raw: string | null | undefined): string | null => {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
return stripTrailingSlash(`${u.protocol}//${u.host}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Construit la liste de directives CSP pour le mode donné.
|
||||
* Sources autorisées :
|
||||
* - `'self'` partout
|
||||
* - Origine Matomo (si fournie) sur `script-src`, `connect-src`, `img-src`
|
||||
* - Dev uniquement : `'unsafe-inline'`, `'unsafe-eval'` (script & style), `ws:`
|
||||
* / `wss:` (connect-src pour HMR Vite)
|
||||
*/
|
||||
export const buildCspDirectives = (
|
||||
mode: CspMode,
|
||||
matomoOrigin: string | null
|
||||
): readonly CspDirective[] => {
|
||||
const matomo = matomoOrigin ? [matomoOrigin] : [];
|
||||
|
||||
const scriptSources: string[] = ["self"];
|
||||
const styleSources: string[] = ["self"];
|
||||
const connectSources: string[] = ["self"];
|
||||
|
||||
if (mode === "development") {
|
||||
scriptSources.push("unsafe-inline", "unsafe-eval");
|
||||
connectSources.push("ws:", "wss:");
|
||||
}
|
||||
|
||||
// `'unsafe-inline'` sur `style-src` est **toujours** requis : Solid pose des styles
|
||||
// inline (`style={{...}}`) sur des nœuds (cf. `apps/web/src/ui/SpriteTile.tsx`).
|
||||
// L'attribut `style="..."` est restreint par `style-src-attr` (fallback `style-src`).
|
||||
styleSources.push("unsafe-inline");
|
||||
|
||||
if (matomo.length) {
|
||||
scriptSources.push(...matomo);
|
||||
connectSources.push(...matomo);
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: "default-src", sources: ["self"] },
|
||||
{ name: "script-src", sources: scriptSources },
|
||||
{ name: "style-src", sources: styleSources },
|
||||
{ name: "img-src", sources: ["self", ...matomo] },
|
||||
{ name: "font-src", sources: ["self"] },
|
||||
{ name: "connect-src", sources: connectSources },
|
||||
{ name: "media-src", sources: ["self"] },
|
||||
{ name: "object-src", sources: ["none"] },
|
||||
{ name: "base-uri", sources: ["self"] },
|
||||
{ name: "form-action", sources: ["self"] },
|
||||
{ name: "frame-ancestors", sources: ["none"] },
|
||||
{ name: "frame-src", sources: ["none"] }
|
||||
];
|
||||
};
|
||||
|
||||
/** Construit la chaîne `Content-Security-Policy` pour le mode donné. */
|
||||
export const buildCspHeader = (mode: CspMode, matomoOrigin: string | null): string =>
|
||||
serializeCsp(buildCspDirectives(mode, matomoOrigin));
|
||||
|
||||
/** Format d'en-têtes pour `serve.json` (cf. https://github.com/vercel/serve#configuration). */
|
||||
export type ServeJsonHeader = Readonly<{ key: string; value: string }>;
|
||||
export type ServeJsonRule = Readonly<{ source: string; headers: readonly ServeJsonHeader[] }>;
|
||||
export type ServeJson = Readonly<{ headers: readonly ServeJsonRule[] }>;
|
||||
|
||||
/** En-têtes statiques (indépendants de la CSP). */
|
||||
const STATIC_SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
// Aucune fonctionnalité avancée nécessaire sur la V1 (app statique, pas de WebRTC, pas de paiement).
|
||||
value: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Construit l'objet `serve.json` qui sera écrit dans `dist/` (lu par `serve` au runtime).
|
||||
* Les en-têtes sont scopés sur le glob HTML pour la CSP, et sur un glob catch-all pour le
|
||||
* reste (ne pas « fuir » la CSP sur des assets qui n'en ont pas besoin ; l'en-tête reste
|
||||
* valide sur tout contenu textuel, mais limiter la surface évite les bugs).
|
||||
*/
|
||||
export const buildServeJson = (matomoOrigin: string | null): ServeJson => ({
|
||||
headers: [
|
||||
{
|
||||
source: "**/*.html",
|
||||
headers: [
|
||||
{ key: "Content-Security-Policy", value: buildCspHeader("production", matomoOrigin) },
|
||||
...STATIC_SECURITY_HEADERS
|
||||
]
|
||||
},
|
||||
{
|
||||
source: "**/*",
|
||||
headers: STATIC_SECURITY_HEADERS
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/** Injecte la balise `<meta http-equiv="Content-Security-Policy">` dans un HTML. */
|
||||
export const injectCspMetaIntoHtml = (html: string, csp: string): string => {
|
||||
const metaTag = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
|
||||
// Stratégie : insérer juste après la première balise d'en-tête de charset.
|
||||
// Si absente, on insère juste après `<head>`.
|
||||
const charsetMatch = /<meta\s+charset="[^"]*"\s*\/?>/i.exec(html);
|
||||
if (charsetMatch) {
|
||||
const insertAt = charsetMatch.index + charsetMatch[0].length;
|
||||
return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`;
|
||||
}
|
||||
const headMatch = /<head[^>]*>/i.exec(html);
|
||||
if (headMatch) {
|
||||
const insertAt = headMatch.index + headMatch[0].length;
|
||||
return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`;
|
||||
}
|
||||
return ` ${metaTag}\n${html}`;
|
||||
};
|
||||
|
||||
/** Vrai si la requête vise un document HTML. */
|
||||
const isHtmlRequest = (req: IncomingMessage): boolean => {
|
||||
const accept = String(req.headers.accept ?? "");
|
||||
// `fetch` côté JS : pas un document ; `curl` sans Accept : on autorise pour le smoke test.
|
||||
return accept.includes("text/html") || accept.includes("*/*") || accept === "";
|
||||
};
|
||||
|
||||
/** Vrai si la requête vise une page recette (HTML pré-rendu `/r/...`). */
|
||||
const isRecipeSharePath = (url: string | undefined): boolean => {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const pathname = new URL(url, "http://localhost").pathname.replace(/\/$/, "");
|
||||
return /^\/r\/[^/]+\/[^/]+$/.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Middleware Vite : pose les en-têtes de sécurité sur les réponses HTML du dev server. */
|
||||
const makeSecurityHeadersMiddleware = (
|
||||
cspValue: string
|
||||
): Connect.NextHandleFunction => {
|
||||
return function securityHeadersMiddleware(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: Connect.NextFunction
|
||||
): void {
|
||||
if (!isHtmlRequest(req) && !isRecipeSharePath(req.url)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.setHeader("Content-Security-Policy", cspValue);
|
||||
for (const h of STATIC_SECURITY_HEADERS) {
|
||||
res.setHeader(h.key, h.value);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin Vite qui injecte la CSP (en meta) dans le HTML de l'app
|
||||
* et émet les en-têtes de sécurité en dev + un `dist/serve.json` en prod.
|
||||
*/
|
||||
export function cspPlugin(options: CspPluginOptions): Plugin {
|
||||
let config: ResolvedConfig;
|
||||
const matomoOrigin = options.matomoOrigin;
|
||||
|
||||
return {
|
||||
name: "ben-to-csp",
|
||||
enforce: "pre",
|
||||
configResolved(resolved) {
|
||||
config = resolved;
|
||||
},
|
||||
transformIndexHtml(html) {
|
||||
const mode: CspMode = config.command === "build" ? "production" : "development";
|
||||
const csp = buildCspHeader(mode, matomoOrigin);
|
||||
return injectCspMetaIntoHtml(html, csp);
|
||||
},
|
||||
configureServer(server) {
|
||||
const csp = buildCspHeader("development", matomoOrigin);
|
||||
// Place ce middleware **avant** celui de `recipeSeoPlugin` (enregistré après) pour
|
||||
// que `res.setHeader` soit effectif avant que `recipeSeoPlugin` n'écrive la réponse.
|
||||
server.middlewares.use(makeSecurityHeadersMiddleware(csp));
|
||||
},
|
||||
async closeBundle() {
|
||||
if (config.command !== "build") return;
|
||||
const outDir = path.resolve(config.root, config.build.outDir);
|
||||
const serveJsonPath = path.join(outDir, "serve.json");
|
||||
const payload = buildServeJson(matomoOrigin);
|
||||
await fs.writeFile(serveJsonPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
||||
config.logger.info(
|
||||
`csp: dist/serve.json écrit (${payload.headers.length} règle(s), matomo=${matomoOrigin ?? "désactivé"
|
||||
})`
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCspDirectives,
|
||||
buildCspHeader,
|
||||
buildServeJson,
|
||||
injectCspMetaIntoHtml,
|
||||
serializeCsp,
|
||||
toOrigin
|
||||
} from "./vite-plugin-csp";
|
||||
|
||||
describe("toOrigin", () => {
|
||||
it("renvoie null pour une entrée vide ou non string", () => {
|
||||
expect(toOrigin(null)).toBeNull();
|
||||
expect(toOrigin(undefined)).toBeNull();
|
||||
expect(toOrigin("")).toBeNull();
|
||||
expect(toOrigin(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("extrait le protocole + host (+ port)", () => {
|
||||
expect(toOrigin("https://analytics.example.com/")).toBe("https://analytics.example.com");
|
||||
expect(toOrigin("https://analytics.example.com")).toBe("https://analytics.example.com");
|
||||
expect(toOrigin("https://analytics.example.com:8443/foo/bar?x=1")).toBe(
|
||||
"https://analytics.example.com:8443"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuse les protocoles non http(s)", () => {
|
||||
expect(toOrigin("file:///etc/passwd")).toBeNull();
|
||||
expect(toOrigin("javascript:alert(1)")).toBeNull();
|
||||
expect(toOrigin("data:text/plain,foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("renvoie null pour une URL invalide", () => {
|
||||
expect(toOrigin("not a url")).toBeNull();
|
||||
expect(toOrigin("http://")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeCsp", () => {
|
||||
it("joint les directives par `; ` et quote chaque source", () => {
|
||||
const out = serializeCsp([
|
||||
{ name: "default-src", sources: ["self"] },
|
||||
{ name: "script-src", sources: ["self", "https://x.test"] }
|
||||
]);
|
||||
expect(out).toBe("default-src 'self'; script-src 'self' 'https://x.test'");
|
||||
});
|
||||
|
||||
it("préserve les tokens spéciaux (unsafe-inline, etc.)", () => {
|
||||
const out = serializeCsp([{ name: "script-src", sources: ["self", "unsafe-inline", "unsafe-eval"] }]);
|
||||
expect(out).toBe("script-src 'self' 'unsafe-inline' 'unsafe-eval'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCspDirectives", () => {
|
||||
it("en prod sans Matomo : aucune origine externe, pas de unsafe-eval, frame-ancestors none", () => {
|
||||
const dirs = buildCspDirectives("production", null);
|
||||
const find = (name: string) => dirs.find((d) => d.name === name);
|
||||
expect(find("default-src")?.sources).toEqual(["self"]);
|
||||
expect(find("script-src")?.sources).toEqual(["self"]);
|
||||
// `style-src` inclut `unsafe-inline` même en prod (styles inline Solid).
|
||||
expect(find("style-src")?.sources).toEqual(["self", "unsafe-inline"]);
|
||||
expect(find("img-src")?.sources).toEqual(["self"]);
|
||||
expect(find("font-src")?.sources).toEqual(["self"]);
|
||||
expect(find("connect-src")?.sources).toEqual(["self"]);
|
||||
expect(find("media-src")?.sources).toEqual(["self"]);
|
||||
expect(find("object-src")?.sources).toEqual(["none"]);
|
||||
expect(find("base-uri")?.sources).toEqual(["self"]);
|
||||
expect(find("form-action")?.sources).toEqual(["self"]);
|
||||
expect(find("frame-ancestors")?.sources).toEqual(["none"]);
|
||||
expect(find("frame-src")?.sources).toEqual(["none"]);
|
||||
});
|
||||
|
||||
it("en prod avec Matomo : autorise l'origine sur script/connect/img", () => {
|
||||
const dirs = buildCspDirectives("production", "https://analytics.example.com");
|
||||
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
||||
"self",
|
||||
"https://analytics.example.com"
|
||||
]);
|
||||
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual([
|
||||
"self",
|
||||
"https://analytics.example.com"
|
||||
]);
|
||||
expect(dirs.find((d) => d.name === "img-src")?.sources).toEqual([
|
||||
"self",
|
||||
"https://analytics.example.com"
|
||||
]);
|
||||
// Pas de Matomo sur font-src / default-src.
|
||||
expect(dirs.find((d) => d.name === "font-src")?.sources).toEqual(["self"]);
|
||||
});
|
||||
|
||||
it("en dev : autorise unsafe-eval (HMR), ws:/wss:, et unsafe-inline sur style-src", () => {
|
||||
const dirs = buildCspDirectives("development", null);
|
||||
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
||||
"self",
|
||||
"unsafe-inline",
|
||||
"unsafe-eval"
|
||||
]);
|
||||
expect(dirs.find((d) => d.name === "style-src")?.sources).toEqual(["self", "unsafe-inline"]);
|
||||
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual(["self", "ws:", "wss:"]);
|
||||
// frame-ancestors reste 'none' en dev aussi.
|
||||
expect(dirs.find((d) => d.name === "frame-ancestors")?.sources).toEqual(["none"]);
|
||||
});
|
||||
|
||||
it("en dev avec Matomo : cumule unsafe-* et l'origine Matomo", () => {
|
||||
const dirs = buildCspDirectives("development", "https://analytics.example.com");
|
||||
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
||||
"self",
|
||||
"unsafe-inline",
|
||||
"unsafe-eval",
|
||||
"https://analytics.example.com"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCspHeader", () => {
|
||||
it("inclut toutes les directives attendues en prod", () => {
|
||||
const csp = buildCspHeader("production", null);
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
// `unsafe-inline` reste sur `style-src` (styles inline Solid) — seul `unsafe-eval` est
|
||||
// strictement exclu en prod (HMR Vite ne sert qu'en dev).
|
||||
expect(csp).not.toContain("unsafe-eval");
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectCspMetaIntoHtml", () => {
|
||||
const sample = `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
it("insère la balise CSP juste après le charset", () => {
|
||||
const out = injectCspMetaIntoHtml(sample, "default-src 'self'");
|
||||
expect(out).toMatch(
|
||||
/<meta\s+charset="UTF-8"\s*\/>\s*<meta\s+http-equiv="Content-Security-Policy"\s+content="default-src 'self'">/
|
||||
);
|
||||
});
|
||||
|
||||
it("insère après <head> si pas de charset", () => {
|
||||
const html = "<html><head><title>X</title></head><body/></html>";
|
||||
const out = injectCspMetaIntoHtml(html, "default-src 'self'");
|
||||
expect(out).toMatch(/<head>\s*<meta\s+http-equiv="Content-Security-Policy"/);
|
||||
});
|
||||
|
||||
it("quote correctement la valeur CSP", () => {
|
||||
const out = injectCspMetaIntoHtml(sample, "default-src 'self'; script-src 'self'");
|
||||
expect(out).toContain(`content="default-src 'self'; script-src 'self'"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildServeJson", () => {
|
||||
it("déclare une règle HTML avec CSP + en-têtes statiques, et une règle catch-all sans CSP", () => {
|
||||
const sj = buildServeJson(null);
|
||||
expect(sj.headers).toHaveLength(2);
|
||||
|
||||
const htmlRule = sj.headers.find((r) => r.source === "**/*.html");
|
||||
expect(htmlRule).toBeDefined();
|
||||
const htmlKeys = htmlRule!.headers.map((h) => h.key);
|
||||
expect(htmlKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Content-Security-Policy",
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"X-Frame-Options",
|
||||
"Permissions-Policy"
|
||||
])
|
||||
);
|
||||
const cspEntry = htmlRule!.headers.find((h) => h.key === "Content-Security-Policy");
|
||||
expect(cspEntry?.value).not.toContain("unsafe-eval");
|
||||
expect(cspEntry?.value).toContain("frame-ancestors 'none'");
|
||||
|
||||
const allRule = sj.headers.find((r) => r.source === "**/*");
|
||||
expect(allRule).toBeDefined();
|
||||
const allKeys = allRule!.headers.map((h) => h.key);
|
||||
expect(allKeys).not.toContain("Content-Security-Policy");
|
||||
expect(allKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"X-Frame-Options",
|
||||
"Permissions-Policy"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("la CSP s'adapte à l'origine Matomo fournie", () => {
|
||||
const sj = buildServeJson("https://analytics.example.com");
|
||||
const cspEntry = sj.headers
|
||||
.find((r) => r.source === "**/*.html")!
|
||||
.headers.find((h) => h.key === "Content-Security-Policy");
|
||||
expect(cspEntry?.value).toContain("https://analytics.example.com");
|
||||
});
|
||||
|
||||
it("la Permissions-Policy désactive les capacités sensibles", () => {
|
||||
const sj = buildServeJson(null);
|
||||
const allHeaders = sj.headers.flatMap((r) => r.headers);
|
||||
const pp = allHeaders.find((h) => h.key === "Permissions-Policy");
|
||||
expect(pp?.value).toContain("camera=()");
|
||||
expect(pp?.value).toContain("microphone=()");
|
||||
expect(pp?.value).toContain("geolocation=()");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Plugin, ResolvedConfig } from "vite";
|
||||
import type { RecipesIndex } from "@ben-to/recipes/catalog";
|
||||
import {
|
||||
buildRecipeOgHeadModel,
|
||||
forEachRecipeSharePair,
|
||||
injectRecipeSeoIntoIndexHtml
|
||||
} from "@ben-to/recipes/recipe-og-meta";
|
||||
|
||||
const DEFAULT_SITE_ORIGIN = "https://ben-to.fr";
|
||||
|
||||
/**
|
||||
* Pré-rendu des pages recette pour les métadonnées Open Graph / Twitter (HTML initial).
|
||||
* - **Dev** : middleware qui réinjecte le `<head>` pour les URLs `/r/:base/:variant`.
|
||||
* - **Build** : écrit `dist/r/<base>/<variant>/index.html` pour chaque couple catalogue.
|
||||
*/
|
||||
export function recipeSeoPlugin(): Plugin {
|
||||
let config: ResolvedConfig;
|
||||
|
||||
return {
|
||||
name: "ben-to-recipe-seo",
|
||||
configResolved(resolved) {
|
||||
config = resolved;
|
||||
},
|
||||
configureServer(server) {
|
||||
const root = server.config.root;
|
||||
const port = server.config.server.port ?? 3000;
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (req.method !== "GET" || !req.url) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(req.url, "http://localhost").pathname.replace(/\/$/, "");
|
||||
const match = /^\/r\/([^/]+)\/([^/]+)$/.exec(pathname);
|
||||
if (!match) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const baseId = decodeURIComponent(match[1]);
|
||||
const variantId = decodeURIComponent(match[2]);
|
||||
const catalogPath = path.join(root, "public/recipes/catalog.json");
|
||||
const indexPath = path.join(root, "index.html");
|
||||
const [catalogRaw, rawIndexHtml] = await Promise.all([
|
||||
fs.readFile(catalogPath, "utf8"),
|
||||
fs.readFile(indexPath, "utf8")
|
||||
]);
|
||||
// On passe par le pipeline de transformation HTML de Vite (injecte la CSP
|
||||
// et tout autre plugin qui utilise `transformIndexHtml`) avant d'injecter
|
||||
// les balises OG/Twitter recette.
|
||||
const indexHtml = await server.transformIndexHtml(req.url ?? pathname, rawIndexHtml);
|
||||
const catalog = JSON.parse(catalogRaw) as RecipesIndex;
|
||||
const base = catalog.bases.find((b) => b.id === baseId);
|
||||
const variant = catalog.variants.find((v) => v.id === variantId);
|
||||
if (!base || !variant || !variant.baseIds.includes(baseId)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const devOrigin = process.env.BEN_TO_SITE_ORIGIN ?? `http://localhost:${port}`;
|
||||
const model = buildRecipeOgHeadModel({
|
||||
siteOrigin: devOrigin,
|
||||
base,
|
||||
variant,
|
||||
baseId,
|
||||
variantId
|
||||
});
|
||||
const html = injectRecipeSeoIntoIndexHtml(indexHtml, model);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(html);
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
async closeBundle() {
|
||||
const outDir = path.resolve(config.root, config.build.outDir);
|
||||
const indexPath = path.join(outDir, "index.html");
|
||||
const catalogPath = path.join(outDir, "recipes/catalog.json");
|
||||
const siteOrigin = process.env.BEN_TO_SITE_ORIGIN ?? DEFAULT_SITE_ORIGIN;
|
||||
const [indexHtml, catalogRaw] = await Promise.all([
|
||||
fs.readFile(indexPath, "utf8"),
|
||||
fs.readFile(catalogPath, "utf8")
|
||||
]);
|
||||
const catalog = JSON.parse(catalogRaw) as RecipesIndex;
|
||||
const tasks: Promise<void>[] = [];
|
||||
forEachRecipeSharePair(catalog, ({ base, variant, baseId, variantId }) => {
|
||||
tasks.push(
|
||||
(async () => {
|
||||
const model = buildRecipeOgHeadModel({
|
||||
siteOrigin,
|
||||
base,
|
||||
variant,
|
||||
baseId,
|
||||
variantId
|
||||
});
|
||||
const html = injectRecipeSeoIntoIndexHtml(indexHtml, model);
|
||||
const dir = path.join(outDir, "r", encodeURIComponent(baseId), encodeURIComponent(variantId));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "index.html"), html, "utf8");
|
||||
})()
|
||||
);
|
||||
});
|
||||
await Promise.all(tasks);
|
||||
config.logger.info(`recipe-seo: ${tasks.length} page(s) OG générée(s) sous dist/r/`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
import { recipeSeoPlugin } from "./vite-plugin-recipe-seo";
|
||||
import { cspPlugin } from "./vite-plugin-csp";
|
||||
import { toOrigin } from "./vite-plugin-csp";
|
||||
|
||||
const pkg = (relative: string) => path.resolve(__dirname, relative);
|
||||
|
||||
const matomoOrigin = toOrigin(process.env.VITE_MATOMO_URL);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [cspPlugin({ matomoOrigin }), recipeSeoPlugin(), solidPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ben-to/builder/domain": pkg("../../packages/features/builder/src/domain.ts"),
|
||||
"@ben-to/recipes/catalog": pkg("../../packages/features/recipes/src/catalog.ts"),
|
||||
"@ben-to/recipes/domain": pkg("../../packages/features/recipes/src/domain.ts"),
|
||||
"@ben-to/recipes/recipe-og-meta": pkg("../../packages/features/recipes/src/recipe-og-meta.ts"),
|
||||
"@ben-to/i18n/dictionary": pkg("../../packages/shared/i18n/src/dictionary.ts"),
|
||||
"@ben-to/testing/iso-date": pkg("../../packages/shared/testing/src/iso-date.ts"),
|
||||
"@ben-to/privacy/cookie-consent": pkg("../../packages/shared/privacy/src/cookie-consent.ts")
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user