Commit 5f48f4ae authored by Johnny Leveneur's avatar Johnny Leveneur
Browse files

factorisation qr.js et initialisation procedure de scan par zone

parent c32d87f5
Loading
Loading
Loading
Loading
+0 −259
Original line number Diff line number Diff line
<?php
/** @var string|null $callbackUrl */
$callbackUrl = $this->callbackUrl ?? '/qr/callback';
?>
<div class="container"
     style="max-width:680px;margin:2rem auto;font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial;">
    <h1>Scanner un QR code</h1>

    <p>
        Autorisez l’accès à la caméra. Le QR sera détecté en continu.
    </p>

    <video id="qr-video" autoplay playsinline
           style="width:100%;max-width:320px;border:1px solid #ddd;border-radius:8px;"></video>
    <div id="status" style="margin-top:0.5rem;color:#555;">Initialisation caméra…</div>

    <br>
    <!-- Panneau message serveur (succès/erreur) -->
    <div id="server-panel" class="qr-status" data-kind="info" hidden></div>

    <!-- Détails de la réponse bruts -->
    <details id="server-details" style="margin-top:8px;" hidden>
        <summary>Réponse serveur</summary>
        <div style="display:flex;gap:8px;align-items:center;margin:8px 0;">
            <button id="copy-json" type="button">Copier</button>
            <small id="server-ct" style="opacity:.7"></small>
        </div>
        <pre id="server-json"
             style="background:#f6f8fa;border-radius:8px;padding:12px;overflow:auto;max-height:260px;"></pre>
    </details>

    <details style="margin-top:1rem;">
        <summary>Paramètres caméra</summary>
        <div style="margin-top:0.5rem;">
            <label for="cameraSelect">Source vidéo :</label>
            <select id="cameraSelect"></select>
        </div>
    </details>

    <pre id="result"
         style="margin-top:1rem;padding:0.75rem;background:#f8f9fa;border:1px solid #eee;border-radius:8px;white-space:pre-wrap;"></pre>
</div>

<script type="module">
    // import { BrowserMultiFormatReader, NotFoundException } from "https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/+esm";
    import {BrowserMultiFormatReader, NotFoundException} from "/vendor/zxing/zxing-library.bundle.esm.js";

    const statusEl = document.getElementById('status');
    const videoEl = document.getElementById('qr-video');
    const selectEl = document.getElementById('cameraSelect');
    const resultEl = document.getElementById('result');

    const codeReader = new BrowserMultiFormatReader();

    // Décode les entités HTML (&lt; &gt; &amp; …)
    function decodeHTMLEntities(str) {
        const t = document.createElement('textarea');
        t.innerHTML = String(str);
        return t.value;
    }

    // (facultatif) neutralise les liens dangereux
    function hardenLinks(root) {
        root.querySelectorAll('a[href]').forEach(a => {
            const url = a.getAttribute('href') || '';
            if (!/^(https?:|mailto:|tel:)/i.test(url)) a.removeAttribute('href');
            a.setAttribute('rel', 'noopener noreferrer');
            if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');
        });
    }

    // Helper pour mettre à jour le message + la couleur (info/ok/warn/err)
    function setStatus(text, kind = 'info') {
        if (!statusEl) return;
        statusEl.textContent = text ?? '';
        statusEl.setAttribute('data-kind', kind);
    }

    // Sécurité minimale : échappe le texte pour éviter l'HTML injection
    function escapeHTML(s) {
        return String(s).replace(/[&<>"']/g, m => ({
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        }[m]));
    }

    // Affiche proprement le résultat du serveur
    function showServerResponse({ contentType, data, rawText, networkError }) {
        const panel    = document.getElementById('server-panel');
        const details  = document.getElementById('server-details');
        const pre      = document.getElementById('server-json');
        const ctEl     = document.getElementById('server-ct');
        const copyBtn  = document.getElementById('copy-json');

        // Toujours rendre visibles dès le début
        panel.hidden = false;
        details.hidden = false;

        // RAZ
        panel.textContent = '';
        pre.textContent = '';
        ctEl.textContent = contentType ? `content-type: ${contentType}` : '';

        let kind = 'info';
        let msgText = '';

        if (networkError) {
            kind = 'err';
            msgText = `Erreur réseau : ${escapeHTML(networkError)}`;
            panel.textContent = msgText;
            panel.setAttribute('data-kind', kind);
        } else if (data) {
            kind = data.ok === false ? 'err' : 'ok';

            // 1) Panneau message (HTML décodé si présent)
            if (data.msg) {
                const html = decodeHTMLEntities(String(data.msg));
                panel.innerHTML = html;
                hardenLinks(panel);
            } else if (data.msg_text) {
                panel.textContent = String(data.msg_text);
            } else {
                panel.textContent = data.ok ? 'Opération réussie.' : 'Une erreur est survenue.';
            }
            panel.setAttribute('data-kind', kind);

            // 2) Bloc JSON pretty-print
            try {
                pre.textContent = JSON.stringify(data, null, 2);
            } catch {
                pre.textContent = String(data);
            }
        } else {
            // Réponse non JSON
            kind = 'warn';
            panel.textContent = 'Réponse non-JSON reçue du serveur.';
            pre.textContent = rawText || '';
            panel.setAttribute('data-kind', kind);
        }

        // Bouton "Copier"
        copyBtn.onclick = async () => {
            try {
                await navigator.clipboard.writeText(pre.textContent || '');
                const old = copyBtn.textContent;
                copyBtn.textContent = 'Copié ✓';
                setTimeout(() => (copyBtn.textContent = old), 1200);
            } catch {}
        };
    }

    async function listCameras() {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const videoInputs = devices.filter(d => d.kind === 'videoinput');

        selectEl.innerHTML = '';
        videoInputs.forEach((d, i) => {
            const opt = document.createElement('option');
            opt.value = d.deviceId;
            opt.textContent = d.label || `Caméra ${i + 1}`;
            selectEl.appendChild(opt);
        });

        return videoInputs;
    }

    async function start(deviceId = null) {
        try {
            statusEl.textContent = 'Démarrage caméra…';

            // Arrête toute lecture précédente
            codeReader.reset();

            await codeReader.decodeFromVideoDevice(deviceId, videoEl, async (result, err) => {
                if (result) {
                    const text = result.getText();
                    statusEl.textContent = 'QR détecté ✅';
                    resultEl.textContent = `Payload : ${text}`;

                    // Envoi au serveur (callback Laminas)
                    try {
                         const callbackUrl = <?= json_encode($callbackUrl, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
                         const resp = await fetch('<?php echo $callbackUrl; ?>', {
                            method: 'POST',
                            headers: {'Content-Type': 'application/json'},
                            body: JSON.stringify({payload: text})
                        });

                        const contentType = resp.headers.get('content-type') || '';
                        if (contentType.includes('application/json')) {
                            const data = await resp.json();
                            // Met à jour la barre d’état + panneau serveur
                            showServerResponse({contentType, payload: text, data});
                            setStatus(data?.ok === false ? 'Le serveur a renvoyé une erreur.' : 'Réponse serveur reçue ✅', data?.ok === false ? 'err' : 'ok');
                        } else {
                            const rawText = await resp.text();
                            showServerResponse({contentType, payload: text, rawText});
                            setStatus('Réponse non-JSON reçue (voir détails).', 'warn');
                        }
                    } catch (e) {
                        showServerResponse({networkError: e?.message || String(e)});
                        setStatus('Erreur lors de l’envoi du résultat (réseau).', 'err');
                    }

                    // 👉 Si tu veux arrêter au 1er scan :
                    //codeReader.reset();
                    //const stream = videoEl.srcObject; stream && stream.getTracks().forEach(t => t.stop());
                } else if (err && !(err instanceof NotFoundException)) {
                    // Erreurs autres que "pas trouvé"
                    console.warn(err);
                    statusEl.textContent = 'Erreur lecture : ' + (err.message || err);
                }
            });

            statusEl.textContent = 'Recherche du QR code…';
        } catch (e) {
            statusEl.textContent = 'Erreur caméra : ' + (e.message || e);
        }
    }

    async function init() {
        if (!navigator.mediaDevices?.getUserMedia) {
            statusEl.textContent = 'getUserMedia non supporté par ce navigateur.';
            return;
        }

        try {
            const cams = await listCameras();
            // Choisir de préférence la caméra "arrière" si identifiable
            let preferredId = null;
            const back = cams.find(d => /back|rear|environment/i.test(d.label));
            if (back) preferredId = back.deviceId;

            if (cams.length) {
                selectEl.value = preferredId || cams[0].deviceId;
                await start(selectEl.value);
            } else {
                // Essaie sans deviceId (laisser le navigateur choisir)
                await start(null);
            }

            selectEl.addEventListener('change', () => start(selectEl.value));
        } catch (e) {
            statusEl.textContent = 'Impossible d’accéder à la caméra : ' + (e.message || e);
        }
    }

    init();
</script>

<!-- Conseils d’usage -->
<style>
    details summary {
        cursor: pointer;
    }
</style>
+0 −373
Original line number Diff line number Diff line
<?php
/** @var string|null $callbackUrl */
$callbackUrl = $this->callbackUrl ?? '/qr/callback';
?>
<div class="container"
     style="max-width:800px;margin:2rem auto;font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial;">
    <h1>Scanner un QR code</h1>

    <p>
        Autorisez l’accès à la caméra. Le QR sera détecté en continu.
    </p>

    <video id="qr-video" autoplay playsinline
           style="width:100%;max-width:320px;border:1px solid #ddd;border-radius:8px;"></video>
    <div id="status" style="margin-top:0.5rem;color:#555;">Initialisation caméra…</div>

    <div class="qr-toolbar">
        <button id="start" type="button">Démarrer</button>
        <button id="stop" type="button" disabled>Arrêter</button>
        <button id="pause" type="button" disabled>Pause</button>
        <button id="resume" type="button" disabled>Reprendre</button>
        <!-- ... le reste (lampe, select caméras, etc.) -->
    </div>

    <br>
    <!-- Panneau message serveur (succès/erreur) -->
    <div id="server-panel" class="qr-status" data-kind="info" hidden></div>

    <!-- Détails de la réponse bruts -->
    <details id="server-details" style="margin-top:8px;" hidden>
        <summary>Réponse serveur</summary>
        <div style="display:flex;gap:8px;align-items:center;margin:8px 0;">
            <button id="copy-json" type="button">Copier</button>
            <small id="server-ct" style="opacity:.7"></small>
        </div>
        <pre id="server-json"
             style="background:#f6f8fa;border-radius:8px;padding:12px;overflow:auto;max-height:260px;"></pre>
    </details>

    <details style="margin-top:1rem;">
        <summary>Paramètres caméra</summary>
        <div style="margin-top:0.5rem;">
            <label for="cameraSelect">Source vidéo :</label>
            <select id="cameraSelect"></select>
        </div>
    </details>

    <pre id="result"
         style="margin-top:1rem;padding:0.75rem;background:#f8f9fa;border:1px solid #eee;border-radius:8px;white-space:pre-wrap;"></pre>
</div>

<script type="module">
    // import { BrowserMultiFormatReader, NotFoundException } from "https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/+esm";
    import {BrowserMultiFormatReader, NotFoundException} from "/vendor/zxing/zxing-library.bundle.esm.js";

    const statusEl = document.getElementById('status');
    const videoEl = document.getElementById('qr-video');
    const selectEl = document.getElementById('cameraSelect');
    const resultEl = document.getElementById('result');
    const reader = new BrowserMultiFormatReader();
    const btnStart = document.getElementById('start');
    const btnStop = document.getElementById('stop');
    const btnPause = document.getElementById('pause');
    const btnResume = document.getElementById('resume');
    // État caméra / décodage
    let currentStream = null;
    let scanningPaused = false;   // <- état pause douce
    let currentTrack = null;      // <- ta piste vidéo (si tu ne las pas déjà)
    let lastText = null; // pour éviter le double traitement du même QR

    // fonctions lecteur
    function setButtonsRunning(running) {
        // running = caméra en marche (tu peux déjà avoir une fonction équivalente)
        if (!btnStart) return; // au cas où
        btnStart.disabled = running;
        btnStop.disabled = !running;
        btnPause.disabled = !running || scanningPaused;
        btnResume.disabled = !running || !scanningPaused;
    }

    function setPaused(paused) {
        scanningPaused = paused;
        if (btnPause)  btnPause.disabled  = paused;
        if (btnResume) btnResume.disabled = !paused;
        setStatus(paused ? 'Scan en pause ⏸' : 'Reprise du scan ▶');
    }

    // Décode les entités HTML (&lt; &gt; &amp; …)
    function decodeHTMLEntities(str) {
        const t = document.createElement('textarea');
        t.innerHTML = String(str);
        return t.value;
    }

    // (facultatif) neutralise les liens dangereux
    function hardenLinks(root) {
        root.querySelectorAll('a[href]').forEach(a => {
            const url = a.getAttribute('href') || '';
            if (!/^(https?:|mailto:|tel:)/i.test(url)) a.removeAttribute('href');
            a.setAttribute('rel', 'noopener noreferrer');
            if (!a.hasAttribute('target')) a.setAttribute('target', '_blank');
        });
    }

    // Helper pour mettre à jour le message + la couleur (info/ok/warn/err)
    function setStatus(text, kind = 'info') {
        if (!statusEl) return;
        statusEl.textContent = text ?? '';
        statusEl.setAttribute('data-kind', kind);
    }

    // Sécurité minimale : échappe le texte pour éviter l'HTML injection
    function escapeHTML(s) {
        return String(s).replace(/[&<>"']/g, m => ({
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        }[m]));
    }

    // Affiche proprement le résultat du serveur
    function showServerResponse({contentType, data, rawText, networkError}) {
        const panel = document.getElementById('server-panel');
        const details = document.getElementById('server-details');
        const pre = document.getElementById('server-json');
        const ctEl = document.getElementById('server-ct');
        const copyBtn = document.getElementById('copy-json');

        // Toujours rendre visibles dès le début
        panel.hidden = false;
        details.hidden = false;

        // RAZ
        panel.textContent = '';
        pre.textContent = '';
        ctEl.textContent = contentType ? `content-type: ${contentType}` : '';

        let kind = 'info';
        let msgText = '';

        if (networkError) {
            kind = 'err';
            msgText = `Erreur réseau : ${escapeHTML(networkError)}`;
            panel.textContent = msgText;
            panel.setAttribute('data-kind', kind);
        } else if (data) {
            kind = data.ok === false ? 'err' : 'ok';

            // 1) Panneau message (HTML décodé si présent)
            if (data.msg) {
                const html = decodeHTMLEntities(String(data.msg));
                panel.innerHTML = html;
                hardenLinks(panel);
            } else if (data.msg_text) {
                panel.textContent = String(data.msg_text);
            } else {
                panel.textContent = data.ok ? 'Opération réussie.' : 'Une erreur est survenue.';
            }
            panel.setAttribute('data-kind', kind);

            // 2) Bloc JSON pretty-print
            try {
                pre.textContent = JSON.stringify(data, null, 2);
            } catch {
                pre.textContent = String(data);
            }
        } else {
            // Réponse non JSON
            kind = 'warn';
            panel.textContent = 'Réponse non-JSON reçue du serveur.';
            pre.textContent = rawText || '';
            panel.setAttribute('data-kind', kind);
        }

        // Bouton "Copier"
        copyBtn.onclick = async () => {
            try {
                await navigator.clipboard.writeText(pre.textContent || '');
                const old = copyBtn.textContent;
                copyBtn.textContent = 'Copié ✓';
                setTimeout(() => (copyBtn.textContent = old), 1200);
            } catch {
            }
        };
    }

    async function listCameras() {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const videoInputs = devices.filter(d => d.kind === 'videoinput');

        selectEl.innerHTML = '';
        videoInputs.forEach((d, i) => {
            const opt = document.createElement('option');
            opt.value = d.deviceId;
            opt.textContent = d.label || `Caméra ${i + 1}`;
            selectEl.appendChild(opt);
        });

        return videoInputs;
    }

    async function start(deviceId = null) {
        // Stop propre avant de (re)lancer
        try { await stop(); } catch {}

        const videoEl = document.getElementById('qr-video');

        // Contraintes “safe” (ok desktop + mobile)
        const constraints = {
            video: {
                deviceId: deviceId ? { exact: deviceId } : undefined,
                width:  { ideal: 1280 },
                height: { ideal: 720 },
                facingMode: { ideal: 'environment' },
                advanced: [{ focusMode: 'continuous' }]
            },
            audio: false
        };

        try {
            // 1) Ouvre la caméra
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            currentStream = stream;
            currentTrack  = stream.getVideoTracks()[0];

            // 2) Branche la vidéo
            videoEl.srcObject = stream;
            await videoEl.play();

            // 3) UI
            setPaused(false);
            setButtonsRunning(true);
            setStatus('Cadrez le QR dans le cadre blanc.');

            // 4) Démarre le décodage continu
            //    (tant que tu ne fais pas reader.reset(), le callback est rappelé en boucle)
            reader.decodeFromVideoDevice(deviceId || undefined, videoEl, async (result, err) => {
                // Ignore pendant la pause
                if (scanningPaused) return;

                if (result) {
                    const text = result.getText();
                    // commenter afin de pouvoir scanner 2 fois de suite le même qr-code
                    // if (text && text !== lastText) {
                    //     lastText = text;

                        // >>>> Ton traitement : affichage + callback serveur
                        // (si tu as déjà un bloc qui fait fetch + showServerResponse, conserve-le)
                        try {
                            // Indice visuel/sonore (optionnel)
                            if (navigator.vibrate) navigator.vibrate(60);

                            const resp = await fetch('<?php echo $callbackUrl; ?>', {
                                method: 'POST',
                                headers: { 'Content-Type': 'application/json' },
                                body: JSON.stringify({ payload: text })
                            });

                            const ct = resp.headers.get('content-type') || '';
                            if (ct.includes('application/json')) {
                                const data = await resp.json();
                                showServerResponse({ contentType: ct, data });
                                setStatus(data?.ok === false ? 'Le serveur a renvoyé une erreur.' : 'Réponse serveur reçue ✅', data?.ok === false ? 'err' : 'ok');
                            } else {
                                const rawText = await resp.text();
                                showServerResponse({ contentType: ct, rawText });
                                setStatus('Réponse non-JSON reçue (voir détails).', 'warn');
                            }
                        } catch (e) {
                            showServerResponse({ networkError: e?.message || String(e) });
                            setStatus('Erreur lors de l’envoi du résultat (réseau).', 'err');
                        }

                        // Anti double-scan (pause 1s)
                        setPaused(true);
                        setTimeout(() => setPaused(false), 1000);
                    // }
                    return;
                }

                // Erreurs non fatales : on ignore (NotFoundException = rien trouvé sur cette frame)
                if (err && !(err instanceof NotFoundException)) {
                    // console.debug(err);
                }
            });

        } catch (e) {
            setButtonsRunning(false);
            setStatus('Impossible d’accéder à la caméra : ' + (e?.name || e?.message || e), 'err');
        }
    }

    async function stop() {
        try {
            // 1) Arrête le décodage ZXing
            reader.reset(); // BrowserMultiFormatReader
        } catch {
        }

        // 2) Stoppe proprement la(les) piste(s) vidéo
        try {
            if (currentTrack) {
                currentTrack.stop();
                currentTrack = null;
            }
            if (currentStream) {
                currentStream.getTracks().forEach(t => t.stop());
                currentStream = null;
            }
        } catch {
        }

        // 3) Libère la vidéo
        try {
            const videoEl = document.getElementById('qr-video');
            if (videoEl) videoEl.srcObject = null;
        } catch {
        }

        // 4) UI
        setButtonsRunning(false);
        setPaused(false);
        setStatus('Caméra arrêtée.');
    }

    async function init() {
        if (!navigator.mediaDevices?.getUserMedia) {
            statusEl.textContent = 'getUserMedia non supporté par ce navigateur.';
            return;
        }

        try {
            const cams = await listCameras();
            // Choisir de préférence la caméra "arrière" si identifiable
            let preferredId = null;
            const back = cams.find(d => /back|rear|environment/i.test(d.label));
            if (back) preferredId = back.deviceId;

            if (cams.length) {
                selectEl.value = preferredId || cams[0].deviceId;
                await start(selectEl.value);
            } else {
                // Essaie sans deviceId (laisser le navigateur choisir)
                await start(null);
            }

            btnStart?.addEventListener('click', () => start(selectEl?.value || undefined));
            btnStop?.addEventListener('click',  stop);
            btnPause?.addEventListener('click', () => setPaused(true));
            btnResume?.addEventListener('click', () => setPaused(false));
            selectEl?.addEventListener('change', () => start(selectEl.value));
        } catch (e) {
            statusEl.textContent = 'Impossible d’accéder à la caméra : ' + (e.message || e);
        }
    }

    init();
</script>

<!-- Conseils d’usage -->
<style>
    details summary {
        cursor: pointer;
    }

    .qr-toolbar button:disabled {
        opacity: .5;
        cursor: not-allowed;
    }

</style>
+0 −384

File deleted.

Preview size limit exceeded, changes collapsed.

+0 −378

File deleted.

Preview size limit exceeded, changes collapsed.

+0 −415

File deleted.

Preview size limit exceeded, changes collapsed.

Loading