]> _ Git - fluidbook-html5.git/commitdiff
WIP #5051 @30
authorStephen Cameron <stephen@cubedesigners.com>
Thu, 9 Jun 2022 15:51:46 +0000 (17:51 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Thu, 9 Jun 2022 15:51:46 +0000 (17:51 +0200)
js/libs/fluidbook/cart/fluidbook.cart.cfoc.js [new file with mode: 0644]
js/libs/fluidbook/fluidbook.cart.js
style/cart/cfoc.less [new file with mode: 0644]

diff --git a/js/libs/fluidbook/cart/fluidbook.cart.cfoc.js b/js/libs/fluidbook/cart/fluidbook.cart.cfoc.js
new file mode 100644 (file)
index 0000000..8b9a2e4
--- /dev/null
@@ -0,0 +1,736 @@
+function FluidbookCartCFOC(cart) {
+    var $this = this;
+    this.cart = cart;
+    this.fluidbook = this.cart.fluidbook;
+    this.data = this.fluidbook.settings.basketReferences;
+    this.TVA_percentage = 20; // Displayed in Cart and used to calculate HT value from TTC
+    this.form_endpoint = 'https://workshop.fluidbook.com/services/CFOC'; // Where cart form is processed
+    this.init();
+}
+
+FluidbookCartCFOC.prototype = {
+    init: function () {
+        let $this = this;
+        this.items = this.fluidbook.cache.get('cart', []);
+
+        // Save changes to colour dropdown
+        $(document).on('change', '#CFOC_cart [data-item-EAN]', function () {
+            let EAN = $(this).val();
+            let item_index = $(this).data('item-index');
+
+            if ($this.items[item_index]) {
+                $this.items[item_index].EAN = EAN;
+                $this.save();
+                $this.updateCart();
+                // console.log('EAN changed!', EAN, 'IDX:', item_index)
+            } else {
+                console.warn(`Unable to update EAN for cart item ${item_index}`);
+            }
+
+            return true;
+        });
+
+        // Save changes in quantity field
+        $(document).on('input', '#CFOC_cart [data-item-quantity]', function () {
+            let item_index = $(this).data('item-index');
+            let quantity = $(this).val();
+            let min_quantity = parseInt($(this).attr('min'));
+
+            // Make sure a valid quantity is saved:
+            // - it can't be less than the minimum
+            // - it must be divisible by the minimum (for items that come in lots, only full lots are allowed)
+            // (even though the HTML field is restricted, you can still type an invalid value)
+            quantity = quantity - (quantity % min_quantity); // Subtract any remainders from full lots
+            quantity = Math.max(quantity, min_quantity);
+            $(this).val(quantity);
+
+            if ($this.items[item_index]) {
+                $this.items[item_index].quantity = quantity;
+                $this.save();
+                $this.updateCart();
+                // console.log('Quantity changed:', quantity, 'Index:', $(this).data('item-index'))
+            } else {
+                console.warn(`Unable to update quantity for cart item ${item_index}`);
+            }
+
+            return true;
+        });
+
+        $(document).on('change', '#CFOC_user_details [name="profile"]', function() {
+            let profile = $(this).val();
+            $('#CFOC_user_details form').addClass('hidden'); // Hide all forms
+
+            if (profile === '') {
+                $(this).addClass('alert');
+                $('html').removeClass('cart-form-visible');
+            } else {
+                // Show the form that belongs to this profile
+                let form = $(this).find('option:selected').data('form');
+                $('#CFOC_user_details').find(`form[data-name="${form}"]`).removeClass('hidden');
+                $('html').addClass('cart-form-visible');
+                $(this).removeClass('alert');
+            }
+
+            // Call the resize function so modal will be repositioned correctly after height changes
+            $this.fluidbook.resize.resize();
+        });
+
+        // Handle "Valider ma sélection" button in cart
+        $(document).on(this.fluidbook.input.clickEvent, '#CFOC_cart [data-validate-cart]', function(event) {
+            event.preventDefault();
+
+            // Use the built-in HTML5 validation to make sure all fields are valid in the cart
+            let form = $(this).parents('form');
+            let isValid = form[0].reportValidity();
+
+            if (isValid) {
+                // Originally this was in the link's href but for some reason the validation was
+                // skipped and the URL change would always occur, even when using
+                // event.preventDefault() / event.stopImmediatePropagation / event.stopPropagation();
+                window.location.hash = '/cart/validate';
+            }
+        });
+
+        // Handle details form validation and submission
+        $(document).on(this.fluidbook.input.clickEvent, '#CFOC_user_details [data-send-cart]', function(event) {
+            event.preventDefault();
+
+            // Since there are multiple forms depending on the profile,
+            // we need to figure out which form is being submitted...
+            let form = $('#CFOC_user_details form:visible');
+
+            if (!form.length) {
+                console.warn('Error finding form...');
+                return false;
+            }
+
+            // Get the form name so that we can look up the field labels later
+            let form_name = form.data('name');
+            let forms = $this.getForms();
+
+            // Get the user's details from the form...
+            let user_data = new FormData(form[0]);
+            let user_details = {}; // Restructured user data to send to the endpoint
+            // Include the "profile" field that is separate from the form
+            user_details['profile'] = {
+                label: 'Profil',
+                value: $('#CFOC_user_details select[name="profile"]').val()
+            }
+            user_data.forEach(function(value, key) {
+                user_details[key] = {
+                    label: forms[form_name][key]['label'],
+                    value: value,
+                };
+            });
+
+            // Prepare the main form data for sending
+            let data = new FormData();
+
+            // To make our endpoint more flexible, we need to give it some extra details
+            data.append('action', 'process-cart'); // There could be other actions handled by the endpoint
+            data.append('fluidbook_id', $this.fluidbook.settings.id); // In case we need special treatment
+            data.append('email_subject', $this.fluidbook.settings.cartEmailSubject);
+
+            // Now add the user details to the main form data...
+            // FormData objects can't be nested, so a loop is needed.
+            // user_details.forEach(function(value, key) {
+            //     data.append(`user_details[${key}]`, value);
+            // });
+            // Simpler to use JSON because we need it for the other data
+            data.append('user_details', JSON.stringify(user_details));
+
+            // Get data from the cart summary that can be used to generate the XLS
+            // This is passed as a JSON string to make it easier to handle in the FormData
+            data.append('cart_items', JSON.stringify($this.getItemsForXLS()));
+            data.append('column_headings', JSON.stringify($this.getColumnsForXLS()));
+
+            // Cart totals
+            data.append('cart_totals', JSON.stringify($this.getCartSummary()));
+
+            // Next, make sure the form is valid. Uses the built-in HTML5 validation
+            if (form[0].reportValidity()) {
+                $.ajax({
+                    url: $this.form_endpoint,
+                    cache: false,
+                    data: data,
+                    processData: false,
+                    contentType: false,
+                    method: 'post',
+                    dataType: 'json',
+                    success: function (data) {
+
+                        if (!data.success) {
+                            console.warn('Error processing request', data);
+                        }
+
+                        let container = $('#CFOC_user_details');
+                        // Hide the form and footer content, then show the message from the endpoint
+                        container.find('.details-columns, .details-footer').hide();
+                        container.find('.server-response').text(data.message).show();
+                    }
+                });
+            }
+
+            event.preventDefault();
+        });
+
+    },
+
+    // emptyCart: function () {
+    //     this.items = [];
+    //     this.updateCart();
+    //     this.save();
+    //     resize();
+    // },
+
+    addToCart: function (reference) {
+        let $this = this;
+        // Since the same reference number can be used for multiple product variations, we can't use the ref as the key
+        // Instead, items are added as an array of objects. It's possible to have the same product more than once but
+        // this is by design to allow the same product to be selected in different colours...
+
+        // Set up the cart item with initial values
+        let item = {};
+        item.reference = reference;
+
+        // Set the EAN if there's only a single colour variation.
+        let colours = Object.values(this.data[reference]);
+        if (colours.length === 1) {
+            item.EAN = colours[0].EAN;
+        } else {
+            item.EAN = ''; // Need to wait for the user to select an option from the dropdown
+        }
+
+        // Set a default quantity, taking into account minimum quantity value (if defined)
+        item.quantity = parseInt(colours[0]['QTE MINI']) || 1;
+
+        // Items are added to the beginning of the array so most recently added are displayed at the top
+        // This is better than reversing the array when we display it because we use the index for removing items
+        this.items.unshift(item);
+        this.save();
+
+        setTimeout(function() {
+            $this.fluidbook.tooltip.displayTooltip(fluidbook.l10n.__("the item has been added to your cart"));
+            setTimeout($this.fluidbook.tooltip.hideTooltip, 3000);
+        }, 100);
+
+        return true;
+    },
+
+    removeFromCart: function (index) {
+        if (index >= 0) {
+            this.items.splice(index, 1);
+            this.save();
+        }
+    },
+
+    save: function () {
+        this.fluidbook.cache.set('cart', this.getItems());
+        this.updateIcon();
+        //this.fluidbook.cart.updateLinks();
+    },
+
+    getItems: function () {
+        let res = [];
+        let $this = this;
+        $(this.items).each(function (index, item) {
+            if (typeof $this.data[item.reference] !== 'undefined') {
+                res.push(item);
+            }
+        });
+        return res;
+    },
+
+    // Required by FluidbookCart.updateLinks()
+    getItemsReferences: function () {
+        return this.getItems();
+    },
+
+    getItemCount: function () {
+        return this.getItems().length;
+    },
+
+    getItemsForXLS: function () {
+        let $this = this;
+        let items = [];
+
+        $.each(this.getItems(), function (index, cart_item) {
+
+            let item = {};
+            let cart_reference = cart_item['reference'];
+            let cart_EAN = cart_item['EAN'];
+            let data = $this.data[cart_reference][cart_EAN]; // Source data matched from spreadsheet
+
+            $.each($this.getColumnsForXLS(), function (key, title) {
+
+                switch(key) {
+                    case 'QTE':
+                        item[key] = cart_item['quantity'];
+                        break;
+                    case 'PRIX HT':
+                        item[key] = $this.calculateHT(data['PRIX TTC'] * cart_item['quantity']);
+                        break;
+                    case 'PRIX TTC':
+                        item[key] = $this.parseFloat(data['PRIX TTC']) * cart_item['quantity'];
+                        break;
+                    default:
+                        item[key] = data[key];
+                }
+            });
+
+            items.push(item);
+        });
+
+        return items;
+    },
+
+    updateCart: function () {
+        if ($('#CFOC_cart').length > 0) {
+            $('#CFOC_cart .content').html(this.getCartContent());
+            this.fluidbook.resize.resize();
+        }
+    },
+
+    updateIcon: function () {
+        $(this.fluidbook).trigger('fluidbook.cart.updateIcon', {number: this.getItemCount()});
+    },
+
+    openModal: function (title, content, callback) {
+        var view = `<div id="CFOC_cart">
+                        ${this.fluidbook.menu.getCaption(title)}
+                        <div class="content">
+                            ${content}
+                        </div>
+                    </div>`;
+        this.fluidbook.menu.viewWrap(view, 'cart');
+        callback();
+    },
+
+    openMenu: function (p1, p2, callback) {
+
+        this.fluidbook.menu.quickCloseView();
+
+        // Handle the validation screen when accessed via #/cart/validate
+        if (p1 === 'validate') {
+            return this.openCartUserDetails(callback);
+        }
+
+        // The cart opens every time an item is added, so the user can pick a colour and quantity
+        // These URLs use the #/cart/add/xxxxx URL to pass the reference across
+        if (p1 === 'add' && p2) {
+            this.addToCart(p2);
+        }
+        return this.openCart(p2, callback);
+    },
+
+    openCart: function (p2, callback) {
+        this.openModal('Ma Sélection', this.getCartContent(), function () {
+            callback();
+        });
+    },
+
+    openCartUserDetails: function (callback) {
+        $('html').removeClass('cart-form-visible'); // Make sure forms are hidden initially
+        this.openModal('Mes Coordonnées', this.getCartUserDetailsContent(), function () {
+            callback();
+        });
+    },
+
+    getColumns: function () {
+        // Map of data key names to their display labels - this controls the order of the columns
+        // Note: the key names here should match the first row column titles in the spreadsheet
+        return {
+            'LIGNE': 'Ligne',
+            'REF': 'Réf.',
+            'DESIGNATION': 'Désignation',
+            'COULEUR': 'Couleur',
+            'QTE MINI': 'Qté Mini',
+            'QTE': 'Qté', // Special case: not part of the data
+            'PRIX HT': 'Prix HT', // Special case: calculated based on Prix TTC
+            'PRIX TTC': 'Prix TTC',
+            'DELETE': '', // No column label for delete buttons
+        };
+    },
+
+    getColumnsForXLS: function () {
+        // The columns should be the same as for the cart except we add "EAN" after LIGNE and skip "DELETE"
+        let columns = {};
+
+        $.each(this.getColumns(), function(key, title) {
+
+            if (key === 'DELETE') return;
+
+            columns[key] = title;
+
+            if (key === 'LIGNE') { // Add EAN column after "LIGNE"
+                columns['EAN'] = 'EAN';
+            }
+        });
+
+        return columns;
+    },
+
+    getForms: function () {
+        return {
+            'business': {
+                'company_name': {
+                    'label': "Nom de l'entreprise",
+                    'type': 'text',
+                    'required': true
+                },
+                'last_name': {
+                    'label': "Nom",
+                    'type': 'text',
+                    'required': true
+                },
+                'first_name': {
+                    'label': "Prénom",
+                    'type': 'text',
+                    'required': true
+                },
+                'phone': {
+                    'label': "Téléphone",
+                    'type': 'text',
+                    'required': true
+                },
+                'email': {
+                    'label': "Email",
+                    'type': 'email',
+                    'required': true
+                },
+                'billing_address': {
+                    'label': "Adresse de facturation",
+                    'type': 'textarea',
+                    'rows': 3,
+                    'required': true
+                },
+                'project_name': {
+                    'label': "Nom du projet",
+                    'type': 'text',
+                    'required': false
+                },
+                'message': {
+                    'label': "Message",
+                    'type': 'textarea',
+                    'rows': 6,
+                    'required': false
+                },
+            },
+
+            'reseller': {
+                'shop_name': {
+                    'label': "Nom de la boutique",
+                    'type': 'text',
+                    'required': true
+                },
+                'client_number': {
+                    'label': "Numéro de client (si existant)",
+                    'type': 'text',
+                    'required': false
+                },
+                'email': {
+                    'label': "Email",
+                    'type': 'email',
+                    'required': true
+                },
+                'billing_address': {
+                    'label': "Adresse de facturation",
+                    'type': 'textarea',
+                    'rows': 3,
+                    'required': true
+                },
+                'message': {
+                    'label': "Message",
+                    'type': 'textarea',
+                    'rows': 6,
+                    'required': false
+                },
+            },
+        }
+    },
+
+    getCartContent: function () {
+        if (this.getItemCount() === 0) {
+            return `<div class="cart-empty">${ this.fluidbook.l10n.__('your cart is empty') }</div>`;
+        }
+
+        let $this = this;
+        let columns = this.getColumns();
+
+        let content =
+          `<div class="inner-content">
+             <form>
+             <table id="cfoc-cart-table" class="cart-items">
+               <thead style="text-transform:uppercase">
+                 <tr>
+                   ${Object.entries(columns)
+                     .map(heading => `<th data-name="${heading[0]}">${heading[1]}</th>`)
+                     .join('')
+                   }
+                 </tr>
+               </thead>
+               <tbody>`;
+
+        $.each(this.getItems(), function (index, item) {
+
+            content += '<tr>';
+
+            $.each(columns, function (key, title) {
+                // Each Reference can have multiple variations, stored as nested objects.
+                // Since only the colour is different between the variations and this is manually selected by the user,
+                // we can base our data off the first nested object, which should exist even when there are no variations
+                // Object.values() returns array, so we can get the first item without knowing the key
+                let data = Object.values($this.data[item.reference])[0];
+                let value = data[key] || '&mdash;'; // Fallback for missing values
+                let output = '';
+
+                switch(key) {
+                    case 'REF':
+                        output  = value;
+                        output += data['EXCLU'] ? '*' : ''; // Mark exclusive products with an asterisk
+                        break;
+                    case 'COULEUR':
+                        var colours = $this.data[item.reference]; // Colour variations are stored as nested objects, grouped by reference
+                        // When there are multiple colour variations, display a dropdown:
+                        if (Object.keys(colours).length > 1) {
+
+                            let css_class = (item.EAN === '') ? 'alert' : '';
+
+                            output  = `<select data-item-EAN data-item-index="${index}" class="${css_class}" required>
+                                         <option value="">${ $this.fluidbook.l10n.__('Choisir une couleur') }</option>`;
+                            $.each(colours, function(EAN, product) {
+                                let selected = (item.EAN === EAN) ? 'selected' : '';
+                                output += `<option value="${EAN}" ${selected}>${product['COULEUR']}</option>`;
+                            });
+                            output += '</select>';
+
+                        } else {
+                            output = value;
+                        }
+                        break;
+                    case 'QTE':
+                        let min_quantity = data['QTE MINI'] || 1;
+
+                        output  = `<div class="quantity-controls">`;
+
+                        output += `<button onclick="this.parentNode.querySelector('input[type=number]').stepDown();
+                                                    this.parentNode.querySelector('input[type=number]').dispatchEvent(new Event('input', { bubbles: true }));
+                                                    return false;"
+                                           class="quantity-button">
+                                   <svg viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="M64 224h384v64H64z"/></svg>        
+                                   </button>`;
+
+                        output += `<input data-item-quantity type="number" min="${min_quantity}" value="${item.quantity}" step="${min_quantity}" data-item-index="${index}" required>`
+
+                        output += `<button onclick="this.parentNode.querySelector('input[type=number]').stepUp();
+                                                    this.parentNode.querySelector('input[type=number]').dispatchEvent(new Event('input', { bubbles: true }));
+                                                    return false;"
+                                           class="quantity-button">
+                                    <svg viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="M448 224H288V64h-64v160H64v64h160v160h64V288h160z"/></svg>
+                                    </button>`;
+
+                        output += `</div>`; // .quantity-controls
+
+                        break;
+                    case 'PRIX HT':
+                        // Calculate the hors tax value from the TTC value
+                        let prix_HT = $this.calculateHT(data['PRIX TTC']);
+                        let total_HT = item.quantity * prix_HT;
+                        output = $this.formatPrice(total_HT);
+                        break;
+                    case 'PRIX TTC':
+                        let total_TTC = item.quantity * value;
+                        output = $this.formatPrice(total_TTC);
+                        break;
+                    case 'DELETE':
+                        output = `<a href="#" data-cart-delete="${index}" title="Supprimer">
+                                    ${getSpriteIcon('interface-close')}
+                                 </a>`;
+                        break;
+                    default:
+                        output = value;
+                }
+
+                // The data-name is used as a hook for CSS styles
+                // The data-label is displayed as the inline label in small screen views
+                content += `<td data-name="${key}" data-label="${title}">${output}</td>`;
+            });
+
+            content += '</tr>';
+        });
+
+        content += '</tbody>';
+
+        // Cart summary
+        // How many columns do we need to span before getting to the final 3 (Prix HT, Prix TTC, [X])
+        let column_count = Object.keys(this.getColumns()).length;
+        let columns_to_span = column_count - 3;
+
+        content += '<tfoot class="cart-footer">';
+
+        $.each(this.getCartSummary(), function (label, value) {
+            content += `<tr>
+                          <td colspan="${columns_to_span}">&nbsp;<!-- span detail columns --></td>
+                          <td class="cart-summary-label">${label} :</td> 
+                          <td>${$this.formatPrice(value)}</td>
+                          <td>&nbsp;<!-- empty last column --></td> 
+                       </tr>`;
+        });
+
+        content += `<tr>
+                      <td colspan="${column_count - 3}">&nbsp;</td>
+                      <td colspan="2" class="notice">
+                        <div class="exclusivity-notice">*Exclusivité boutique</div>
+                        Frais de port en sus.
+                      </td>
+                      <td>&nbsp;</td>
+                    </tr>`;
+
+        content += '</tfoot></table>';
+
+        content += `<div class="fonctions">
+                      <a href="#" class="validate-cart" data-validate-cart>
+                        Valider ma Sélection
+                      </a>
+                    </div>`;
+
+        content += '</form>';
+        content += '</div><!-- .inner-content -->';
+
+        return content;
+    },
+
+    getCartSummary: function() {
+        // Calculate totals
+        let total_TTC = this.cartTotalTTC();
+        let total_HT = this.calculateHT(total_TTC);
+        let total_tax = total_TTC - total_HT;
+
+        return {
+            'Total HT': total_HT,
+            [`TVA ${this.TVA_percentage}%`]: total_tax,
+            'Total TTC': total_TTC
+        }
+    },
+
+    getCartUserDetailsContent: function() {
+
+        let forms = '';
+
+        $.each(this.getForms(), function(name, fields) {
+            forms += `<form class="hidden" data-name="${name}">`;
+
+            $.each(fields, function(field_name, field_details) {
+                let required = field_details.required ? 'required' : '';
+                let label = field_details.required ? `${field_details.label}*` : field_details.label;
+                let field_ID = `${name}_${field_name}`;
+
+                forms += `<label for="${field_ID}">${label}</label>`;
+
+                switch (field_details.type) {
+                    case 'textarea':
+                        forms += `<textarea id="${field_ID}" name="${field_name}" rows="${field_details.rows || 3}" ${required}></textarea>`
+                        break;
+                    default:
+                        forms += `<input id="${field_name}" name="${field_name}" type="${field_details.type}" ${required}>`;
+                }
+            });
+
+            forms += `</form>`;
+        });
+
+        let content =
+          `<div class="inner-content" id="CFOC_user_details">
+             <div class="details-columns">
+               <div class="details-left">
+                 <select name="profile" class="alert" required>
+                   <option value="">Mon Profil*</option>
+                   <option data-form="business">Architectes / Décorateurs</option>
+                   <option data-form="reseller">Revendeurs</option>
+                   <option data-form="business">Autre</option>
+                 </select>
+              </div>
+              <div class="details-right">
+                  ${forms}
+              </div>
+            </div><!-- .details-columns -->
+            <div class="details-footer fonctions">
+              <div class="required-fields-notice">*Champs obligatoires</div>
+              <a href="#/cart" class="back-to-cart">
+                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5.06 8.71" xml:space="preserve"><path fill="none" stroke="#000" stroke-miterlimit="10" d="m4.71 8.35-4-4 4-4"/></svg>
+                Retourner à ma Sélection
+              </a>
+              <a href="#" class="send-cart" data-send-cart>Envoyer ma commande</a>
+            </div><!-- .details-footer -->
+            <div class="server-response" style="display:none"><!-- placeholder for AJAX response --></div>
+          </div><!-- .inner-content -->
+          `;
+
+        return content;
+    },
+
+    // Calculate the Hors Tax amount based on the TTC amount
+    calculateHT: function(TTC_value) {
+        return parseFloat(TTC_value) * 100 / (100 + this.TVA_percentage);
+    },
+
+    cartTotalTTC: function() {
+        let $this = this;
+
+        // Sum all the raw totals in the cart
+        // Ref: https://stackoverflow.com/a/63211701
+        // return this.getItems().reduce((sum, item) => sum + (parseFloat(Object.values($this.data[item.reference])[item.EAN]['PRIX TTC']) * item.quantity), 0);
+        // Note: can't rely on EAN being present if the user hasn't selected a colour variation yet...
+        // That's why we need to use Object.values to get the first entry - the price doesn't change with colour
+        return this.getItems().reduce((sum, item) => sum + (parseFloat(Object.values($this.data[item.reference])[0]['PRIX TTC']) * item.quantity), 0);
+
+        // let total = 0;
+        //
+        // this.getItems().forEach(function(item) {
+        //     console.log('adding item', item, $this.data[item.reference][item.EAN]['PRIX TTC'])
+        //     total += parseFloat(Object.values($this.data[item.reference])[0]['PRIX TTC']) * item.quantity;
+        // })
+        //
+        // return total;
+    },
+
+    formatPrice: function (price) {
+
+        if (typeof price !== 'number') {
+            price = this.parseFloat(price);
+        }
+
+        return price.toLocaleString("fr-FR", {
+            style: "currency",
+            currency: "EUR",
+            minimumFractionDigits: 2,
+            maximumFractionDigits: 2
+        });
+    },
+
+    getMenuWidth: function () {
+        return 1380;
+    },
+
+    parseFloat: function (s) {
+        if (typeof s === 'number') {
+            return s;
+        }
+        if (s === undefined || s === null || s === '') {
+            return 0;
+        }
+        s = s.replace(/\s/g, '');
+        return parseFloat(s);
+    },
+
+    parseInt: function (s) {
+        if (typeof s === 'number') {
+            return Math.round(s);
+        }
+        if (s === undefined || s === null || s === '') {
+            return 0;
+        }
+        s = s.replace(/\s/g, '');
+        return parseInt(s);
+    },
+};
index ec8d29ed48652cc2102dc993ae9969b1bb77b5b5..f80b457f92c6c502586dc7596a6634890d1c1693 100644 (file)
@@ -171,6 +171,8 @@ FluidbookCart.prototype = {
                 return new FluidbookCartGrandVision(this);
             case 'Thiriet':
                 return new FluidbookCartThiriet(this);
+            case 'CFOC':
+                return new FluidbookCartCFOC(this);
             default:
                 return null;
         }
@@ -272,4 +274,4 @@ FluidbookCart.prototype = {
             $(this).data('inputNumber', new JQinputNumber($this));
         });
     };
-})(jQuery);
\ No newline at end of file
+})(jQuery);
diff --git a/style/cart/cfoc.less b/style/cart/cfoc.less
new file mode 100644 (file)
index 0000000..f5bdad3
--- /dev/null
@@ -0,0 +1,480 @@
+@breakpoint_table: ~"(max-width: 800px)";
+
+// Re-use fonts from main site. Will fallback to defaults if this fails for any reason
+// Light
+@font-face {
+  font-family: 'Euclid Square';
+  src: local('Euclid Square Light'),local('Euclid-Square-Light'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Light.woff2) format('woff2'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Light.woff) format('woff'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Light.ttf) format('truetype');
+  font-weight: 300;
+  font-style: normal;
+  font-display: swap
+}
+
+// Medium
+@font-face {
+  font-family: 'Euclid Square';
+  src: local('Euclid Square Medium'),local('Euclid-Square-Medium'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Medium.woff2) format('woff2'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Medium.woff) format('woff'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/EuclidSquare-Medium.ttf) format('truetype');
+  font-weight: 500;
+  font-style: normal;
+  font-display: swap
+}
+
+// Light italic
+@font-face {
+  font-family: 'Euclid Square';
+  src: url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-lightitalic-webfont.woff2) format('woff2'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-lightitalic-webfont.woff) format('woff'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-lightitalic-webfont.otf) format('otf'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-lightitalic-webfont.ttf) format('truetype');
+  font-weight: 300;
+  font-style: italic;
+  font-display: swap
+}
+
+// Regular
+@font-face {
+  font-family: 'Euclid Square';
+  src: url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-regular-webfont.woff2) format('woff2'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-regular-webfont.woff) format('woff'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-regular-webfont.otf) format('otf'),url(https://www.cfoc.fr/themes/cfoc/assets/css/fonts/euclidsquare-regular-webfont.ttf) format('truetype');
+  font-weight: 400;
+  font-style: normal;
+  font-display: swap
+}
+
+// Nav override
+.icon-cart span.number {
+  top: 1em;
+  right: 1em;
+}
+
+// Cart modals
+#CFOC_cart {
+  background-color: #fff;
+  height: 100%;
+  min-height: 35vh;
+  min-width: 320px;
+  font-family: 'Euclid Square', sans-serif;
+
+  @media (max-width: 1379px) {
+    font-size: 15px;
+  }
+  @media (max-width: 1230px) {
+    font-size: 14px;
+  }
+  @media (max-width: 1150px) {
+    font-size: 13px;
+  }
+  @media (max-width: 1060px) {
+    font-size: 12px;
+  }
+  @media @breakpoint_table {
+    font-size: 1rem; // Once we're in the stacked view, the font can be a normal size again
+  }
+  @media (max-width: 450px) {
+    font-size: 14px;
+  }
+
+  .caption {
+    padding: 30px;
+    height: auto;
+  }
+
+  #mview-dialog-title {
+    font-size: 20px;
+    font-weight: 500;
+    text-transform: uppercase;
+  }
+
+  > .content .inner-content {
+    width: 96%;
+    margin: 0 auto;
+    position: relative;
+
+    @media @breakpoint_table {
+      width: 90%;
+    }
+  }
+
+  // Main table
+  table.cart-items {
+    width: 100%;
+    max-width: none;
+    margin: 0;
+    border-collapse: collapse;
+
+    thead {
+      background-color: @menu-background;
+
+      @media @breakpoint_table {
+        display: none;
+      }
+    }
+
+    th {
+      font-weight: normal;
+      text-align: left;
+    }
+
+    th, td {
+      padding: 1.5em 1.75em;
+
+      @media (max-width: 1060px) {
+        padding: 1em 0.5em;
+      }
+    }
+
+    tbody tr {
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.05);
+      }
+
+      @media @breakpoint_table {
+        display: block;
+        padding-top: 1em;
+        border-top: 1px solid;
+      }
+    }
+
+    // Collapse table cells and display header labels before items
+    tbody td {
+      @media @breakpoint_table {
+        display: flex;
+        align-items: center;
+        text-align: left;
+        padding-top: 0;
+
+        &:before {
+          content: attr(data-label) ' : ';
+          font-weight: 500;
+          flex: 0 0 6.5em; // Set width of "label" column in stacked mode
+          text-align: right;
+          padding-right: 10px;
+          line-height: 1;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+
+  // Modal close button
+  .caption .back {
+    background-color: transparent;
+  }
+
+  [data-name="DESIGNATION"] {
+    white-space: normal; // Allow descriptions to wrap
+  }
+
+  [data-name="QTE MINI"], [data-name="QTE"] {
+    text-align: center !important;
+  }
+
+  [data-name="QTE"] {
+    user-select: none; // Stop text being selected when clicking quickly on -/+ buttons
+  }
+
+  .quantity-controls {
+    display: flex;
+    align-items: center;
+  }
+
+  .quantity-button {
+    -webkit-appearance: none;
+    appearance: none;
+    border: none;
+    background: transparent;
+    padding: 0.25em 0.5em;
+    cursor: pointer;
+    height: 16px; // For better vertical alignment with input
+    box-sizing: content-box;
+
+    svg {
+      height: 100%;
+      width: auto;
+    }
+  }
+
+  [data-name="PRIX HT"], [data-name="PRIX TTC"] {
+    text-align: right !important;
+  }
+
+  // Cart item delete buttons
+  [data-name="DELETE"] {
+
+    a {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      background: #000;
+      width: 2em;
+      height: 2em;
+    }
+
+    svg {
+      width: 0.9em;
+      fill: #fff;
+    }
+
+    @media @breakpoint_table {
+      &:before {
+        content: '' !important; // No label in stacked mode for this item
+      }
+
+      a {
+        width: auto;
+        color: #fff;
+        padding: 0 1em;
+
+        &:before {
+          content: attr(title);
+          text-transform: uppercase;
+          margin-right: 1em;
+        }
+      }
+
+    }
+
+  }
+
+  input[type=email], input[type=number], input[type=text], select, textarea {
+    -webkit-appearance: none;
+    appearance: none;
+    border: 2px solid;
+    border-radius: 0;
+    padding: 0.25em 0.5em;
+    font-size: 1em;
+    width: 100%;
+  }
+  input, select, textarea {
+    background-color: #fff;
+    font-family: inherit;
+    outline: none;
+  }
+
+  select {
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13.4 8.1' xml:space='preserve'%3E%3Cpath d='m.7.7 6 6 6-6' fill='none' stroke='%231c1c1c' stroke-width='2'/%3E%3C/svg%3E");
+    background-position: calc(100% - 0.6em) 50%;
+    background-repeat: no-repeat;
+    background-size: 0.85em auto;
+    padding-right: 1.8em;
+
+    &.alert, &:invalid { // Warning for unset colour selection
+      color: red;
+      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13.4 8.1' xml:space='preserve'%3E%3Cpath d='m.7.7 6 6 6-6' fill='none' stroke='red' stroke-width='2'/%3E%3C/svg%3E");
+    }
+
+    @media @breakpoint_table {
+      width: max-content; // When switching to stacked view, select boxes should only be as wide as their content
+    }
+  }
+
+  // Quantity inputs - hide browser up/down arrows
+  input::-webkit-outer-spin-button,
+  input::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+  }
+
+  input[type=number] {
+    -moz-appearance: textfield;
+  }
+
+  input[data-item-quantity] {
+    width: 2.5em;
+    text-align: center;
+    padding: 0.25em 0.25em;
+  }
+
+  // Cart footer
+  tfoot.cart-footer {
+
+    // Extra spacing between table footer and table body
+    // Margins don't work in the middle of a table and we need to ensure the columns structure remains consistent
+    &:before {
+      content: '';
+      display: table-row;
+      height: 1em;
+    }
+
+    // Border + extra padding on the first row of cells
+    // Once again, this is more convoluted because we can't use margins in the table
+    tr:first-of-type td {
+      padding-top: 1.5em;
+      border-top: 2px solid;
+    }
+
+    td {
+      padding-top: 0.5em;
+      padding-bottom: 0.5em;
+      text-align: right;
+    }
+
+    .cart-summary-label {
+      font-weight: 500;
+    }
+
+    .notice {
+      font-weight: 300;
+    }
+
+    .exclusivity-notice {
+      position: absolute;
+      left: 0;
+      bottom: 2.75em;
+      font-weight: 300;
+
+      @media @breakpoint_table {
+        position: initial;
+        margin-bottom: 0.25em;
+      }
+    }
+
+    // When cart popup goes into "full screen" / cover mode
+    [data-menu="cart"].fs & {
+      top: unset;
+      position: unset; // This needs to be unset so .exclusivity-notice remains relative to .content-inner
+    }
+
+    // Responsive overrides for small screens
+    @media @breakpoint_table {
+      tr {
+        display: flex;
+      }
+
+      td {
+        // Middle columns that contain labels and totals
+        // The fixed size makes them centred, while other cells flex to take space on left and right
+        // Their size is set in em units so that it works regardless of the actual font size
+        flex: 0 0 7.5em; // The flex-basis might need to be adjusted if labels or values get any wider
+
+        &:first-child, &:last-child {
+          flex: 1;
+        }
+      }
+
+      .notice {
+        flex-basis: 15em; // To align, this should be 2x the flex-basis above because it spans 2 columns
+      }
+    }
+
+  }
+
+  .fonctions {
+    padding: 2em 0;
+
+    a {
+      background-color: #000;
+      color: #fff;
+      white-space: nowrap;
+
+      @media @breakpoint_table {
+        flex: 1; // Allows items to grow to maximise width
+      }
+    }
+  }
+
+  a.validate-cart {
+    min-width: 30%;
+  }
+
+  //==== User details modal
+
+  .details-columns {
+    display: flex;
+    gap: 10%;
+    text-align: left;
+
+    > :nth-child(1) { flex: 1 1 40%; }
+    > :nth-child(2) { flex: 1 1 60%; }
+
+    @media @breakpoint_table {
+      flex-direction: column;
+      gap: 2em;
+    }
+  }
+
+  select[name="profile"] {
+    max-width: 28em;
+    text-transform: uppercase;
+
+    @media @breakpoint_table {
+      width: 100%;
+      max-width: none;
+    }
+  }
+
+  #CFOC_user_details form {
+    &.hidden {
+      display: none; // Forms are hidden by default: displayed via profile selection
+    }
+
+    display: grid;
+    /* 1st column should be max-content but it can shrink if needed... */
+    /* 2nd column should take remaining width but not get smaller than 15em */
+    grid-template-columns: minmax(min-content, max-content) minmax(15em, 1fr);
+    text-align: right; /* Right align for labels */
+    grid-row-gap: 0.5em;
+    grid-column-gap: 1em;
+    
+    @media (max-width: 550px) {
+      grid-template-columns: 1fr;
+      text-align: left;
+    }
+
+    label {
+      text-transform: uppercase;
+      margin-top: 0.4em;
+      margin-bottom: -0.2em; // Only really applies when form is 1 column
+    }
+
+  }
+
+  .details-footer {
+    display: none; // Hidden until a profile is selected
+    position: relative;
+    align-items: center;
+    justify-content: flex-end;
+
+    .cart-form-visible & {
+      display: flex;
+    }
+
+    // Reorganise footer for better display and wrapping
+    @media @breakpoint_table {
+      gap: 1.5em;
+      flex-wrap: wrap;
+      flex-direction: row-reverse;
+      justify-content: flex-start;
+    }
+
+  }
+
+  .required-fields-notice {
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    font-weight: 300;
+    line-height: 1;
+
+    @media @breakpoint_table {
+      position: initial;
+      transform: none;
+      flex-basis: 100%;
+    }
+  }
+
+  .back-to-cart {
+    background-color: transparent !important;
+    color: #000 !important;
+    display: inline-flex;
+    align-items: center;
+    margin: 0;
+
+    @media @breakpoint_table {
+      order: 3; // Switch position (since we're in flex-direction: row-reverse)
+    }
+
+    svg {
+      height: 1em;
+      margin-right: 1.5em;
+    }
+  }
+
+}