@lystech/sections 0.5.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ JavaScript et 1,2 Mo de CSS. Une clinique qui veut changer trois textes n'a
10
10
  aucune raison de charger un magasin, un panier et PayPal — et beaucoup de sites
11
11
  clients sont en React 19.
12
12
 
13
- Ce paquet pèse **4,7 Ko** en lecture (1,9 Ko compressé) et ne dépend que de
13
+ Ce paquet pèse **11 Ko** en lecture (4,4 Ko compressé) et ne dépend que de
14
14
  React. `core` le réexporte, pour qu'une boutique qui l'a déjà n'ait rien de
15
15
  plus à installer.
16
16
 
@@ -59,7 +59,7 @@ une version anglaise oubliée vaut mieux affichée en français qu'absente.
59
59
 
60
60
  La personne qui édite ouvre SON site avec un jeton dans l'adresse. Le paquet le
61
61
  détecte, le retire aussitôt de la barre d'adresse, et charge l'interface
62
- d'édition — 22 Ko qu'aucun visiteur ne télécharge jamais, parce que le
62
+ d'édition — ~27 Ko compressés qu'aucun visiteur ne télécharge jamais, parce que le
63
63
  `import()` qui y mène n'est atteint que s'il y a un jeton.
64
64
 
65
65
  Elle voit alors son site, ses polices, sa mise en page, avec chaque bloc
@@ -77,6 +77,24 @@ return <section {...ancrage}>…</section>;
77
77
 
78
78
  Hors édition, c'est un attribut `data-` inerte : aucun poids, aucun effet.
79
79
 
80
+ ### Corriger un prix sur place : `champ`
81
+
82
+ Un texte traduit se retrouve tout seul dans la page. Un prix, non : « 85 $ » ne
83
+ dit pas que ce 85 est le prix de la séance. Le site pose `champ(…)` sur
84
+ l'élément qui ne contient QUE la valeur :
85
+
86
+ ```tsx
87
+ const { contenu, champ } = useSection("tarifs", { defaut });
88
+ return <p><span {...champ("seance")}>{contenu.seance}</span> $</p>;
89
+ ```
90
+
91
+ En mode « modifier », un clic sur la valeur ouvre une saisie posée dessus :
92
+ Entrée enregistre le brouillon, Échap annule, et la page montre aussitôt la
93
+ nouvelle valeur. Un champ qui ne se saisit pas sur une ligne (image, liste,
94
+ texte riche) ouvre plutôt le panneau de son bloc. Si le format vient d'une
95
+ traduction (« {{amount}} $ »), découpez-le autour de la valeur au lieu de
96
+ rendre la phrase d'un bloc : seul le nombre doit porter `champ`.
97
+
80
98
  ### Trois états, jamais confondus
81
99
 
82
100
  | | Qui le voit |
@@ -129,6 +147,128 @@ qui se rendent hors de l'arbre :
129
147
  `<SignatureLystech mention={null} />` ne garde que le bouton « Connexion » —
130
148
  pour un pied de page qui porte déjà sa propre mention de Lystech.
131
149
 
150
+ ## La boutique dans l'éditeur (0.7)
151
+
152
+ Une personne qui **administre** la boutique gère, depuis son propre site,
153
+ ses produits, leurs photos et ses collections — au bureau comme au
154
+ téléphone. Maquette : `docs/maquette-editeur.html`.
155
+
156
+ ### Ce que voit la cliente
157
+
158
+ - un bouton **Boutique** dans la barre d'édition : la liste de ses produits
159
+ (recherche, filtres En vente / Masqués / Épuisés / Sans collection) et ses
160
+ collections ;
161
+ - un **crayon** sur chaque carte produit de la page, qui ouvre sa fiche ;
162
+ - une tuile **+ Ajouter** juste après la dernière carte de la grille ;
163
+ - la **fiche** : photos (glisser-déposer, ou « Prendre une photo » qui ouvre
164
+ la caméra du téléphone), nom, prix, prix avant rabais, stock en trois mots
165
+ (pièce unique / quantité / sans limite), visible ou masqué, collections,
166
+ description. Panneau latéral au bureau, feuille glissante au téléphone ;
167
+ - un bouton **Mobile** : la page courante dans un cadre de 390 px, brouillons
168
+ de textes compris.
169
+
170
+ **Deux rythmes, dits à l'écran.** Les textes passent par brouillon puis
171
+ « Publier ». Le catalogue, lui, est en ligne dès « Enregistrer » ; un bijou
172
+ pas prêt reste « Masqué ».
173
+
174
+ **Rien ne disparaît d'un clic.** Masquer est réversible. « Supprimer pour
175
+ toujours » n'existe que sur un produit déjà masqué, après une confirmation
176
+ explicite, et le serveur refuse un produit déjà vendu. Retirer une photo la
177
+ détache : elle reste dans la librairie d'images.
178
+
179
+ ### Ce que le site doit faire : deux lignes
180
+
181
+ **1. Marquer ses cartes produit** — sans ce marquage, pas de crayon ni de
182
+ tuile (le module reste accessible par la barre) :
183
+
184
+ ```tsx
185
+ import { marqueProduit } from "@lystech/sections";
186
+
187
+ <div className="carte" {...marqueProduit(produit.UID)}>…</div>
188
+ ```
189
+
190
+ L'attribut posé est `data-lystech-produit="<UID>"`, inerte hors édition.
191
+ Crayons et tuile vivent dans le Shadow DOM de l'éditeur, posés par-dessus :
192
+ rien n'est inséré dans l'arbre React du site. La tuile se place d'après les
193
+ rectangles réels des cartes (grille CSS, flexbox, 2 ou 4 colonnes).
194
+
195
+ **2. Relire ses produits quand l'éditeur en change un.** Après chaque
196
+ enregistrement, l'éditeur émet `lystech:catalogue-modifie` sur `window`
197
+ (détail : `{ quoi, UID, action }`). Avec React Query :
198
+
199
+ ```tsx
200
+ import { useCatalogueModifie } from "@lystech/sections";
201
+
202
+ const qc = useQueryClient();
203
+ useCatalogueModifie(() => qc.invalidateQueries());
204
+ ```
205
+
206
+ L'aperçu mobile relaie l'événement dans son cadre : le site s'y met à jour
207
+ de la même façon.
208
+
209
+ Un site peut aussi ouvrir le module lui-même — `useSectionsContext()
210
+ ?.ouvrirBoutique(UID | "nouveau")` rend faux si la personne n'y a pas droit.
211
+
212
+ ### Qui y a droit
213
+
214
+ Routes `/site-sections/boutique/*` du serveur. Le jeton d'édition reste
215
+ plafonné à l'éditeur partout ailleurs ; ici, le serveur relit à chaque
216
+ requête le **rôle réel** de la personne et exige **administrateur** (un
217
+ éditeur reçoit 403 : il garde l'édition des textes, le bouton Boutique ne
218
+ s'affiche pas). Organisation = celle du jeton, jamais un en-tête. Aucune
219
+ route de commandes, clients ou paiements.
220
+
221
+ Le module (≈12 Ko compressés) et l'aperçu mobile sont deux fichiers à part,
222
+ chargés au premier clic.
223
+
224
+ ### L'aperçu mobile
225
+
226
+ Un cadre nommé `lystech-apercu` charge la même page ; le fournisseur qui s'y
227
+ trouve le reconnaît, ne monte pas l'éditeur et reçoit les brouillons par
228
+ `postMessage` (même origine seulement). `useSectionsContext()?.modeApercu`
229
+ permet à un site de couper ce qui n'a pas de sens dans un aperçu.
230
+
231
+ ## Sections par audience (0.8)
232
+
233
+ Une section peut viser une **audience** : tout le monde, clients connectés,
234
+ non-membres, membres (n'importe quel plan actif) ou des plans précis. Le choix
235
+ se fait dans l'éditeur (« Visible pour », en tête du panneau d'un bloc) et
236
+ s'applique tout de suite.
237
+
238
+ **Le filtrage est fait par le serveur, jamais par la page :**
239
+
240
+ - la route publique (`/site-sections/public/...`) — donc aussi le HTML
241
+ prérendu, fabriqué en la lisant — ne contient QUE les sections « tout le
242
+ monde » ;
243
+ - le fournisseur lit d'abord `/site-sections/membre/...`, où le serveur
244
+ reconnaît le visiteur (session, adhésion) et ne livre que ce à quoi il a
245
+ droit. Une section non permise arrive en coquille vide (`hidden`) : le site
246
+ sait qu'elle existe et n'affiche PAS son repli à la place.
247
+
248
+ ```tsx
249
+ const { contenu, visible } = useSection("offre-membres", { defaut });
250
+ if (!visible) return null;
251
+ ```
252
+
253
+ **Règle :** un contenu réservé ne va jamais dans le `defaut` du code — le code
254
+ d'un site est public. Il s'écrit dans l'éditeur.
255
+
256
+ Session : par défaut le cookie de l'API est envoyé (`credentials: "include"`).
257
+ Un site branché sur `@lystech/core` passe la sienne :
258
+
259
+ ```tsx
260
+ <SectionsProvider site="unyk" page="accueil" session={useSectionsSession()}>
261
+ ```
262
+
263
+ Pour un bloc codé en dur (un bouton, un mot d'accueil), `<Audience>` décide de
264
+ l'**affichage** d'après le visiteur reconnu par le serveur — jamais pour un
265
+ contenu sensible :
266
+
267
+ ```tsx
268
+ <Audience show="nonMembers"><BoutonDevenirMembre /></Audience>
269
+ <Audience show="plans" plans={[uidPlanOr]} fallback={<Teaser />}>…</Audience>
270
+ ```
271
+
132
272
  ## Sites prérendus
133
273
 
134
274
  Un site généré au build affiche les replis du code dans son HTML, puis bascule
@@ -0,0 +1,14 @@
1
+ import { ReactNode } from 'react';
2
+ import { Visiteur } from './types';
3
+ export type AudienceProps = {
4
+ show: "everyone" | "loggedIn" | "members" | "nonMembers" | "plans";
5
+ /** UID des plans, pour `show="plans"`. */
6
+ plans?: string[] | undefined;
7
+ /** Ce qu'on rend à la place (défaut : rien). */
8
+ fallback?: ReactNode;
9
+ children: ReactNode;
10
+ };
11
+ /** Le visiteur tel que le serveur l'a reconnu, ou `null` tant qu'on ne sait pas. */
12
+ export declare function useVisiteur(): Visiteur | null;
13
+ export declare function visiteurCorrespond(v: Visiteur | null, show: AudienceProps["show"], plans?: string[]): boolean;
14
+ export declare function Audience({ show, plans, fallback, children }: AudienceProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { SourceTexte } from './sourceTexte';
3
- import { SectionPubliee } from './types';
3
+ import { SectionPubliee, Visiteur } from './types';
4
4
  type Etat = "chargement" | "pret" | "erreur";
5
5
  type Contexte = {
6
6
  /** Sous-domaine de l'organisation dans Lystech. */
@@ -54,8 +54,40 @@ type Contexte = {
54
54
  * Hors mode édition, l'appel échoue sans rien envoyer.
55
55
  */
56
56
  appelEdition: (chemin: string, init?: RequestInit) => Promise<Response>;
57
+ /**
58
+ * OUVRIR LE MODULE BOUTIQUE de l'éditeur, depuis un bouton du site : sur la
59
+ * fiche d'un produit (son UID), une fiche vierge (« nouveau »), ou la liste
60
+ * (sans argument). Rend faux hors édition, ou si la personne connectée
61
+ * n'administre pas la boutique — le site peut alors cacher son bouton.
62
+ */
63
+ ouvrirBoutique: (cible?: string | null) => boolean;
64
+ /**
65
+ * Vrai dans l'APERÇU MOBILE de l'éditeur (la page affichée dans un cadre de
66
+ * 390 px). L'éditeur n'y est pas monté ; un site peut s'en servir pour
67
+ * couper ce qui n'a pas de sens dans un aperçu (une fenêtre surgissante).
68
+ */
69
+ modeApercu: boolean;
70
+ /**
71
+ * LE VISITEUR, tel que le serveur l'a reconnu (session + adhésion), ou
72
+ * `null` tant qu'on ne sait pas (premier rendu, serveur ancien, réseau
73
+ * coupé). Sert à `<Audience>` ; le contenu réservé, lui, est déjà filtré
74
+ * par le serveur.
75
+ */
76
+ visiteur: Visiteur | null;
57
77
  };
58
- export declare function SectionsProvider({ site, page, langue, apiBaseUrl, sectionsInitiales, sourceTexte, pagesPartagees, children, }: {
78
+ /**
79
+ * LA SESSION DU VISITEUR, pour que le serveur reconnaisse un membre.
80
+ *
81
+ * Par défaut, le cookie de session de l'API est envoyé (`credentials:
82
+ * "include"`). Un site qui s'authentifie autrement (en-tête posé par
83
+ * @lystech/core) passe ses en-têtes ici. `cle` change quand la personne se
84
+ * connecte ou se déconnecte : le contenu est alors relu.
85
+ */
86
+ export type SessionSections = {
87
+ cle?: string | null | undefined;
88
+ entetes?: Record<string, string> | undefined;
89
+ };
90
+ export declare function SectionsProvider({ site, page, langue, apiBaseUrl, sectionsInitiales, sourceTexte, pagesPartagees, session, children, }: {
59
91
  /** Sous-domaine de l'organisation (ex. « armoise »). */
60
92
  site: string;
61
93
  /** La page dont on veut les sections (ex. « accueil »). */
@@ -93,6 +125,11 @@ export declare function SectionsProvider({ site, page, langue, apiBaseUrl, secti
93
125
  * sur la page courante, jamais dans le commun par accident.
94
126
  */
95
127
  pagesPartagees?: string[] | undefined;
128
+ /**
129
+ * Qui regarde (voir `SessionSections`). Facultatif : sans elle, le cookie
130
+ * de session suffit quand le site et l'API le partagent.
131
+ */
132
+ session?: SessionSections | undefined;
96
133
  children: ReactNode;
97
134
  }): import("react").JSX.Element;
98
135
  /**
@@ -0,0 +1,18 @@
1
+ export type MarqueProduit = {
2
+ "data-lystech-produit": string;
3
+ };
4
+ /** Les attributs à poser sur la carte d'un produit. */
5
+ export declare function marqueProduit(UID: string): MarqueProduit;
6
+ /** Ce que l'éditeur dit d'un changement — pour relire juste ce qu'il faut, si on veut. */
7
+ export type ChangementCatalogue = {
8
+ quoi: "produit" | "photo" | "collection";
9
+ /** L'UID du produit ou de la collection touché. */
10
+ UID: string | null;
11
+ action: "cree" | "modifie" | "masque" | "visible" | "supprime" | "ajoute" | "retire";
12
+ };
13
+ /**
14
+ * Appelle `rappel` à chaque changement du catalogue fait par l'éditeur.
15
+ * La dernière version du rappel est toujours celle appelée : pas besoin de le
16
+ * mémoriser avec useCallback.
17
+ */
18
+ export declare function useCatalogueModifie(rappel: (c: ChangementCatalogue) => void): void;
@@ -12,3 +12,26 @@
12
12
  * général qu'un nom de classe (`rootClassName`).
13
13
  */
14
14
  export declare const CLASSE_HORS_EDITION = "lystech-hors-edition";
15
+ /**
16
+ * LE MARQUAGE D'UNE CARTE PRODUIT : `data-lystech-produit="<UID>"`.
17
+ *
18
+ * Posé par le site sur l'élément qui encadre UN produit (sa carte). En mode
19
+ * édition, l'éditeur y pose un crayon qui ouvre la fiche du produit, et
20
+ * ajoute une tuile « + Ajouter » au bout de la grille. Hors édition, c'est un
21
+ * attribut inerte. `marqueProduit(UID)` l'écrit pour vous.
22
+ */
23
+ export declare const ATTR_PRODUIT = "data-lystech-produit";
24
+ /**
25
+ * L'ÉVÉNEMENT QUI DIT « LE CATALOGUE A CHANGÉ » — émis sur `window` par
26
+ * l'éditeur après chaque enregistrement d'un produit, d'une photo ou d'une
27
+ * collection. Le site (ou @lystech/core) l'écoute pour relire ses produits :
28
+ * le catalogue s'écrit en direct, sans brouillon, et la page doit le montrer.
29
+ */
30
+ export declare const EVENEMENT_CATALOGUE = "lystech:catalogue-modifie";
31
+ /**
32
+ * LE NOM DE LA FENÊTRE D'APERÇU MOBILE. L'éditeur affiche la page dans un
33
+ * cadre de 390 px portant ce nom ; le fournisseur qui s'y charge le
34
+ * reconnaît et NE monte PAS l'éditeur dedans. Le nom d'un cadre survit à ses
35
+ * navigations — un paramètre d'adresse, non.
36
+ */
37
+ export declare const FENETRE_APERCU = "lystech-apercu";
@@ -0,0 +1,7 @@
1
+ export default function ApercuMobile({ brouillons, surFermer, }: {
2
+ brouillons: Array<{
3
+ blockType: string;
4
+ data: Record<string, unknown> | null;
5
+ }>;
6
+ surFermer: () => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { TypeDeChamp } from '../types';
2
+ export type InfoChamp = {
3
+ libelle: string;
4
+ type: TypeDeChamp;
5
+ valeur: unknown;
6
+ };
7
+ export declare function ChampsEnPlace({ actif, lire, enregistrer, ouvrirPanneau, }: {
8
+ /** Faux hors du mode « modifier », ou pendant qu'un panneau est ouvert. */
9
+ actif: boolean;
10
+ /** Ce qu'on sait de ce champ — null s'il n'appartient à aucun bloc déclaré. */
11
+ lire: (blockType: string, champ: string) => InfoChamp | null;
12
+ /** Enregistre la valeur dans le brouillon du bloc ; faux si ça a échoué. */
13
+ enregistrer: (blockType: string, champ: string, valeur: unknown) => Promise<boolean>;
14
+ ouvrirPanneau: (blockType: string) => void;
15
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,6 @@
1
+ import { Api, SectionEditeur } from './api';
2
+ export declare function ChoixAudience({ api, section, apresChangement, }: {
3
+ api: Api;
4
+ section: SectionEditeur | null;
5
+ apresChangement: () => Promise<void> | void;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ type Rect = {
2
+ top: number;
3
+ left: number;
4
+ width: number;
5
+ height: number;
6
+ };
7
+ /**
8
+ * OÙ POSER LA TUILE : la case qui suit la dernière carte, dans la même grille.
9
+ * À droite si la rangée a de la place, sinon au début de la rangée suivante.
10
+ * Calculé depuis les rectangles réels, donc juste pour une grille CSS, une
11
+ * flexbox, deux colonnes au téléphone ou quatre au bureau.
12
+ */
13
+ export declare function caseSuivante(cartes: Rect[], conteneur: Rect): Rect | null;
14
+ export declare function MarquesBoutique({ actif, ouvrir }: {
15
+ actif: boolean;
16
+ ouvrir: (cible: string) => void;
17
+ }): import("react").JSX.Element | null;
18
+ export {};
@@ -1,12 +1,15 @@
1
1
  import { SourceTexte } from '../sourceTexte';
2
- export declare function TexteEnPlace({ source, actif, modifiable, surModification, }: {
2
+ export declare function TexteEnPlace({ source, actif, pertinence, surModification, }: {
3
3
  source: SourceTexte;
4
4
  /** Faux pendant qu'un panneau est ouvert : on ne veut pas deux modes. */
5
5
  actif: boolean;
6
6
  /**
7
- * Ce texte a-t-il un bloc où s'enregistrer ? Change quand le catalogue
8
- * arrive : avant lui, rien n'est repéré — c'est le bon défaut.
7
+ * 0 : ce texte n'a aucun bloc où s'enregistrer — on ne le repère pas.
8
+ * Au-delà, plus c'est haut, plus il est proche de la page affichée : un
9
+ * même texte présent sur deux pages (« Séances et tarifs ») se corrige
10
+ * d'office sur la page qu'on regarde, sans demander lequel. Change quand
11
+ * le catalogue arrive : avant lui, rien n'est repéré — le bon défaut.
9
12
  */
10
- modifiable: (ns: string, chemin: string) => boolean;
13
+ pertinence: (ns: string, chemin: string) => number;
11
14
  surModification: (ns: string, chemin: string, langue: string, valeur: string) => void | Promise<void>;
12
15
  }): import("react").JSX.Element | null;
@@ -1,4 +1,4 @@
1
- import { BlocDeclare } from '../types';
1
+ import { AudienceSection, BlocDeclare } from '../types';
2
2
  /** Une section telle que l'éditeur la voit — brouillon compris. */
3
3
  export type SectionEditeur = {
4
4
  UID: string;
@@ -9,6 +9,8 @@ export type SectionEditeur = {
9
9
  data: Record<string, unknown> | null;
10
10
  draft: Record<string, unknown> | null;
11
11
  publishedOn: string | null;
12
+ /** Qui peut la voir (null = tout le monde). */
13
+ audience?: AudienceSection | null;
12
14
  };
13
15
  export type Api = ReturnType<typeof creerApi>;
14
16
  export declare function creerApi({ jeton, site, apiBaseUrl, surJetonRefuse, }: {
@@ -25,6 +27,7 @@ export declare function creerApi({ jeton, site, apiBaseUrl, surJetonRefuse, }: {
25
27
  */
26
28
  surJetonRefuse: () => void;
27
29
  }): {
30
+ brut: (chemin: string, init?: RequestInit) => Promise<Response>;
28
31
  /** Les blocs que le site a déclaré savoir afficher. Route publique. */
29
32
  schemas: () => Promise<BlocDeclare[]>;
30
33
  /** Ce qui existe sur une page, brouillons compris. */
@@ -36,4 +39,16 @@ export declare function creerApi({ jeton, site, apiBaseUrl, surJetonRefuse, }: {
36
39
  publier: (UID: string) => Promise<any>;
37
40
  /** Ramène le brouillon à ce qui est en ligne. */
38
41
  annuler: (UID: string) => Promise<any>;
42
+ /** Qui peut voir la section. S'applique TOUT DE SUITE (pas de brouillon). */
43
+ audience: (UID: string, audience: AudienceSection) => Promise<{
44
+ success: boolean;
45
+ audience: AudienceSection;
46
+ }>;
47
+ /** Les plans d'abonnement de la boutique, pour « Visible pour : plans… ». */
48
+ optionsAudience: () => Promise<{
49
+ plans: Array<{
50
+ UID: string;
51
+ name: string | null;
52
+ }>;
53
+ }>;
39
54
  };
@@ -0,0 +1,11 @@
1
+ import { ChangementCatalogue } from '../../catalogue';
2
+ import { ApiBoutique, Collection, Produit } from './api';
3
+ export declare function Collections({ api, collections, produits, majCollection, majProduits, signaler, }: {
4
+ api: ApiBoutique;
5
+ collections: Collection[];
6
+ produits: Produit[];
7
+ majCollection: (c: Collection) => void;
8
+ /** Relire les bijoux : leurs listes de collections ont changé. */
9
+ majProduits: () => void;
10
+ signaler: (c: ChangementCatalogue) => void;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { ChangementCatalogue } from '../../catalogue';
2
+ import { ApiBoutique, Collection, Produit } from './api';
3
+ export declare function FicheProduit({ api, produit, collections, surEnregistre, surSupprime, surRetour, signaler, annonce, }: {
4
+ api: ApiBoutique;
5
+ /** null = nouveau bijou. */
6
+ produit: Produit | null;
7
+ collections: Collection[];
8
+ surEnregistre: (p: Produit) => void;
9
+ surSupprime: (UID: string) => void;
10
+ surRetour: () => void;
11
+ signaler: (c: ChangementCatalogue) => void;
12
+ /** Un message à afficher en ouvrant la fiche (« Bijou créé »). */
13
+ annonce?: string | null;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { ChangementCatalogue } from '../../catalogue';
2
+ import { ApiBoutique } from './api';
3
+ /** Ce que l'éditeur demande d'ouvrir : un UID, « nouveau », ou la liste (null). */
4
+ export type DemandeBoutique = {
5
+ cible: string | null;
6
+ n: number;
7
+ };
8
+ export default function ModuleBoutique({ api, demande, surFermer, signaler, }: {
9
+ api: ApiBoutique;
10
+ demande: DemandeBoutique;
11
+ surFermer: () => void;
12
+ signaler: (c: ChangementCatalogue) => void;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,83 @@
1
+ export type ModeStock = "unique" | "quantite" | "illimite";
2
+ export type Photo = {
3
+ UID: string;
4
+ src: string;
5
+ principale: boolean;
6
+ };
7
+ export type Produit = {
8
+ UID: string;
9
+ nom: string;
10
+ prix: number;
11
+ prixAvant: number | null;
12
+ description: string;
13
+ format: string;
14
+ stock: {
15
+ mode: ModeStock;
16
+ restant: number | null;
17
+ };
18
+ vendus: number;
19
+ vendu: boolean;
20
+ visible: boolean;
21
+ photos: Photo[];
22
+ collections: string[];
23
+ };
24
+ export type NiveauAcces = "fermee" | "invites" | "lien" | "tous";
25
+ export type Collection = {
26
+ UID: string;
27
+ nom: string;
28
+ description: string;
29
+ acces: NiveauAcces;
30
+ accesLibelle: string;
31
+ ouverte: boolean;
32
+ nbProduits: number;
33
+ image: string | null;
34
+ };
35
+ /** Ce qu'on envoie pour créer ou modifier — seulement ce qui change. */
36
+ export type SaisieProduit = {
37
+ nom?: string;
38
+ prix?: string | number;
39
+ prixAvant?: string | number | null;
40
+ description?: string;
41
+ format?: string;
42
+ stock?: {
43
+ mode: ModeStock;
44
+ quantite: number | string | null;
45
+ };
46
+ visible?: boolean;
47
+ collections?: string[];
48
+ };
49
+ /** Une erreur avec la phrase du serveur, et les champs à corriger s'il y en a. */
50
+ export declare class ErreurBoutique extends Error {
51
+ statut: number;
52
+ erreurs: Record<string, string>;
53
+ raison: string | null;
54
+ constructor(message: string, statut: number, erreurs?: Record<string, string>, raison?: string | null);
55
+ }
56
+ type Brut = (chemin: string, init?: RequestInit) => Promise<Response>;
57
+ export declare function creerApiBoutique(brut: Brut): {
58
+ /** Le module s'affiche-t-il ? Faux en cas de doute (ancien serveur, éditeur). */
59
+ droits: () => Promise<boolean>;
60
+ produits: () => Promise<Produit[]>;
61
+ produit: (UID: string) => Promise<Produit>;
62
+ creer: (s: SaisieProduit) => Promise<Produit>;
63
+ modifier: (UID: string, s: SaisieProduit) => Promise<Produit>;
64
+ visible: (UID: string, visible: boolean) => Promise<Produit>;
65
+ supprimer: (UID: string) => Promise<{
66
+ supprime: boolean;
67
+ }>;
68
+ ajouterPhoto: (UID: string, fichier: Blob, nom: string) => Promise<Produit>;
69
+ principale: (UID: string, photo: string) => Promise<Produit>;
70
+ retirerPhoto: (UID: string, photo: string) => Promise<Produit>;
71
+ collections: () => Promise<Collection[]>;
72
+ creerCollection: (nom: string) => Promise<Collection>;
73
+ modifierCollection: (UID: string, v: {
74
+ nom?: string;
75
+ description?: string;
76
+ acces?: NiveauAcces;
77
+ }) => Promise<Collection>;
78
+ garnirCollection: (UID: string, ajouter: string[], retirer: string[]) => Promise<Collection>;
79
+ };
80
+ export type ApiBoutique = ReturnType<typeof creerApiBoutique>;
81
+ /** Un montant à la québécoise : « 58,50 $ ». */
82
+ export declare function montant(n: number): string;
83
+ export {};
@@ -0,0 +1,9 @@
1
+ export declare function preparerPhoto(fichier: File): Promise<{
2
+ donnees: Blob;
3
+ nom: string;
4
+ }>;
5
+ /** Les fichiers qui sont des images ; le reste est écarté, et compté. */
6
+ export declare function seulementImages(fichiers: FileList | File[] | null | undefined): {
7
+ images: File[];
8
+ ecartes: number;
9
+ };
@@ -0,0 +1 @@
1
+ export declare const CSS_BOUTIQUE = "\n.panneau--boutique { width: min(440px, 100vw); }\n.panneau--boutique .bouton { min-height: 40px; }\n@media (max-width: 520px) {\n .panneau--boutique {\n top: auto; left: 0; right: 0; width: auto; height: 88vh; height: 88dvh;\n border-radius: 18px 18px 0 0;\n }\n .panneau--boutique .bouton { min-height: 44px; }\n .panneau--boutique .bouton--icone { min-width: 44px; }\n}\n.poignee { display: none; width: 44px; height: 5px; border-radius: 3px; background: #4a505d; margin: 8px auto 0; flex: none; }\n@media (max-width: 520px) { .poignee { display: block; } }\n\n.b-tete { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--bord); }\n.b-tete h2 { flex: 1; margin: 0; font-size: 16px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.b-tete .bouton--icone { min-width: 40px; min-height: 40px; font-size: 16px; }\n\n.b-onglets { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--bord); }\n.b-onglets button {\n border: 0; background: transparent; color: var(--texte-doux); font: inherit; font-weight: 600;\n min-height: 44px; padding: 0 12px; cursor: pointer; border-bottom: 2px solid transparent;\n}\n.b-onglets button[aria-selected=\"true\"] { color: var(--texte); border-bottom-color: var(--accent); }\n.b-onglets button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }\n.b-onglets small { opacity: .65; margin-left: 4px; font-weight: 500; }\n\n.b-avis {\n margin: 0 0 12px; padding: 9px 11px; border-radius: 10px; font-size: 12.5px; line-height: 1.45;\n background: rgba(227, 181, 91, 0.1); border: 1px solid rgba(227, 181, 91, 0.35); color: #f1d9a6;\n}\n.b-ajout {\n width: 100%; min-height: 48px; margin-bottom: 12px; border-radius: 12px; cursor: pointer;\n border: 2px dashed var(--accent); background: transparent; color: #9fe3d1; font: inherit; font-weight: 650;\n}\n.b-ajout:hover, .b-ajout:focus-visible { background: rgba(59, 165, 141, 0.1); outline: none; }\n\n.b-recherche { min-height: 44px; border-radius: 10px !important; font-size: 14px !important; }\n.b-filtres { display: flex; gap: 6px; overflow-x: auto; padding: 10px 0 12px; scrollbar-width: none; }\n.b-puce {\n flex: none; display: inline-flex; align-items: center; gap: 4px; min-height: 36px; padding: 0 12px;\n border-radius: 999px; border: 1px solid var(--bord); background: transparent; color: var(--texte-doux);\n font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;\n}\n.b-puce small { opacity: .7; font-weight: 500; }\n.b-puce[aria-pressed=\"true\"] { background: rgba(59, 165, 141, 0.16); border-color: var(--accent); color: #9fe3d1; }\n.b-puce:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n@media (max-width: 520px) { .b-puce { min-height: 44px; } }\n\n.b-rangee {\n display: flex; align-items: center; gap: 12px; width: 100%; min-height: 64px; margin-bottom: 6px; padding: 8px;\n border-radius: 12px; border: 1px solid var(--bord); background: var(--fond-clair); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.b-rangee:hover, .b-rangee:focus-visible { border-color: var(--accent); outline: none; }\n.b-vignette { width: 48px; height: 48px; flex: none; border-radius: 9px; object-fit: cover; background: #2a2e37; display: block; }\n.b-vignette--vide { display: grid; place-items: center; color: var(--texte-doux); font-size: 18px; }\n.b-rangee__texte { flex: 1; min-width: 0; }\n.b-rangee__nom { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.b-rangee__meta { display: block; font-size: 12px; color: var(--texte-doux); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.b-etiquette { flex: none; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; }\n.b-etiquette--vente { background: rgba(59, 165, 141, 0.18); color: #8fd9c6; }\n.b-etiquette--masque { background: rgba(163, 169, 182, 0.16); color: var(--texte-doux); }\n.b-etiquette--vendu { background: rgba(217, 119, 106, 0.18); color: #f0b3a9; }\n.b-vide { color: var(--texte-doux); font-size: 13px; padding: 18px 4px; text-align: center; }\n\n/* ---------------------------------------------------------- FICHE ------ */\n.b-section { margin-bottom: 18px; }\n.b-section > h3 { margin: 0 0 8px; font-size: 11.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--texte-doux); }\n.b-champ { margin-bottom: 12px; }\n.b-champ label, .b-champ .b-etiq { display: block; margin-bottom: 5px; font-size: 12.5px; font-weight: 600; }\n.b-champ label small { font-weight: 400; color: var(--texte-doux); }\n.panneau--boutique input[type=\"text\"], .panneau--boutique input[type=\"search\"], .panneau--boutique textarea {\n min-height: 44px; padding: 10px 12px; border-radius: 10px; font-size: 14px;\n}\n.b-champ--erreur input, .b-champ--erreur textarea { border-color: var(--danger) !important; }\n.b-erreur { margin: 4px 0 0; font-size: 12px; color: #f0b3a9; }\n.b-aide { margin: 4px 0 0; font-size: 11.5px; color: var(--texte-doux); }\n.b-deux { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n.b-prix { position: relative; }\n.b-prix span { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: var(--texte-doux); pointer-events: none; }\n.b-prix input { padding-right: 28px !important; }\n\n.b-seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }\n.b-seg button {\n min-height: 44px; border-radius: 10px; border: 1px solid var(--bord); background: var(--fond-clair);\n color: var(--texte-doux); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;\n}\n.b-seg button[aria-pressed=\"true\"] { border-color: var(--accent); color: #9fe3d1; background: rgba(59, 165, 141, 0.14); }\n.b-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n.b-inter {\n display: flex; align-items: center; gap: 12px; width: 100%; min-height: 56px; padding: 10px 12px;\n border-radius: 12px; background: var(--fond-clair); border: 1px solid var(--bord); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.b-inter:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.b-inter small { display: block; color: var(--texte-doux); font-size: 11.5px; font-weight: 400; }\n.b-rail { position: relative; width: 46px; height: 28px; flex: none; border-radius: 999px; background: #4a505d; transition: background .15s; }\n.b-rail::after { content: \"\"; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: #fff; transition: left .15s; }\n[aria-checked=\"true\"] > .b-rail { background: var(--accent); }\n[aria-checked=\"true\"] > .b-rail::after { left: 21px; }\n\n.b-photos { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }\n.b-photo { position: relative; aspect-ratio: 1; border-radius: 10px; overflow: hidden; border: 2px solid transparent; background: #2a2e37; }\n.b-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.b-photo--principale { border-color: var(--accent); }\n.b-photo__badge { position: absolute; left: 6px; bottom: 6px; background: var(--accent); color: #06120e; font-size: 10.5px; font-weight: 700; padding: 2px 6px; border-radius: 999px; }\n.b-photo__actions { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }\n.b-photo__actions button {\n width: 36px; height: 36px; border-radius: 50%; border: 0; cursor: pointer;\n background: rgba(22, 24, 29, 0.85); color: #fff; font: inherit; font-size: 14px;\n}\n.b-photo__actions button:hover, .b-photo__actions button:focus-visible { background: var(--accent); color: #06120e; outline: none; }\n.b-photo--envoi::after { content: \"Envoi\u2026\"; position: absolute; inset: 0; display: grid; place-items: center; background: rgba(22,24,29,.6); color: #fff; font-size: 12px; }\n.b-depot {\n grid-column: 1 / -1; padding: 14px; border-radius: 12px; border: 2px dashed #4a505d;\n text-align: center; color: var(--texte-doux); font-size: 12.5px; transition: background .12s, border-color .12s;\n}\n.b-depot--survol { border-color: var(--accent); background: rgba(59, 165, 141, 0.1); color: #9fe3d1; }\n.b-depot__boutons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 8px; }\n.b-depot__boutons label { cursor: pointer; }\n.b-depot__boutons input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }\n.b-depot__boutons label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }\n.b-camera { display: none !important; }\n@media (pointer: coarse) { .b-camera { display: inline-flex !important; } .b-glisser { display: none; } }\n\n.b-puces { display: flex; flex-wrap: wrap; gap: 6px; }\n.b-danger { border-top: 1px solid var(--bord); padding-top: 14px; }\n.b-danger p { margin: 0 0 8px; font-size: 12.5px; color: var(--texte-doux); }\n.bouton--danger { background: transparent; color: var(--danger); border-color: rgba(217, 119, 106, 0.5); }\n.bouton--danger:hover { background: rgba(217, 119, 106, 0.14); }\n.bouton--danger-plein { background: #b3261e; border-color: #b3261e; color: #fff; }\n.bouton--danger-plein:hover { background: #c9372e; }\n.b-confirmer { padding: 12px; border-radius: 12px; border: 1px solid rgba(217, 119, 106, 0.5); background: rgba(217, 119, 106, 0.08); }\n.b-confirmer p { margin: 0 0 10px; color: var(--texte); }\n.b-confirmer .rang { display: flex; gap: 8px; flex-wrap: wrap; }\n.b-bandeau { margin: 0 0 12px; padding: 9px 11px; border-radius: 10px; font-size: 12.5px; background: rgba(217, 119, 106, 0.12); border: 1px solid rgba(217, 119, 106, 0.4); color: #f0b3a9; }\n\n/* ------------------------------------------------------ COLLECTIONS ---- */\n.b-coll { border: 1px solid var(--bord); border-radius: 12px; background: var(--fond-clair); margin-bottom: 8px; }\n.b-coll__ligne { display: flex; align-items: center; gap: 10px; padding: 8px 8px 8px 12px; min-height: 64px; }\n.b-coll__texte { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--texte); font: inherit; text-align: left; cursor: pointer; padding: 6px 0; min-height: 44px; }\n.b-coll__texte:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }\n.b-coll__nom { display: block; font-weight: 600; }\n.b-coll__meta { display: block; font-size: 12px; color: var(--texte-doux); }\n.b-coll .b-inter { width: auto; min-height: 44px; padding: 0 6px; border: 0; background: transparent; }\n.b-coll__corps { border-top: 1px solid var(--bord); padding: 10px 12px 12px; }\n.b-choix { display: grid; gap: 6px; margin-top: 8px; }\n.b-mini { display: flex; align-items: center; gap: 10px; padding: 6px 0; min-height: 52px; }\n.b-mini img, .b-mini .b-vignette { width: 40px; height: 40px; }\n.b-mini span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.b-coche { display: flex; align-items: center; gap: 10px; min-height: 48px; cursor: pointer; }\n.b-coche input { width: 20px; height: 20px; accent-color: var(--accent); flex: none; }\n.b-ligne-ajout { display: flex; gap: 8px; margin-top: 8px; }\n.b-ligne-ajout input { flex: 1; }\n\n@media (prefers-reduced-motion: reduce) { .b-rail, .b-rail::after, .b-depot { transition: none; } }\n";
@@ -27,4 +27,11 @@ export type PontEdition = {
27
27
  * recharger laisserait l'éditeur sur les sections de la page précédente.
28
28
  */
29
29
  surChangementDePage?: (page: string) => void;
30
+ /**
31
+ * Posé par l'ÉDITEUR quand la personne a le droit de gérer le catalogue :
32
+ * ouvre le module Boutique, sur la fiche d'un produit (son UID), sur une
33
+ * fiche vierge (« nouveau »), ou sur la liste (null). Faux si le module
34
+ * n'est pas disponible. Le site l'atteint par `ouvrirBoutique` du contexte.
35
+ */
36
+ ouvrirBoutique?: (cible: string | null) => boolean;
30
37
  };
@@ -1,3 +1,3 @@
1
1
  export declare const CSS = "\n:host {\n all: initial;\n --fond: #16181d;\n --fond-clair: #21242b;\n --bord: #333844;\n --texte: #f2f3f5;\n --texte-doux: #a3a9b6;\n --accent: #3ba58d;\n --accent-sombre: #2d8570;\n --danger: #d9776a;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Arial, sans-serif;\n font-size: 14px;\n line-height: 1.45;\n color: var(--texte);\n}\n\n* { box-sizing: border-box; }\n\n/* LE CALQUE LAISSE PASSER LES CLICS. Un conteneur fixe en plein \u00E9cran qui\n intercepte tout rendrait le site inutilisable derri\u00E8re lui : on ne pourrait\n plus naviguer, ni m\u00EAme faire d\u00E9filer correctement. Seuls ses ENFANTS\n reprennent les clics. */\n.calque {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 2147483000;\n}\n.calque > * { pointer-events: auto; }\n\n/* ---------------------------------------------------------- CADRES ------ */\n\n.cadre {\n position: fixed;\n border: 2px dashed rgba(59, 165, 141, 0.55);\n border-radius: 6px;\n pointer-events: none;\n transition: border-color 120ms ease;\n}\n.cadre--actif { border-color: var(--accent); border-style: solid; }\n\n.pastille {\n position: fixed;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border: 0;\n border-radius: 999px;\n background: var(--accent);\n color: #06120e;\n font-size: 12px;\n font-weight: 650;\n letter-spacing: 0.01em;\n cursor: pointer;\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);\n}\n.pastille:hover { background: #45bda2; }\n.pastille:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }\n\n/* ------------------------------------------------------------ BARRE ----- */\n\n.barre {\n position: fixed;\n left: 50%;\n bottom: 18px;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 14px;\n padding: 9px 14px;\n border: 1px solid var(--bord);\n border-radius: 999px;\n background: var(--fond);\n box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);\n max-width: calc(100vw - 24px);\n}\n.barre__point {\n width: 8px; height: 8px; border-radius: 50%;\n background: var(--accent);\n flex: none;\n}\n.barre__texte { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.barre__texte b { font-weight: 650; }\n.barre__compte { color: var(--texte-doux); font-size: 12px; white-space: nowrap; }\n\n/* ----------------------------------------------------------- PANNEAU ---- */\n\n.panneau {\n position: fixed;\n top: 0; right: 0; bottom: 0;\n width: min(420px, 100vw);\n display: flex;\n flex-direction: column;\n background: var(--fond);\n border-left: 1px solid var(--bord);\n box-shadow: -12px 0 40px rgba(0, 0, 0, 0.4);\n}\n@media (max-width: 520px) {\n .panneau { top: auto; height: 78vh; border-left: 0; border-top: 1px solid var(--bord); }\n}\n\n.panneau__tete {\n display: flex; align-items: flex-start; gap: 10px;\n padding: 16px 16px 12px;\n border-bottom: 1px solid var(--bord);\n}\n.panneau__titre { font-size: 15px; font-weight: 650; margin: 0; }\n.panneau__sous { margin: 3px 0 0; font-size: 12px; color: var(--texte-doux); }\n.panneau__corps { flex: 1; overflow-y: auto; padding: 14px 16px 18px; }\n.panneau__pied {\n display: flex; gap: 8px; align-items: center;\n padding: 12px 16px;\n border-top: 1px solid var(--bord);\n background: var(--fond-clair);\n}\n.panneau__pied .espace { flex: 1; }\n\n/* --------------------------------------------------- CONNEXION ---------- */\n\n/* LA CONNEXION EST CLAIRE, AUX COULEURS DE LYSTECH. L'\u00E9diteur reste un outil\n sombre qui s'efface derri\u00E8re le travail ; la connexion, elle, est l'instant\n o\u00F9 quelqu'un d\u00E9cide de faire confiance. Il doit y reconna\u00EEtre Lystech \u2014 la\n fleur, son p\u00E9trole et son turquoise, sur du blanc \u2014 et non une bo\u00EEte noire\n pos\u00E9e sur son site.\n\n Le th\u00E8me se red\u00E9finit ICI, sur le voile : les boutons, messages et champs\n partag\u00E9s avec l'\u00E9diteur suivent ces variables sans une r\u00E8gle de plus, et\n l'\u00E9diteur garde les siennes. Clair quel que soit le th\u00E8me du syst\u00E8me :\n c'est un choix de marque. */\n.voile {\n --fond: #ffffff;\n --fond-clair: #f4f7f8;\n --bord: #dfe6e9;\n --texte: #0b1f26;\n --texte-doux: #52666f;\n --petrole: #084050;\n --petrole-survol: #0b5468;\n --turquoise: #10b8b0;\n --focus: #0c8f89;\n\n position: fixed;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n /* Le voile PREND les clics, lui : une bo\u00EEte de dialogue qui demande une\n identit\u00E9 ne doit pas laisser cliquer la page derri\u00E8re elle. C'est\n l'exception \u00E0 la r\u00E8gle du calque, et elle est volontaire. Teint\u00E9 p\u00E9trole\n plut\u00F4t que noir : on assombrit le site sans l'\u00E9teindre. */\n background: rgba(8, 44, 56, 0.34);\n backdrop-filter: blur(4px) saturate(115%);\n color: var(--texte);\n}\n\n.fenetre {\n position: relative;\n width: min(400px, 100%);\n max-height: 92vh;\n overflow-y: auto;\n padding: 34px 30px 26px;\n border: 1px solid rgba(8, 64, 80, 0.10);\n border-radius: 20px;\n /* La lueur turquoise de la fleur, \u00E0 peine, en haut de la carte. */\n background:\n radial-gradient(90% 60% at 50% -12%, rgba(16, 184, 176, 0.13), transparent 70%),\n var(--fond);\n box-shadow:\n 0 1px 2px rgba(8, 64, 80, 0.06),\n 0 28px 64px -18px rgba(8, 44, 56, 0.45);\n text-align: center;\n}\n\n.fenetre__fermer {\n position: absolute; top: 14px; right: 14px;\n width: 32px; height: 32px;\n display: inline-flex; align-items: center; justify-content: center;\n border: 0; border-radius: 9px;\n background: transparent; color: var(--texte-doux);\n font: inherit; font-size: 14px; cursor: pointer;\n}\n.fenetre__fermer:hover { background: var(--fond-clair); color: var(--texte); }\n.fenetre__fermer:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }\n\n/* ------------------------------------------------------------ MARQUE ---- */\n\n.marque {\n display: inline-flex; flex-direction: column; align-items: center; gap: 8px;\n margin-bottom: 18px;\n}\n.marque img {\n width: 44px; height: 44px; display: block;\n filter: drop-shadow(0 6px 14px rgba(16, 184, 176, 0.28));\n}\n.marque__mot {\n font-size: 11.5px; font-weight: 700;\n letter-spacing: 0.24em; text-transform: uppercase;\n color: var(--petrole);\n}\n\n.fenetre__titre {\n margin: 0 0 8px;\n font-size: 21px; font-weight: 700; letter-spacing: -0.015em;\n color: var(--texte);\n text-wrap: balance;\n}\n.fenetre__sous {\n margin: 0 auto 24px;\n max-width: 36ch;\n font-size: 13.5px; line-height: 1.55; color: var(--texte-doux);\n text-wrap: balance;\n}\n.fenetre__corps { text-align: left; }\n\n/* ------------------------------------------------------ FOURNISSEURS ---- */\n\n.fournisseurs { display: flex; flex-direction: column; gap: 10px; }\n\n.fournisseur {\n display: flex; align-items: center; justify-content: center; gap: 10px;\n width: 100%; min-height: 46px; padding: 12px 16px;\n border: 1px solid transparent; border-radius: 12px;\n font: inherit; font-size: 14px; font-weight: 600;\n cursor: pointer;\n transition: transform 90ms ease, background-color 120ms ease, box-shadow 120ms ease;\n}\n.fournisseur:active { transform: translateY(1px); }\n.fournisseur:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }\n.fournisseur[disabled] { opacity: 0.55; cursor: default; transform: none; }\n.fournisseur .logo { width: 18px; height: 18px; flex: none; display: block; }\n\n/* LES COULEURS DE CHAQUE MARQUE, telles qu'elles les publient. Un bouton\n Google repeint aux couleurs du site devient moins reconnaissable \u2014 et un\n bouton de connexion qu'on ne reconna\u00EEt pas est un bouton sur lequel on\n h\u00E9site \u00E0 cliquer. */\n.fournisseur--google {\n background: #fff; color: #1f1f1f; border-color: #dadce0;\n box-shadow: 0 1px 2px rgba(8, 64, 80, 0.06);\n}\n.fournisseur--google:hover:not([disabled]) { background: #f6f9fa; border-color: #c9d3d8; }\n.fournisseur--facebook { background: #1877f2; color: #fff; }\n.fournisseur--facebook:hover:not([disabled]) { background: #166fe5; }\n\n.bouton--large { width: 100%; padding: 12px 16px; font-size: 14px; }\n\n/* Les boutons et messages PARTAG\u00C9S avec l'\u00E9diteur, en version claire. */\n.voile .bouton {\n min-height: 44px; border-radius: 12px;\n background: #fff; color: var(--texte); border-color: #cfd9dd;\n}\n.voile .bouton:hover { background: var(--fond-clair); }\n.voile .bouton:focus-visible { outline-color: var(--focus); }\n.voile .bouton--principal { background: var(--petrole); border-color: var(--petrole); color: #fff; }\n.voile .bouton--principal:hover { background: var(--petrole-survol); }\n\n.voile .message { font-size: 13px; line-height: 1.5; border-radius: 10px; }\n.voile .message--erreur { background: #fdf0ee; border-color: #f4c7bf; color: #a3321f; }\n.voile .message--ok { background: #e8f7f5; border-color: #a9e2dc; color: #0a5c57; }\n.voile .message--info { background: var(--fond-clair); border-color: var(--bord); color: #3a5159; }\n\n@media (prefers-reduced-motion: reduce) {\n .fournisseur { transition: none; }\n}\n\n/* ------------------------------------------------------------ CHAMPS ---- */\n\n.champ { margin-bottom: 16px; }\n.champ__etiquette {\n display: block; margin-bottom: 5px;\n font-size: 12px; font-weight: 600; color: var(--texte);\n}\n.champ__aide { margin: 4px 0 0; font-size: 11.5px; color: var(--texte-doux); }\n.champ__langue {\n display: inline-block; margin: 0 0 4px;\n font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;\n text-transform: uppercase; color: var(--texte-doux);\n}\n\n/* TOUS les types de champs, pas seulement ceux auxquels on pensait. Un\n type oublie \u2014 \u00AB password \u00BB, \u00AB email \u00BB \u2014 retombe sur le style brut du\n navigateur : une ligne blanche au milieu d'un panneau sombre. */\ninput[type=\"text\"], input[type=\"url\"], input[type=\"number\"], input[type=\"date\"],\ninput[type=\"password\"], input[type=\"email\"], input[type=\"tel\"], input[type=\"search\"],\nselect, textarea {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--bord);\n border-radius: 6px;\n background: var(--fond-clair);\n color: var(--texte);\n font: inherit;\n font-size: 13px;\n}\ntextarea { min-height: 92px; resize: vertical; line-height: 1.5; }\ninput:focus, select:focus, textarea:focus {\n outline: none; border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(59, 165, 141, 0.18);\n}\n\n.bascule { display: flex; align-items: center; gap: 8px; }\n.bascule input { width: 16px; height: 16px; accent-color: var(--accent); }\n\n.vignette {\n margin-top: 8px; max-height: 110px; border-radius: 6px;\n border: 1px solid var(--bord); display: block;\n}\n\n/* --------------------------------------------------------- LISTES ------- */\n\n.liste__item {\n border: 1px solid var(--bord); border-radius: 8px;\n padding: 12px; margin-bottom: 10px; background: var(--fond-clair);\n}\n.liste__tete {\n display: flex; align-items: center; justify-content: space-between;\n margin-bottom: 8px;\n}\n.liste__rang { font-size: 11px; font-weight: 700; color: var(--texte-doux); }\n\n/* --------------------------------------------------------- BOUTONS ------ */\n\n.bouton {\n display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n padding: 8px 14px; border-radius: 7px; border: 1px solid transparent;\n font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;\n background: var(--fond-clair); color: var(--texte); border-color: var(--bord);\n}\n.bouton:hover { background: #2a2e37; }\n.bouton:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.bouton--principal { background: var(--accent); border-color: var(--accent); color: #06120e; }\n.bouton--principal:hover { background: #45bda2; }\n.bouton--discret { background: transparent; border-color: transparent; color: var(--texte-doux); }\n.bouton--discret:hover { background: var(--fond-clair); color: var(--texte); }\n.bouton[disabled] { opacity: 0.5; cursor: default; }\n.bouton--icone { padding: 6px 9px; }\n\n.message {\n margin: 0 0 12px; padding: 9px 11px; border-radius: 7px;\n font-size: 12.5px; border: 1px solid;\n}\n.message--erreur { background: rgba(217, 119, 106, 0.12); border-color: rgba(217, 119, 106, 0.4); color: #f0b3a9; }\n.message--ok { background: rgba(59, 165, 141, 0.12); border-color: rgba(59, 165, 141, 0.4); color: #8fd9c6; }\n.message--info { background: var(--fond-clair); border-color: var(--bord); color: var(--texte-doux); }\n\n@media (prefers-reduced-motion: reduce) {\n * { transition: none !important; animation: none !important; }\n}\n";
2
- export declare const CSS_EN_PLACE = "\n/* --------------------------------------------------------- CHOIX -------- */\n\n/* Quand un meme texte vient de plusieurs cles, on demande laquelle plutot que\n de deviner \u2014 et de modifier la mauvaise sans que personne le voie. */\n.choix {\n position: fixed;\n width: 260px;\n padding: 12px;\n border: 1px solid var(--bord);\n border-radius: 10px;\n background: var(--fond);\n box-shadow: 0 14px 40px rgba(0, 0, 0, 0.45);\n}\n.choix__titre { margin: 0 0 2px; font-size: 12.5px; font-weight: 650; }\n.choix__aide { margin: 0 0 10px; font-size: 11.5px; color: var(--texte-doux); }\n.choix__option {\n display: flex; flex-direction: column; align-items: flex-start; gap: 2px;\n width: 100%; margin-bottom: 6px; padding: 7px 9px;\n border: 1px solid var(--bord); border-radius: 7px;\n background: var(--fond-clair); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.choix__option:hover { background: #2a2e37; border-color: var(--accent); }\n.choix__chemin { font-size: 12px; font-weight: 600; }\n.choix__ns { font-size: 10.5px; color: var(--texte-doux); }\n\n/* ------------------------------------------------------- RANGEES -------- */\n\n.rangee {\n display: flex; align-items: center; justify-content: space-between; gap: 10px;\n width: 100%; margin-bottom: 6px; padding: 10px 12px;\n border: 1px solid var(--bord); border-radius: 8px;\n background: var(--fond-clair); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.rangee:hover { background: #2a2e37; border-color: var(--accent); }\n.rangee__nom { font-size: 13px; font-weight: 600; }\n.rangee__info { font-size: 11.5px; color: var(--texte-doux); white-space: nowrap; }\n\n/* ------------------------------------------------------ FLOTTANT -------- */\n\n/* Un message au-dessus de la barre, pour confirmer sans ouvrir de panneau. */\n.flottant {\n position: fixed;\n left: 50%; bottom: 76px;\n transform: translateX(-50%);\n max-width: min(460px, calc(100vw - 24px));\n}\n.flottant .message { margin: 0; box-shadow: 0 8px 26px rgba(0,0,0,.4); }\n";
3
- export declare const CSS_MODE = "\n/* ON NE DOIT JAMAIS SE DEMANDER SI ON EST EN MODE EDITION. Une barre en bas\n se confond avec un bandeau de temoins ; un lisere qui borde tout l'ecran,\n present sur chaque page, ne se confond avec rien. Il ne prend aucun clic. */\n.cadre-global {\n position: fixed;\n inset: 0;\n pointer-events: none;\n border: 2px solid var(--accent);\n border-radius: 4px;\n box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);\n opacity: 0.85;\n}\n\n/* EN MODE CIRCULER, le cadre change d'allure. On est toujours connecte \u2014 le\n dire reste necessaire \u2014 mais les clics rendent la main aux liens, et rien\n ne serait plus desagreable que de croire qu'on edite alors qu'on navigue,\n ou l'inverse. Trait interrompu et plus efface : present, mais en retrait. */\n.cadre-global--circuler {\n border-style: dashed;\n border-color: var(--texte-doux);\n opacity: 0.5;\n}\n\n/* La sortie est rouge au survol seulement : visible quand on la cherche,\n discrete le reste du temps \u2014 c'est le geste qu'on fait une fois. */\n.bouton--sortie { color: var(--texte-doux); background: transparent; border-color: transparent; }\n.bouton--sortie:hover { background: rgba(217, 119, 106, 0.16); color: var(--danger); }\n";
2
+ export declare const CSS_EN_PLACE = "\n/* --------------------------------------------------------- SAISIE ------- */\n\n/* Une valeur marqu\u00E9e par le site (un prix, un num\u00E9ro) se corrige dans une\n saisie pos\u00E9e EXACTEMENT dessus, dans la police de la page : on \u00E9crit l\u00E0 o\u00F9\n on lisait. Le libell\u00E9 au-dessus dit quel champ on touche \u2014 \u00AB S\u00E9ance\n d'acupuncture \u00BB, pas \u00AB seance \u00BB. */\n.saisie {\n position: fixed;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 4px;\n}\n.saisie__libelle,\n.saisie__aide,\n.saisie__erreur {\n padding: 3px 8px;\n border-radius: 6px;\n font: 600 11px/1.35 ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n white-space: nowrap;\n}\n.saisie__libelle { background: var(--fond); color: var(--texte); }\n.saisie__aide { background: rgba(22, 24, 29, 0.82); color: var(--texte-doux); font-weight: 500; }\n.saisie__erreur { background: #b3261e; color: #fff; }\n.saisie__champ {\n min-width: 0;\n padding: 2px 8px;\n border: 2px solid var(--accent);\n border-radius: 6px;\n background: #fff;\n color: #111;\n line-height: 1.2;\n box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);\n outline: none;\n}\n.saisie__champ:focus { box-shadow: 0 0 0 3px rgba(59, 165, 141, 0.35), 0 10px 28px rgba(0, 0, 0, 0.28); }\n.saisie__champ:disabled { opacity: 0.7; }\n\n/* --------------------------------------------------------- CHOIX -------- */\n\n/* Quand un meme texte vient de plusieurs cles, on demande laquelle plutot que\n de deviner \u2014 et de modifier la mauvaise sans que personne le voie. */\n.choix {\n position: fixed;\n width: 260px;\n padding: 12px;\n border: 1px solid var(--bord);\n border-radius: 10px;\n background: var(--fond);\n box-shadow: 0 14px 40px rgba(0, 0, 0, 0.45);\n}\n.choix__titre { margin: 0 0 2px; font-size: 12.5px; font-weight: 650; }\n.choix__aide { margin: 0 0 10px; font-size: 11.5px; color: var(--texte-doux); }\n.choix__option {\n display: flex; flex-direction: column; align-items: flex-start; gap: 2px;\n width: 100%; margin-bottom: 6px; padding: 7px 9px;\n border: 1px solid var(--bord); border-radius: 7px;\n background: var(--fond-clair); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.choix__option:hover { background: #2a2e37; border-color: var(--accent); }\n.choix__chemin { font-size: 12px; font-weight: 600; }\n.choix__ns { font-size: 10.5px; color: var(--texte-doux); }\n\n/* ------------------------------------------------------- RANGEES -------- */\n\n.rangee {\n display: flex; align-items: center; justify-content: space-between; gap: 10px;\n width: 100%; margin-bottom: 6px; padding: 10px 12px;\n border: 1px solid var(--bord); border-radius: 8px;\n background: var(--fond-clair); color: var(--texte);\n font: inherit; text-align: left; cursor: pointer;\n}\n.rangee:hover { background: #2a2e37; border-color: var(--accent); }\n.rangee__nom { font-size: 13px; font-weight: 600; }\n.rangee__info { font-size: 11.5px; color: var(--texte-doux); white-space: nowrap; }\n\n/* ------------------------------------------------------ FLOTTANT -------- */\n\n/* Un message au-dessus de la barre, pour confirmer sans ouvrir de panneau. */\n.flottant {\n position: fixed;\n left: 50%; bottom: 76px;\n transform: translateX(-50%);\n max-width: min(460px, calc(100vw - 24px));\n}\n.flottant .message { margin: 0; box-shadow: 0 8px 26px rgba(0,0,0,.4); }\n";
3
+ export declare const CSS_MODE = "\n/* ON NE DOIT JAMAIS SE DEMANDER SI ON EST EN MODE EDITION. Une barre en bas\n se confond avec un bandeau de temoins ; un lisere qui borde tout l'ecran,\n present sur chaque page, ne se confond avec rien. Il ne prend aucun clic. */\n.cadre-global {\n position: fixed;\n inset: 0;\n pointer-events: none;\n border: 2px solid var(--accent);\n border-radius: 4px;\n box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);\n opacity: 0.85;\n}\n\n/* EN MODE CIRCULER, le cadre change d'allure. On est toujours connecte \u2014 le\n dire reste necessaire \u2014 mais les clics rendent la main aux liens, et rien\n ne serait plus desagreable que de croire qu'on edite alors qu'on navigue,\n ou l'inverse. Trait interrompu et plus efface : present, mais en retrait. */\n.cadre-global--circuler {\n border-style: dashed;\n border-color: var(--texte-doux);\n opacity: 0.5;\n}\n\n/* La sortie est rouge au survol seulement : visible quand on la cherche,\n discrete le reste du temps \u2014 c'est le geste qu'on fait une fois. */\n.bouton--sortie { color: var(--texte-doux); background: transparent; border-color: transparent; }\n.bouton--sortie:hover { background: rgba(217, 119, 106, 0.16); color: var(--danger); }\n\n.barre__pastille {\n display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 999px;\n background: rgba(6, 18, 14, 0.25); font-size: 11px; line-height: 18px; text-align: center;\n}\n\n/* LA BARRE AU T\u00C9L\u00C9PHONE. Une pilule de 600 px ne tient pas dans 390 : elle\n devient une bande pleine largeur, en bas, et chaque bouton fait au moins\n 44 px \u2014 la taille d'un doigt, pas d'un pointeur. On tait ce qui se lit\n ailleurs (la phrase d'aide, le compte, que la pastille du bouton Publier\n porte d\u00E9j\u00E0). L'aper\u00E7u mobile n'a pas de sens sur un t\u00E9l\u00E9phone. */\n@media (max-width: 640px) {\n .barre {\n left: 8px; right: 8px; bottom: 8px; transform: none;\n max-width: none; gap: 6px; padding: 6px;\n border-radius: 18px; justify-content: space-between;\n padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));\n }\n .barre .bouton { min-height: 44px; padding: 8px 12px; }\n .barre .bouton--icone { min-width: 44px; }\n .barre__texte, .barre__compte, .barre__point, .bouton--bureau-seul { display: none; }\n .flottant { bottom: 84px; }\n}\n\n/* ------------------------------------------------ BOUTIQUE SUR LA PAGE --- */\n\n/* LE CRAYON d'une carte produit : rond, sombre, 44 px \u2014 visible sur toutes\n les photos, claires comme fonc\u00E9es. */\n.crayon {\n position: fixed; width: 44px; height: 44px; border-radius: 50%; border: 0;\n display: grid; place-items: center; cursor: pointer;\n background: var(--fond); color: #fff;\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);\n}\n.crayon svg { width: 18px; height: 18px; }\n.crayon:hover, .crayon:focus-visible { background: var(--accent); color: #06120e; outline: 2px solid #fff; outline-offset: 2px; }\n\n/* LA TUILE \u00AB + AJOUTER \u00BB : l\u00E0 o\u00F9 irait le prochain bijou. */\n.tuile-ajout {\n position: fixed; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;\n border: 2px dashed var(--accent); border-radius: 14px; cursor: pointer;\n background: rgba(240, 250, 247, 0.92); color: #1f6f5d;\n font: 650 13px/1.3 ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n}\n.tuile-ajout b { font-size: 32px; font-weight: 400; line-height: 1; }\n.tuile-ajout:hover, .tuile-ajout:focus-visible { background: rgba(220, 244, 237, 0.97); outline: 2px solid var(--accent); outline-offset: 2px; }\n\n/* --------------------------------------------------- APER\u00C7U MOBILE ------ */\n.apercu {\n position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;\n background: rgba(16, 18, 22, 0.82); backdrop-filter: blur(3px);\n}\n.apercu__barre {\n display: flex; align-items: center; gap: 10px; padding: 6px 6px 6px 14px;\n background: var(--fond); border: 1px solid var(--bord); border-radius: 999px; font-size: 12.5px;\n}\n.apercu__tel { padding: 12px; border-radius: 44px; background: #0b0c0f; box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55); }\n.apercu__tel iframe { display: block; border: 0; border-radius: 32px; background: #fff; }\n";