Anubys / Rexel price

// ==UserScript==
// @name         Rexel price
// @namespace    http://tampermonkey.net/
// @version      0.2.2
// @description  Affiche les détails du prix sans montrer le pop-up
// @author       Anubys
// @copyright    2026, Anubys
// @license      MIT
// @match        https://www.rexel.fr/frx/*
// @updateURL    https://openuserjs.org/meta/Anubys/Rexel_price.meta.js
// @downloadURL  https://openuserjs.org/install/Anubys/Rexel_price.user.js
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Rexel price]';

    const RESULT_ID = 'rexel-price-details';
    const HIDDEN_STYLE_ID = 'rexel-hidden-popover-style';
    const TIMEOUT = 15000;

    console.log(`${LOG_PREFIX} Script chargé.`);


    // ============================================================
    // OUTILS
    // ============================================================

    function cleanText(value) {
        return String(value || '')
            .replace(/\u00a0/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }


    function waitForCondition(
        callback,
        timeout = TIMEOUT,
        description = 'Condition'
    ) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();

            function check() {
                let result = null;

                try {
                    result = callback();
                } catch (error) {
                    console.debug(
                        `${LOG_PREFIX} Attente ${description} :`,
                        error
                    );
                }

                if (result) {
                    resolve(result);
                    return;
                }

                if (Date.now() - startTime >= timeout) {
                    reject(
                        new Error(
                            `${description} non satisfaite après ${timeout} ms.`
                        )
                    );
                    return;
                }

                setTimeout(check, 100);
            }

            check();
        });
    }


    // ============================================================
    // RECHERCHE DU BOUTON TARIFAIRE
    // ============================================================

    function findPricingTrigger() {
        const exactSelector =
            'i.fas.fa-info-circle.fa-sm.ml-1.ds-mt-2.popover-icon[role="button"]';

        const exactMatches = Array.from(
            document.querySelectorAll(exactSelector)
        );

        if (!exactMatches.length) {
            console.debug(
                `${LOG_PREFIX} Bouton exact pas encore présent.`,
                exactSelector
            );

            return null;
        }

        console.log(
            `${LOG_PREFIX} Bouton tarifaire exact trouvé :`,
            exactMatches.length,
            exactMatches
        );

        /*
         * Priorité au bouton situé dans le vrai composant tarifaire.
         */
        for (const candidate of exactMatches) {
            const component = candidate.closest(
                'mf-pdp-price-info-popover'
            );

            if (component) {
                console.log(
                    `${LOG_PREFIX} Bouton tarifaire retenu :`,
                    candidate,
                    'Composant :',
                    component
                );

                return candidate;
            }
        }

        /*
         * Fallback si Rexel modifie légèrement le composant.
         */
        for (const candidate of exactMatches) {
            let parent = candidate.parentElement;

            for (let level = 0; parent && level < 8; level++) {
                const text = cleanText(
                    parent.textContent
                ).toLowerCase();

                if (
                    text.includes('€') ||
                    text.includes('prix') ||
                    text.includes('ht') ||
                    text.includes('ttc') ||
                    text.includes('remise')
                ) {
                    console.log(
                        `${LOG_PREFIX} Bouton tarifaire fallback retenu :`,
                        candidate
                    );

                    return candidate;
                }

                parent = parent.parentElement;
            }
        }

        return exactMatches[0];
    }


    // ============================================================
    // MASQUAGE TEMPORAIRE DU POP-UP
    // ============================================================

    function hidePopoversBeforeOpening() {
        if (!document.getElementById(HIDDEN_STYLE_ID)) {
            const style = document.createElement('style');

            style.id = HIDDEN_STYLE_ID;

            style.textContent = `
                body.rexel-price-reading
                mf-pdp-price-info-popover ds-popover {
                    visibility: hidden !important;
                    opacity: 0 !important;
                    pointer-events: none !important;
                }

                body.rexel-price-reading
                mf-pdp-price-info-popover ds-elevation.ds-popover {
                    visibility: hidden !important;
                    opacity: 0 !important;
                    pointer-events: none !important;
                }
            `;

            document.head.appendChild(style);
        }

        document.body.classList.add(
            'rexel-price-reading'
        );
    }


    function removePopoverHidingMode() {
        document.body.classList.remove(
            'rexel-price-reading'
        );
    }


    // ============================================================
    // RECHERCHE DU POP-UP TARIFAIRE
    // ============================================================

    function findPricingPopover(trigger) {
        if (!trigger) {
            return null;
        }

        const component = trigger.closest(
            'mf-pdp-price-info-popover'
        );

        if (!component) {
            console.debug(
                `${LOG_PREFIX} Composant mf-pdp-price-info-popover introuvable.`
            );

            return null;
        }

        const popover = component.querySelector(
            'ds-popover ds-elevation.ds-popover'
        );

        if (!popover) {
            console.debug(
                `${LOG_PREFIX} Popover tarifaire pas encore créé.`
            );

            return null;
        }

        const text = cleanText(
            popover.textContent
        );

        const textLower = text.toLowerCase();

        const hasPricingContent =
            textLower.includes('prix de base') ||
            textLower.includes('mon prix') ||
            textLower.includes('prix net') ||
            textLower.includes('décomposition du tarif') ||
            textLower.includes('remise');

        if (!hasPricingContent) {
            console.debug(
                `${LOG_PREFIX} Popover trouvé mais non reconnu comme tarifaire :`,
                text
            );

            return null;
        }

        console.log(
            `${LOG_PREFIX} Popover tarifaire détecté :`,
            popover,
            'Texte :',
            text
        );

        return popover;
    }


    // ============================================================
    // EXTRACTION DES PRIX
    // ============================================================

    function getValueByLabel(
        popoverBody,
        expectedLabels
    ) {
        const labels = expectedLabels.map(
            label => cleanText(label).toLowerCase()
        );

        const rows = popoverBody.querySelectorAll(
            '.d-flex.justify-content-between'
        );

        for (const row of rows) {
            const children = Array.from(
                row.children
            );

            if (children.length < 2) {
                continue;
            }

            const label = cleanText(
                children[0].textContent
            ).toLowerCase();

            const matches = labels.some(
                expectedLabel =>
                    label.includes(expectedLabel)
            );

            if (!matches) {
                continue;
            }

            return cleanText(
                children[
                    children.length - 1
                ].textContent
            );
        }

        /*
         * Fallback plus souple.
         */
        const allElements =
            popoverBody.querySelectorAll('*');

        for (const element of allElements) {
            const text = cleanText(
                element.textContent
            ).toLowerCase();

            const matchedLabel = labels.find(
                expectedLabel =>
                    text === expectedLabel ||
                    text.startsWith(
                        expectedLabel + ' '
                    )
            );

            if (!matchedLabel) {
                continue;
            }

            const parent = element.parentElement;

            if (!parent) {
                continue;
            }

            const siblings =
                Array.from(parent.children);

            if (siblings.length < 2) {
                continue;
            }

            const value = cleanText(
                siblings[
                    siblings.length - 1
                ].textContent
            );

            if (
                value &&
                value.toLowerCase() !== matchedLabel
            ) {
                return value;
            }
        }

        return '';
    }


    function extractPrices(popoverBody) {
        return {
            basePrice: getValueByLabel(
                popoverBody,
                [
                    'Prix de base HT',
                    'Prix de base'
                ]
            ),

            discount: getValueByLabel(
                popoverBody,
                [
                    'Remise'
                ]
            ),

            netPrice: getValueByLabel(
                popoverBody,
                [
                    'Mon prix',
                    'Prix net HT',
                    'Prix net'
                ]
            )
        };
    }


    // ============================================================
    // RECHERCHE DE LA ZONE D'AFFICHAGE
    // ============================================================

    function findDisplayContainer(trigger) {
        let parent = trigger.parentElement;

        for (
            let level = 0;
            parent && level < 8;
            level++
        ) {
            const text = cleanText(
                parent.textContent
            ).toLowerCase();

            if (
                text.includes('€') ||
                text.includes('prix') ||
                text.includes('ht')
            ) {
                return parent;
            }

            parent = parent.parentElement;
        }

        const selectors = [
            '.rex-product-price',
            '[class*="product-price"]',
            '[class*="price-container"]',
            '[class*="price-information"]',
            '[class*="price-block"]'
        ];

        for (const selector of selectors) {
            const container =
                document.querySelector(selector);

            if (container) {
                return container;
            }
        }

        return trigger.parentElement;
    }


    // ============================================================
    // AFFICHAGE
    // ============================================================

    function createRow(
        label,
        value,
        emphasized = false
    ) {
        const row =
            document.createElement('tr');

        const labelCell =
            document.createElement('td');

        const valueCell =
            document.createElement('td');

        labelCell.textContent =
            `${label} :`;

        valueCell.textContent =
            value || 'Non disponible';

        Object.assign(
            labelCell.style,
            {
                padding: '4px 10px 4px 0',
                whiteSpace: 'nowrap'
            }
        );

        Object.assign(
            valueCell.style,
            {
                padding: '4px 0',
                textAlign: 'right',
                whiteSpace: 'nowrap',
                fontWeight:
                    emphasized
                        ? '700'
                        : '400'
            }
        );

        row.append(
            labelCell,
            valueCell
        );

        return row;
    }


    function displayPrices(
        prices,
        trigger
    ) {
        const container =
            findDisplayContainer(trigger);

        if (!container) {
            throw new Error(
                'Zone d’affichage du prix introuvable.'
            );
        }

        document
            .getElementById(RESULT_ID)
            ?.remove();

        const wrapper =
            document.createElement('div');

        wrapper.id = RESULT_ID;

        Object.assign(
            wrapper.style,
            {
                display: 'block',
                width: '260px',
                marginTop: '12px',
                marginLeft: 'auto',
                padding: '10px',
                border: '1px solid #444',
                borderRadius: '4px',
                boxSizing: 'border-box',
                backgroundColor: '#ffffff',
                color: '#222222',
                fontSize: '14px',
                lineHeight: '1.4'
            }
        );

        const table =
            document.createElement('table');

        Object.assign(
            table.style,
            {
                width: '100%',
                borderCollapse: 'collapse'
            }
        );

        table.append(
            createRow(
                'Prix de base HT',
                prices.basePrice
            ),

            createRow(
                'Remise',
                prices.discount
            ),

            createRow(
                'Prix net HT',
                prices.netPrice,
                true
            )
        );

        wrapper.appendChild(table);

        container.appendChild(wrapper);
    }


    // ============================================================
    // FERMETURE DU POP-UP
    // ============================================================

    async function closePopover(
        trigger,
        popoverBody
    ) {
        const component = trigger?.closest(
            'mf-pdp-price-info-popover'
        );

        if (!component) {
            removePopoverHidingMode();
            return;
        }

        let popover =
            popoverBody ||
            component.querySelector(
                'ds-popover ds-elevation.ds-popover'
            );

        if (!popover) {
            removePopoverHidingMode();
            return;
        }

        console.log(
            `${LOG_PREFIX} Fermeture du popover tarifaire.`,
            popover
        );

        /*
         * Première tentative :
         * simuler un véritable clic extérieur.
         */
        const outsideTarget =
            document.querySelector('h1') ||
            document.querySelector('main') ||
            document.body;

        const eventOptions = {
            bubbles: true,
            cancelable: true,
            view: window
        };

        try {
            outsideTarget.dispatchEvent(
                new PointerEvent(
                    'pointerdown',
                    eventOptions
                )
            );
        } catch (error) {
            console.debug(
                `${LOG_PREFIX} PointerEvent non disponible :`,
                error
            );
        }

        outsideTarget.dispatchEvent(
            new MouseEvent(
                'mousedown',
                eventOptions
            )
        );

        outsideTarget.dispatchEvent(
            new MouseEvent(
                'mouseup',
                eventOptions
            )
        );

        outsideTarget.dispatchEvent(
            new MouseEvent(
                'click',
                eventOptions
            )
        );

        /*
         * On laisse Angular traiter le clic extérieur.
         */
        await new Promise(
            resolve =>
                setTimeout(
                    resolve,
                    200
                )
        );

        /*
         * On vérifie si le panneau est toujours présent.
         */
        popover = component.querySelector(
            'ds-popover ds-elevation.ds-popover'
        );

        if (popover) {
            console.log(
                `${LOG_PREFIX} Popover toujours présent après clic extérieur : suppression forcée.`
            );

            /*
             * Fallback garanti :
             * suppression du panneau généré.
             */
            popover.remove();
        }

        /*
         * On laisse un très court délai avant de retirer
         * le mode de masquage pour éviter un flash visuel.
         */
        await new Promise(
            resolve =>
                setTimeout(
                    resolve,
                    50
                )
        );

        removePopoverHidingMode();

        console.log(
            `${LOG_PREFIX} Popover tarifaire fermé.`
        );
    }


    // ============================================================
    // CHATBOT
    // ============================================================

    function closeChatbotIfOpen() {
        const chatbotWindow =
            document.querySelector(
                '.chatbot-window'
            );

        if (!chatbotWindow) {
            return false;
        }

        let closeButton =
            chatbotWindow.querySelector(
                '.chatbot-header__actions--right ds-icon-button button'
            );

        if (!closeButton) {
            closeButton = Array.from(
                chatbotWindow.querySelectorAll(
                    'button'
                )
            ).find(button => {
                const icon =
                    button.querySelector(
                        '.ds-icon'
                    );

                return cleanText(
                    icon?.textContent
                ).toLowerCase() ===
                    'expand_more';
            });
        }

        if (!closeButton) {
            console.debug(
                `${LOG_PREFIX} Chatbot ouvert mais bouton de fermeture non trouvé.`,
                chatbotWindow
            );

            return false;
        }

        console.log(
            `${LOG_PREFIX} Chatbot ouvert : fermeture.`,
            closeButton
        );

        closeButton.click();

        return true;
    }


    async function closeChatbotOnceWhenItAppears(
        timeout = 3000
    ) {
        const startTime =
            Date.now();

        while (
            Date.now() - startTime <
            timeout
        ) {
            if (closeChatbotIfOpen()) {
                return true;
            }

            await new Promise(
                resolve =>
                    setTimeout(
                        resolve,
                        100
                    )
            );
        }

        console.debug(
            `${LOG_PREFIX} Aucun chatbot ouvert pendant la fenêtre de fermeture.`
        );

        return false;
    }


    // ============================================================
    // PROGRAMME PRINCIPAL
    // ============================================================

    async function run() {
        let trigger = null;
        let popoverBody = null;

        try {
            trigger =
                await waitForCondition(
                    findPricingTrigger,
                    TIMEOUT,
                    'Bouton de détail du prix introuvable'
                );

            console.log(
                `${LOG_PREFIX} Bouton du prix trouvé.`,
                trigger
            );

            /*
             * Masquage installé AVANT le clic pour éviter
             * l'affichage visuel du pop-up.
             */
            hidePopoversBeforeOpening();

            /*
             * Ouverture du pop-up.
             */
            trigger.click();

            /*
             * On cherche uniquement le pop-up rattaché
             * au composant du bouton tarifaire.
             */
            popoverBody =
                await waitForCondition(
                    () =>
                        findPricingPopover(
                            trigger
                        ),
                    TIMEOUT,
                    'Popover tarifaire introuvable'
                );

            /*
             * Extraction des valeurs.
             */
            const prices =
                extractPrices(
                    popoverBody
                );

            console.log(
                `${LOG_PREFIX} Valeurs extraites :`,
                prices
            );

            if (
                !prices.basePrice &&
                !prices.discount &&
                !prices.netPrice
            ) {
                throw new Error(
                    'Aucune donnée tarifaire trouvée dans le pop-up.'
                );
            }

            /*
             * Gestion éventuelle du chatbot.
             */
            await closeChatbotOnceWhenItAppears(
                3000
            );

            /*
             * Affichage des informations tarifaires.
             */
            displayPrices(
                prices,
                trigger
            );

            /*
             * Fermeture du pop-up.
             */
            await closePopover(
                trigger,
                popoverBody
            );

        } catch (error) {
            console.error(
                `${LOG_PREFIX} Échec du script :`,
                error
            );

            /*
             * On retire toujours le masquage CSS.
             */
            removePopoverHidingMode();

            /*
             * Nettoyage de secours du pop-up tarifaire.
             */
            if (trigger) {
                const component =
                    trigger.closest(
                        'mf-pdp-price-info-popover'
                    );

                const popover =
                    component?.querySelector(
                        'ds-popover ds-elevation.ds-popover'
                    );

                if (popover) {
                    console.warn(
                        `${LOG_PREFIX} Nettoyage du popover après erreur.`
                    );

                    popover.remove();
                }
            }
        }
    }


    run();

})();