--- /dev/null
+function FluidbookCartBastide(cart) {
+ this.cart = cart;
+ this.fluidbook = this.cart.fluidbook;
+ this.data = this.fluidbook.settings.basketReferences;
+ this.form_endpoint = 'https://workshop.fluidbook.com/services/bastide'; // Where cart form is processed
+ this.init();
+}
+
+FluidbookCartBastide.prototype = {
+ init: function () {
+ let $this = this;
+ this.items = this.fluidbook.cache.get('cart', []);
+
+ // Save changes in quantity field
+ $(document).on('input', '#Bastide_cart [data-item-quantity]', function () {
+ let item_index = $(this).data('item-index');
+ let quantity = $(this).val();
+
+ if ($this.items[item_index]) {
+ $this.items[item_index].quantity = quantity;
+ $this.save();
+ $this.updateCart();
+ } else {
+ console.warn(`Unable to update quantity for cart item ${item_index}`);
+ }
+
+ return true;
+ });
+
+ // Handle "Valider ma sélection" button in cart
+ $(document).on(this.fluidbook.input.clickEvent, '#Bastide_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, '#Bastide_user_details [data-send-cart]', function(event) {
+ event.preventDefault();
+
+ let form = $('#Bastide_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
+
+ 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 per Fluidbook
+
+ // Now add the user details to the main form 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()));
+
+ // It's also possible that the Fluidbook will include a querystring parameter (?a=xxxxx) to determine who
+ // the e-mail should be sent to. This is needs to be passed to the endpoint for handling
+ let querystring = (new URL(document.location)).searchParams;
+ data.append('recipient_code', querystring.get('a')); // If the querystring isn't set, it will return null
+
+ // 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 = $('#Bastide_user_details');
+ // Hide the form and footer content, then show the message from the endpoint
+ container.find('.details-form, .details-footer').hide();
+ container.find('.server-response').text(data.message).show();
+ }
+ });
+ }
+
+ event.preventDefault();
+ });
+
+ },
+
+ // emptyCart: function () {
+ // this.items = [];
+ // this.updateCart();
+ // this.save();
+ // resize();
+ // },
+
+ getCartItemIndex: function(reference) {
+ let cartItems = this.getItems();
+ for (let i = 0; i < cartItems.length; i++) {
+ if (cartItems[i].reference === reference) {
+ return i;
+ }
+ }
+
+ return -1;
+ },
+
+ addToCart: function (reference) {
+ let existingIndex = this.getCartItemIndex(reference);
+
+ // If this item has already been added to the cart, increment the quantity instead of adding a new item
+ if (existingIndex >= 0) {
+ this.items[existingIndex].quantity += 1;
+ } else {
+ let item = {};
+ item.reference = reference;
+ item.quantity = 1; // default initial quantity
+
+ // 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();
+ 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 data = $this.data[cart_reference]; // Source data matched from spreadsheet
+
+ $.each($this.getColumnsForXLS(), function (key, title) {
+
+ switch(key) {
+ case 'QUANTITY':
+ item[key] = cart_item['quantity'];
+ break;
+ default:
+ item[key] = data[key];
+ }
+ });
+
+ items.push(item);
+ });
+
+ return items;
+ },
+
+ updateCart: function () {
+ if ($('#Bastide_cart').length > 0) {
+ $('#Bastide_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) {
+ let view = `<div id="Bastide_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 {
+ 'ARTICLE CODE': 'Réf',
+ 'ARTICLE': 'Article',
+ 'CONDITIONNEMENT VENTE': 'Conditionnement',
+ 'QUANTITY': 'Quantité', // Special case: not part of the data
+ 'DELETE': '', // No column label for delete buttons
+ };
+ },
+
+ getColumnsForXLS: function () {
+ // The columns should be the same as for the cart except we skip "DELETE"
+ let columns = {};
+
+ $.each(this.getColumns(), function(key, title) {
+
+ if (key === 'DELETE') return;
+
+ columns[key] = title;
+ });
+
+ return columns;
+ },
+
+ // This cart was based off CFOC's, so we're keeping the structure where it's possible to have multiple forms
+ getForms: function () {
+ return {
+ 'default': {
+ 'name': {
+ 'label': "Nom et 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': false
+ },
+ '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="bastide-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) {
+
+ let data = $this.data[item.reference];
+ let value = data[key] || '—'; // Fallback for missing values
+ let output = '';
+
+ switch(key) {
+
+ case 'QUANTITY':
+ let min_quantity = 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 '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>';
+ content += '</table>';
+
+ content += `<div class="fonctions">
+ <a href="#/closeview" class="close-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>
+ Compléter ma sélection
+ </a>
+ <a href="#" class="validate-cart" data-validate-cart>
+ Valider ma Sélection
+ </a>
+ </div>`;
+
+ content += '</form>';
+ content += '</div><!-- .inner-content -->';
+
+ return content;
+ },
+
+ getCartUserDetailsContent: function() {
+
+ let forms = '';
+
+ $.each(this.getForms(), function(name, fields) {
+ forms += `<form 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="Bastide_user_details">
+ <div class="details-form">
+ ${forms}
+ <div class="required-fields-notice">*Champs obligatoires</div>
+ </div><!-- .details-columns -->
+ <div class="details-footer fonctions">
+ <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;
+ },
+
+ getMenuWidth: function () {
+ return window.location.hash === '#/cart/validate' ? 880 : 1060; // Validate modal is narrower than main cart
+ },
+};
--- /dev/null
+@breakpoint_table: ~"(max-width: 700px)";
+
+@import url('https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;700&display=swap');
+
+// Nav override
+.icon-cart {
+ // Display the tooltip label before the cart icon
+ &:before {
+ content: attr(data-tooltip);
+ display: inline-block;
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: bold;
+ font-family: 'Roboto Condensed', sans-serif;
+ letter-spacing: 1px;
+ margin-left: 17px;
+ margin-right: -14px;
+ vertical-align: text-bottom;
+ }
+
+ span.number {
+ background-color: #E41C38;
+ top: 1em;
+ right: 1em;
+ }
+}
+
+// Cart modals
+#Bastide_cart {
+ background-color: #fff;
+ color: #000;
+ height: 100%;
+ min-height: 35vh;
+ min-width: 320px;
+ font-family: 'Roboto Condensed', sans-serif;
+
+ @media (max-width: 1060px) {
+ font-size: 15px;
+ }
+ @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 20px;
+ height: auto;
+ }
+
+ #mview-dialog-title {
+ font-size: 16px;
+ font-weight: bold;
+ text-transform: uppercase;
+ }
+
+ > .content .inner-content {
+ width: 94%;
+ 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;
+ font-size: 0.75em;
+
+ thead {
+ background-color: @menu-background;
+
+ @media @breakpoint_table {
+ display: none;
+ }
+ }
+
+ th {
+ color: #fff;
+ font-weight: bold;
+ 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 {
+ white-space: normal;
+
+ @media @breakpoint_table {
+ display: flex;
+ align-items: center;
+ text-align: left;
+ padding-top: 0;
+
+ &:before {
+ content: attr(data-label) ' : ';
+ font-weight: bold;
+ flex: 0 0 7.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="QUANTITY"] {
+ 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;
+ }
+ }
+
+ // Cart item delete buttons
+ [data-name="DELETE"] {
+
+ a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #E41C38;
+ 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;
+ }
+
+ // 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;
+ }
+ }
+ }
+
+ }
+
+ .fonctions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 2em 0;
+
+ // Reorganise footer for better display and wrapping
+ @media @breakpoint_table {
+ gap: 1.5em;
+ flex-wrap: wrap;
+ flex-direction: row-reverse;
+ justify-content: flex-start;
+ }
+
+ a {
+ background-color: @menu-background;
+ color: #fff;
+ white-space: nowrap;
+
+ @media @breakpoint_table {
+ flex: 1; // Allows items to grow to maximise width
+ }
+ }
+ }
+
+ .validate-cart, .send-cart {
+ min-width: 30%;
+ font-weight: bold;
+
+ @media @breakpoint_table {
+ min-width: unset; // Without resetting this, the flex items weren't wrapping
+ }
+ }
+
+ //==== User details modal
+
+ #Bastide_user_details {
+ width: 90% !important;
+
+ .details-form {
+ font-size: 0.75em;
+ }
+
+ form {
+ max-width: 680px;
+ margin-left: auto;
+
+ 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
+ }
+
+ }
+ }
+
+ .required-fields-notice {
+ margin-top: 1em;
+ text-align: right;
+
+ @media @breakpoint_table {
+ position: initial;
+ transform: none;
+ flex-basis: 100%;
+ }
+ }
+
+ .back-to-cart, .close-cart {
+ background-color: transparent !important;
+ color: #000 !important;
+ display: inline-flex;
+ font-size: 0.75em;
+ align-items: center;
+ margin: 0;
+ padding-left: 0;
+
+ @media @breakpoint_table {
+ order: 3; // Switch position (since we're in flex-direction: row-reverse)
+ }
+
+ svg {
+ height: 1em;
+ margin-right: 1.5em;
+ }
+ }
+
+}