
{"id":11068,"date":"2025-09-23T00:38:55","date_gmt":"2025-09-23T00:38:55","guid":{"rendered":"https:\/\/tunisia3dprint.com\/?page_id=11068"},"modified":"2026-04-10T13:16:43","modified_gmt":"2026-04-10T13:16:43","slug":"upload-get-quote","status":"publish","type":"page","link":"https:\/\/tunisia3dprint.com\/fr\/upload-get-quote\/","title":{"rendered":"T\u00e9l\u00e9versez et obtenez un devis"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"11068\" class=\"elementor elementor-11068\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"wd-negative-gap elementor-element elementor-element-ac7328a e-flex e-con-boxed e-con e-parent\" data-id=\"ac7328a\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-62bf3e5 elementor-widget elementor-widget-shortcode\" data-id=\"62bf3e5\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"shortcode.default\">\n\t\t\t\t\t\t\t<div class=\"elementor-shortcode\">        <!-- INCLURE LE TEMPLATE PRO AVEC NOUVEAU SYST\u00c8ME R\u00c9DUCTIONS -->\r\n        \r\n<style type=\"text\/css\">\r\n    .stl-upload-container-pro {\r\n        padding: 25px;\r\n        background: #ffffff;\r\n        border-radius: 8px;\r\n        border: 1px solid #e8e8e8;\r\n        border-left: 4px solid #d32f2f;\r\n        box-shadow: 0 2px 10px rgba(0,0,0,0.05);\r\n        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\r\n    }\r\n    \r\n    .stl-upload-container-pro h3 {\r\n        text-align: left;\r\n        margin: 0 0 22px;\r\n        color: #1a1a1a;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .7px;\r\n        padding-bottom: 12px;\r\n        border-bottom: 1px solid #f0f0f0;\r\n    }\r\n    \r\n    .upload-area-pro {\r\n        border: 2px dashed #d0d5dd;\r\n        border-radius: 10px;\r\n        padding: 40px 20px;\r\n        text-align: center;\r\n        background: #fafbfc;\r\n        margin-bottom: 25px;\r\n        transition: border-color .2s, background .2s;\r\n    }\r\n\r\n    .upload-area-pro:hover {\r\n        border-color: #d32f2f;\r\n        background: #fff8f8;\r\n    }\r\n\r\n    .upload-area-pro.dragover {\r\n        background: #fff8f8;\r\n        border-color: #d32f2f;\r\n        border-style: solid;\r\n    }\r\n    \r\n    \r\n    .upload-button-pro {\r\n        display: inline-flex;\r\n        align-items: center;\r\n        gap: 8px;\r\n        padding: 12px 26px;\r\n        background: #fff;\r\n        color: #d32f2f;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\r\n        border: 2px solid #d32f2f;\r\n        box-shadow: 0 2px 8px rgba(211,47,47,.12);\r\n        font-family: inherit;\r\n    }\r\n\r\n    .upload-button-pro::before {\r\n        content: '';\r\n        display: inline-block;\r\n        width: 15px;\r\n        height: 15px;\r\n        background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23d32f2f' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4'\/%3E%3Cpolyline points='17 8 12 3 7 8'\/%3E%3Cline x1='12' y1='3' x2='12' y2='15'\/%3E%3C\/svg%3E\");\r\n        background-size: contain;\r\n        background-repeat: no-repeat;\r\n        transition: background-image .2s;\r\n    }\r\n\r\n    .upload-button-pro:hover {\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 4px 16px rgba(211,47,47,.28);\r\n    }\r\n\r\n    .upload-button-pro:hover::before {\r\n        background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4'\/%3E%3Cpolyline points='17 8 12 3 7 8'\/%3E%3Cline x1='12' y1='3' x2='12' y2='15'\/%3E%3C\/svg%3E\");\r\n    }\r\n    \r\n    .upload-info-pro {\r\n        margin-top: 15px;\r\n        color: #666;\r\n        font-size: 14px;\r\n    }\r\n    \r\n    .stl-file-card-pro {\r\n        background: #ffffff;\r\n        border-radius: 8px;\r\n        padding: 20px;\r\n        margin-bottom: 20px;\r\n        border: 2px solid #e9ecef;\r\n        box-shadow: 0 2px 8px rgba(0,0,0,0.08);\r\n        transition: all 0.3s ease;\r\n        position: relative;\r\n    }\r\n    \r\n    .file-header-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        align-items: flex-start;\r\n        margin-bottom: 15px;\r\n        padding-bottom: 10px;\r\n        border-bottom: 2px solid #f0f0f0;\r\n        position: relative;\r\n        padding-right: 50px;\r\n    }\r\n    \r\n    .file-name-pro {\r\n        font-size: 18px;\r\n        font-weight: 600;\r\n        color: #000000;\r\n        margin: 0;\r\n        flex: 1;\r\n    }\r\n    \r\n    .file-status-pro {\r\n        padding: 6px 12px;\r\n        border-radius: 20px;\r\n        font-size: 12px;\r\n        font-weight: 600;\r\n        min-width: 80px;\r\n        text-align: center;\r\n        position: absolute;\r\n        right: 50px;\r\n        top: 0;\r\n    }\r\n    \r\n    .status-processing-pro {\r\n        background: #fff3cd;\r\n        color: #856404;\r\n    }\r\n    \r\n    .status-success-pro {\r\n        background: #d4edda;\r\n        color: #155724;\r\n    }\r\n    \r\n    .status-error-pro {\r\n        background: #f8d7da;\r\n        color: #721c24;\r\n    }\r\n    \r\n    .remove-file-pro {\r\n        position: absolute;\r\n        top: 15px;\r\n        right: 15px;\r\n        background: #dc3545;\r\n        color: white;\r\n        border: none;\r\n        border-radius: 50%;\r\n        width: 30px;\r\n        height: 30px;\r\n        cursor: pointer;\r\n        font-size: 16px;\r\n        display: flex;\r\n        align-items: center;\r\n        justify-content: center;\r\n        transition: all 0.3s ease;\r\n        z-index: 10;\r\n    }\r\n    \r\n    .remove-file-pro:hover {\r\n        background: #c82333;\r\n        transform: scale(1.1);\r\n    }\r\n    \r\n    .preview-section-pro {\r\n        display: grid;\r\n        grid-template-columns: 4fr 1fr;\r\n        gap: 15px;\r\n        margin: 20px 0;\r\n    }\r\n    \r\n    .viewer-container-pro {\r\n        height: 320px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        background: #f8f9fa;\r\n        position: relative;\r\n        overflow: hidden;\r\n    }\r\n    \r\n    .viewer-placeholder-pro {\r\n        width: 100%;\r\n        height: 100%;\r\n        display: flex;\r\n        flex-direction: column;\r\n        align-items: center;\r\n        justify-content: center;\r\n        text-align: center;\r\n        color: #666;\r\n        padding: 20px;\r\n    }\r\n    \r\n    .info-panel-pro {\r\n        background: #f8f9fa;\r\n        padding: 12px;\r\n        border-radius: 6px;\r\n        border: 2px solid #e9ecef;\r\n        display: flex;\r\n        flex-direction: column;\r\n        gap: 10px;\r\n        min-width: 200px;\r\n        height: 320px;\r\n        overflow-y: auto;\r\n    }\r\n    \r\n    .dimension-box-pro {\r\n        background: #f8f9fa;\r\n        padding: 10px;\r\n        border-radius: 6px;\r\n        border-left: 4px solid #000000;\r\n        font-size: 12px;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .volume-box-pro {\r\n        background: #f8f9fa;\r\n        padding: 12px;\r\n        border-radius: 6px;\r\n        text-align: center;\r\n        border-left: 4px solid #28a745;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .volume-value-pro {\r\n        font-size: 20px;\r\n        font-weight: 700;\r\n        color: #000000;\r\n        margin: 5px 0;\r\n    }\r\n    \r\n    .settings-grid-pro {\r\n        display: grid;\r\n        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));\r\n        gap: 10px;\r\n        margin-top: 20px;\r\n    }\r\n    \r\n    .form-group-pro {\r\n        display: flex;\r\n        flex-direction: column;\r\n        position: relative;\r\n    }\r\n    \r\n    .form-group-pro label {\r\n        font-size: 11px;\r\n        font-weight: 600;\r\n        color: #000000;\r\n        margin-bottom: 4px;\r\n    }\r\n    \r\n    .form-group-pro select,\r\n    .form-group-pro input {\r\n        padding: 8px 10px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        font-size: 12px;\r\n        background: #ffffff;\r\n        color: #000000;\r\n    }\r\n    \r\n    .form-group-pro select {\r\n        appearance: none;\r\n        background-image: url(\"data:image\/svg+xml;charset=UTF-8,%3csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23000000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c\/polyline%3e%3c\/svg%3e\");\r\n        background-repeat: no-repeat;\r\n        background-position: right 8px center;\r\n        background-size: 16px;\r\n    }\r\n    \r\n    .form-group-pro input[readonly] {\r\n        background: #f8f9fa;\r\n        font-weight: 600;\r\n    }\r\n    \r\n    .cost-breakdown-pro {\r\n        background: #f8f9fa;\r\n        padding: 15px;\r\n        border-radius: 8px;\r\n        margin: 15px 0;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .cost-item-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        padding: 5px 0;\r\n        font-size: 12px;\r\n        border-bottom: 1px solid #e9ecef;\r\n    }\r\n    \r\n    .cost-item-pro:last-child {\r\n        border-bottom: none;\r\n        font-weight: 600;\r\n        color: #dc3545;\r\n    }\r\n    \r\n    .total-section-pro {\r\n        background: #fff;\r\n        padding: 22px 24px;\r\n        border-radius: 10px;\r\n        margin: 25px 0;\r\n        text-align: left;\r\n        border: 1px solid #e8e8e8;\r\n        border-left: 4px solid #d32f2f;\r\n        box-shadow: 0 2px 10px rgba(0,0,0,.05);\r\n    }\r\n\r\n    \/* label: \"Total Project Cost (incl. tax)\" *\/\r\n    .total-section-pro > div:first-child {\r\n        font-size: 11px !important;\r\n        font-weight: 800 !important;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #d32f2f !important;\r\n        margin-bottom: 4px !important;\r\n    }\r\n\r\n    .total-amount-pro {\r\n        font-size: 30px;\r\n        font-weight: 900;\r\n        margin: 4px 0 8px;\r\n        letter-spacing: -.5px;\r\n        color: #1a1a1a;\r\n    }\r\n    \r\n    .submit-section-pro {\r\n        text-align: center;\r\n    }\r\n    \r\n    .submit-btn-pro {\r\n        padding: 14px 32px;\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        border: 2px solid #d32f2f;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        margin: 8px 5px;\r\n        transition: background .2s, transform .15s, box-shadow .2s;\r\n        box-shadow: 0 4px 14px rgba(211,47,47,.22);\r\n    }\r\n\r\n    .cart-btn-pro {\r\n        padding: 14px 32px;\r\n        background: #fff;\r\n        color: #1a1a1a;\r\n        border: 2px solid #1a1a1a;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        margin: 8px 5px;\r\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\r\n    }\r\n\r\n    .submit-btn-pro:hover {\r\n        background: #b71c1c;\r\n        border-color: #b71c1c;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 6px 20px rgba(211,47,47,.32);\r\n    }\r\n\r\n    .cart-btn-pro:hover {\r\n        background: #1a1a1a;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 6px 20px rgba(0,0,0,.18);\r\n    }\r\n\r\n    .submit-btn-pro:disabled,\r\n    .cart-btn-pro:disabled {\r\n        background: #f0f0f0;\r\n        color: #aaa;\r\n        border-color: #e0e0e0;\r\n        cursor: not-allowed;\r\n        transform: none;\r\n        box-shadow: none;\r\n    }\r\n    \r\n    .cart-btn-pro.loading {\r\n        background: #f0f0f0 !important;\r\n        color: #aaa !important;\r\n        border-color: #e0e0e0 !important;\r\n        cursor: not-allowed !important;\r\n        position: relative;\r\n    }\r\n    \r\n    .cart-btn-pro.loading::after {\r\n        content: '';\r\n        position: absolute;\r\n        top: 50%;\r\n        left: 50%;\r\n        margin: -8px 0 0 -8px;\r\n        width: 16px;\r\n        height: 16px;\r\n        border: 2px solid transparent;\r\n        border-top: 2px solid #ffffff;\r\n        border-radius: 50%;\r\n        animation: spin 1s linear infinite;\r\n    }\r\n    \r\n    @keyframes spin {\r\n        0% { transform: rotate(0deg); }\r\n        100% { transform: rotate(360deg); }\r\n    }\r\n    \r\n    .file-counter-pro {\r\n        text-align: center;\r\n        margin: 10px 0;\r\n        font-size: 14px;\r\n        color: #666;\r\n    }\r\n    \r\n    .contact-section-pro {\r\n        text-align: center;\r\n        margin-top: 20px;\r\n        padding: 16px 20px;\r\n        background: #fafbfc;\r\n        border-radius: 8px;\r\n        border: 1px solid #e8e8e8;\r\n    }\r\n\r\n    .contact-section-label-pro {\r\n        display: block;\r\n        font-size: 11px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #999;\r\n        margin-bottom: 10px;\r\n    }\r\n\r\n    .contact-btn-pro {\r\n        display: inline-block;\r\n        padding: 9px 18px;\r\n        background: #fff;\r\n        color: #d32f2f;\r\n        text-decoration: none;\r\n        border-radius: 6px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        margin: 4px;\r\n        transition: background .2s, color .2s, transform .15s;\r\n        border: 1.5px solid #d32f2f;\r\n    }\r\n\r\n    .contact-btn-pro:hover {\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        text-decoration: none;\r\n    }\r\n\r\n    .contact-btn-pro.whatsapp-btn-pro {\r\n        color: #25a244;\r\n        border-color: #25a244;\r\n    }\r\n\r\n    .contact-btn-pro.whatsapp-btn-pro:hover {\r\n        background: #25a244;\r\n        color: #fff;\r\n    }\r\n\r\n    .printability-bar-pro {\r\n        width: 100%;\r\n        height: 6px;\r\n        background: #e9ecef;\r\n        border-radius: 4px;\r\n        margin: 6px 0;\r\n        overflow: hidden;\r\n    }\r\n    \r\n    .printability-fill-pro {\r\n        height: 100%;\r\n        border-radius: 4px;\r\n        transition: all 0.3s ease;\r\n    }\r\n    \r\n    .printability-excellent-pro {\r\n        background: #28a745;\r\n        width: 90%;\r\n    }\r\n    .printability-good-pro {\r\n        background: #ffc107;\r\n        width: 70%;\r\n    }\r\n    .printability-fair-pro {\r\n        background: #fd7e14;\r\n        width: 50%;\r\n    }\r\n    .printability-poor-pro {\r\n        background: #dc3545;\r\n        width: 30%;\r\n    }\r\n    \r\n    .printability-label-pro {\r\n        font-size: 10px;\r\n        font-weight: 600;\r\n        text-align: center;\r\n        margin-top: 4px;\r\n    }\r\n    \r\n    .printability-excellent-text-pro {\r\n        color: #28a745;\r\n    }\r\n    .printability-good-text-pro {\r\n        color: #ffc107;\r\n    }\r\n    .printability-fair-text-pro {\r\n        color: #fd7e14;\r\n    }\r\n    .printability-poor-text-pro {\r\n        color: #dc3545;\r\n    }\r\n    \r\n    .quotation-modal-pro {\r\n        display: none;\r\n        position: fixed;\r\n        top: 0; left: 0;\r\n        width: 100%; height: 100%;\r\n        background: rgba(0,0,0,.6);\r\n        z-index: 1000;\r\n        align-items: flex-start;\r\n        justify-content: center;\r\n        padding: 20px 0;\r\n        overflow-y: auto;\r\n    }\r\n\r\n    .quotation-content-pro {\r\n        background: #fff;\r\n        border-radius: 10px;\r\n        max-width: 860px;\r\n        width: 95%;\r\n        box-shadow: 0 20px 60px rgba(0,0,0,.35);\r\n        overflow: hidden;\r\n    }\r\n\r\n    .quote-modal-actions {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        align-items: center;\r\n        padding: 14px 20px;\r\n        background: #f8f8f8;\r\n        border-bottom: 1px solid #e0e0e0;\r\n    }\r\n\r\n    .quote-action-btn {\r\n        display: inline-flex;\r\n        align-items: center;\r\n        gap: 6px;\r\n        padding: 9px 18px;\r\n        border: none;\r\n        border-radius: 6px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        cursor: pointer;\r\n        transition: background .2s;\r\n    }\r\n\r\n    .quote-print-btn {\r\n        background: #1a1a1a;\r\n        color: #fff;\r\n    }\r\n\r\n    .quote-print-btn:hover { background: #d32f2f; }\r\n\r\n    .close-quotation-pro {\r\n        background: none;\r\n        color: #666;\r\n        border: 1.5px solid #ccc;\r\n        border-radius: 6px;\r\n        padding: 8px 14px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        cursor: pointer;\r\n        transition: border-color .2s, color .2s;\r\n    }\r\n\r\n    .close-quotation-pro:hover { border-color: #d32f2f; color: #d32f2f; }\r\n\r\n    #quotationContentPro {\r\n        padding: 32px 36px;\r\n    }\r\n    \r\n    .alert-message-pro {\r\n        padding: 12px 15px;\r\n        border-radius: 6px;\r\n        margin: 10px 0;\r\n        font-weight: 600;\r\n        border: 2px solid;\r\n    }\r\n    \r\n    .alert-success-pro {\r\n        background: #d4edda;\r\n        color: #155724;\r\n        border-color: #c3e6cb;\r\n    }\r\n    \r\n    .alert-error-pro {\r\n        background: #f8d7da;\r\n        color: #721c24;\r\n        border-color: #f5c6cb;\r\n    }\r\n    \r\n    .info-row-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        font-size: 11px;\r\n        padding: 3px 0;\r\n    }\r\n    \r\n    .info-label-pro {\r\n        color: #666;\r\n    }\r\n    \r\n    .info-value-pro {\r\n        font-weight: 600;\r\n        color: #000000;\r\n    }\r\n    \r\n    @media (max-width: 768px) {\r\n        .preview-section-pro {\r\n            grid-template-columns: 1fr;\r\n        }\r\n        .settings-grid-pro {\r\n            grid-template-columns: 1fr;\r\n        }\r\n        .contact-btn-pro {\r\n            display: block;\r\n            margin: 5px auto;\r\n            max-width: 200px;\r\n        }\r\n        .submit-btn-pro,\r\n        .cart-btn-pro {\r\n            display: block;\r\n            width: 100%;\r\n            margin: 5px 0;\r\n        }\r\n        .file-header-pro {\r\n            padding-right: 40px;\r\n        }\r\n        .file-status-pro {\r\n            right: 40px;\r\n        }\r\n    }\r\n    \r\n    \/* print handled via JS new-window \u2014 no @media print needed here *\/\r\n\r\n    \/* Guest Capture Modal *\/\r\n    .guest-capture-modal-pro {\r\n        display: none;\r\n        position: fixed;\r\n        top: 0;\r\n        left: 0;\r\n        width: 100%;\r\n        height: 100%;\r\n        background: rgba(0,0,0,0.5);\r\n        z-index: 1100;\r\n        align-items: center;\r\n        justify-content: center;\r\n    }\r\n\r\n    .guest-capture-content-pro {\r\n        background: white;\r\n        padding: 30px;\r\n        border-radius: 10px;\r\n        max-width: 460px;\r\n        width: 90%;\r\n        box-shadow: 0 10px 30px rgba(0,0,0,0.3);\r\n    }\r\n\r\n    .guest-capture-content-pro h3 {\r\n        margin-top: 0;\r\n        color: #dc3545;\r\n        font-size: 20px;\r\n    }\r\n\r\n    .form-group-pro {\r\n        margin-bottom: 15px;\r\n    }\r\n\r\n    .form-group-pro label {\r\n        display: block;\r\n        font-weight: 600;\r\n        margin-bottom: 5px;\r\n        color: #333;\r\n        font-size: 14px;\r\n    }\r\n\r\n    .form-group-pro input[type=\"text\"],\r\n    .form-group-pro input[type=\"email\"],\r\n    .form-group-pro input[type=\"tel\"] {\r\n        width: 100%;\r\n        padding: 10px 12px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        font-size: 14px;\r\n        box-sizing: border-box;\r\n        transition: border-color 0.2s;\r\n    }\r\n\r\n    .form-group-pro input:focus {\r\n        border-color: #dc3545;\r\n        outline: none;\r\n    }\r\n\r\n    .close-btn {\r\n        padding: 10px 20px;\r\n        background: #6c757d;\r\n        color: white;\r\n        border: none;\r\n        border-radius: 6px;\r\n        font-size: 14px;\r\n        cursor: pointer;\r\n        margin-left: 10px;\r\n        transition: background 0.2s;\r\n    }\r\n\r\n    .close-btn:hover {\r\n        background: #5a6268;\r\n    }\r\n\r\n    \/* Rate limit banner *\/\r\n    .rate-limit-banner-pro {\r\n        background: #fff3cd;\r\n        border: 2px solid #ffc107;\r\n        border-radius: 6px;\r\n        padding: 12px 15px;\r\n        margin: 10px 0;\r\n        color: #856404;\r\n        font-weight: 600;\r\n        display: none;\r\n    }\r\n\r\n    \/* \u2500\u2500\u2500 PPP PAGE LAYOUT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\r\n    .ppp-page {\r\n        max-width: 1200px;\r\n        margin: 0 auto;\r\n        padding: 0 20px 60px;\r\n        font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;\r\n        color: #2d2d2d;\r\n    }\r\n\r\n    .ppp-hero {\r\n        padding: 40px 0 32px;\r\n        text-align: center;\r\n    }\r\n\r\n    .ppp-hero-title {\r\n        font-size: 32px;\r\n        font-weight: 900;\r\n        color: #1a1a1a;\r\n        margin: 0 0 10px;\r\n        letter-spacing: -.5px;\r\n        line-height: 1.1;\r\n    }\r\n\r\n    .ppp-hero-title span { color: #d32f2f; }\r\n\r\n    .ppp-hero-sub {\r\n        font-size: 15px;\r\n        color: #777;\r\n        margin: 0 auto;\r\n        max-width: 560px;\r\n        line-height: 1.6;\r\n    }\r\n\r\n    .ppp-layout {\r\n        display: grid;\r\n        grid-template-columns: 1fr 292px;\r\n        gap: 28px;\r\n        align-items: start;\r\n    }\r\n\r\n    .ppp-aside {\r\n        position: sticky;\r\n        top: 90px;\r\n        display: flex;\r\n        flex-direction: column;\r\n        gap: 16px;\r\n    }\r\n\r\n    .ppp-info-block {\r\n        background: #fff;\r\n        border: 1px solid #eee;\r\n        border-left: 4px solid #d32f2f;\r\n        border-radius: 10px;\r\n        padding: 16px 18px;\r\n        box-shadow: 0 2px 8px rgba(0,0,0,.05);\r\n    }\r\n\r\n    .ppp-info-block-blue   { border-left-color: #1976D2; }\r\n    .ppp-info-block-green  { border-left-color: #4CAF50; }\r\n    .ppp-info-block-orange { border-left-color: #f57c00; }\r\n\r\n    .ppp-info-block-header {\r\n        display: flex;\r\n        align-items: center;\r\n        gap: 8px;\r\n        margin-bottom: 10px;\r\n        padding-bottom: 8px;\r\n        border-bottom: 1px solid #f0f0f0;\r\n    }\r\n\r\n    .ppp-info-block-header .ppp-icon { font-size: 16px; line-height: 1; }\r\n\r\n    .ppp-info-block h4 {\r\n        font-size: 11px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #d32f2f;\r\n        margin: 0;\r\n    }\r\n\r\n    .ppp-info-block-blue   h4 { color: #1565c0; }\r\n    .ppp-info-block-green  h4 { color: #388e3c; }\r\n    .ppp-info-block-orange h4 { color: #e65100; }\r\n\r\n    .ppp-info-block p {\r\n        font-size: 13px;\r\n        color: #666;\r\n        margin: 0;\r\n        line-height: 1.6;\r\n    }\r\n\r\n    .ppp-info-block ul {\r\n        margin: 0;\r\n        padding: 0 0 0 16px;\r\n    }\r\n\r\n    .ppp-info-block li {\r\n        font-size: 13px;\r\n        color: #666;\r\n        padding: 3px 0;\r\n        line-height: 1.5;\r\n    }\r\n\r\n    @media (max-width: 960px) {\r\n        .ppp-layout { grid-template-columns: 1fr; }\r\n        .ppp-aside  { position: static; }\r\n    }\r\n\r\n    @media (max-width: 700px) {\r\n        .ppp-hero-title { font-size: 24px; }\r\n        .ppp-hero { padding: 24px 0 20px; }\r\n    }\r\n<\/style>\r\n\r\n<!-- Modal pour le devis -->\r\n<div class=\"quotation-modal-pro\" id=\"quotationModalPro\">\r\n    <div class=\"quotation-content-pro\">\r\n        <div class=\"quote-modal-actions\">\r\n            <button class=\"quote-action-btn quote-print-btn\" onclick=\"printQuotePro()\">\r\n                \ud83d\udda8\ufe0f Print \/ Save PDF            <\/button>\r\n            <button class=\"close-quotation-pro\" onclick=\"closeQuotationPro()\">\u2715 Close<\/button>\r\n        <\/div>\r\n        <div id=\"quotationContentPro\"><\/div>\r\n    <\/div>\r\n<\/div>\r\n\r\n<div class=\"ppp-page\">\r\n\r\n    <!-- PAGE HERO -->\r\n    <div class=\"ppp-hero\">\r\n        <h1 class=\"ppp-hero-title\">3D Printing <span>Services<\/span><\/h1>\r\n        <p class=\"ppp-hero-sub\">Professional 3D printing services with fast turnaround and competitive pricing. Upload your STL files to get an instant quote.<\/p>\r\n    <\/div>\r\n\r\n    <div class=\"ppp-layout\">\r\n\r\n        <!-- MAIN FORM COLUMN -->\r\n        <main class=\"ppp-main\">\r\n            <div class=\"stl-upload-container-pro\">\r\n                <h3>3D Printing Quote Calculator<\/h3>\r\n    \r\n    <div class=\"upload-area-pro\" id=\"uploadAreaPro\">\r\n        <input type=\"file\" id=\"stlFilesPro\" accept=\".stl,.obj\" multiple style=\"display:none;\" \/>\r\n        <button type=\"button\" class=\"upload-button-pro\" onclick=\"document.getElementById('stlFilesPro').click()\">\r\n            Upload 3D Files        <\/button>\r\n        <div class=\"upload-info-pro\">\r\n            Select up to 10 files (STL, OBJ) \u2022 Max 100MB per file \u2022 Max 200MB total        <\/div>\r\n    <\/div>\r\n    \r\n    <div id=\"alertContainerPro\"><\/div>\r\n    <div class=\"rate-limit-banner-pro\" id=\"rateLimitBannerPro\"><\/div>\r\n    <div class=\"file-counter-pro\" id=\"fileCounterPro\"><\/div>\r\n    <div id=\"stlFilesListPro\"><\/div>\r\n    \r\n    <div class=\"total-section-pro\">\r\n        <div>Total Project Cost (incl. tax)<\/div>\r\n        <div class=\"total-amount-pro\"><span id=\"totalPricePro\">0.000<\/span> TND<\/div>\r\n        <div style=\"font-size: 12px; color: #888; margin-top: 4px;\">\r\n            <div id=\"priceBreakdownPro\">\r\n                <div>Total excl. tax: <span id=\"breakdownHT\">0.000 TND<\/span><\/div>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n    \r\n    <div class=\"submit-section-pro\">\r\n        <button class=\"submit-btn-pro\" id=\"submitStlOrderPro\" disabled>\r\n            View Detailed Quote        <\/button>\r\n        \r\n        <button class=\"cart-btn-pro\" id=\"addToCartPro\" disabled>\r\n            Add to Cart        <\/button>\r\n        \r\n        <div class=\"contact-section-pro\">\r\n            <span class=\"contact-section-label-pro\">Need help? Contact us<\/span>\r\n            <div>\r\n                <a href=\"tel:+21656401310\" class=\"contact-btn-pro\">\ud83d\udcde Call: 56 401 310<\/a>\r\n                <a href=\"https:\/\/wa.me\/21656401310\" class=\"contact-btn-pro whatsapp-btn-pro\" target=\"_blank\">\ud83d\udcac WhatsApp<\/a>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n<\/div><!-- \/.stl-upload-container-pro -->\r\n        <\/main><!-- \/.ppp-main -->\r\n\r\n        <!-- SIDEBAR -->\r\n        <aside class=\"ppp-aside\">\r\n\r\n            <div class=\"ppp-info-block\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\u23f1<\/span>\r\n                    <h4>Quick Turnaround<\/h4>\r\n                <\/div>\r\n                <p>Most prints completed within 3-5 business days. Rush orders available.<\/p>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-blue\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\u2705<\/span>\r\n                    <h4>Quality Guarantee<\/h4>\r\n                <\/div>\r\n                <p>Professional-grade prints and materials. 100% satisfaction guaranteed.<\/p>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-orange\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\ud83e\uddf1<\/span>\r\n                    <h4>Supported Materials<\/h4>\r\n                <\/div>\r\n                <ul>\r\n                    <li>PLA, PLA+ (Standard &amp; Premium)<\/li>\r\n                    <li>PETG (Chemical Resistant)<\/li>\r\n                    <li>ABS (High Temperature)<\/li>\r\n                    <li>TPU (Flexible)<\/li>\r\n                    <li>SLA - Resin (High Detail)<\/li>\r\n                <\/ul>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-green\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\ud83d\udcc1<\/span>\r\n                    <h4>File Requirements<\/h4>\r\n                <\/div>\r\n                <ul>\r\n                    <li>STL, Obj format<\/li>\r\n                    <li>Max 100MB per file \u2022 Max 200MB total<\/li>\r\n                    <li>Watertight meshes<\/li>\r\n                    <li>Minimum 0.4mm features<\/li>\r\n                <\/ul>\r\n            <\/div>\r\n\r\n        <\/aside><!-- \/.ppp-aside -->\r\n\r\n    <\/div><!-- \/.ppp-layout -->\r\n<\/div><!-- \/.ppp-page -->\r\n\r\n<script>\r\n\/\/ Configuration\r\nconst materialConfigPro = [];\r\nconst qualityConfigPro = [];\r\nconst machineConfigPro = [];\r\nconst generalConfigPro = {\"waste_factor\":\"1.05\",\"fixed_cost\":\"5.0\",\"markup_under_10\":\"1.3\",\"markup_over_10\":\"1.25\",\"tva_rate\":\"0.19\"};\r\nconst quantityConfigPro = {\"1_markup\":\"1.30\",\"2_5_markup\":\"1.25\",\"6_10_markup\":\"1.20\",\"11_plus_markup\":\"1.1\"};\r\n\r\n\/\/ Translatable strings for JavaScript\r\nconst i18n = {\r\n    processing: \"Processing...\",\r\n    ready: \"Ready\",\r\n    error: \"Error\",\r\n    calculating: \"Calculating...\",\r\n    calculatingCosts: \"Calculating costs...\",\r\n    allFilesProcessed: \"\u2705 All files processed successfully!\",\r\n    noFiles: \"Please upload at least one 3D file (STL or OBJ).\",\r\n    orderCreated: \"\u2705 Order created successfully! Redirecting to cart...\",\r\n    orderError: \"\u274c Error: Unable to create order.\",\r\n    connectionError: \"\u274c Connection error. Please contact us directly.\",\r\n    fileCountPrefix: \"\ud83d\udcca\",\r\n    noFileSelected: \"\ud83d\udcc1 No file selected\",\r\n    loadingViewer: \"Loading 3D viewer...\",\r\n    uploadPlaceholder: \"\ud83d\udce4 Upload 3D file (STL or OBJ)\",\r\n    dimensions: {\r\n        width: \"Width\",\r\n        height: \"Height\",\r\n        depth: \"Depth\"\r\n    },\r\n    volume: \"Volume\",\r\n    printability: \"Printability\",\r\n    totalHT: \"Total excl. tax:\",\r\n    totalTTC: \"Total incl. tax:\"\r\n};\r\n\r\n\/\/ Mapping des couleurs pour les mat\u00e9riaux\r\nconst materialColors = {\r\n    'White': 0xFFFFFF, 'Black': 0x111111, 'Red': 0xFF0000, 'Green': 0x00FF00,\r\n    'Blue': 0x0000FF, 'Gray': 0x808080, 'Yellow': 0xFFFF00\r\n};\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE VOLUME ET SURFACE STL COMPLET\r\n\/\/ =====================================================================\r\n\r\nclass AccurateSTLVolumeCalculator {\r\n    static calculateVolumeAndSurface(arrayBuffer) {\r\n        try {\r\n            const data = new DataView(arrayBuffer);\r\n            if (arrayBuffer.byteLength < 84) {\r\n                throw new Error('Fichier trop petit pour \u00eatre un STL valide');\r\n            }\r\n\r\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n            let totalVolume = 0;\r\n            let totalSurfaceArea = 0;\r\n            let triangleCount = 0;\r\n            \r\n            const header = new TextDecoder().decode(new DataView(arrayBuffer, 0, 80));\r\n            const isAscii = this.isAsciiSTL(header, arrayBuffer);\r\n\r\n            if (isAscii) {\r\n                const result = this.processAsciiSTL(arrayBuffer);\r\n                return this.finalizeVolumeAndSurface(result);\r\n            } else {\r\n                const result = this.processBinarySTL(data, arrayBuffer);\r\n                return this.finalizeVolumeAndSurface(result);\r\n            }\r\n            \r\n        } catch (error) {\r\n            console.error('Erreur dans le calcul du volume et surface:', error);\r\n            return this.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\r\n        }\r\n    }\r\n\r\n    static isAsciiSTL(header, arrayBuffer) {\r\n        const headerLower = header.toLowerCase();\r\n        const hasSolid = headerLower.includes('solid');\r\n        const hasFacet = headerLower.includes('facet');\r\n        \r\n        if (hasSolid && hasFacet) return true;\r\n        \r\n        if (arrayBuffer.byteLength >= 84) {\r\n            const triangleCount = new DataView(arrayBuffer).getUint32(80, true);\r\n            const expectedSize = 84 + (triangleCount * 50);\r\n            if (Math.abs(arrayBuffer.byteLength - expectedSize) <= 2) return false;\r\n        }\r\n        \r\n        return hasSolid;\r\n    }\r\n\r\n    static processAsciiSTL(arrayBuffer) {\r\n        const text = new TextDecoder().decode(arrayBuffer);\r\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n        let totalVolume = 0;\r\n        let totalSurfaceArea = 0;\r\n        let triangleCount = 0;\r\n\r\n        const vertexRegex = \/vertex\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\/gi;\r\n        let match;\r\n        const foundVertices = [];\r\n\r\n        while ((match = vertexRegex.exec(text)) !== null) {\r\n            const x = parseFloat(match[1]);\r\n            const y = parseFloat(match[2]);\r\n            const z = parseFloat(match[3]);\r\n            \r\n            if (isNaN(x) || isNaN(y) || isNaN(z)) continue;\r\n            \r\n            foundVertices.push({x, y, z});\r\n            minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);\r\n            maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);\r\n        }\r\n\r\n        for (let i = 0; i < foundVertices.length; i += 3) {\r\n            if (i + 2 < foundVertices.length) {\r\n                const v1 = foundVertices[i];\r\n                const v2 = foundVertices[i + 1];\r\n                const v3 = foundVertices[i + 2];\r\n                const volume = this.calculateTetrahedronVolume(v1, v2, v3);\r\n                const surfaceArea = this.calculateTriangleArea(v1, v2, v3);\r\n                totalVolume += volume;\r\n                totalSurfaceArea += surfaceArea;\r\n                triangleCount++;\r\n            }\r\n        }\r\n\r\n        return { minX, minY, minZ, maxX, maxY, maxZ, totalVolume, totalSurfaceArea, triangleCount, estimated: false, accurate: triangleCount > 0 };\r\n    }\r\n\r\n    static processBinarySTL(data, arrayBuffer) {\r\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n        let totalVolume = 0;\r\n        let totalSurfaceArea = 0;\r\n        \r\n        let triangleCount = data.getUint32(80, true);\r\n        const maxReasonableTriangles = (arrayBuffer.byteLength - 84) \/ 50;\r\n        if (triangleCount > maxReasonableTriangles) {\r\n            triangleCount = data.getUint32(80, false);\r\n            if (triangleCount > maxReasonableTriangles) throw new Error('Nombre de triangles invalide');\r\n        }\r\n\r\n        let offset = 84;\r\n        for (let i = 0; i < triangleCount; i++) {\r\n            if (offset + 48 > arrayBuffer.byteLength) break;\r\n            offset += 12;\r\n            \r\n            const vertices = [];\r\n            for (let j = 0; j < 3; j++) {\r\n                const x = data.getFloat32(offset, true);\r\n                const y = data.getFloat32(offset + 4, true);\r\n                const z = data.getFloat32(offset + 8, true);\r\n                \r\n                if (isNaN(x) || isNaN(y) || isNaN(z)) throw new Error('Donn\u00e9es de vertex invalides');\r\n                vertices.push({x, y, z});\r\n                minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);\r\n                maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);\r\n                offset += 12;\r\n            }\r\n            \r\n            const v1 = vertices[0], v2 = vertices[1], v3 = vertices[2];\r\n            const volume = this.calculateTetrahedronVolume(v1, v2, v3);\r\n            const surfaceArea = this.calculateTriangleArea(v1, v2, v3);\r\n            totalVolume += volume;\r\n            totalSurfaceArea += surfaceArea;\r\n            offset += 2;\r\n        }\r\n\r\n        return { minX, minY, minZ, maxX, maxY, maxZ, totalVolume, totalSurfaceArea, triangleCount, estimated: false, accurate: true };\r\n    }\r\n\r\n    static calculateTetrahedronVolume(v1, v2, v3) {\r\n        const crossX = (v2.y * v3.z) - (v2.z * v3.y);\r\n        const crossY = (v2.z * v3.x) - (v2.x * v3.z);\r\n        const crossZ = (v2.x * v3.y) - (v2.y * v3.x);\r\n        const dot = (v1.x * crossX) + (v1.y * crossY) + (v1.z * crossZ);\r\n        return dot \/ 6.0;\r\n    }\r\n\r\n    static calculateTriangleArea(v1, v2, v3) {\r\n        const ab = { x: v2.x - v1.x, y: v2.y - v1.y, z: v2.z - v1.z };\r\n        const ac = { x: v3.x - v1.x, y: v3.y - v1.y, z: v3.z - v1.z };\r\n        \r\n        const crossX = ab.y * ac.z - ab.z * ac.y;\r\n        const crossY = ab.z * ac.x - ab.x * ac.z;\r\n        const crossZ = ab.x * ac.y - ab.y * ac.x;\r\n        \r\n        const magnitude = Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ);\r\n        return magnitude \/ 2.0;\r\n    }\r\n\r\n    static finalizeVolumeAndSurface(result) {\r\n        const { minX, minY, minZ, maxX, maxY, maxZ, totalVolume, totalSurfaceArea, triangleCount, estimated, accurate } = result;\r\n        const dimensions = {\r\n            width: Math.max(maxX - minX, 0.001),\r\n            height: Math.max(maxY - minY, 0.001),\r\n            depth: Math.max(maxZ - minZ, 0.001)\r\n        };\r\n        \r\n        const actualVolume = Math.abs(totalVolume) \/ 1000;\r\n        const actualSurfaceArea = Math.abs(totalSurfaceArea) \/ 100;\r\n        const boundingBoxVolume = (dimensions.width * dimensions.height * dimensions.depth) \/ 1000;\r\n        let finalVolume = Math.max(actualVolume, 0.001);\r\n        let finalSurfaceArea = Math.max(actualSurfaceArea, 0.001);\r\n        \r\n        if (actualVolume < 0.01 && boundingBoxVolume > 1.0) {\r\n            finalVolume = boundingBoxVolume * 0.3;\r\n            finalSurfaceArea = Math.pow(finalVolume * 1000, 2\/3) * 6 \/ 100;\r\n        }\r\n        \r\n        const efficiency = actualVolume > 0 ? (actualVolume \/ boundingBoxVolume) * 100 : 0;\r\n        let printability = 'excellent';\r\n        let printabilityText = 'Printability: Excellent';\r\n        let printabilityReason = '';\r\n        \r\n        if (efficiency < 20) {\r\n            printability = 'poor'; printabilityText = 'Printability: Poor';\r\n            printabilityReason = 'Low material efficiency';\r\n        } else if (efficiency < 40) {\r\n            printability = 'fair'; printabilityText = 'Printability: Fair';\r\n            printabilityReason = 'Moderate material efficiency';\r\n        } else if (efficiency < 60) {\r\n            printability = 'good'; printabilityText = 'Printability: Good';\r\n            printabilityReason = 'Good material efficiency';\r\n        } else printabilityReason = 'Excellent material efficiency';\r\n        \r\n        return {\r\n            volume: finalVolume, \r\n            surfaceArea: finalSurfaceArea,\r\n            dimensions, \r\n            triangleCount, \r\n            boundingBoxVolume,\r\n            printability, \r\n            printabilityText, \r\n            printabilityReason, \r\n            estimated, \r\n            accurate\r\n        };\r\n    }\r\n\r\n    static estimateVolumeAndSurfaceFromFileSize(fileSize) {\r\n        const trianglesEstimate = fileSize \/ 50;\r\n        const surfaceAreaEstimate = trianglesEstimate * 5;\r\n        const volumeEstimate = Math.cbrt(surfaceAreaEstimate * surfaceAreaEstimate);\r\n        const estimatedVolume = Math.max(volumeEstimate \/ 1000, 0.01);\r\n        const estimatedSurfaceArea = Math.max(surfaceAreaEstimate \/ 100, 0.01);\r\n        const side = Math.cbrt(estimatedVolume * 1000);\r\n        \r\n        return {\r\n            volume: estimatedVolume, \r\n            surfaceArea: estimatedSurfaceArea,\r\n            dimensions: { width: side, height: side, depth: side }, \r\n            triangleCount: 0, \r\n            boundingBoxVolume: estimatedVolume, \r\n            printability: 'fair',\r\n            printabilityText: 'Printability: Fair (Estimated)',\r\n            printabilityReason: 'Volume and surface estimated from file size',\r\n            estimated: true, \r\n            accurate: false\r\n        };\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE VOLUME ET SURFACE OBJ\r\n\/\/ =====================================================================\r\n\r\nclass AccurateOBJVolumeCalculator {\r\n    static calculateVolumeAndSurface(arrayBuffer) {\r\n        try {\r\n            const text  = new TextDecoder().decode(arrayBuffer);\r\n            const lines = text.split('\\n');\r\n\r\n            const vertices = [];\r\n            const faces    = [];\r\n\r\n            for (const line of lines) {\r\n                const trimmed = line.trim();\r\n                if (!trimmed || trimmed.startsWith('#')) continue;\r\n\r\n                const parts = trimmed.split(\/\\s+\/);\r\n                if (parts[0] === 'v') {\r\n                    \/\/ Geometric vertex: v x y z [w]\r\n                    const x = parseFloat(parts[1]);\r\n                    const y = parseFloat(parts[2]);\r\n                    const z = parseFloat(parts[3]);\r\n                    if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {\r\n                        vertices.push({ x, y, z });\r\n                    }\r\n                } else if (parts[0] === 'f') {\r\n                    \/\/ Face: indices are 1-based; tokens may be \"v\", \"v\/vt\", or \"v\/vt\/vn\"\r\n                    const faceVerts = parts.slice(1).map(p => parseInt(p.split('\/')[0], 10) - 1);\r\n                    \/\/ Fan-triangulate from first vertex (handles triangles, quads, n-gons)\r\n                    for (let i = 1; i < faceVerts.length - 1; i++) {\r\n                        faces.push([ faceVerts[0], faceVerts[i], faceVerts[i + 1] ]);\r\n                    }\r\n                }\r\n            }\r\n\r\n            if (vertices.length === 0 || faces.length === 0) {\r\n                throw new Error('No geometry found in OBJ file');\r\n            }\r\n\r\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n            let totalVolume      = 0;\r\n            let totalSurfaceArea = 0;\r\n\r\n            for (const [i0, i1, i2] of faces) {\r\n                const v1 = vertices[i0];\r\n                const v2 = vertices[i1];\r\n                const v3 = vertices[i2];\r\n                if (!v1 || !v2 || !v3) continue;\r\n\r\n                \/\/ Bounding box update\r\n                for (const v of [v1, v2, v3]) {\r\n                    if (v.x < minX) minX = v.x; if (v.x > maxX) maxX = v.x;\r\n                    if (v.y < minY) minY = v.y; if (v.y > maxY) maxY = v.y;\r\n                    if (v.z < minZ) minZ = v.z; if (v.z > maxZ) maxZ = v.z;\r\n                }\r\n\r\n                \/\/ Signed tetrahedron volume (same formula as STL calculator)\r\n                const crossX = v2.y * v3.z - v2.z * v3.y;\r\n                const crossY = v2.z * v3.x - v2.x * v3.z;\r\n                const crossZ = v2.x * v3.y - v2.y * v3.x;\r\n                totalVolume += (v1.x * crossX + v1.y * crossY + v1.z * crossZ) \/ 6.0;\r\n\r\n                \/\/ Triangle surface area\r\n                const ab = { x: v2.x - v1.x, y: v2.y - v1.y, z: v2.z - v1.z };\r\n                const ac = { x: v3.x - v1.x, y: v3.y - v1.y, z: v3.z - v1.z };\r\n                const cx = ab.y * ac.z - ab.z * ac.y;\r\n                const cy = ab.z * ac.x - ab.x * ac.z;\r\n                const cz = ab.x * ac.y - ab.y * ac.x;\r\n                totalSurfaceArea += Math.sqrt(cx * cx + cy * cy + cz * cz) \/ 2.0;\r\n            }\r\n\r\n            return AccurateSTLVolumeCalculator.finalizeVolumeAndSurface({\r\n                minX, minY, minZ, maxX, maxY, maxZ,\r\n                totalVolume, totalSurfaceArea,\r\n                triangleCount: faces.length,\r\n                estimated: false,\r\n                accurate:  faces.length > 0,\r\n            });\r\n\r\n        } catch (error) {\r\n            console.error('OBJ volume calculation error:', error);\r\n            return AccurateSTLVolumeCalculator.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\r\n        }\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ VISIONNEUSE 3D COMPL\u00c8TE\r\n\/\/ =====================================================================\r\n\r\nclass Real3DViewer {\r\n    static viewers = new Map();\r\n    \r\n    static initViewer(containerId, fileData, fileName, dimensions, initialColor = 0xdc3545) {\r\n        const container = document.getElementById(containerId);\r\n        container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 32px; margin-bottom: 10px;\">\ud83d\udd04<\/div><div>${i18n.loadingViewer}<\/div><\/div>`;\r\n\r\n        const isOBJ = fileName.toLowerCase().endsWith('.obj');\r\n        const checkThreeJS = setInterval(() => {\r\n            \/\/ For OBJ files also wait for OBJLoader; for STL wait for STLLoader\r\n            const baseReady = typeof THREE !== 'undefined' && typeof THREE.OrbitControls !== 'undefined';\r\n            const loaderReady = isOBJ\r\n                ? baseReady && typeof THREE.OBJLoader !== 'undefined'\r\n                : baseReady && typeof THREE.STLLoader !== 'undefined';\r\n            if (loaderReady) {\r\n                clearInterval(checkThreeJS);\r\n                this.create3DViewer(containerId, fileData, fileName, dimensions, initialColor);\r\n            }\r\n        }, 100);\r\n    }\r\n    \r\n    static create3DViewer(containerId, fileData, fileName, dimensions, initialColor) {\r\n        const container = document.getElementById(containerId);\r\n        container.innerHTML = '';\r\n\r\n        try {\r\n            const scene    = new THREE.Scene();\r\n            scene.background = new THREE.Color(0xf8f9fa);\r\n            const camera   = new THREE.PerspectiveCamera(75, container.clientWidth \/ container.clientHeight, 0.1, 1000);\r\n            const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });\r\n            renderer.setSize(container.clientWidth, container.clientHeight);\r\n            renderer.setClearColor(0x000000, 0);\r\n            container.appendChild(renderer.domElement);\r\n\r\n            const ambientLight = new THREE.AmbientLight(0x404040, 0.6);\r\n            scene.add(ambientLight);\r\n            const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);\r\n            directionalLight1.position.set(1, 1, 1).normalize();\r\n            scene.add(directionalLight1);\r\n            const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);\r\n            directionalLight2.position.set(-1, -1, -1).normalize();\r\n            scene.add(directionalLight2);\r\n\r\n            const controls = new THREE.OrbitControls(camera, renderer.domElement);\r\n            controls.enableDamping = true;\r\n            controls.dampingFactor = 0.05;\r\n\r\n            const isOBJ    = fileName.toLowerCase().endsWith('.obj');\r\n            const material = new THREE.MeshPhongMaterial({\r\n                color: initialColor, specular: 0x111111, shininess: 100, transparent: true, opacity: 0.9\r\n            });\r\n            const blob = new Blob([fileData], { type: 'application\/octet-stream' });\r\n            const url  = URL.createObjectURL(blob);\r\n\r\n            \/\/ Unified post-load handler: works for both STL (BufferGeometry) and OBJ (THREE.Group)\r\n            const onModelLoad = (object) => {\r\n                let rootObject;\r\n\r\n                if (isOBJ) {\r\n                    \/\/ OBJLoader returns a THREE.Group \u2014 apply shared material to every mesh child\r\n                    object.traverse(child => {\r\n                        if (child.isMesh) { child.material = material; }\r\n                    });\r\n                    scene.add(object);\r\n                    rootObject = object;\r\n                } else {\r\n                    \/\/ STLLoader returns a BufferGeometry\r\n                    object.computeVertexNormals();\r\n                    const mesh = new THREE.Mesh(object, material);\r\n                    scene.add(mesh);\r\n                    rootObject = mesh;\r\n                }\r\n\r\n                \/\/ Centre and fit camera to bounding box\r\n                const box    = new THREE.Box3().setFromObject(rootObject);\r\n                const center = new THREE.Vector3();\r\n                box.getCenter(center);\r\n                rootObject.position.sub(center);\r\n\r\n                const size   = new THREE.Vector3();\r\n                box.getSize(size);\r\n                const maxDim = Math.max(size.x, size.y, size.z);\r\n                camera.position.z = maxDim * 2;\r\n                camera.position.y = maxDim * 0.5;\r\n                controls.update();\r\n\r\n                const infoDiv     = document.createElement('div');\r\n                infoDiv.className = 'viewer-info';\r\n                infoDiv.innerHTML = `\ud83d\uddb1\ufe0f Drag to rotate \u2022 Scroll to zoom`;\r\n                container.appendChild(infoDiv);\r\n\r\n                \/\/ Store material + renderer reference so updateColor() and disposal work\r\n                this.viewers.set(containerId, { scene, mesh: rootObject, material, renderer });\r\n\r\n                function animate() {\r\n                    requestAnimationFrame(animate);\r\n                    controls.update();\r\n                    renderer.render(scene, camera);\r\n                }\r\n                animate();\r\n\r\n                window.addEventListener('resize', () => {\r\n                    camera.aspect = container.clientWidth \/ container.clientHeight;\r\n                    camera.updateProjectionMatrix();\r\n                    renderer.setSize(container.clientWidth, container.clientHeight);\r\n                });\r\n            };\r\n\r\n            const onError = (error) => {\r\n                console.error('Error loading 3D model:', error);\r\n                container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 48px; margin-bottom: 15px;\">\u274c<\/div><div style=\"font-size: 16px; color: #dc3545;\">Failed to load 3D model<\/div><\/div>`;\r\n            };\r\n\r\n            if (isOBJ) {\r\n                const loader = new THREE.OBJLoader();\r\n                loader.load(url, onModelLoad, undefined, onError);\r\n            } else {\r\n                const loader = new THREE.STLLoader();\r\n                loader.load(url, onModelLoad, undefined, onError);\r\n            }\r\n\r\n        } catch (error) {\r\n            console.error('3D viewer initialisation error:', error);\r\n            container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 48px; margin-bottom: 15px;\">\u274c<\/div><div style=\"font-size: 16px; color: #dc3545;\">3D viewer error<\/div><\/div>`;\r\n        }\r\n    }\r\n    \r\n    static updateColor(containerId, colorName) {\r\n        const viewer = this.viewers.get(containerId);\r\n        if (viewer && viewer.material) {\r\n            const colorHex = materialColors[colorName] || 0xdc3545;\r\n            viewer.material.color.setHex(colorHex);\r\n            viewer.material.needsUpdate = true;\r\n        }\r\n    }\r\n\r\n    static disposeAllViewers() {\r\n        this.viewers.forEach((viewer, containerId) => {\r\n            try {\r\n                if (viewer.renderer) {\r\n                    viewer.renderer.forceContextLoss();\r\n                    viewer.renderer.dispose();\r\n                }\r\n            } catch(e) { \/* ignore disposal errors *\/ }\r\n        });\r\n        this.viewers.clear();\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE PRIX (avec r\u00e9ductions par pi\u00e8ce)\r\n\/\/ =====================================================================\r\n\r\nconst pricingPro = {\r\n    calculateDetailedPrice: function(volume, surfaceArea, technology, material, quality, quantity, color) {\r\n        let breakdown = { priceHT: 0, priceTTC: 0, costPerUnit: 0, totalCost: 0 };\r\n\r\n        try {\r\n            const qualitySlug = quality.toLowerCase().replace(' ', '-');\r\n            const qci = parseFloat(qualityConfigPro[qualitySlug + '_qci'] || this.getDefaultQci(quality));\r\n            const qct = parseFloat(qualityConfigPro[qualitySlug + '_qct'] || this.getDefaultQct(quality));\r\n            \r\n            const materialSlug = material.toLowerCase().replace('+', '-plus').replace(' ', '-');\r\n            const materialPrice = parseFloat(materialConfigPro[materialSlug + '_price'] || this.getDefaultMaterialPrice(material));\r\n            const materialDensity = parseFloat(materialConfigPro[materialSlug + '_density'] || this.getDefaultMaterialDensity(material));\r\n            const materialCoc = parseFloat(materialConfigPro[materialSlug + '_coc'] || this.getDefaultMaterialCoc(material));\r\n            \r\n            const wasteFactor = parseFloat(generalConfigPro.waste_factor || 1.05);\r\n            const fixedCost = parseFloat(generalConfigPro.fixed_cost || 5.0);\r\n            const machineSpeed = parseFloat(machineConfigPro.machine_speed || 0.4);\r\n            const machineHourlyCost = parseFloat(machineConfigPro.machine_hourly_cost || 1.0);\r\n            \r\n            const colorCoef = (color === 'White' || color === 'Black') ? \r\n                parseFloat(materialConfigPro.color_standard_coef || 1.0) : \r\n                parseFloat(materialConfigPro.color_premium_coef || 1.1);\r\n            \r\n            \/\/ Calcul pour 1 pi\u00e8ce\r\n            const materialCostPerUnit = this.calculateMaterialCost(volume, surfaceArea, qci, qct, materialDensity, wasteFactor, materialPrice, colorCoef);\r\n            const machineCostPerUnit = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\r\n            const manufacturingCostPerUnit = this.calculateManufacturingCost(materialCostPerUnit, machineCostPerUnit, materialCoc, fixedCost, 1);\r\n            const basePriceHT = this.calculatePriceHT(manufacturingCostPerUnit, 1);\r\n            \r\n            \/\/ Application des r\u00e9ductions par quantit\u00e9 (pi\u00e8ce par pi\u00e8ce)\r\n            let totalPriceHT = 0;\r\n            for (let i = 1; i <= quantity; i++) {\r\n                let reductionRate = this.getReductionRateForPiece(i);\r\n                totalPriceHT += basePriceHT * (1 - reductionRate);\r\n            }\r\n            \r\n            breakdown.costPerUnit = totalPriceHT \/ quantity;\r\n            breakdown.totalCost = totalPriceHT;\r\n            breakdown.priceHT = totalPriceHT;\r\n            \r\n            \/\/ Application multiplicateur SLA\r\n            if (technology === 'SLA') {\r\n                const plaPrice = parseFloat(materialConfigPro['pla_price'] || 80.00);\r\n                const plaDensity = parseFloat(materialConfigPro['pla_density'] || 1.24);\r\n                const plaCoc = parseFloat(materialConfigPro['pla_coc'] || 1.0);\r\n                \r\n                const plaMaterialCost = this.calculateMaterialCost(volume, surfaceArea, qci, qct, plaDensity, wasteFactor, plaPrice, colorCoef);\r\n                const plaMachineCost = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\r\n                const plaManufacturingCost = this.calculateManufacturingCost(plaMaterialCost, plaMachineCost, plaCoc, fixedCost, 1);\r\n                const plaBasePriceHT = this.calculatePriceHT(plaManufacturingCost, 1);\r\n                \r\n                let slaTotalPriceHT = 0;\r\n                for (let i = 1; i <= quantity; i++) {\r\n                    let reductionRate = this.getReductionRateForPiece(i);\r\n                    slaTotalPriceHT += plaBasePriceHT * (1 - reductionRate);\r\n                }\r\n                \r\n                let slaMultiplier = (material === 'High Resolution Resin') \r\n                    ? parseFloat(machineConfigPro.sla_highres_multiplier || 2.5)\r\n                    : parseFloat(machineConfigPro.sla_standard_multiplier || 2.2);\r\n                \r\n                breakdown.priceHT = slaTotalPriceHT * slaMultiplier;\r\n            }\r\n            \r\n            const tvaRate = parseFloat(generalConfigPro.tva_rate || 0.19);\r\n            breakdown.priceTTC = breakdown.priceHT * (1 + tvaRate);\r\n            \r\n        } catch (error) {\r\n            console.error('Erreur calcul:', error);\r\n        }\r\n        \r\n        return breakdown;\r\n    },\r\n    \r\n    getReductionRateForPiece: function(pieceNumber) {\r\n        if (pieceNumber === 1) return 0;\r\n        if (pieceNumber === 2) return 0.15;\r\n        if (pieceNumber === 3) return 0.16;\r\n        if (pieceNumber >= 4 && pieceNumber <= 5) return 0.17;\r\n        if (pieceNumber >= 6 && pieceNumber <= 9) return 0.18;\r\n        if (pieceNumber >= 10 && pieceNumber <= 29) return 0.20;\r\n        if (pieceNumber >= 30 && pieceNumber <= 99) return 0.30;\r\n        if (pieceNumber >= 100) return 0.35;\r\n        return 0;\r\n    },\r\n    \r\n    calculateMaterialCost: function(volume, surfaceArea, qci, qct, density, wasteFactor, materialPrice, colorCoef) {\r\n        const volumePart = volume * qci;\r\n        const surfacePart = surfaceArea * (qct \/ 10);\r\n        const totalVolume = volumePart + surfacePart;\r\n        const weight = totalVolume * density * wasteFactor;\r\n        const cost = (weight \/ 1000) * materialPrice * colorCoef;\r\n        return Math.max(cost, 0.001);\r\n    },\r\n    \r\n    calculateBaseMachineCost: function(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost) {\r\n        const volumePart = volume * qci;\r\n        const surfacePart = surfaceArea * qct;\r\n        const totalWork = volumePart + surfacePart;\r\n        const cost = (machineHourlyCost \/ (60 * machineSpeed)) * totalWork;\r\n        return Math.max(cost, 0.001);\r\n    },\r\n    \r\n    calculateManufacturingCost: function(materialCost, machineCost, materialCoc, fixedCost, quantity) {\r\n        const variableCost = materialCost + (machineCost * materialCoc);\r\n        const fixedCostPerUnit = fixedCost \/ quantity;\r\n        return variableCost + fixedCostPerUnit;\r\n    },\r\n    \r\n    calculatePriceHT: function(manufacturingCost, quantity) {\r\n        let markup;\r\n        if (quantity === 1) markup = parseFloat(quantityConfigPro['1_markup'] || 1.30);\r\n        else if (quantity >= 2 && quantity <= 5) markup = parseFloat(quantityConfigPro['2_5_markup'] || 1.30);\r\n        else if (quantity >= 6 && quantity <= 10) markup = parseFloat(quantityConfigPro['6_10_markup'] || 1.30);\r\n        else markup = parseFloat(quantityConfigPro['11_plus_markup'] || 1.25);\r\n        return manufacturingCost * markup;\r\n    },\r\n    \r\n    \/\/ Valeurs par d\u00e9faut\r\n    getDefaultQci: function(quality) {\r\n        const defaults = { 'Basse': 0.1, 'Standard': 0.3, 'Haute': 1.0 };\r\n        return defaults[quality] || 0.3;\r\n    },\r\n    getDefaultQct: function(quality) {\r\n        const defaults = { 'Basse': 0.8, 'Standard': 1.0, 'Haute': 2.0 };\r\n        return defaults[quality] || 1.0;\r\n    },\r\n    getDefaultMaterialPrice: function(material) {\r\n        const defaults = { \r\n            'PLA': 80.00, 'PLA+': 95.00, 'PETG': 130.00, \r\n            'ABS': 155.00, 'TPU': 165.00,\r\n            'Standard Resin': 100.00, 'High Resolution Resin': 150.00 \r\n        };\r\n        return defaults[material] || 80.00;\r\n    },\r\n    getDefaultMaterialDensity: function(material) {\r\n        const defaults = { \r\n            'PLA': 1.24, 'PLA+': 1.24, 'PETG': 1.27, \r\n            'ABS': 1.04, 'TPU': 1.20,\r\n            'Standard Resin': 1.10, 'High Resolution Resin': 1.10 \r\n        };\r\n        return defaults[material] || 1.24;\r\n    },\r\n    getDefaultMaterialCoc: function(material) {\r\n        const defaults = { \r\n            'PLA': 1.0, 'PLA+': 1.2, 'PETG': 1.5, \r\n            'ABS': 2.2, 'TPU': 3.0,\r\n            'Standard Resin': 1.0, 'High Resolution Resin': 1.0 \r\n        };\r\n        return defaults[material] || 1.0;\r\n    }\r\n};\r\n\r\n\/\/ =====================================================================\r\n\/\/ APPLICATION PRINCIPALE\r\n\/\/ =====================================================================\r\n\r\ndocument.addEventListener(\"DOMContentLoaded\", function() {\r\n    const MAX_FILES      = 10;\r\n    const MAX_VIEWERS    = 6;  \/\/ WebGL context limit \u2014 browsers cap at ~8-16; stay safe at 6\r\n    const MAX_FILE_SIZE  = 100 * 1024 * 1024; \/\/ 100 MB per file\r\n    const MAX_TOTAL_SIZE = 200 * 1024 * 1024; \/\/ 200 MB across all files\r\n    \r\n    const elements = {\r\n        fileInput: document.getElementById('stlFilesPro'),\r\n        alertContainer: document.getElementById('alertContainerPro'),\r\n        rateLimitBanner: document.getElementById('rateLimitBannerPro'),\r\n        fileCounter: document.getElementById('fileCounterPro'),\r\n        filesList: document.getElementById('stlFilesListPro'),\r\n        totalPrice: document.getElementById('totalPricePro'),\r\n        submitBtn: document.getElementById('submitStlOrderPro'),\r\n        cartBtn: document.getElementById('addToCartPro'),\r\n        uploadArea: document.getElementById('uploadAreaPro'),\r\n        quotationModal: document.getElementById('quotationModalPro'),\r\n        quotationContent: document.getElementById('quotationContentPro')\r\n    };\r\n    \r\n    let state = {\r\n        files: [],\r\n        processing: 0,\r\n        fileData: new Map(),\r\n        hasLargeDimensions: false,\r\n        quotationNumber: null   \/\/ set server-side before quote is shown\r\n    };\r\n\r\n    \/\/ Guest info state\r\n    let guestInfo = {\r\n        identified: false,\r\n        name: '',\r\n        email: '',\r\n        phone: '',\r\n        gdpr_consent: 0,\r\n        wp_user_id: null,\r\n        guest_identifier: '',\r\n        billing: {\r\n            company:   '',\r\n            address_1: '',\r\n            address_2: '',\r\n            city:      '',\r\n            postcode:  '',\r\n            country:   ''\r\n        }\r\n    };\r\n\r\n    \r\n    const technologyMaterials = {\r\n        'FDM': ['PLA', 'PLA+', 'PETG', 'ABS', 'TPU'],\r\n        'SLA': ['Standard Resin', 'High Resolution Resin']\r\n    };\r\n\r\n    const colorOptions = [\r\n        { name: 'White', value: 'White' }, { name: 'Black', value: 'Black' },\r\n        { name: 'Red', value: 'Red' }, { name: 'Green', value: 'Green' },\r\n        { name: 'Blue', value: 'Blue' }, { name: 'Gray', value: 'Gray' },\r\n        { name: 'Yellow', value: 'Yellow' }\r\n    ];\r\n    \r\n    function init() {\r\n        elements.fileInput.addEventListener('change', handleFileSelect);\r\n        elements.submitBtn.addEventListener('click', handleSubmit);\r\n        elements.cartBtn.addEventListener('click', handleAddToCart);\r\n        elements.uploadArea.addEventListener('dragover', handleDragOver);\r\n        elements.uploadArea.addEventListener('dragleave', handleDragLeave);\r\n        elements.uploadArea.addEventListener('drop', handleFileDrop);\r\n        \r\n        window.pro_ajax_nonce = '8b762e62cf';\r\n        window.t3d_tracking_nonce = '7c8bb71829';\r\n\r\n        updateFileCounter();\r\n    }\r\n    \r\n    function handleDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover'); }\r\n    function handleDragLeave(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); }\r\n    \r\n    function handleFileDrop(e) {\r\n        e.preventDefault();\r\n        e.currentTarget.classList.remove('dragover');\r\n        const files = Array.from(e.dataTransfer.files).filter(f => \/\\.(stl|obj)$\/i.test(f.name));\r\n        processFiles(files);\r\n    }\r\n    \r\n    function handleFileSelect(event) {\r\n        const files = Array.from(event.target.files);\r\n        processFiles(files);\r\n    }\r\n    \r\n    function processFiles(newFiles) {\r\n        const validFiles = [];\r\n        const errors = [];\r\n        \/\/ Running total of bytes already queued (existing + newly accepted)\r\n        let accumulatedSize = state.files.reduce((sum, f) => sum + f.size, 0);\r\n\r\n        newFiles.forEach(file => {\r\n            if (file.size > MAX_FILE_SIZE) {\r\n                errors.push(`${file.name} exceeds 100MB limit`);\r\n            } else if (state.files.length + validFiles.length >= MAX_FILES) {\r\n                errors.push(`Maximum ${MAX_FILES} files allowed`);\r\n            } else if (state.files.some(f => f.name === file.name)) {\r\n                errors.push(`${file.name} is already added`);\r\n            } else if (accumulatedSize + file.size > MAX_TOTAL_SIZE) {\r\n                errors.push(`Adding ${file.name} would exceed the 200MB total upload limit`);\r\n            } else {\r\n                validFiles.push(file);\r\n                accumulatedSize += file.size;\r\n            }\r\n        });\r\n        \r\n        if (errors.length > 0) showAlert(errors.join('<br>'), 'error');\r\n        if (validFiles.length === 0) return;\r\n\r\n        \/\/ Rate limit check for unidentified guests\r\n        if (!guestInfo.identified) {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: {\r\n                    action: 't3d_check_rate_limit',\r\n                    nonce: window.t3d_tracking_nonce,\r\n                    email: guestInfo.email\r\n                },\r\n                success: function(response) {\r\n                    if (response.success) {\r\n                        if (response.data.allowed) {\r\n                            \/\/ Log upload attempt\r\n                            if (response.data.guest_identifier) {\r\n                                guestInfo.guest_identifier = response.data.guest_identifier;\r\n                            }\r\n                            doProcessFiles(validFiles);\r\n                            logUploadAttempt();\r\n                        } else {\r\n                            \/\/ Rate limit reached \u2014 redirect to login\r\n                            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n                        }\r\n                    } else {\r\n                        doProcessFiles(validFiles);\r\n                    }\r\n                },\r\n                error: function() {\r\n                    doProcessFiles(validFiles);\r\n                }\r\n            });\r\n        } else {\r\n            doProcessFiles(validFiles);\r\n        }\r\n    }\r\n\r\n    function logUploadAttempt() {\r\n        jQuery.ajax({\r\n            type: 'POST',\r\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n            data: {\r\n                action: 't3d_capture_guest',\r\n                nonce: window.t3d_tracking_nonce,\r\n                name: guestInfo.name,\r\n                email: guestInfo.email,\r\n                phone: guestInfo.phone,\r\n                gdpr_consent: guestInfo.gdpr_consent\r\n            }\r\n        });\r\n    }\r\n\r\n    function doProcessFiles(validFiles) {\r\n        if (elements.rateLimitBanner) {\r\n            elements.rateLimitBanner.style.display = 'none';\r\n        }\r\n        state.files.push(...validFiles);\r\n        updateFileCounter();\r\n        renderFiles();\r\n        processAllFiles();\r\n    }\r\n    \r\n    function renderFiles() {\r\n        elements.filesList.innerHTML = '';\r\n        \r\n        state.files.forEach((file, index) => {\r\n            const fileId = `file_${index}`;\r\n            const viewerId = `viewer_${fileId}`;\r\n            \r\n            const card = document.createElement('div');\r\n            card.className = 'stl-file-card-pro';\r\n            card.innerHTML = `\r\n                <button class=\"remove-file-pro\" onclick=\"removeFilePro(${index})\" title=\"Remove file\">\u00d7<\/button>\r\n                \r\n                <div class=\"file-header-pro\">\r\n                    <div class=\"file-name-pro\">${file.name}<\/div>\r\n                    <div class=\"file-status-pro status-processing-pro\" id=\"status_${fileId}\">\u23f3 ${i18n.processing}<\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"preview-section-pro\">\r\n                    <div class=\"viewer-container-pro\" id=\"${viewerId}\">\r\n                        ${index < MAX_VIEWERS\r\n                            ? `<div class=\"viewer-placeholder-pro\"><div style=\"font-size:32px;margin-bottom:10px;\">\ud83d\udce4<\/div><div>${i18n.uploadPlaceholder}<\/div><\/div>`\r\n                            : `<div class=\"viewer-placeholder-pro\" style=\"font-size:13px;color:#666;padding:20px 10px;line-height:1.6;\"><div style=\"font-size:28px;margin-bottom:8px;\">\ud83d\udcd0<\/div><div>Aper\u00e7u 3D d\u00e9sactiv\u00e9<br><span style=\"font-size:11px;color:#999;\">Max ${MAX_VIEWERS} visionneuses simultan\u00e9es<\/span><\/div><\/div>`\r\n                        }<\/div>\r\n                    \r\n                    <div class=\"info-panel-pro\">\r\n                        <div class=\"dimension-box-pro\">\r\n                            <strong>Dimensions (mm)<\/strong><br>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.width}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"width_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.height}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"height_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.depth}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"depth_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                        <\/div>\r\n                        \r\n                        <div class=\"volume-box-pro\">\r\n                            <div style=\"font-size: 12px; color: #666;\">${i18n.volume}<\/div>\r\n                            <div class=\"volume-value-pro\" id=\"vol_${fileId}\">--<\/div>\r\n                            <div style=\"font-size: 11px; color: #666;\">cm\u00b3<\/div>\r\n                        <\/div>\r\n                        \r\n                        <div class=\"printability-bar-pro\">\r\n                            <div class=\"printability-fill-pro\" id=\"printabilityBar_${fileId}\"><\/div>\r\n                        <\/div>\r\n                        <div class=\"printability-label-pro\" id=\"printabilityLabel_${fileId}\">${i18n.calculating}<\/div>\r\n                    <\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"cost-breakdown-pro\" id=\"costBreakdown_${fileId}\">\r\n                    <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>-- TND<\/span><\/div>\r\n                    <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>-- TND<\/span><\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"settings-grid-pro\">\r\n                    <div class=\"form-group-pro\">\r\n                        <label>Technology<\/label>\r\n                        <select class=\"tech-pro\" onchange=\"updateMaterialsPro(${index})\">\r\n                            <option value=\"FDM\">FDM<\/option>\r\n                            <option value=\"SLA\">SLA<\/option>\r\n                        <\/select>\r\n                    <\/div>\r\n                    \r\n                    <div class=\"form-group-pro\">\r\n                        <label>Material<\/label>\r\n                        <select class=\"material-pro\" id=\"material_${fileId}\"><\/select>\r\n                    <\/div>\r\n                    \r\n                    <div class=\"form-group-pro\">\r\n                        <label>Color<\/label>\r\n                        <select class=\"color-pro\" onchange=\"updateColorAndPricePro(${index}, this.value)\">\r\n                            ${colorOptions.map(color => `<option value=\"${color.value}\">${color.name}<\/option>`).join('')}\r\n                        <\/select>\r\n                    <\/div>\r\n                    \r\n                    <div class=\"form-group-pro\">\r\n                        <label>Quality<\/label>\r\n                        <select class=\"quality-pro\">\r\n                            <option value=\"Basse\">Low<\/option>\r\n                            <option value=\"Standard\" selected>Standard<\/option>\r\n                            <option value=\"Haute\">High<\/option>\r\n                        <\/select>\r\n                    <\/div>\r\n                    \r\n                    <div class=\"form-group-pro\">\r\n                        <label>Quantity<\/label>\r\n                        <input type=\"number\" class=\"quantity-pro\" value=\"1\" min=\"1\" max=\"1000\">\r\n                    <\/div>\r\n                <\/div>\r\n                \r\n                <input type=\"hidden\" class=\"volume-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"surface-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"bounding-volume-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"unit-price-ht-pro\" value=\"0\">\r\n            `;\r\n            \r\n            elements.filesList.appendChild(card);\r\n            updateMaterialsPro(index);\r\n            \r\n            setTimeout(() => {\r\n                const cardElement = document.querySelectorAll('.stl-file-card-pro')[index];\r\n                const inputs = cardElement.querySelectorAll('input, select');\r\n                inputs.forEach(input => {\r\n                    input.addEventListener('change', updatePricesPro);\r\n                });\r\n            }, 100);\r\n        });\r\n    }\r\n    \r\n    \/\/ Global functions\r\n    window.removeFilePro = function(fileIndex) {\r\n        \/\/ Dispose all active WebGL contexts before re-rendering to avoid context leaks\r\n        Real3DViewer.disposeAllViewers();\r\n\r\n        state.files.splice(fileIndex, 1);\r\n        const newFileData = new Map();\r\n        state.files.forEach((file, index) => {\r\n            const oldFileId = `file_${fileIndex <= index ? index + 1 : index}`;\r\n            const newFileId = `file_${index}`;\r\n            if (state.fileData.has(oldFileId)) newFileData.set(newFileId, state.fileData.get(oldFileId));\r\n        });\r\n        state.fileData = newFileData;\r\n        renderFiles();\r\n        processAllFiles();\r\n        updatePricesPro();\r\n        updateFileCounter();\r\n    };\r\n    \r\n    window.updateMaterialsPro = function(fileIndex) {\r\n        const fileId = `file_${fileIndex}`;\r\n        const card = document.getElementById(`viewer_${fileId}`)?.closest('.stl-file-card-pro');\r\n        if (!card) return;\r\n        \r\n        const techSelect = card.querySelector('.tech-pro');\r\n        const materialSelect = card.querySelector('.material-pro');\r\n        const selectedTech = techSelect.value;\r\n        const materials = technologyMaterials[selectedTech] || [];\r\n        \r\n        materialSelect.innerHTML = '';\r\n        materials.forEach(material => {\r\n            const option = document.createElement('option');\r\n            option.value = material;\r\n            option.textContent = material;\r\n            materialSelect.appendChild(option);\r\n        });\r\n        \r\n        updatePricesPro();\r\n    };\r\n    \r\n    window.updateColorAndPricePro = function(fileIndex, colorName) {\r\n        const fileId = `file_${fileIndex}`;\r\n        const viewerId = `viewer_${fileId}`;\r\n        Real3DViewer.updateColor(viewerId, colorName);\r\n        updatePricesPro();\r\n    };\r\n    \r\n    function processAllFiles() {\r\n        state.processing = 0;\r\n        elements.submitBtn.disabled = true;\r\n        elements.cartBtn.disabled = true;\r\n        \r\n        if (state.files.length === 0) {\r\n            checkAllProcessed();\r\n            return;\r\n        }\r\n        \r\n        state.files.forEach((file, index) => processSingleFile(file, index));\r\n    }\r\n    \r\n    function processSingleFile(file, index) {\r\n        const fileId = `file_${index}`;\r\n        const viewerId = `viewer_${fileId}`;\r\n        const reader = new FileReader();\r\n        \r\n        reader.onload = function(e) {\r\n            try {\r\n                const isOBJ  = file.name.toLowerCase().endsWith('.obj');\r\n                const result = isOBJ\r\n                    ? AccurateOBJVolumeCalculator.calculateVolumeAndSurface(e.target.result)\r\n                    : AccurateSTLVolumeCalculator.calculateVolumeAndSurface(e.target.result);\r\n                state.fileData.set(fileId, result);\r\n                \r\n                document.getElementById(`status_${fileId}`).className = 'file-status-pro status-success-pro';\r\n                document.getElementById(`status_${fileId}`).innerHTML = `\u2705 ${i18n.ready}`;\r\n                document.getElementById(`width_${fileId}`).textContent = result.dimensions.width.toFixed(1);\r\n                document.getElementById(`height_${fileId}`).textContent = result.dimensions.height.toFixed(1);\r\n                document.getElementById(`depth_${fileId}`).textContent = result.dimensions.depth.toFixed(1);\r\n                document.getElementById(`vol_${fileId}`).textContent = result.volume.toFixed(2);\r\n                \r\n                const printabilityBar = document.getElementById(`printabilityBar_${fileId}`);\r\n                const printabilityLabel = document.getElementById(`printabilityLabel_${fileId}`);\r\n                if (printabilityBar && printabilityLabel) {\r\n                    printabilityBar.className = `printability-fill-pro printability-${result.printability}-pro`;\r\n                    printabilityLabel.className = `printability-label-pro printability-${result.printability}-text-pro`;\r\n                    printabilityLabel.textContent = result.printabilityText;\r\n                }\r\n                \r\n                const card = document.getElementById(viewerId)?.closest('.stl-file-card-pro');\r\n                if (card) {\r\n                    card.querySelector('.volume-pro').value = result.volume.toFixed(2);\r\n                    card.querySelector('.surface-pro').value = result.surfaceArea.toFixed(2);\r\n                    card.querySelector('.bounding-volume-pro').value = result.boundingBoxVolume.toFixed(2);\r\n                }\r\n                \r\n                if (index < MAX_VIEWERS) {\r\n                    Real3DViewer.initViewer(viewerId, e.target.result, file.name, result.dimensions, materialColors['White']);\r\n                }\r\n                \r\n            } catch (error) {\r\n                console.error(`Error processing ${file.name}:`, error);\r\n                document.getElementById(`status_${fileId}`).className = 'file-status-pro status-error-pro';\r\n                document.getElementById(`status_${fileId}`).innerHTML = `\u274c ${i18n.error}`;\r\n            } finally {\r\n                state.processing++;\r\n                checkAllProcessed();\r\n            }\r\n        };\r\n        \r\n        reader.onerror = function() {\r\n            console.error(`Read error for ${file.name}`);\r\n            document.getElementById(`status_${fileId}`).className = 'file-status-pro status-error-pro';\r\n            document.getElementById(`status_${fileId}`).innerHTML = `\u274c ${i18n.error}`;\r\n            state.processing++;\r\n            checkAllProcessed();\r\n        };\r\n        \r\n        reader.readAsArrayBuffer(file);\r\n    }\r\n    \r\n    function checkAllProcessed() {\r\n        if (state.processing === state.files.length) {\r\n            updatePricesPro();\r\n            elements.submitBtn.disabled = state.files.length === 0;\r\n            elements.cartBtn.disabled = state.files.length === 0;\r\n            if (state.files.length > 0) showAlert(i18n.allFilesProcessed, 'success');\r\n        }\r\n    }\r\n    \r\n    function updatePricesPro() {\r\n        let totalHT = 0;\r\n        let totalTTC = 0;\r\n        \r\n        document.querySelectorAll('.stl-file-card-pro').forEach((card, index) => {\r\n            if (index < state.files.length) {\r\n                const fileId = `file_${index}`;\r\n                const fileData = state.fileData.get(fileId);\r\n                \r\n                if (fileData) {\r\n                    const technology = card.querySelector('.tech-pro').value;\r\n                    const material = card.querySelector('.material-pro').value;\r\n                    const quality = card.querySelector('.quality-pro').value;\r\n                    const color = card.querySelector('.color-pro').value;\r\n                    const materialVolume = parseFloat(card.querySelector('.volume-pro').value) || 0;\r\n                    const surfaceArea = parseFloat(card.querySelector('.surface-pro').value) || 0;\r\n                    const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n                    \r\n                    const priceResult = pricingPro.calculateDetailedPrice(\r\n                        materialVolume, surfaceArea, technology, material, quality, quantity, color\r\n                    );\r\n                    \r\n                    card.querySelector('.unit-price-ht-pro').value = (priceResult.priceHT \/ quantity).toFixed(3);\r\n                    \r\n                    const breakdownElement = document.getElementById(`costBreakdown_${fileId}`);\r\n                    if (breakdownElement) {\r\n                        breakdownElement.innerHTML = `\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>${priceResult.priceHT.toFixed(3)} TND<\/span><\/div>\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>${priceResult.priceTTC.toFixed(3)} TND<\/span><\/div>\r\n                        `;\r\n                    }\r\n                    \r\n                    totalHT += priceResult.priceHT;\r\n                    totalTTC += priceResult.priceTTC;\r\n                }\r\n            }\r\n        });\r\n        \r\n        elements.totalPrice.textContent = totalTTC.toFixed(3);\r\n        document.getElementById('breakdownHT').textContent = totalHT.toFixed(3) + ' TND';\r\n    }\r\n    \r\n    function handleSubmit() {\r\n        if (state.files.length === 0) {\r\n            showAlert(i18n.noFiles, 'error');\r\n            return;\r\n        }\r\n        if (!guestInfo.identified) {\r\n            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n            return;\r\n        }\r\n        showQuotationPro();\r\n    }\r\n    \r\n    \/\/ \u2500\u2500 Number-to-words (French, TND) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    function amountToWordsFR(amount) {\r\n        const units  = ['','un','deux','trois','quatre','cinq','six','sept','huit','neuf',\r\n                        'dix','onze','douze','treize','quatorze','quinze','seize','dix-sept','dix-huit','dix-neuf'];\r\n        const tens   = ['','','vingt','trente','quarante','cinquante','soixante','soixante','quatre-vingt','quatre-vingt'];\r\n        function belowHundred(n) {\r\n            if (n < 20) return units[n];\r\n            const t = Math.floor(n \/ 10), u = n % 10;\r\n            if (t === 7 || t === 9) return tens[t] + (u === 0 ? (t===9?'s':'') : '-' + units[10 + u]);\r\n            if (t === 8) return 'quatre-vingt' + (u === 0 ? 's' : '-' + units[u]);\r\n            return tens[t] + (u === 1 ? ' et un' : u > 0 ? '-' + units[u] : '');\r\n        }\r\n        function belowThousand(n) {\r\n            if (n < 100) return belowHundred(n);\r\n            const h = Math.floor(n \/ 100), r = n % 100;\r\n            return (h === 1 ? 'cent' : units[h] + ' cent') + (r > 0 ? (h===1?'':' ') + ' ' + belowHundred(r) : (h>1?'s':''));\r\n        }\r\n        const intPart  = Math.floor(amount);\r\n        const millPart = Math.round((amount - intPart) * 1000);\r\n        let words = '';\r\n        if (intPart === 0) {\r\n            words = 'z\u00e9ro';\r\n        } else if (intPart < 1000) {\r\n            words = belowThousand(intPart);\r\n        } else {\r\n            const th = Math.floor(intPart \/ 1000), rem = intPart % 1000;\r\n            words = (th === 1 ? 'mille' : belowThousand(th) + ' mille') + (rem > 0 ? ' ' + belowThousand(rem) : '');\r\n        }\r\n        words = words.charAt(0).toUpperCase() + words.slice(1) + ' dinars';\r\n        if (millPart > 0) words += ' et ' + belowThousand(millPart) + ' millimes';\r\n        return words;\r\n    }\r\n\r\n    function showQuotationPro() {\r\n        \/\/ First get a server-side sequential quote number, then render\r\n        if (!state.quotationNumber) {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: { action: 't3d_generate_quote_number', nonce: window.pro_ajax_nonce },\r\n                success: function(res) {\r\n                    const yr = String(new Date().getFullYear()).slice(-2);\r\n                    state.quotationNumber = res.success ? res.data.quote_number : ('DEV3D' + yr + '-' + Date.now().toString().slice(-4));\r\n                    renderQuotationPro();\r\n                },\r\n                error: function() {\r\n                    const yr = String(new Date().getFullYear()).slice(-2);\r\n                    state.quotationNumber = 'DEV3D' + yr + '-' + Date.now().toString().slice(-4);\r\n                    renderQuotationPro();\r\n                }\r\n            });\r\n        } else {\r\n            renderQuotationPro();\r\n        }\r\n    }\r\n\r\n    function renderQuotationPro() {\r\n        const today   = new Date();\r\n        const dateStr = today.toLocaleDateString('fr-TN', { day:'2-digit', month:'2-digit', year:'numeric' });\r\n\r\n        \/\/ Collect item data\r\n        const orderData = state.files.map((file, index) => {\r\n            const card       = document.querySelectorAll('.stl-file-card-pro')[index];\r\n            const quantity   = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n            const unitPriceHT = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\r\n            return {\r\n                ref:          String(index + 1).padStart(3, '0'),\r\n                description:  file.name.replace(\/\\.[^.]+$\/, '') +\r\n                              ' \u2014 ' + card.querySelector('.tech-pro').value +\r\n                              ' \/ ' + card.querySelector('.material-pro').value +\r\n                              ' \/ ' + card.querySelector('.color-pro').value +\r\n                              ' \/ ' + card.querySelector('.quality-pro').value,\r\n                quantity:     quantity,\r\n                unit_price_ht: unitPriceHT,\r\n                total_ht:     unitPriceHT * quantity\r\n            };\r\n        });\r\n\r\n        const subtotalHT    = orderData.reduce((s, i) => s + i.total_ht, 0);\r\n        const vatAmount     = subtotalHT * 0.19;\r\n        const timbreFiscal  = 1.000;\r\n        const grandTotal    = subtotalHT + vatAmount + timbreFiscal;\r\n\r\n        \/\/ Client address block\r\n        const b = guestInfo.billing;\r\n        let clientAddr = '';\r\n        if (b.company)   clientAddr += `<div style=\"font-weight:700;\">${b.company}<\/div>`;\r\n        if (b.address_1) clientAddr += `<div>${b.address_1}<\/div>`;\r\n        if (b.address_2) clientAddr += `<div>${b.address_2}<\/div>`;\r\n        if (b.city || b.postcode) clientAddr += `<div>${[b.postcode, b.city].filter(Boolean).join(' ')}<\/div>`;\r\n        if (b.country)   clientAddr += `<div>${b.country}<\/div>`;\r\n        if (!clientAddr) clientAddr  = `<div style=\"color:#999;font-style:italic;\">\u2014 address not set in profile \u2014<\/div>`;\r\n\r\n        \/\/ Items rows\r\n        let rowsHTML = '';\r\n        orderData.forEach(item => {\r\n            rowsHTML += `\r\n            <tr>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;font-size:12px;color:#555;\">${item.ref}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;font-size:13px;\">${item.description}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;\">${item.quantity}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;\">${item.unit_price_ht.toFixed(3)}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;font-weight:600;\">${item.total_ht.toFixed(3)}<\/td>\r\n            <\/tr>`;\r\n        });\r\n\r\n        const quotationHTML = `\r\n<div id=\"ppp-quote-doc\" style=\"font-family:'Segoe UI',Arial,sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;font-size:13px;line-height:1.5;\">\r\n\r\n    <!-- HEADER -->\r\n    <div style=\"display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:28px;padding-bottom:18px;border-bottom:3px solid #1a1a1a;\">\r\n        <div>\r\n            <div style=\"font-size:22px;font-weight:900;color:#1a1a1a;letter-spacing:-.5px;\">FM MECA<\/div>\r\n            <div style=\"font-size:11px;color:#d32f2f;font-weight:700;text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px;\">tunisia3dprint.com<\/div>\r\n            <div style=\"font-size:12px;color:#555;line-height:1.7;\">\r\n                Cit\u00e9 Ezzayatine, Ksibet Sousse<br>\r\n                4041 Sousse, Tunisia<br>\r\n                T\u00e9l&nbsp;: 56 401 310<br>\r\n                MF&nbsp;: 1814198PAM000\r\n            <\/div>\r\n        <\/div>\r\n        <div style=\"text-align:right;\">\r\n            <div style=\"font-size:28px;font-weight:900;color:#1a1a1a;text-transform:uppercase;letter-spacing:1px;\">Devis<\/div>\r\n            <div style=\"margin-top:8px;border:1px solid #ddd;border-radius:6px;overflow:hidden;font-size:12px;\">\r\n                <table style=\"border-collapse:collapse;width:100%;\">\r\n                    <tr style=\"background:#f4f4f4;\">\r\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Num\u00e9ro<\/th>\r\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Date<\/th>\r\n                        <th style=\"padding:6px 12px;font-weight:700;text-align:left;\">Page<\/th>\r\n                    <\/tr>\r\n                    <tr>\r\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;color:#d32f2f;\">${state.quotationNumber}<\/td>\r\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;\">${dateStr}<\/td>\r\n                        <td style=\"padding:6px 12px;\">1<\/td>\r\n                    <\/tr>\r\n                <\/table>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n\r\n    <!-- CLIENT BLOCK -->\r\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:28px;\">\r\n        <div style=\"border:1px solid #ddd;border-top:3px solid #d32f2f;border-radius:4px;padding:14px 18px;min-width:260px;font-size:13px;\">\r\n            <div style=\"font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#d32f2f;margin-bottom:8px;\">Client<\/div>\r\n            <div style=\"font-weight:700;margin-bottom:2px;\">${guestInfo.name}<\/div>\r\n            <div style=\"color:#555;\">${guestInfo.email}<\/div>\r\n            <div style=\"color:#555;margin-top:4px;\">${clientAddr}<\/div>\r\n            ${guestInfo.phone ? `<div style=\"color:#555;margin-top:4px;\">T\u00e9l : ${guestInfo.phone}<\/div>` : ''}\r\n        <\/div>\r\n    <\/div>\r\n\r\n    <!-- ITEMS TABLE -->\r\n    <table style=\"width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;\">\r\n        <thead>\r\n            <tr style=\"background:#fff;color:#1a1a1a;border-bottom:2px solid #1a1a1a;\">\r\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">R\u00e9f.<\/th>\r\n                <th style=\"padding:10px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Description<\/th>\r\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Qt\u00e9<\/th>\r\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">P.U. HT (TND)<\/th>\r\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Total HT (TND)<\/th>\r\n            <\/tr>\r\n        <\/thead>\r\n        <tbody>${rowsHTML}<\/tbody>\r\n        <!-- empty filler rows up to 10 lines total -->\r\n        ${Array(Math.max(0, 10 - orderData.length)).fill('<tr style=\"height:36px;\"><td colspan=\"5\" style=\"border-bottom:1px solid #eee;\"><\/td><\/tr>').join('')}\r\n    <\/table>\r\n\r\n    <!-- TOTALS -->\r\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:24px;\">\r\n        <table style=\"border-collapse:collapse;font-size:13px;min-width:320px;\">\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">Total H.T.<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;font-weight:600;\">${subtotalHT.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">TVA (19%)<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;\">${vatAmount.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">Timbre fiscal<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;\">${timbreFiscal.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr style=\"border-top:2px solid #1a1a1a;\">\r\n                <td style=\"padding:11px 14px;border:1px solid #e0e0e0;font-weight:800;font-size:14px;color:#1a1a1a;\">TOTAL T.T.C.<\/td>\r\n                <td style=\"padding:11px 14px;border:1px solid #e0e0e0;text-align:right;font-weight:800;font-size:14px;color:#d32f2f;\">${grandTotal.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n        <\/table>\r\n    <\/div>\r\n\r\n    <!-- AMOUNT IN WORDS -->\r\n    <div style=\"background:#f8f8f8;border-left:4px solid #d32f2f;padding:10px 14px;font-size:13px;margin-bottom:20px;\">\r\n        <strong>Arr\u00eat\u00e9 le pr\u00e9sent devis \u00e0 la somme de :<\/strong><br>\r\n        <span style=\"font-style:italic;\">${amountToWordsFR(grandTotal)}<\/span>\r\n    <\/div>\r\n\r\n    <!-- NOTES -->\r\n    <div style=\"font-size:12px;color:#555;margin-bottom:16px;\">\r\n        <strong style=\"color:#1a1a1a;display:block;margin-bottom:6px;\">Notes :<\/strong>\r\n        <div>\u2014 Ce devis est valable un mois \u00e0 compter de sa date d'\u00e9mission.<\/div>\r\n        <div>\u2014 D\u00e9lai de livraison estim\u00e9 : 3 \u00e0 5 jours ouvrables apr\u00e8s confirmation de commande.<\/div>\r\n        <div>\u2014 Les prix sont donn\u00e9s hors options non mentionn\u00e9es dans ce devis.<\/div>\r\n    <\/div>\r\n\r\n    <!-- FOOTER -->\r\n    <div class=\"quote-footer-pro\" style=\"border-top:1px solid #e0e0e0;padding-top:10px;text-align:center;font-size:11px;color:#999;\">\r\n        FM MECA \u2014 MF 1814198PAM000 \u2014 Cit\u00e9 Ezzayatine, 4041 Sousse, Tunisia \u2014 tunisia3dprint.com \u2014 Contact@Fmmeca.com\r\n    <\/div>\r\n\r\n<\/div>`;\r\n\r\n        elements.quotationContent.innerHTML = quotationHTML;\r\n        elements.quotationModal.style.display = 'flex';\r\n        saveFilesToServer('quote_viewed');\r\n    }\r\n    \r\n    window.closeQuotationPro = function() {\r\n        elements.quotationModal.style.display = 'none';\r\n    };\r\n\r\n    window.printQuotePro = function() {\r\n        const doc = document.getElementById('ppp-quote-doc');\r\n        if (!doc) return;\r\n\r\n        const itemCount = state.files.length;\r\n\r\n        \/\/ Clone and compact for print\r\n        const clone = doc.cloneNode(true);\r\n\r\n        \/\/ 1. Shrink filler rows: detect by single td with colspan=5 and no text\r\n        clone.querySelectorAll('tr').forEach(tr => {\r\n            const tds = tr.querySelectorAll('td');\r\n            if (tds.length === 1 && tds[0].getAttribute('colspan') === '5' && tds[0].textContent.trim() === '') {\r\n                tr.style.height = '10px';\r\n                tds[0].style.padding = '0';\r\n            }\r\n        });\r\n\r\n        \/\/ 2. Compact all table cells\r\n        clone.querySelectorAll('td, th').forEach(el => {\r\n            el.style.padding = '5px 8px';\r\n            el.style.fontSize = '11px';\r\n        });\r\n\r\n        \/\/ 3. Scale down large decorative font sizes, reduce paddings on divs\r\n        const fontMap = {'32px':'18px','28px':'18px','22px':'15px','14px':'12px','15px':'12px','13px':'11px','12px':'10px'};\r\n        clone.querySelectorAll('*').forEach(el => {\r\n            const fs = el.style.fontSize;\r\n            if (fontMap[fs]) el.style.fontSize = fontMap[fs];\r\n            \/\/ Reduce large div paddings\r\n            const p = el.style.padding;\r\n            if (p && el.tagName !== 'TD' && el.tagName !== 'TH') {\r\n                el.style.padding = p.replace(\/\\b(2[4-9]|[3-9]\\d)px\\b\/g, '10px')\r\n                                    .replace(\/\\b(1[6-9])px\\b\/g, '8px');\r\n            }\r\n            \/\/ Reduce large margins\r\n            const mb = el.style.marginBottom;\r\n            if (mb && parseInt(mb) > 14) el.style.marginBottom = '10px';\r\n        });\r\n\r\n        \/\/ 4. For many items, allow a second page break before totals section\r\n        \/\/ (items > 7: totals block gets page-break-before)\r\n        if (itemCount > 7) {\r\n            const totalsDiv = clone.querySelector('div[style*=\"justify-content:flex-end\"]');\r\n            if (totalsDiv) totalsDiv.style.pageBreakBefore = 'always';\r\n        }\r\n\r\n        \/\/ 5. Pin footer to bottom of A4 page \u2014 make doc a flex column, push footer down\r\n        clone.style.display = 'flex';\r\n        clone.style.flexDirection = 'column';\r\n        clone.style.minHeight = 'calc(297mm - 16mm)';\r\n        const footerEl = clone.querySelector('.quote-footer-pro');\r\n        if (footerEl) footerEl.style.marginTop = 'auto';\r\n\r\n        const pw = window.open('', '_blank', 'width=860,height=700');\r\n        pw.document.write(`<!DOCTYPE html>\r\n<html>\r\n<head>\r\n<meta charset=\"UTF-8\">\r\n<title>Devis ${state.quotationNumber}<\/title>\r\n<style>\r\n  * { box-sizing: border-box; }\r\n  body {\r\n    font-family: Arial, sans-serif;\r\n    font-size: 11px;\r\n    color: #1a1a1a;\r\n    line-height: 1.35;\r\n    margin: 0;\r\n    padding: 8mm 10mm;\r\n  }\r\n  table { border-collapse: collapse; width: 100%; }\r\n  thead { display: table-header-group; }\r\n  tr { page-break-inside: avoid; }\r\n  @page { size: A4; margin: 0; }\r\n<\/style>\r\n<\/head>\r\n<body>\r\n${clone.outerHTML}\r\n<script>\r\n  window.onload = function() {\r\n    setTimeout(function() { window.print(); window.close(); }, 500);\r\n  };\r\n<\\\/script>\r\n<\/body>\r\n<\/html>`);\r\n        pw.document.close();\r\n    };\r\n    \r\n    function handleAddToCart() {\r\n        if (state.files.length === 0) {\r\n            showAlert(i18n.noFiles, 'error');\r\n            return;\r\n        }\r\n\r\n        if (!guestInfo.identified) {\r\n            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n            return;\r\n        }\r\n\r\n        doAddToCart();\r\n    }\r\n\r\n    function doAddToCart() {\r\n        elements.cartBtn.disabled = true;\r\n        elements.cartBtn.classList.add('loading');\r\n        const originalText = elements.cartBtn.innerHTML;\r\n        elements.cartBtn.innerHTML = '\u23f3 Processing...';\r\n        \r\n        const productData = {\r\n            action: 'create_3d_printing_order_pro',\r\n            nonce: window.pro_ajax_nonce,\r\n            quotation_number: state.quotationNumber,\r\n            total_price: parseFloat(elements.totalPrice.textContent),\r\n            user_email: guestInfo.email,\r\n            user_name: guestInfo.name,\r\n            user_phone: guestInfo.phone,\r\n            gdpr_consent: guestInfo.gdpr_consent,\r\n            files: state.files.map((file, index) => {\r\n                const card = document.querySelectorAll('.stl-file-card-pro')[index];\r\n                const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n                const unitPrice = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\r\n                const totalHT = unitPrice * quantity;\r\n                \r\n                return {\r\n                    file_name: file.name,\r\n                    technology: card.querySelector('.tech-pro').value,\r\n                    material: card.querySelector('.material-pro').value,\r\n                    color: card.querySelector('.color-pro').value,\r\n                    quality: card.querySelector('.quality-pro').value,\r\n                    quantity: quantity,\r\n                    volume: card.querySelector('.volume-pro').value,\r\n                    surface: card.querySelector('.surface-pro').value,\r\n                    unit_price: unitPrice.toFixed(3),\r\n                    price_ht: totalHT.toFixed(3),\r\n                    price_ttc: (totalHT * 1.19).toFixed(3)\r\n                };\r\n            })\r\n        };\r\n        \r\n        jQuery.ajax({\r\n            type: 'POST',\r\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n            data: productData,\r\n            success: function(response) {\r\n                if (response.success) {\r\n                    saveFilesToServer('cart_added');\r\n                    showAlert(i18n.orderCreated, 'success');\r\n                    setTimeout(() => {\r\n                        window.location.href = response.data.redirect_url || '\/cart\/';\r\n                    }, 1500);\r\n                } else {\r\n                    showAlert(i18n.orderError, 'error');\r\n                }\r\n            },\r\n            error: function(xhr, status, error) {\r\n                console.error('AJAX error:', error);\r\n                showAlert(i18n.connectionError, 'error');\r\n            },\r\n            complete: function() {\r\n                elements.cartBtn.disabled = false;\r\n                elements.cartBtn.classList.remove('loading');\r\n                elements.cartBtn.innerHTML = originalText;\r\n            }\r\n        });\r\n    }\r\n    \r\n    function updateFileCounter() {\r\n        elements.fileCounter.innerHTML = state.files.length > 0\r\n            ? `${i18n.fileCountPrefix} ${state.files.length} file(s) selected`\r\n            : i18n.noFileSelected;\r\n    }\r\n    \r\n    function showAlert(message, type) {\r\n        const alert = document.createElement('div');\r\n        alert.className = `alert-message-pro alert-${type}-pro`;\r\n        alert.innerHTML = message;\r\n        elements.alertContainer.appendChild(alert);\r\n        setTimeout(() => alert.remove(), 5000);\r\n    }\r\n\r\n    \/\/ ==================== FILE SAVING ====================\r\n\r\n    function saveFilesToServer(eventType) {\r\n        if (state.files.length === 0) return;\r\n\r\n        const filesData = [];\r\n        const readPromises = [];\r\n\r\n        state.files.forEach(file => {\r\n            const promise = new Promise(resolve => {\r\n                const reader = new FileReader();\r\n                reader.onload = function(e) {\r\n                    \/\/ Convert ArrayBuffer to base64 using Array.from for efficiency\r\n                    \/\/ Chunk the Uint8Array to avoid call-stack overflow on large files\r\n                    const bytes     = new Uint8Array(e.target.result);\r\n                    const chunkSize = 8192;\r\n                    let binary = '';\r\n                    for (let i = 0; i < bytes.length; i += chunkSize) {\r\n                        binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));\r\n                    }\r\n                    const base64 = btoa(binary);\r\n                    filesData.push({ name: file.name, data: base64 });\r\n                    resolve();\r\n                };\r\n                reader.onerror = resolve;\r\n                reader.readAsArrayBuffer(file);\r\n            });\r\n            readPromises.push(promise);\r\n        });\r\n\r\n        Promise.all(readPromises).then(function() {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: {\r\n                    action: 't3d_save_uploaded_files',\r\n                    nonce: window.t3d_tracking_nonce,\r\n                    quotation_number: state.quotationNumber,\r\n                    event_type: eventType,\r\n                    total_price: elements.totalPrice.textContent,\r\n                    files: filesData,\r\n                    user_email: guestInfo.email,\r\n                    user_name: guestInfo.name,\r\n                    user_phone: guestInfo.phone,\r\n                    gdpr_consent: guestInfo.gdpr_consent,\r\n                    guest_identifier: guestInfo.guest_identifier\r\n                }\r\n            });\r\n        });\r\n    }\r\n    \r\n    init();\r\n});\r\n<\/script>        <\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"","protected":false},"author":4,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_header_footer","meta":{"_acf_changed":false,"footnotes":""},"class_list":["post-11068","page","type-page","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/users\/4"}],"replies":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/comments?post=11068"}],"version-history":[{"count":64,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068\/revisions"}],"predecessor-version":[{"id":13633,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068\/revisions\/13633"}],"wp:attachment":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/media?parent=11068"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}