From: Stephen Cameron Date: Tue, 12 Jul 2022 16:34:51 +0000 (+0200) Subject: WIP #5345 @8 X-Git-Url: http://git.cubedesigners.com/?a=commitdiff_plain;h=3bb3393dd4fff06f4047503c80c7de3c188efae6;p=fluidbook-html5.git WIP #5345 @8 --- diff --git a/js/libs/fluidbook/cart/fluidbook.cart.bastide.js b/js/libs/fluidbook/cart/fluidbook.cart.bastide.js new file mode 100644 index 00000000..8a7e25a6 --- /dev/null +++ b/js/libs/fluidbook/cart/fluidbook.cart.bastide.js @@ -0,0 +1,475 @@ +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 = `
+ ${this.fluidbook.menu.getCaption(title)} +
+ ${content} +
+
`; + 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 `
${ this.fluidbook.l10n.__('your cart is empty') }
`; + } + + let $this = this; + let columns = this.getColumns(); + + let content = + `
+
+ + + + ${Object.entries(columns) + .map(heading => ``) + .join('') + } + + + `; + + $.each(this.getItems(), function (index, item) { + + content += ''; + + $.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 = `
`; + + output += ``; + + output += `` + + output += ``; + + output += `
`; // .quantity-controls + + break; + case 'DELETE': + output = ` + ${getSpriteIcon('interface-close')} + `; + 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 += ``; + }); + + content += ''; + }); + + content += ''; + content += '
${heading[1]}
${output}
'; + + content += ``; + + content += '
'; + content += '
'; + + return content; + }, + + getCartUserDetailsContent: function() { + + let forms = ''; + + $.each(this.getForms(), function(name, fields) { + forms += `
`; + + $.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 += ``; + + switch (field_details.type) { + case 'textarea': + forms += `` + break; + default: + forms += ``; + } + }); + + forms += `
`; + }); + + let content = + `
+
+ ${forms} +
*Champs obligatoires
+
+ + +
+ `; + + return content; + }, + + getMenuWidth: function () { + return window.location.hash === '#/cart/validate' ? 880 : 1060; // Validate modal is narrower than main cart + }, +}; diff --git a/js/libs/fluidbook/fluidbook.cart.js b/js/libs/fluidbook/fluidbook.cart.js index f80b457f..b3b3f834 100644 --- a/js/libs/fluidbook/fluidbook.cart.js +++ b/js/libs/fluidbook/fluidbook.cart.js @@ -173,6 +173,8 @@ FluidbookCart.prototype = { return new FluidbookCartThiriet(this); case 'CFOC': return new FluidbookCartCFOC(this); + case 'Bastide': + return new FluidbookCartBastide(this); default: return null; } diff --git a/style/cart/bastide.less b/style/cart/bastide.less new file mode 100644 index 00000000..ead06a4a --- /dev/null +++ b/style/cart/bastide.less @@ -0,0 +1,399 @@ +@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; + } + } + +} diff --git a/style/fluidbook.less b/style/fluidbook.less index 82825c2c..54c36b32 100644 --- a/style/fluidbook.less +++ b/style/fluidbook.less @@ -1103,7 +1103,7 @@ footer { color: @menu-background; background-color: @menu-text; display: inline-block; - position: relative; + position: static; font-size: 0.8em; width: 1.6em; height: 1.6em; @@ -3133,4 +3133,4 @@ body > input { @import "posad.less"; @import "notes.less"; @import "cart.less"; -@import "audioplayer.less"; \ No newline at end of file +@import "audioplayer.less";