@mostajs/qrpanel 0.5.0 → 0.6.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/CHANGELOG.md +12 -0
- package/browser/qr-scan.js +10144 -0
- package/dist/qr-svg.js +165 -0
- package/llms.txt +20 -103
- package/package.json +28 -43
- package/src/cli.ts +74 -0
- package/src/client.tsx +184 -0
- package/src/composer.ts +164 -0
- package/src/config.ts +213 -0
- package/src/index.js +6 -0
- package/src/index.ts +16 -0
- package/src/qr-decode.js +23 -0
- package/src/qr-figures.js +46 -0
- package/src/qr-image.tsx +105 -0
- package/src/qr-payloads.js +59 -0
- package/src/qr-png.js +40 -0
- package/src/qr-svg.js +185 -0
- package/src/server.ts +212 -0
- package/src/themes.ts +266 -0
package/dist/qr-svg.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @mostajs/qrpanel/qr-svg — ARTEFACT GÉNÉRÉ depuis src/qr-svg.js (pur JS, copie 1:1) — ne pas éditer ici. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Complète qrpanel (qr-image = navigateur via `qrcode` ; server.js = @resvg natif) par un encodeur PUR JS server-side, idéal apps zéro-dép.
|
|
3
|
+
// Modes numérique & octet (byte/UTF-8), niveau de correction M, versions 1→10 (auto). Reed-Solomon GF(256), 8 masques + pénalité.
|
|
4
|
+
// Vérifié contre le vecteur de référence publié (« 01234567 », v1-M → 10 mots de code EC connus). Sortie : matrice booléenne + SVG.
|
|
5
|
+
|
|
6
|
+
// ── GF(256) — corps de Galois, primitif 0x11d (x^8+x^4+x^3+x^2+1). ──
|
|
7
|
+
const EXP = new Array(512), LOG = new Array(256);
|
|
8
|
+
(() => { let x = 1; for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; })();
|
|
9
|
+
const gmul = (a, b) => (a === 0 || b === 0) ? 0 : EXP[LOG[a] + LOG[b]];
|
|
10
|
+
|
|
11
|
+
// Polynôme générateur Reed-Solomon de degré `n`.
|
|
12
|
+
function rsGenPoly(n) {
|
|
13
|
+
let g = [1];
|
|
14
|
+
for (let i = 0; i < n; i++) { const ng = new Array(g.length + 1).fill(0); for (let j = 0; j < g.length; j++) { ng[j] ^= gmul(g[j], 1); ng[j + 1] ^= gmul(g[j], EXP[i]); } g = ng; }
|
|
15
|
+
return g;
|
|
16
|
+
}
|
|
17
|
+
// Mots de code de correction d'erreur pour `data` (octets) avec `ecLen` symboles.
|
|
18
|
+
function rsEncode(data, ecLen) {
|
|
19
|
+
const gen = rsGenPoly(ecLen); const res = new Array(ecLen).fill(0); // gen a ecLen+1 coeffs (gen[0]=1, ignoré dans la division)
|
|
20
|
+
for (const d of data) { const factor = d ^ res[0]; res.shift(); res.push(0); if (factor !== 0) for (let i = 0; i < ecLen; i++) res[i] ^= gmul(gen[i + 1], factor); }
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Tables niveau M, versions 1→10 : [totalDataCodewords, ecPerBlock, [ [nbBlocks, dataPerBlock], ... ] ]. ──
|
|
25
|
+
const VERSIONS_M = {
|
|
26
|
+
1: [16, 10, [[1, 16]]], 2: [28, 16, [[1, 28]]], 3: [44, 26, [[1, 44]]], 4: [64, 18, [[2, 32]]],
|
|
27
|
+
5: [86, 24, [[2, 43]]], 6: [108, 16, [[4, 27]]], 7: [124, 18, [[4, 31]]], 8: [154, 22, [[2, 38], [2, 39]]],
|
|
28
|
+
9: [182, 22, [[3, 36], [2, 37]]], 10: [216, 26, [[4, 43], [1, 44]]],
|
|
29
|
+
};
|
|
30
|
+
// Positions des motifs d'alignement (centres) par version.
|
|
31
|
+
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
|
32
|
+
const sizeOf = (v) => 17 + 4 * v;
|
|
33
|
+
|
|
34
|
+
const isNumeric = (s) => /^[0-9]+$/.test(s);
|
|
35
|
+
|
|
36
|
+
// ── Encodage des données (bitstream) selon le mode. ──
|
|
37
|
+
function encodeData(text, version, totalDataCw) {
|
|
38
|
+
const bits = [];
|
|
39
|
+
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
|
40
|
+
if (isNumeric(text)) {
|
|
41
|
+
push(0b0001, 4); // mode numérique
|
|
42
|
+
push(text.length, version < 10 ? 10 : 12);
|
|
43
|
+
for (let i = 0; i < text.length; i += 3) { const chunk = text.substr(i, 3); push(parseInt(chunk, 10), chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4); }
|
|
44
|
+
} else {
|
|
45
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
46
|
+
push(0b0100, 4); // mode octet
|
|
47
|
+
push(bytes.length, version < 10 ? 8 : 16);
|
|
48
|
+
for (const b of bytes) push(b, 8);
|
|
49
|
+
}
|
|
50
|
+
const cap = totalDataCw * 8;
|
|
51
|
+
for (let i = 0; i < 4 && bits.length < cap; i++) bits.push(0); // terminateur
|
|
52
|
+
while (bits.length % 8 !== 0) bits.push(0); // alignement octet
|
|
53
|
+
const pads = [0xec, 0x11]; let p = 0;
|
|
54
|
+
while (bits.length < cap) { push(pads[p % 2], 8); p++; }
|
|
55
|
+
const cw = []; for (let i = 0; i < bits.length; i += 8) { let b = 0; for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j]; cw.push(b); }
|
|
56
|
+
return cw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Choisit la plus petite version (≤10) qui contient `text`.
|
|
60
|
+
function pickVersion(text) {
|
|
61
|
+
const len = isNumeric(text) ? null : Buffer.from(text, 'utf8').length;
|
|
62
|
+
for (let v = 1; v <= 10; v++) {
|
|
63
|
+
const totalData = VERSIONS_M[v][0];
|
|
64
|
+
// estimation grossière de capacité : entête (~4 + count bits) puis données
|
|
65
|
+
const headerBits = 4 + (v < 10 ? (isNumeric(text) ? 10 : 8) : (isNumeric(text) ? 12 : 16));
|
|
66
|
+
const dataBits = isNumeric(text) ? Math.ceil(text.length / 3) * 10 : (len * 8);
|
|
67
|
+
if (headerBits + dataBits <= totalData * 8) return v;
|
|
68
|
+
}
|
|
69
|
+
throw new Error('contenu trop long pour QR v≤10 (raccourcir l\'URL/jeton)');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Entrelace données + EC par blocs (spec QR).
|
|
73
|
+
function interleave(dataCw, version) {
|
|
74
|
+
const [, ecLen, groups] = VERSIONS_M[version];
|
|
75
|
+
const blocks = []; let idx = 0;
|
|
76
|
+
for (const [nb, dpb] of groups) for (let i = 0; i < nb; i++) { const d = dataCw.slice(idx, idx + dpb); idx += dpb; blocks.push({ d, e: rsEncode(d, ecLen) }); }
|
|
77
|
+
const maxD = Math.max(...blocks.map((b) => b.d.length));
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let i = 0; i < maxD; i++) for (const b of blocks) if (i < b.d.length) out.push(b.d[i]);
|
|
80
|
+
for (let i = 0; i < ecLen; i++) for (const b of blocks) out.push(b.e[i]);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Matrice : motifs de repérage, séparateurs, timing, alignement, module sombre, réservé format. ──
|
|
85
|
+
function buildMatrix(version) {
|
|
86
|
+
const n = sizeOf(version);
|
|
87
|
+
const m = Array.from({ length: n }, () => new Array(n).fill(null)); // null = libre
|
|
88
|
+
const fn = Array.from({ length: n }, () => new Array(n).fill(false)); // true = fonction (non masquable)
|
|
89
|
+
const set = (r, c, v) => { m[r][c] = v ? 1 : 0; fn[r][c] = true; };
|
|
90
|
+
const finder = (r, c) => { for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) { const rr = r + i, cc = c + j; if (rr < 0 || rr >= n || cc < 0 || cc >= n) continue; const inb = i >= 0 && i <= 6 && j >= 0 && j <= 6; const ring = i === 0 || i === 6 || j === 0 || j === 6; const core = i >= 2 && i <= 4 && j >= 2 && j <= 4; set(rr, cc, inb && (ring || core)); } };
|
|
91
|
+
finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
|
|
92
|
+
for (let i = 0; i < n; i++) { if (m[6][i] === null) set(6, i, i % 2 === 0); if (m[i][6] === null) set(i, 6, i % 2 === 0); } // timing
|
|
93
|
+
const ac = ALIGN[version];
|
|
94
|
+
for (const r of ac) for (const c of ac) { if ((r <= 7 && c <= 7) || (r <= 7 && c >= n - 8) || (r >= n - 8 && c <= 7)) continue; for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) set(r + i, c + j, Math.max(Math.abs(i), Math.abs(j)) !== 1); }
|
|
95
|
+
set(n - 8, 8, 1); // module sombre
|
|
96
|
+
// Réserve les zones d'info de format (sans valeur définitive) → marquées fonction.
|
|
97
|
+
for (let i = 0; i < 9; i++) { if (m[8][i] === null) { m[8][i] = 0; fn[8][i] = true; } if (m[i][8] === null) { m[i][8] = 0; fn[i][8] = true; } }
|
|
98
|
+
for (let i = 0; i < 8; i++) { if (m[8][n - 1 - i] === null) { m[8][n - 1 - i] = 0; fn[8][n - 1 - i] = true; } if (m[n - 1 - i][8] === null) { m[n - 1 - i][8] = 0; fn[n - 1 - i][8] = true; } }
|
|
99
|
+
return { m, fn, n };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Place le bitstream en zigzag (colonnes de 2, de droite à gauche, en sautant la colonne 6).
|
|
103
|
+
function placeData(m, fn, n, bytes) {
|
|
104
|
+
let bitIdx = 0; const bitsArr = []; for (const b of bytes) for (let i = 7; i >= 0; i--) bitsArr.push((b >> i) & 1);
|
|
105
|
+
let up = true;
|
|
106
|
+
for (let col = n - 1; col > 0; col -= 2) {
|
|
107
|
+
if (col === 6) col--;
|
|
108
|
+
for (let k = 0; k < n; k++) { const row = up ? n - 1 - k : k; for (const c of [col, col - 1]) { if (!fn[row][c]) { m[row][c] = bitIdx < bitsArr.length ? bitsArr[bitIdx] : 0; bitIdx++; } } }
|
|
109
|
+
up = !up;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MASKS = [
|
|
114
|
+
(r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
|
|
115
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
116
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
117
|
+
];
|
|
118
|
+
function applyMask(m, fn, n, maskFn) { const o = m.map((row) => row.slice()); for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (!fn[r][c] && maskFn(r, c)) o[r][c] ^= 1; return o; }
|
|
119
|
+
|
|
120
|
+
// Info de format (niveau M = bits 00) + masque → 15 bits BCH, placée aux deux emplacements.
|
|
121
|
+
function placeFormat(m, n, maskId) {
|
|
122
|
+
const data = (0b00 << 3) | maskId; let bch = data << 10;
|
|
123
|
+
for (let i = 14; i >= 10; i--) if ((bch >> i) & 1) bch ^= 0b10100110111 << (i - 10);
|
|
124
|
+
const bits = (((data << 10) | bch) ^ 0b101010000010010);
|
|
125
|
+
const get = (i) => (bits >> i) & 1;
|
|
126
|
+
for (let i = 0; i <= 5; i++) m[8][i] = get(i);
|
|
127
|
+
m[8][7] = get(6); m[8][8] = get(7); m[7][8] = get(8);
|
|
128
|
+
for (let i = 9; i <= 14; i++) m[14 - i][8] = get(i);
|
|
129
|
+
for (let i = 0; i <= 7; i++) m[n - 1 - i][8] = get(i);
|
|
130
|
+
for (let i = 8; i <= 14; i++) m[8][n - 15 + i] = get(i);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function penalty(m, n) {
|
|
134
|
+
let p = 0;
|
|
135
|
+
for (let r = 0; r < n; r++) for (const line of [m[r], m.map((x) => x[r])]) { let run = 1; for (let c = 1; c < n; c++) { if (line[c] === line[c - 1]) { run++; if (run === 5) p += 3; else if (run > 5) p++; } else run = 1; } }
|
|
136
|
+
for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) p += 3;
|
|
137
|
+
let dark = 0; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += m[r][c]; const ratio = (dark * 100) / (n * n); p += Math.floor(Math.abs(ratio - 50) / 5) * 10;
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Encode `text` en matrice QR (booléens true=module sombre). Niveau M, version auto ≤10. */
|
|
142
|
+
export function qrMatrix(text) {
|
|
143
|
+
const version = pickVersion(text);
|
|
144
|
+
const totalData = VERSIONS_M[version][0];
|
|
145
|
+
const dataCw = encodeData(text, version, totalData);
|
|
146
|
+
const finalCw = interleave(dataCw, version);
|
|
147
|
+
const { m, fn, n } = buildMatrix(version);
|
|
148
|
+
placeData(m, fn, n, finalCw);
|
|
149
|
+
let best = null, bestScore = Infinity, bestMask = 0;
|
|
150
|
+
for (let mask = 0; mask < 8; mask++) { const cand = applyMask(m, fn, n, MASKS[mask]); placeFormat(cand, n, mask); const s = penalty(cand, n); if (s < bestScore) { bestScore = s; best = cand; bestMask = mask; } }
|
|
151
|
+
return { matrix: best.map((row) => row.map((v) => v === 1)), size: n, version, mask: bestMask };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Rend `text` en SVG QR (scannable). `scale` px/module, `margin` modules de zone calme. */
|
|
155
|
+
export function qrSvg(text, { scale = 4, margin = 4, dark = '#000', light = '#fff' } = {}) {
|
|
156
|
+
const { matrix, size } = qrMatrix(text);
|
|
157
|
+
const dim = (size + margin * 2) * scale;
|
|
158
|
+
let rects = '';
|
|
159
|
+
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (matrix[r][c]) rects += `<rect x="${(c + margin) * scale}" y="${(r + margin) * scale}" width="${scale}" height="${scale}"/>`;
|
|
160
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="${dim}" height="${dim}" fill="${light}"/><g fill="${dark}">${rects}</g></svg>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Exposés pour les tests (vérification contre le vecteur de référence publié).
|
|
164
|
+
export const _internal = { rsEncode, encodeData, interleave, VERSIONS_M };
|
|
165
|
+
export default { qrMatrix, qrSvg };
|
package/llms.txt
CHANGED
|
@@ -1,103 +1,20 @@
|
|
|
1
|
-
# @mostajs/qrpanel
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
(
|
|
11
|
-
(
|
|
12
|
-
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- ./qr-image : QrImage (default), useQrDataUrl
|
|
22
|
-
- ./themes : THEMES, THEME_KEYS, listThemes, getTheme, pickRandomTheme, buildThemeFrameSvg
|
|
23
|
-
- ./config : DEFAULT_CONFIG, loadQrConfig, ensureQrConfig, clearConfigCache
|
|
24
|
-
- "." (barrel) : ré-exporte server + client + themes + config
|
|
25
|
-
- bin `qrpanel` → CLI
|
|
26
|
-
|
|
27
|
-
## EXPORTS PAR SOUS-CHEMIN
|
|
28
|
-
- "." → barrel complet (server + client + themes + config) — ⚠ tire le code natif serveur
|
|
29
|
-
- "./server" → generateQr*, buildInviteUrls, themes, config (Node uniquement)
|
|
30
|
-
- "./client" → QrPanel (React, qrSrc fourni par l'app)
|
|
31
|
-
- "./qr-image" → QrImage, useQrDataUrl (React, génération locale)
|
|
32
|
-
- "./themes" → registry des 12 thèmes natifs
|
|
33
|
-
- "./config" → chargement/écriture de .qrconfig.json
|
|
34
|
-
|
|
35
|
-
## API — SIGNATURES
|
|
36
|
-
- function generateQrPng(text: string, opts?: QrOptions): Promise<Buffer>
|
|
37
|
-
- function generateQrSvg(text: string, opts?: QrOptions): Promise<string>
|
|
38
|
-
- function generateQrDataUrl(text: string, opts?: QrOptions): Promise<string> // data:image/png;base64
|
|
39
|
-
- function buildInviteUrls(opts: BuildInviteUrlsOptions): InviteUrls
|
|
40
|
-
- function QrPanel(props: QrPanelProps): JSX.Element | null
|
|
41
|
-
- function QrImage(props: QrImageProps): JSX.Element
|
|
42
|
-
- function useQrDataUrl(data: string, opts?: QrDataUrlOptions): { src: string; loading: boolean; error: string|null }
|
|
43
|
-
- function listThemes(): ThemeKey[]
|
|
44
|
-
- function getTheme(key: ThemeKey): ThemeAsset
|
|
45
|
-
- function pickRandomTheme(pool?: ThemeKey[]): ThemeKey
|
|
46
|
-
- function buildThemeFrameSvg(theme: ThemeAsset, opts: BuildFrameOpts): string
|
|
47
|
-
- function loadQrConfig(cwd?: string): QrConfig
|
|
48
|
-
- function ensureQrConfig(cwd?: string, overrides?: Partial<QrConfigDefaults>): string
|
|
49
|
-
- function clearConfigCache(): void
|
|
50
|
-
|
|
51
|
-
## TYPES CLÉS
|
|
52
|
-
- QrOptions { width?=600; margin?=2; errorCorrectionLevel?='L'|'M'|'Q'|'H'; darkColor?='#0f172a';
|
|
53
|
-
lightColor?='#ffffff'; genimage?: boolean; theme?: ThemeKey|'random'|'none'|{ svg; label? };
|
|
54
|
-
themePool?: ThemeKey[]; framePadding?=0.13; centerWhiteRatio?=0.62; themeOpacity?=1; themeColor?='#1e293b' }
|
|
55
|
-
- BuildInviteUrlsOptions { baseUrl: string; directPath: string; inviteSecret: string|Buffer;
|
|
56
|
-
inviteId: string; ttlMs?; invitePath?='/invite'; inviteMeta? }
|
|
57
|
-
- InviteUrls { directUrl: string; inviteUrl: string; inviteToken: string }
|
|
58
|
-
- QrPanelMode { key; label; url; qrSrc: string; description? }
|
|
59
|
-
- QrPanelProps { modes: QrPanelMode[]; initialModeIndex?=0; title?; mailSubject?; mailBodyTemplate?;
|
|
60
|
-
mailTo?: string[]|string; mailBcc?: string[]|string; qrSize?=260; className? }
|
|
61
|
-
- QrImageProps extends QrDataUrlOptions { data: string; alt?; style?; className? }
|
|
62
|
-
- QrDataUrlOptions { size?=200; margin?=2; errorCorrectionLevel?='M'; darkColor?='#0f172a'; lightColor?='#ffffff' }
|
|
63
|
-
- ThemeKey = 'baby'|'animals'|'science'|'physics'|'chemistry'|'math'|'nature'|'tech'|'space'|'music'|'book'|'health'
|
|
64
|
-
- ThemeAsset { key: ThemeKey; label: string; motif: string }
|
|
65
|
-
- QrConfig { default: QrConfigDefaults; customThemes?: Record<string,{svg;label?}> }
|
|
66
|
-
- QrFormat = 'svg'|'png'|'dataUrl' ; QrEcc = 'L'|'M'|'Q'|'H'
|
|
67
|
-
|
|
68
|
-
## PATTERN
|
|
69
|
-
```ts
|
|
70
|
-
// Serveur — QR thématique en route API (Node uniquement)
|
|
71
|
-
import { generateQrPng } from '@mostajs/qrpanel/server';
|
|
72
|
-
const png = await generateQrPng('https://app.example.com/x', { theme: 'tech', errorCorrectionLevel: 'H' });
|
|
73
|
-
|
|
74
|
-
// Client — panneau d'invitation
|
|
75
|
-
import { QrPanel } from '@mostajs/qrpanel/client';
|
|
76
|
-
<QrPanel modes={[{ key: 'direct', label: 'Lien', url, qrSrc: '/api/qr?u=' + url }]} />
|
|
77
|
-
|
|
78
|
-
// Génération locale navigateur (COEP-safe)
|
|
79
|
-
import QrImage from '@mostajs/qrpanel/qr-image';
|
|
80
|
-
<QrImage data="https://example.com" size={240} />
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## DÉPEND DE
|
|
84
|
-
- @mostajs/auth (peer optionnel, >=3.2.0) — requis par buildInviteUrls (signature HMAC du token)
|
|
85
|
-
- @resvg/resvg-js (peer optionnel, >=2.6.2) — requis par generateQr* (rasterisation SVG → PNG)
|
|
86
|
-
- react (peer optionnel, >=18 <20) — pour ./client et ./qr-image
|
|
87
|
-
|
|
88
|
-
## PIÈGES
|
|
89
|
-
- Ne PAS importer le barrel "." ni "./server" dans un bundle navigateur : @resvg/resvg-js
|
|
90
|
-
est un binaire natif Node. Côté client, utiliser exclusivement ./client ou ./qr-image.
|
|
91
|
-
- `<QrPanel>` NE génère PAS le QR : l'app fournit `qrSrc` (endpoint API ou data URL).
|
|
92
|
-
- `<QrPanel>` requiert Tailwind (style compilé par l'app) ; `<QrImage>` est style-neutre.
|
|
93
|
-
- `genimage: false` court-circuite tout le pipeline thématique (QR pur, comportement legacy).
|
|
94
|
-
- `theme: 'H'` (ECC=30%) recommandé pour les thèmes image-as-frame ; un ECC plus bas peut
|
|
95
|
-
rendre le QR illisible avec un cadre.
|
|
96
|
-
- `loadQrConfig` peut auto-créer `.qrconfig.json` à `process.cwd()` ; désactiver via
|
|
97
|
-
`QRPANEL_AUTO_ENSURE=false`. Échec d'écriture → fallback transparent sur DEFAULT_CONFIG.
|
|
98
|
-
- `mailBcc` recommandé pour les cohortes (les destinataires ne se voient pas) vs `mailTo`.
|
|
99
|
-
|
|
100
|
-
## RÉFÉRENCES
|
|
101
|
-
- README.md
|
|
102
|
-
- CHANGELOG.md
|
|
103
|
-
- src/, dist/cli.js (CLI `npx qrpanel`)
|
|
1
|
+
# @mostajs/qrpanel
|
|
2
|
+
Encodeur QR pur JS (zéro-dép) + figures + payloads typés. Versions 1→10, niveau M, v≥7 avec INFO DE VERSION (sinon QR rejeté). Reed-Solomon GF(256), 8 masques.
|
|
3
|
+
## exports (. = src/index.js)
|
|
4
|
+
qrMatrix(text) → {matrix,size}. qrSvg(text,{scale=4,margin=4,dark,light}) → <svg>. qrLive(text,caption?,{scale=6,margin=3,maxWidth=240,dark,light}) → <figure> scannable + légende.
|
|
5
|
+
## ./payloads (chaînes scan-and-act)
|
|
6
|
+
qrWifi({ssid,password,security=WPA,hidden}) · qrTel(num) · qrSms(num,body) · qrEmail(to,{subject,body}) · qrGeo(lat,lng) · qrUrl(u) · qrMeCard({name,tel,email,url,note}) · qrVCard({name,org,title,tel,email,url,adr,note}) · qrEvent({title,start,end,location,description}) [VEVENT]
|
|
7
|
+
## ./figures
|
|
8
|
+
qrLogo(text,{logo,logoScale=.2,scale,margin,dark,light}) → SVG + logo central (⚠ payload court). qrCard(text,{title,subtitle,logo}) · qrBadge(text,{name,role,event}) · qrSheet([{data,caption}],{cols=3})
|
|
9
|
+
## ./png (Node, node:zlib)
|
|
10
|
+
qrPng(text,{scale=6,margin=4}) → data:image/png;base64 (emails/billets où le SVG est bloqué)
|
|
11
|
+
## ./decode (Node, dépend de jsqr) — DÉCODAGE (qrpanel encode ET décode, v0.6)
|
|
12
|
+
decodeQr(imageData|{data,width,height}[, w, h]) → string|null (texte du QR depuis une image RGBA)
|
|
13
|
+
## ./browser-scan (asset navigateur) — scanner caméra prêt à servir (v0.6)
|
|
14
|
+
Fichier JS autonome à servir tel quel (<script src>), moteur jsQR. Expose window.jsQR ET
|
|
15
|
+
window.QRPanel.scan(videoEl, {onResult, onStatus, onError, continuous?, debounceMs?}) → {stop()}.
|
|
16
|
+
L'hôte fournit SON élément <video> (getUserMedia explicite — démarrage fiable sur geste
|
|
17
|
+
utilisateur) ; décodage jsQR à CHAQUE frame (BarcodeDetector en fast-path optionnel non bloquant).
|
|
18
|
+
⚠ Servir avec Cache-Control: no-cache (sinon un asset périmé désynchronise avec le HTML hôte).
|
|
19
|
+
Décodage Node séparé : `./decode` (decodeQr, jsqr).
|
|
20
|
+
## limite : versions ≤10 (~ M v10). Garder payloads raisonnables.
|
package/package.json
CHANGED
|
@@ -1,53 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/qrpanel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "QR code panel — server-side PNG/SVG generator with 12 built-in themes (image-as-frame composite, ECC=H), config-driven (.qrconfig.json), cross-OS no chromium, + React <QrPanel> client with copy/share/mailto.",
|
|
5
5
|
"author": "Dr Hamid MADANI <drmdh@msn.com>",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"main": "
|
|
8
|
+
"main": "src/index.js",
|
|
9
9
|
"types": "dist/index.d.ts",
|
|
10
10
|
"bin": {
|
|
11
11
|
"qrpanel": "dist/cli.js"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
".":
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"./
|
|
20
|
-
|
|
21
|
-
"import": "./dist/server.js",
|
|
22
|
-
"default": "./dist/server.js"
|
|
23
|
-
},
|
|
24
|
-
"./client": {
|
|
25
|
-
"types": "./dist/client.d.ts",
|
|
26
|
-
"import": "./dist/client.js",
|
|
27
|
-
"default": "./dist/client.js"
|
|
28
|
-
},
|
|
29
|
-
"./qr-image": {
|
|
30
|
-
"types": "./dist/qr-image.d.ts",
|
|
31
|
-
"import": "./dist/qr-image.js",
|
|
32
|
-
"default": "./dist/qr-image.js"
|
|
33
|
-
},
|
|
34
|
-
"./themes": {
|
|
35
|
-
"types": "./dist/themes.d.ts",
|
|
36
|
-
"import": "./dist/themes.js",
|
|
37
|
-
"default": "./dist/themes.js"
|
|
38
|
-
},
|
|
39
|
-
"./config": {
|
|
40
|
-
"types": "./dist/config.d.ts",
|
|
41
|
-
"import": "./dist/config.js",
|
|
42
|
-
"default": "./dist/config.js"
|
|
43
|
-
}
|
|
14
|
+
".": "./src/index.js",
|
|
15
|
+
"./qr-svg": "./src/qr-svg.js",
|
|
16
|
+
"./payloads": "./src/qr-payloads.js",
|
|
17
|
+
"./figures": "./src/qr-figures.js",
|
|
18
|
+
"./png": "./src/qr-png.js",
|
|
19
|
+
"./decode": "./src/qr-decode.js",
|
|
20
|
+
"./browser-scan": "./browser/qr-scan.js"
|
|
44
21
|
},
|
|
45
22
|
"files": [
|
|
46
23
|
"dist",
|
|
24
|
+
"browser",
|
|
47
25
|
"LICENSE",
|
|
48
26
|
"README.md",
|
|
49
27
|
"CHANGELOG.md",
|
|
50
|
-
"llms.txt"
|
|
28
|
+
"llms.txt",
|
|
29
|
+
"src"
|
|
51
30
|
],
|
|
52
31
|
"keywords": [
|
|
53
32
|
"qrcode",
|
|
@@ -59,13 +38,9 @@
|
|
|
59
38
|
"magic-link",
|
|
60
39
|
"config-driven"
|
|
61
40
|
],
|
|
62
|
-
"scripts": {
|
|
63
|
-
"build": "tsc && chmod +x dist/cli.js 2>/dev/null || true",
|
|
64
|
-
"dev": "tsc --watch",
|
|
65
|
-
"prepublishOnly": "npm run build"
|
|
66
|
-
},
|
|
67
41
|
"dependencies": {
|
|
68
|
-
"qrcode": "^1.5.4"
|
|
42
|
+
"qrcode": "^1.5.4",
|
|
43
|
+
"jsqr": "^1.4.0"
|
|
69
44
|
},
|
|
70
45
|
"peerDependencies": {
|
|
71
46
|
"react": ">=18 <20",
|
|
@@ -73,9 +48,15 @@
|
|
|
73
48
|
"@resvg/resvg-js": ">=2.6.2"
|
|
74
49
|
},
|
|
75
50
|
"peerDependenciesMeta": {
|
|
76
|
-
"react": {
|
|
77
|
-
|
|
78
|
-
|
|
51
|
+
"react": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@mostajs/auth": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@resvg/resvg-js": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
79
60
|
},
|
|
80
61
|
"devDependencies": {
|
|
81
62
|
"@types/node": "^22.0.0",
|
|
@@ -85,5 +66,9 @@
|
|
|
85
66
|
"@resvg/resvg-js": "^2.6.2",
|
|
86
67
|
"react": "^19.0.0",
|
|
87
68
|
"typescript": "^5.6.0"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsc && chmod +x dist/cli.js 2>/dev/null || true",
|
|
72
|
+
"dev": "tsc --watch"
|
|
88
73
|
}
|
|
89
|
-
}
|
|
74
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @mostajs/qrpanel — CLI
|
|
3
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
4
|
+
//
|
|
5
|
+
// Commandes disponibles :
|
|
6
|
+
// qrpanel init → écrit .qrconfig.json à cwd avec les defaults
|
|
7
|
+
// qrpanel themes → liste les 12 thèmes natifs
|
|
8
|
+
// qrpanel --version → version du package
|
|
9
|
+
|
|
10
|
+
import { ensureQrConfig, DEFAULT_CONFIG } from './config.js'
|
|
11
|
+
import { listThemes, THEMES } from './themes.js'
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2)
|
|
14
|
+
const cmd = args[0] ?? 'help'
|
|
15
|
+
|
|
16
|
+
function help() {
|
|
17
|
+
console.log(`@mostajs/qrpanel — CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
qrpanel init [path] Generate .qrconfig.json at cwd (or [path]).
|
|
21
|
+
Idempotent — does not overwrite existing config.
|
|
22
|
+
qrpanel themes List built-in theme keys.
|
|
23
|
+
qrpanel --version | -v Print version.
|
|
24
|
+
qrpanel help | -h This help.
|
|
25
|
+
|
|
26
|
+
Config file (.qrconfig.json) drives all generators. Master toggle:
|
|
27
|
+
"genimage": false → bypass theme pipeline, QR-only output.
|
|
28
|
+
"theme": "random" → randomize from themePool at each generation.
|
|
29
|
+
`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
switch (cmd) {
|
|
34
|
+
case 'init': {
|
|
35
|
+
const cwd = args[1] ?? process.cwd()
|
|
36
|
+
const path = ensureQrConfig(cwd)
|
|
37
|
+
console.log(`✓ qrpanel config ready at: ${path}`)
|
|
38
|
+
console.log(` Edit it to customize defaults — master toggle "genimage" toggles the whole pipeline.`)
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
case 'themes': {
|
|
42
|
+
console.log(`Built-in themes (${listThemes().length}):`)
|
|
43
|
+
for (const key of listThemes()) {
|
|
44
|
+
console.log(` - ${key.padEnd(10)} (${THEMES[key].label})`)
|
|
45
|
+
}
|
|
46
|
+
console.log(`\nDefaults config :`)
|
|
47
|
+
console.log(` themePool : ${DEFAULT_CONFIG.default.themePool.join(', ')}`)
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
case '--version':
|
|
51
|
+
case '-v': {
|
|
52
|
+
// Lecture programmatique du package.json sans import JSON (cross-runtime safe)
|
|
53
|
+
const { readFileSync } = await import('node:fs')
|
|
54
|
+
const { fileURLToPath } = await import('node:url')
|
|
55
|
+
const { dirname, join } = await import('node:path')
|
|
56
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
57
|
+
const pkgPath = join(here, '..', 'package.json')
|
|
58
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }
|
|
59
|
+
console.log(pkg.version)
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
case 'help':
|
|
63
|
+
case '--help':
|
|
64
|
+
case '-h':
|
|
65
|
+
default:
|
|
66
|
+
help()
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((e) => {
|
|
72
|
+
console.error('[qrpanel] error:', (e as Error).message)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @mostajs/qrpanel/client — React component
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Composant `<QrPanel>` agnostique de l'app. Affiche un QR + l'URL
|
|
5
|
+
// textuelle copiable + 3 actions (copier, ouvrir, mailto).
|
|
6
|
+
// Le composant ne génère pas le QR lui-même : l'app fournit le `qrSrc`
|
|
7
|
+
// (URL d'un endpoint API ou data URL inline).
|
|
8
|
+
//
|
|
9
|
+
// Style : Tailwind classes — l'app les compile dans son build (le
|
|
10
|
+
// component reste portable car les classes sont des chaînes).
|
|
11
|
+
//
|
|
12
|
+
// Note: marqué 'use client' pour Next.js App Router. Pour SPA (Vite,
|
|
13
|
+
// CRA, etc.), la directive est ignorée — le component fonctionne tel
|
|
14
|
+
// quel.
|
|
15
|
+
|
|
16
|
+
'use client'
|
|
17
|
+
import { useState, useId } from 'react'
|
|
18
|
+
|
|
19
|
+
export interface QrPanelMode {
|
|
20
|
+
/** Identifiant interne du mode (libre — sera passé en argument à `qrSrc`). */
|
|
21
|
+
key: string
|
|
22
|
+
/** Libellé bouton. */
|
|
23
|
+
label: string
|
|
24
|
+
/** URL absolue à afficher / copier dans ce mode. */
|
|
25
|
+
url: string
|
|
26
|
+
/** URL de l'image QR pour ce mode (endpoint API ou data URL). */
|
|
27
|
+
qrSrc: string
|
|
28
|
+
/** Description courte affichée sous l'en-tête. */
|
|
29
|
+
description?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QrPanelProps {
|
|
33
|
+
/** Liste des modes proposés (1 ou plusieurs). */
|
|
34
|
+
modes: QrPanelMode[]
|
|
35
|
+
/** Mode initial — index dans `modes`. Default 0. */
|
|
36
|
+
initialModeIndex?: number
|
|
37
|
+
/** Titre du panel. Default 'QR code & lien d\'invitation'. */
|
|
38
|
+
title?: string
|
|
39
|
+
/** Pré-remplissage `mailto`. Default 'Invitation'. */
|
|
40
|
+
mailSubject?: string
|
|
41
|
+
/** Pré-remplissage `mailto` body — `{url}` est remplacé par l'URL courante. */
|
|
42
|
+
mailBodyTemplate?: string
|
|
43
|
+
/**
|
|
44
|
+
* Destinataires placés dans `To:` du `mailto:` *(visibles entre eux)*.
|
|
45
|
+
* Liste d'emails (array ou string CSV). Combinable avec `mailBcc`.
|
|
46
|
+
*/
|
|
47
|
+
mailTo?: string[] | string
|
|
48
|
+
/**
|
|
49
|
+
* Destinataires placés dans `Bcc:` *(invisibles entre eux — recommandé
|
|
50
|
+
* pour les listes de cohort où les participants ne doivent pas voir
|
|
51
|
+
* les adresses des autres)*. Liste d'emails (array ou string CSV).
|
|
52
|
+
*/
|
|
53
|
+
mailBcc?: string[] | string
|
|
54
|
+
/** Taille de l'image QR. Default 260. */
|
|
55
|
+
qrSize?: number
|
|
56
|
+
/** Classe CSS racine optionnelle. */
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_BODY = 'Bonjour,\n\nVoici ton lien personnel :\n\n{url}\n\n'
|
|
61
|
+
|
|
62
|
+
/** Accepte une string CSV ou un array, normalise en array d'emails non-vides. */
|
|
63
|
+
function normaliseRecipients(input: string[] | string | undefined): string[] {
|
|
64
|
+
if (!input) return []
|
|
65
|
+
const arr = Array.isArray(input) ? input : input.split(',')
|
|
66
|
+
return arr.map(s => s.trim()).filter(s => s.length > 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function QrPanel({
|
|
70
|
+
modes, initialModeIndex = 0, title = 'QR code & lien d\'invitation',
|
|
71
|
+
mailSubject = 'Invitation', mailBodyTemplate = DEFAULT_BODY,
|
|
72
|
+
mailTo, mailBcc,
|
|
73
|
+
qrSize = 260, className = '',
|
|
74
|
+
}: QrPanelProps) {
|
|
75
|
+
const [idx, setIdx] = useState(Math.min(Math.max(0, initialModeIndex), modes.length - 1))
|
|
76
|
+
const [copied, setCopied] = useState(false)
|
|
77
|
+
const textareaId = useId()
|
|
78
|
+
const current = modes[idx] ?? modes[0]
|
|
79
|
+
if (!current) return null
|
|
80
|
+
|
|
81
|
+
const toList = normaliseRecipients(mailTo)
|
|
82
|
+
const bccList = normaliseRecipients(mailBcc)
|
|
83
|
+
const parts: string[] = []
|
|
84
|
+
parts.push(`subject=${encodeURIComponent(mailSubject)}`)
|
|
85
|
+
parts.push(`body=${encodeURIComponent(mailBodyTemplate.replace('{url}', current.url))}`)
|
|
86
|
+
if (bccList.length > 0) parts.push(`bcc=${encodeURIComponent(bccList.join(','))}`)
|
|
87
|
+
// `To:` est positionné juste après `mailto:` quand fourni (les
|
|
88
|
+
// destinataires To: sont rendus visibles entre eux par tous les clients).
|
|
89
|
+
// Les Bcc: passent par query param `&bcc=`.
|
|
90
|
+
const toPart = toList.length > 0 ? encodeURIComponent(toList.join(',')) : ''
|
|
91
|
+
const mailto = `mailto:${toPart}?${parts.join('&')}`
|
|
92
|
+
const recipientCount = toList.length + bccList.length
|
|
93
|
+
|
|
94
|
+
async function copy() {
|
|
95
|
+
try {
|
|
96
|
+
await navigator.clipboard.writeText(current.url)
|
|
97
|
+
setCopied(true)
|
|
98
|
+
setTimeout(() => setCopied(false), 2000)
|
|
99
|
+
} catch {
|
|
100
|
+
const el = document.getElementById(textareaId) as HTMLTextAreaElement | null
|
|
101
|
+
el?.select()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={'bg-white border border-slate-200 rounded-xl p-4 space-y-3 ' + className}>
|
|
107
|
+
<div className="flex items-center justify-between gap-2">
|
|
108
|
+
<h2 className="font-semibold text-slate-900">{title}</h2>
|
|
109
|
+
{modes.length > 1 && (
|
|
110
|
+
<div className="flex items-center gap-1 text-xs">
|
|
111
|
+
{modes.map((m, i) => (
|
|
112
|
+
<button
|
|
113
|
+
key={m.key} type="button" onClick={() => setIdx(i)}
|
|
114
|
+
className={'px-2 py-1 rounded ' + (i === idx ? 'bg-blue-600 text-white' : 'bg-slate-100 hover:bg-slate-200')}
|
|
115
|
+
>
|
|
116
|
+
{m.label}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{current.description && (
|
|
124
|
+
<p className="text-xs text-slate-500">{current.description}</p>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<div className="grid md:grid-cols-2 gap-4 items-start">
|
|
128
|
+
<div className="flex flex-col items-center gap-2">
|
|
129
|
+
<img
|
|
130
|
+
src={current.qrSrc}
|
|
131
|
+
alt={`QR ${current.label}`}
|
|
132
|
+
width={qrSize} height={qrSize}
|
|
133
|
+
className="border border-slate-200 rounded-lg"
|
|
134
|
+
/>
|
|
135
|
+
<a
|
|
136
|
+
href={current.qrSrc}
|
|
137
|
+
download={`qr-${current.key}.png`}
|
|
138
|
+
className="px-3 py-1.5 rounded text-xs bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200"
|
|
139
|
+
>
|
|
140
|
+
⬇ Télécharger le PNG
|
|
141
|
+
</a>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className="space-y-2">
|
|
145
|
+
<label className="block">
|
|
146
|
+
<span className="block text-xs font-medium text-slate-700 mb-1">
|
|
147
|
+
Lien direct *(à coller dans un email)*
|
|
148
|
+
</span>
|
|
149
|
+
<textarea
|
|
150
|
+
id={textareaId}
|
|
151
|
+
readOnly
|
|
152
|
+
value={current.url}
|
|
153
|
+
rows={current.url.length > 60 ? 4 : 2}
|
|
154
|
+
onClick={(e) => (e.currentTarget as HTMLTextAreaElement).select()}
|
|
155
|
+
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs font-mono bg-slate-50 break-all"
|
|
156
|
+
/>
|
|
157
|
+
</label>
|
|
158
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
159
|
+
<button
|
|
160
|
+
type="button" onClick={copy}
|
|
161
|
+
className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700"
|
|
162
|
+
>
|
|
163
|
+
{copied ? '✓ Copié' : '📋 Copier le lien'}
|
|
164
|
+
</button>
|
|
165
|
+
<a
|
|
166
|
+
href={current.url}
|
|
167
|
+
target="_blank" rel="noreferrer"
|
|
168
|
+
className="px-3 py-1.5 rounded text-xs bg-slate-100 hover:bg-slate-200 text-slate-700"
|
|
169
|
+
>
|
|
170
|
+
↗ Ouvrir
|
|
171
|
+
</a>
|
|
172
|
+
<a
|
|
173
|
+
href={mailto}
|
|
174
|
+
className="px-3 py-1.5 rounded text-xs bg-amber-50 hover:bg-amber-100 text-amber-700 border border-amber-200"
|
|
175
|
+
title={recipientCount > 0 ? `Ouvre le client mail avec ${recipientCount} destinataire(s) pré-remplis` : 'Ouvre le client mail avec sujet + corps pré-remplis'}
|
|
176
|
+
>
|
|
177
|
+
✉ Pré-remplir un mail{recipientCount > 0 ? ` (${recipientCount})` : ''}
|
|
178
|
+
</a>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|