DoorDash fees buster

Display DoorDash fees in a clearer way.

目前為 2025-01-01 提交的版本,檢視 最新版本

// ==UserScript==
// @name         DoorDash fees buster
// @namespace    http://tampermonkey.net/
// @version      2025-01-01.2
// @description  Display DoorDash fees in a clearer way.
// @author       Somebody
// @match        https://www.doordash.com/consumer/checkout/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=doordash.com
// @grant        none
// @run-at       document-end
// @license      GNU GPLv3
// ==/UserScript==

(function () {
    'use strict';

    const D = window.document;
    const GLOBAL_TIMEOUT = 10 * 1000;

    const KNOWN_MARKUP_BUSINESS_NAMES = [
        'Popeyes Louisiana Kitchen',
        'Jollibee',
        'KFC',
    ];

    const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const createElementWithInnerTextAndStyle = (ElementName, innerText, style = {}) => {
        const element = D.createElement(ElementName);
        if (typeof innerText === 'string') {
            element.innerText = innerText;
        }
        Object.assign(
            element.style,
            style
        );
        return element;
    };

    const createToastWithText = (() => {
        const timeoutIdToElementMap = new Map();
        const showToast = (text, disappearDelayInMs = 1000) => {
            const defaultStyle = {
                position: 'fixed',
                top: '10vh',
                zIndex: 9999,
                transform: 'translateX(-50%)',
                left: '50%',
                transition: 'all 0.25s ease',
                borderRadius: '2em',
                backgroundColor: 'black',
                padding: '0.5em 2em',
                color: 'white',
                textAlign: 'center',
                opacity: '0',
            };
            const toast = createElementWithInnerTextAndStyle('div', text, defaultStyle);
            D.body.append(toast);
            const timeoutId = setTimeout(
                () => {
                    timeoutIdToElementMap.delete(timeoutId);
                    D.body.removeChild(toast);
                },
                disappearDelayInMs
            );
            timeoutIdToElementMap.set(timeoutId, toast);
            setTimeout(
                () => {
                    Object.assign(
                        toast.style, {
                        opacity: '100%'
                    }
                    );
                },
                0
            );
            setTimeout(
                () => {
                    Object.assign(
                        toast.style, {
                        opacity: '0'
                    }
                    );
                },
                disappearDelayInMs - 250
            );
        };
        return showToast;
    })();

    const getInitialServiceFee = () => {
        // This is no longer true if items are modified after page load
        const keyword = 'Service Fee: ';
        let line = Array.from(D.querySelectorAll('script')).find(e => e.innerText.includes(keyword)).innerText;
        line = line.substring(line.indexOf(keyword));
        line = line.substring(0, line.indexOf('\"'));
        return line;
    };

    const getInitialEstimatedTax = () => {
        // This is no longer true if items are modified after page load
        const keyword = 'Estimated Tax: ';
        let line = Array.from(D.querySelectorAll('script')).find(e => e.innerText.includes(keyword)).innerText;
        line = line.substring(line.indexOf(keyword));
        line = line.substring(0, line.indexOf('\"'));
        return line;
    };

    const getInitialBusinessName = () => {
        const businessNameElement = Array.from(document.querySelectorAll('span')).find(spenElement => ['Your cart from', 'Order From', 'Checkout'].includes(spenElement.innerText))?.nextSibling;
        if (businessNameElement instanceof HTMLElement) {
            return Array.from(businessNameElement.querySelectorAll('span')).find(spenElement => typeof spenElement.innerText === 'string' && spenElement.innerText.length > 0)?.innerText;
        }
    };

    const triggerHover = (element) => {
        if (element instanceof HTMLElement) {
            // Create the mouseenter event
            const mouseEnterEvent = new MouseEvent('mouseenter', {
                bubbles: true,
                cancelable: true,
                view: window,
            });

            // Create the mouseover event
            const mouseOverEvent = new MouseEvent('mouseover', {
                bubbles: true,
                cancelable: true,
                view: window,
            });

            // Create the mouseleave event
            const mouseLeaveEvent = new MouseEvent('mouseleave', {
                bubbles: true,
                cancelable: true,
                view: window,
            });

            // Dispatch mouseenter and mouseover events
            element.dispatchEvent(mouseEnterEvent);
            element.dispatchEvent(mouseOverEvent);

            const triggerHoverEnd = () => {
                element.dispatchEvent(mouseLeaveEvent);
            };
            return triggerHoverEnd;
        }
    };

    const findLineItemsElement = () => D.querySelector('[data-testid="LineItems"]');

    // const findStackChildrenElement = () => D.querySelector('[class^="StackChildren"]');

    // const findTooltipElements = () => D.querySelectorAll('[role="tooltip"]');

    const findFeesAndEstimatedTaxElement = () => D.querySelector('[data-testid="Fees & Estimated Tax"]');

    const findFeesAndEstimatedTaxTooltipTriggerElement = () => findFeesAndEstimatedTaxElement()?.querySelector('button');

    const findSubtotalElement = () => D.querySelector('[data-testid="Subtotal"]');

    const findDeliveryFeeElement = () => D.querySelector('[data-testid="Delivery Fee"]');

    const findLongDistanceFeeElement = () => D.querySelector('[data-testid="Long Distance Fee"]');

    const findDasherTipElement = () => D.querySelector('[data-testid="Dasher Tip"]');

    const findTotalElement = () => D.querySelector('[data-testid="Total"]');

    const findCartTotalElement = () => D.querySelector('[data-anchor-id="OrderCartTotal"]');

    const findPopupLayerElement = () => D.querySelector('[data-testid="LAYER-MANAGER-POPOVER_CONTENT"]');

    const findFeesAndEstimatedTaxTotalElement = () => {
        const feesAndEstimatedTaxElement = findFeesAndEstimatedTaxElement();
        if (feesAndEstimatedTaxElement instanceof HTMLElement) {
            const totalElement = Array.from(feesAndEstimatedTaxElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$') && !window.getComputedStyle(spanElement).textDecoration.includes('line-through'));
            return totalElement;
        }
        return undefined;
    };

    const findSubtotalTotalElement = () => {
        const subtotalElement = findSubtotalElement();
        if (subtotalElement instanceof HTMLElement) {
            const totalElement = Array.from(subtotalElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$') && !window.getComputedStyle(spanElement).textDecoration.includes('line-through'));
            return totalElement;
        }
        return undefined;
    };

    const findDoorDashCreditsTotalElement = () => {
        const lineItemsElement = D.querySelector('[data-testid="LineItems"]');
        if (lineItemsElement instanceof HTMLElement) {
            const doorDashCreditsDescriptionElement = Array.from(lineItemsElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('DoorDash Credits'));
            const doorDashCreditsTotalElement = doorDashCreditsDescriptionElement?.parentNode?.lastChild;
            return doorDashCreditsTotalElement;
        }
        return undefined;
    };

    const findDasherTipTotalElement = () => {
        const dasherTipElement = findDasherTipElement();
        if (dasherTipElement instanceof HTMLElement) {
            const doorDashTipTotalElement = Array.from(dasherTipElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$'));
            return doorDashTipTotalElement;
        }
        return undefined;
    }

    const findDeliveryFeeElementTotalElement = () => {
        const deliveryFeeElement = findDeliveryFeeElement();
        if (deliveryFeeElement instanceof HTMLElement) {
            const deliveryFeeTotalElement = Array.from(deliveryFeeElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$') && !window.getComputedStyle(spanElement).textDecoration.includes('line-through'));
            return deliveryFeeTotalElement;
        }
        return undefined;
    };

    const findLongDistanceFeeTotalElement = () => {
        const longDistanceFeeElement = findLongDistanceFeeElement();
        if (longDistanceFeeElement instanceof HTMLElement) {
            const longDistanceFeeTotalElement = Array.from(longDistanceFeeElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$') && !window.getComputedStyle(spanElement).textDecoration.includes('line-through'));
            return longDistanceFeeTotalElement;
        }
        return undefined;
    };

    const findTooltipByKeywordsWithObserver = (keywords) => new Promise((resolve, reject) => {
        const popupLayerElement = findPopupLayerElement();
        if (popupLayerElement instanceof HTMLElement) {
            const timeoutId = setTimeout(() => {
                createToastWithText('Unable to find target popup element before timeout');
                reject(new Error('unable to find target popup element before timeout'));
                observer.disconnect();
            }, GLOBAL_TIMEOUT);
            const observer = new MutationObserver(async (mutationsList, observer) => {
                try {
                    // Iterate over all mutations
                    loop:
                    for (let mutation of mutationsList) {
                        // Check if nodes were added to the DOM
                        if (mutation.type === 'childList') {
                            // Look for the element with the specific keyword
                            for (const addedNode of mutation.addedNodes) {
                                if (keywords.every(keyword => addedNode.innerText?.includes(keyword))) {
                                    // disconnect the observer once the element is found
                                    observer.disconnect();
                                    clearTimeout(timeoutId);
                                    resolve(addedNode);
                                    break loop;
                                }
                            }
                        }
                    }
                } catch (err) {
                    clearTimeout(timeoutId);
                    createToastWithText('Unable to find tooltip due to an error');
                    console.error(err);
                    reject(err);
                }
            });

            // Configure the observer to look for added nodes in the popup layer
            observer.observe(popupLayerElement, {
                childList: true, // Look for added/removed child nodes
                subtree: true, // Include all descendants in the search
            });
        } else {
            reject(new Error('unable to find popup layer element'));
        }
    });

    const getServiceFeeAndEstimatedTaxFromTooltip = async () => {
        const targetTooltipElementPromise = findTooltipByKeywordsWithObserver(['Service Fee', 'Estimated Tax']);
        const triggerHoverEnd = triggerHover(findFeesAndEstimatedTaxTooltipTriggerElement());
        const targetTooltipElement = await targetTooltipElementPromise;
        const targetTooltipElementInnerText = targetTooltipElement?.innerText;
        triggerHoverEnd?.();
        const targetTooltipElementInnerTextLines = targetTooltipElementInnerText?.split('\n')?.map(line => line.trim());
        const serviceFee = targetTooltipElementInnerTextLines?.find(line => line.startsWith('Service Fee'));
        const estimatedTax = targetTooltipElementInnerTextLines?.find(line => line.startsWith('Estimated Tax'));
        return [serviceFee, estimatedTax];
    };

    const getServiceFeeAndEstimatedTaxFromTooltipMemorized = (() => {
        let serviceFee = 'Fee: $0.0';
        let estimatedTax = 'Tax: $0.0';
        let isFirstRun = true;
        return async () => {
            const serviceFeeAndEstimatedTax = findFeesAndEstimatedTaxTotalElement().innerText;
            // Be careful about float point number precision error
            if (parseFloat(getNumberOnly(serviceFeeAndEstimatedTax)) * 100 !== parseFloat(getNumberOnly(serviceFee)) * 100 + parseFloat(getNumberOnly(estimatedTax)) * 100) {
                if (isFirstRun) {
                    [serviceFee, estimatedTax] = [getInitialServiceFee(), getInitialEstimatedTax()];
                    isFirstRun = false;
                } else {
                    [serviceFee, estimatedTax] = await getServiceFeeAndEstimatedTaxFromTooltip();
                }
            }
            return [serviceFee, estimatedTax];
        };
    })();

    const getNumberOnly = (text) => {
        if (typeof text !== 'string') {
            throw new TypeError('text must be string');
        }
        const numberPart = text.substring(text.lastIndexOf('$') + 1);
        return numberPart;
    };

    const getTotalCost = () => {
        const cartTotalElement = findCartTotalElement();
        const cartTotal = parseFloat(getNumberOnly(cartTotalElement.innerText));
        let totalCost = cartTotal;
        const doorDashCreditsTotalElement = findDoorDashCreditsTotalElement();
        if (doorDashCreditsTotalElement instanceof HTMLElement) {
            const doorDashCreditsTotal = Math.abs(parseFloat(getNumberOnly(doorDashCreditsTotalElement.innerText)));
            totalCost += doorDashCreditsTotal;
        }
        return totalCost;
    };

    const insertNewRow = (description, total, insertAfterElement, testid = '') => {
        const newRowElement = findSubtotalElement().cloneNode(true);
        newRowElement.dataset.testid = testid;
        const descriptionElement = Array.from(newRowElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('Subtotal'));
        descriptionElement.innerText = description;
        const totalElement = Array.from(newRowElement.querySelectorAll('span')).find(spanElement => spanElement.innerText.includes('$'));
        totalElement.innerText = total;
        if (insertAfterElement.nextSibling) {
            insertAfterElement.parentNode.insertBefore(newRowElement, insertAfterElement.nextSibling);
        } else {
            insertAfterElement.parentNode.appendChild(newRowElement);
        }
        return newRowElement;
    };

    const calcCostPercentage = (cost) => {
        const subtotalTotalElement = findSubtotalTotalElement();
        const subtotal = parseFloat(getNumberOnly(subtotalTotalElement.innerText));
        const percentage = Math.ceil(cost * 10000 / subtotal) / 100;
        return percentage;
    };

    const setColorRecursively = (parentElement, color = 'red') => {
        Array.from(parentElement.querySelectorAll('span, div')).forEach(spanOrDivElement => {
            Object.assign(
                spanOrDivElement.style,
                {
                    color: color,
                },
            );
        });
    };

    const updateLineItems = async () => {
        const feesAndEstimatedTaxElement = findFeesAndEstimatedTaxElement();
        const [serviceFee, estimatedTax] = await getServiceFeeAndEstimatedTaxFromTooltipMemorized();
        if (feesAndEstimatedTaxElement instanceof HTMLElement) {
            const serviceFeePercentage = calcCostPercentage(parseFloat(getNumberOnly(serviceFee.split(': ')[1])));
            const feesElementTestid = 'Fees';
            D.querySelector(`[data-testid="${feesElementTestid}"]`)?.remove();
            const feesElement = insertNewRow(serviceFee.split(': ')[0], `${serviceFeePercentage}% = ${serviceFee.split(': ')[1]}`, feesAndEstimatedTaxElement, feesElementTestid);
            setColorRecursively(feesElement, 'red');
            const estimatedTaxPercentage = calcCostPercentage(parseFloat(getNumberOnly(estimatedTax.split(': ')[1])));
            const estimatedTaxElementTestid = 'Estimated Tax';
            D.querySelector(`[data-testid="${estimatedTaxElementTestid}"]`)?.remove();
            const estimatedTaxElement = insertNewRow(estimatedTax.split(': ')[0], `${estimatedTaxPercentage}% = ${estimatedTax.split(': ')[1]}`, feesElement, estimatedTaxElementTestid);

            setColorRecursively(feesAndEstimatedTaxElement, 'darkgray');
        }

        const deliveryFeeElement = findDeliveryFeeElement();
        if (deliveryFeeElement instanceof HTMLElement) {
            const deliveryFeeTotalElement = findDeliveryFeeElementTotalElement();
            deliveryFeeTotalElement.innerText = `${calcCostPercentage(parseFloat(getNumberOnly(deliveryFeeTotalElement.innerText)))}% = $${getNumberOnly(deliveryFeeTotalElement.innerText)}`;
            setColorRecursively(deliveryFeeElement, 'red');
        }

        const longDistanceFeeElement = findLongDistanceFeeElement();
        if (longDistanceFeeElement instanceof HTMLElement) {
            const longDistanceFeeElement = findLongDistanceFeeTotalElement();
            longDistanceFeeElement.innerText = `${calcCostPercentage(parseFloat(getNumberOnly(longDistanceFeeElement.innerText)))}% = $${getNumberOnly(longDistanceFeeElement.innerText)}`;
            setColorRecursively(longDistanceFeeElement, 'red');
        }

        const dasherTipElement = findDasherTipElement();
        if (dasherTipElement instanceof HTMLElement) {
            const dasherTipTotalElement = findDasherTipTotalElement();
            dasherTipTotalElement.innerText = `${calcCostPercentage(parseFloat(getNumberOnly(dasherTipTotalElement.innerText)))}% = $${getNumberOnly(dasherTipTotalElement.innerText)}`;
            setColorRecursively(dasherTipElement, 'red');
        }

        const totalElement = findTotalElement();
        if (totalElement instanceof HTMLElement) {
            const totalCost = getTotalCost();
            const cartTotalElement = findCartTotalElement();
            cartTotalElement.innerText = `${calcCostPercentage(totalCost - getNumberOnly(estimatedTax.split(': ')[1]))}% (Pretax) / ${calcCostPercentage(totalCost)}% = $${getNumberOnly(cartTotalElement.innerText)}`;
            const businessName = getInitialBusinessName();
            if (KNOWN_MARKUP_BUSINESS_NAMES.includes(businessName)) {
                const markUpWarningTestid = 'Markup Warning';
                D.querySelector(`[data-testid="${markUpWarningTestid}"]`)?.remove();
                const markUpWarningElement = insertNewRow('This business is known to charge more on DoorDash, the actual pretax subtotal would be even lower in store.', ' ', totalElement, markUpWarningTestid);
                setColorRecursively(markUpWarningElement, 'red');
            }
        }
    };

    const main = () => {
        const timeoutId = setTimeout(() => {
            createToastWithText('Failed to display Fees & Estimated Tax before timeout');
        }, GLOBAL_TIMEOUT);
        const observer = new MutationObserver(async (mutationsList, observer) => {
            try {
                // Iterate over all mutations
                for (let mutation of mutationsList) {
                    // Check if nodes were added to the DOM
                    if (mutation.type === 'childList') {
                        // Look for the element with the specific data-testid
                        const lineItemsElement = findLineItemsElement();
                        if (lineItemsElement instanceof HTMLElement) {
                            // disconnect the observer once the element is found
                            observer.disconnect();
                            try {
                                await updateLineItems();
                            } catch (err) {
                                console.error(err);
                            }

                            const lineItemsElementObserverOptions = {
                                childList: true, // Look for added/removed child nodes
                                subtree: true, // Include all descendants in the search
                                characterData: true,
                            };

                            const lineItemsObserver = new MutationObserver(async (lineItemsMutationsList, lineItemsObserver) => {
                                // Avoid recursive calls
                                lineItemsObserver.disconnect();
                                try {
                                    await updateLineItems();
                                } catch (err) {
                                    console.error(err);
                                }
                                lineItemsObserver.observe(lineItemsElement, lineItemsElementObserverOptions);
                            });

                            lineItemsObserver.observe(lineItemsElement, lineItemsElementObserverOptions);

                            clearTimeout(timeoutId);
                            createToastWithText('Fees & Estimated Tax rate calculated');

                            break;
                        }
                    }
                }
            } catch (err) {
                clearTimeout(timeoutId);
                createToastWithText('Failed to display Fees & Estimated Tax due to an error');
                console.error(err);
            }
        });

        // Configure the observer to look for added nodes in the entire document
        observer.observe(D.body, {
            childList: true, // Look for added/removed child nodes
            subtree: true, // Include all descendants in the search
        });
    };

    main();
})();