@hgdc/ui 0.6.0 → 0.7.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 +2 -0
- package/dist/access-request.d.ts +50 -0
- package/dist/access-request.js +84 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,8 @@ Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôl
|
|
|
99
99
|
| `money-field` | `<MoneyField value={centimes} onChange={…} />` : CHF en centimes, « 1’250.50 », accepte « 12.– » ; `formatChf`, `parseMoney` |
|
|
100
100
|
| `address-fields` | `<AddressFields value={address} onChange={…} errors={validatePostalAddress(address)} />` : rue, numéro, NPA, localité, pays (facture QR) |
|
|
101
101
|
|
|
102
|
+
`access-request` : `<AccessRequest endpoint="https://<deploiement>.convex.site/f/<id>" product="HGDC Mail" signInRender={<Link to="/sign-in" />} />`, la page « Demander un accès » d'un produit (les comptes HGDC s'ouvrent sur invitation). Poste en JSON vers un formulaire HGDC Mail Forms : prénom, nom, entreprise, e-mail professionnel, message facultatif, `produit` caché, piège `_gotcha`. États envoi, réussite, erreur, champ refusé ; `submitAccessRequest` s'appelle seul.
|
|
103
|
+
|
|
102
104
|
Les champs de date sont ceux du navigateur, sans calendrier maison (ADR 0006). Les fonctions pures (`parseMoney`, `contrastRatio`, `validatePostalAddress`, `formatDateRange`) s'importent seules, côté serveur aussi.
|
|
103
105
|
|
|
104
106
|
## Contrôles et surfaces flottantes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Demander un accès à un produit de la famille, sans compte : les comptes HGDC s'ouvrent sur
|
|
4
|
+
* invitation. Le formulaire poste en JSON vers un formulaire HGDC Mail Forms (`POST /f/<id>`,
|
|
5
|
+
* réponse `{ ok }` ou `{ ok: false, error: { code }, errors }`) ; l'équipe reçoit l'avis et
|
|
6
|
+
* invite depuis HGDC Account. Le texte est au tutoiement, comme les pages publiques de la famille.
|
|
7
|
+
*/
|
|
8
|
+
export type AccessRequestValues = {
|
|
9
|
+
prenom: string;
|
|
10
|
+
nom: string;
|
|
11
|
+
entreprise: string;
|
|
12
|
+
email: string;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const EMPTY_ACCESS_REQUEST: AccessRequestValues;
|
|
16
|
+
/** Ce qu'il faut de `fetch` : une adresse et une requête. */
|
|
17
|
+
export type AccessRequestFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
18
|
+
export type AccessRequestResult = {
|
|
19
|
+
kind: "sent";
|
|
20
|
+
} | {
|
|
21
|
+
kind: "invalid";
|
|
22
|
+
errors: Record<string, string>;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "failed";
|
|
25
|
+
message: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Envoie la demande et traduit la réponse. Fonction pure à part du composant : un produit peut
|
|
29
|
+
* l'appeler depuis son propre formulaire. `trap` est le piège à robots (`_gotcha`), vide pour un humain.
|
|
30
|
+
*/
|
|
31
|
+
export declare function submitAccessRequest(endpoint: string, values: AccessRequestValues, product: string, { trap, fetcher }?: {
|
|
32
|
+
trap?: string;
|
|
33
|
+
fetcher?: AccessRequestFetcher;
|
|
34
|
+
}): Promise<AccessRequestResult>;
|
|
35
|
+
export declare function AccessRequest({ endpoint, product, title, description, messageLabel, signInHref, signInRender, homeHref, homeRender, fetcher, }: {
|
|
36
|
+
/** Adresse d'envoi du formulaire Forms : `https://<deploiement>.convex.site/f/<id>`. */
|
|
37
|
+
endpoint: string;
|
|
38
|
+
/** Nom affiché et valeur du champ caché `produit` : « HGDC Mail ». */
|
|
39
|
+
product: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
description?: ReactNode;
|
|
42
|
+
messageLabel?: string;
|
|
43
|
+
signInHref?: string;
|
|
44
|
+
/** Lien du routeur du produit vers la connexion : `signInRender={<Link to="/sign-in" />}`. */
|
|
45
|
+
signInRender?: ReactElement;
|
|
46
|
+
homeHref?: string;
|
|
47
|
+
homeRender?: ReactElement;
|
|
48
|
+
/** Pour les tests. */
|
|
49
|
+
fetcher?: AccessRequestFetcher;
|
|
50
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { mergeProps } from "@base-ui/react/merge-props";
|
|
3
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
4
|
+
import CheckmarkCircle02Icon from "@hugeicons/core-free-icons/CheckmarkCircle02Icon";
|
|
5
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { Alert } from "./alert.js";
|
|
8
|
+
import { Button, buttonVariants } from "./button.js";
|
|
9
|
+
import { Field, FieldError, FieldLabel } from "./field.js";
|
|
10
|
+
import { Input } from "./input.js";
|
|
11
|
+
import { Textarea } from "./textarea.js";
|
|
12
|
+
export const EMPTY_ACCESS_REQUEST = { prenom: "", nom: "", entreprise: "", email: "", message: "" };
|
|
13
|
+
/** Les refus de l'endpoint, dits au tutoiement. */
|
|
14
|
+
const FAILURES = {
|
|
15
|
+
rate_limited: "Trop d'envois en peu de temps. Réessaie dans une minute.",
|
|
16
|
+
origin_not_allowed: "Ce formulaire ne s'envoie que depuis le site de HGDC.",
|
|
17
|
+
form_closed: "Les demandes d'accès sont fermées pour le moment. Réessaie plus tard.",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Envoie la demande et traduit la réponse. Fonction pure à part du composant : un produit peut
|
|
21
|
+
* l'appeler depuis son propre formulaire. `trap` est le piège à robots (`_gotcha`), vide pour un humain.
|
|
22
|
+
*/
|
|
23
|
+
export async function submitAccessRequest(endpoint, values, product, { trap = "", fetcher = (url, init) => fetch(url, init) } = {}) {
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetcher(endpoint, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
29
|
+
body: JSON.stringify({ ...values, produit: product, _gotcha: trap }),
|
|
30
|
+
});
|
|
31
|
+
result = (await response.json());
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { kind: "failed", message: "Impossible d'envoyer la demande. Vérifie ta connexion et réessaie." };
|
|
35
|
+
}
|
|
36
|
+
if (result.ok)
|
|
37
|
+
return { kind: "sent" };
|
|
38
|
+
if (result.errors && Object.keys(result.errors).length)
|
|
39
|
+
return { kind: "invalid", errors: result.errors };
|
|
40
|
+
return {
|
|
41
|
+
kind: "failed",
|
|
42
|
+
message: FAILURES[result.error?.code ?? ""] ?? "La demande n'est pas partie. Réessaie dans un instant.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function LinkSlot({ href, render, className, children, }) {
|
|
46
|
+
return useRender({
|
|
47
|
+
defaultTagName: "a",
|
|
48
|
+
render,
|
|
49
|
+
props: mergeProps({ className, children }, render ? {} : { href }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function AccessRequest({ endpoint, product, title = `Demander un accès à ${product}`, description = "Les comptes HGDC s'ouvrent sur invitation. Dis-nous qui tu es : on te répond rapidement et, si c'est bon, tu reçois une invitation par e-mail.", messageLabel = "Ton projet, en quelques mots (facultatif)", signInHref = "/sign-in", signInRender, homeHref = "/", homeRender, fetcher, }) {
|
|
53
|
+
const [values, setValues] = useState(EMPTY_ACCESS_REQUEST);
|
|
54
|
+
const [trap, setTrap] = useState("");
|
|
55
|
+
const [pending, setPending] = useState(false);
|
|
56
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
57
|
+
const [failure, setFailure] = useState(null);
|
|
58
|
+
const [sentTo, setSentTo] = useState(null);
|
|
59
|
+
const set = (key) => (event) => {
|
|
60
|
+
setValues((current) => ({ ...current, [key]: event.target.value }));
|
|
61
|
+
setFieldErrors(({ [key]: _gone, ...rest }) => rest);
|
|
62
|
+
};
|
|
63
|
+
async function submit(event) {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
setPending(true);
|
|
66
|
+
setFailure(null);
|
|
67
|
+
setFieldErrors({});
|
|
68
|
+
const result = await submitAccessRequest(endpoint, values, product, { trap, ...(fetcher ? { fetcher } : {}) });
|
|
69
|
+
if (result.kind === "sent")
|
|
70
|
+
setSentTo(values.email.trim());
|
|
71
|
+
else if (result.kind === "invalid")
|
|
72
|
+
setFieldErrors(result.errors);
|
|
73
|
+
else
|
|
74
|
+
setFailure(result.message);
|
|
75
|
+
setPending(false);
|
|
76
|
+
}
|
|
77
|
+
if (sentTo) {
|
|
78
|
+
return (_jsxs("div", { "data-slot": "access-request", className: "grid rise gap-6", children: [_jsx(HugeiconsIcon, { icon: CheckmarkCircle02Icon, className: "size-10 pop-in text-status-active" }), _jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: "Demande envoy\u00E9e" }), _jsxs("p", { className: "mt-2 text-muted-foreground", children: ["Merci. Un accus\u00E9 de r\u00E9ception part vers ", sentTo, ". Si ta demande est accept\u00E9e, tu re\u00E7ois une invitation de HGDC Account pour cr\u00E9er ton compte."] })] }), _jsx(LinkSlot, { href: homeHref, render: homeRender, className: buttonVariants({ variant: "outline", size: "lg" }), children: "Retour \u00E0 l'accueil" })] }, "access-done"));
|
|
79
|
+
}
|
|
80
|
+
const invalid = (key) => (fieldErrors[key] ? true : undefined);
|
|
81
|
+
const field = (key, label, input) => (_jsxs(Field, { "data-invalid": invalid(key), children: [_jsx(FieldLabel, { htmlFor: `acces-${key}`, children: label }), input, fieldErrors[key] ? _jsx(FieldError, { children: fieldErrors[key] }) : null] }));
|
|
82
|
+
const text = (key, autoComplete, max, type = "text") => (_jsx(Input, { id: `acces-${key}`, type: type, autoComplete: autoComplete, required: true, maxLength: max, value: values[key], onChange: set(key), "aria-invalid": invalid(key) }));
|
|
83
|
+
return (_jsxs("form", { "data-slot": "access-request", onSubmit: submit, className: "grid rise gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-semibold text-balance", children: title }), description ? _jsx("p", { className: "mt-2 text-muted-foreground", children: description }) : null] }), failure ? (_jsx(Alert, { tone: "destructive", role: "alert", children: failure })) : null, _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [field("prenom", "Prénom", text("prenom", "given-name", 100)), field("nom", "Nom", text("nom", "family-name", 100))] }), field("entreprise", "Entreprise", text("entreprise", "organization", 120)), field("email", "E-mail professionnel", text("email", "email", 320, "email")), field("message", messageLabel, _jsx(Textarea, { id: "acces-message", rows: 3, maxLength: 2000, value: values.message, onChange: set("message"), "aria-invalid": invalid("message") })), _jsx("input", { type: "text", name: "_gotcha", tabIndex: -1, autoComplete: "off", "aria-hidden": "true", value: trap, onChange: (e) => setTrap(e.target.value), className: "absolute -left-[9999px] size-px opacity-0" })] }), _jsx(Button, { type: "submit", size: "lg", isLoading: pending, children: "Envoyer la demande" }), _jsxs("p", { className: "text-sm text-muted-foreground", children: ["D\u00E9j\u00E0 un compte HGDC ?", " ", _jsx(LinkSlot, { href: signInHref, render: signInRender, className: "font-medium text-foreground underline-offset-4 hover:underline", children: "Se connecter" })] })] }, "access"));
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Interface de la H family : jetons de la DA, thèmes, polices et composants.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
|