diff --git a/.eslintrc.json b/.eslintrc.json index 38a6e0a..490cb26 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,7 @@ "Square": "readonly", "Utils": "readonly", "Constants": "readonly", + "SquareTranslations": "readonly", "SquareWebSDK": "readonly" } } diff --git a/theme/assets/css/components/fulfillment-and-scheduling/choose-location.css b/theme/assets/css/components/fulfillment-and-scheduling/choose-location.css new file mode 100644 index 0000000..96e8e05 --- /dev/null +++ b/theme/assets/css/components/fulfillment-and-scheduling/choose-location.css @@ -0,0 +1,5 @@ +.choose-location__container { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/theme/assets/css/components/fulfillment-and-scheduling/scheduling-dialog.css b/theme/assets/css/components/fulfillment-and-scheduling/scheduling-dialog.css new file mode 100644 index 0000000..3e147ee --- /dev/null +++ b/theme/assets/css/components/fulfillment-and-scheduling/scheduling-dialog.css @@ -0,0 +1,30 @@ +.scheduling-dialog__content { + display: flex; + flex-direction: column; + flex-shrink: 1; + gap: var(--space-x2); + height: 100%; +} + +.scheduling-dialog__options { + position: relative; + height: 100%; + + /* adding negative margin so the row hover style doesn't cutoff and the scroll content will extend to the dialog edge */ + margin: calc(var(--space-x2) * -1) calc((var(--space-x2) + var(--browser-scrollbar-width)) * -1) calc(var(--space-x4) * -1) calc(var(--space-x2) * -1); + overflow: hidden; +} + +.scheduling-dialog__options--scroll { + height: 100%; + padding: var(--space-x2); +} + +.scheduling-dialog__options .form-radio__input { + align-self: start; + margin-top: 2px; +} + +.scheduling-dialog__content .form-choice__label { + flex-direction: column; +} diff --git a/theme/assets/css/components/fulfillment-and-scheduling/scheduling.css b/theme/assets/css/components/fulfillment-and-scheduling/scheduling.css new file mode 100644 index 0000000..19985e2 --- /dev/null +++ b/theme/assets/css/components/fulfillment-and-scheduling/scheduling.css @@ -0,0 +1,9 @@ +.scheduling__container { + display: flex; + align-items: center; + justify-content: space-between; +} + +.scheduling__button { + margin-left: auto; +} diff --git a/theme/assets/css/components/fulfillment-and-scheduling/site-wide-fulfillment.css b/theme/assets/css/components/fulfillment-and-scheduling/site-wide-fulfillment.css new file mode 100644 index 0000000..44dbf88 --- /dev/null +++ b/theme/assets/css/components/fulfillment-and-scheduling/site-wide-fulfillment.css @@ -0,0 +1,26 @@ +.site-wide-fulfillment__container { + display: flex; + flex-direction: column; + gap: var(--space-x3); +} + +.scheduling-date-dialog__carousel { + width: calc(var(--theme-dialog-container-max-width-large) - var(--space-x8)); + max-width: calc(100vw - var(--space-x8)); + margin-bottom: var(--space-x2); +} + +.fulfillment-select-container { + display: flex; + gap: var(--space); + width: 100%; +} + +.site-wide-fulfillment__buttons { + display: flex; + flex-direction: column; + gap: var(--space-x2); + padding: var(--space-x2) var(--space-x3); + border: 1px solid var(--theme-border-color); + border-radius: var(--theme-border-radius); +} diff --git a/theme/assets/css/components/store/item/locations-dialog.css b/theme/assets/css/components/store/item/locations-dialog.css index 830dfec..878a87f 100644 --- a/theme/assets/css/components/store/item/locations-dialog.css +++ b/theme/assets/css/components/store/item/locations-dialog.css @@ -11,6 +11,10 @@ margin-bottom: var(--space-x3); } +.locations-dialog__input--second-line.form-text__input { + margin-top: 0; +} + .locations-dialog__content--empty .locations-dialog__input { margin-bottom: 0; } diff --git a/theme/assets/css/components/store/order/.gitkeep b/theme/assets/css/components/store/order/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/theme/assets/css/components/store/order/item-list.css b/theme/assets/css/components/store/order/item-list.css new file mode 100644 index 0000000..9936fd1 --- /dev/null +++ b/theme/assets/css/components/store/order/item-list.css @@ -0,0 +1,5 @@ +.order-item-list { + display: flex; + flex-flow: column; + gap: var(--space-x5); +} diff --git a/theme/assets/css/form/choice.css b/theme/assets/css/form/choice.css index 66fd678..2ac6d4b 100644 --- a/theme/assets/css/form/choice.css +++ b/theme/assets/css/form/choice.css @@ -37,6 +37,11 @@ width: 100%; } +.form-choice__overhead-label-text { + padding-top: var(--space-half); + font-size: calc(var(--theme-font-size-minus-2) - 2px); +} + .form-choice__input { position: absolute; left: 0; diff --git a/theme/assets/css/form/radio.css b/theme/assets/css/form/radio.css index fe42f24..b6fcf2a 100644 --- a/theme/assets/css/form/radio.css +++ b/theme/assets/css/form/radio.css @@ -22,12 +22,21 @@ transition: border-color 0.1s ease; } +.form-radio--row-divider .form-radio__option { + padding-bottom: var(--space-x2); +} + .form-radio--row .form-radio__option { align-items: center; } -.form-radio--row-divider .form-radio__option { - padding-bottom: var(--space-x2); +.form-radio--row .form-radio__options-collapse { + margin-right: calc(var(--space-x1_5) * -1); + margin-left: calc(var(--space-x1_5) * -1); +} + +.form-radio--row .form-radio__options-collapse .form-radio__option { + margin: var(--space-half) var(--space-x1_5); } .form-radio--normal-divider .form-radio__option:last-of-type { diff --git a/theme/assets/css/templates/store/order.css b/theme/assets/css/templates/store/order.css new file mode 100644 index 0000000..1e22720 --- /dev/null +++ b/theme/assets/css/templates/store/order.css @@ -0,0 +1,9 @@ +.order-page { + display: flex; + flex-flow: column; + gap: var(--space-x5); +} + +.order-page__item-loader { + max-height: 100vh; +} diff --git a/theme/assets/css/ui/button.css b/theme/assets/css/ui/button.css index 053bd15..f2c2b83 100644 --- a/theme/assets/css/ui/button.css +++ b/theme/assets/css/ui/button.css @@ -135,12 +135,9 @@ box-shadow: var(--theme-button-primary-shadow); } -.ui-button--primary-fill:hover, -.ui-button[variant="primary"][style="fill"]:hover { - color: var(--theme-button-primary-filled-text-hover); - background-color: var(--theme-button-primary-filled-bg-hover); - border-color: var(--theme-button-primary-outline-hover); - box-shadow: var(--theme-button-primary-shadow-hover); +.ui-button--primary-fill-destructive { + color: var(--theme-button-primary-destructive-text); + background-color: var(--theme-button-primary-destructive-bg); } .ui-button--primary-fill:focus, @@ -151,25 +148,28 @@ box-shadow: var(--theme-button-primary-shadow-hover); } -.ui-button--primary-fill-destructive { - color: var(--theme-button-primary-destructive-text); - background-color: var(--theme-button-primary-destructive-bg); +.ui-button--primary-fill:disabled, +.ui-button[variant="primary"][style="fill"]:disabled, +.ui-button--primary-fill-destructive:disabled { + opacity: 0.5; } -.ui-button--primary-fill-destructive:hover { - background-color: var(--theme-button-primary-destructive-bg-hover); +.ui-button--primary-fill-destructive:focus { + background-color: var(--theme-button-primary-destructive-bg-active); box-shadow: var(--theme-button-primary-shadow-hover); } -.ui-button--primary-fill-destructive:focus { - background-color: var(--theme-button-primary-destructive-bg-active); +.ui-button--primary-fill:not(.ui-button--disable-hover):hover, +.ui-button[variant="primary"][style="fill"]:not(.ui-button--disable-hover):hover { + color: var(--theme-button-primary-filled-text-hover); + background-color: var(--theme-button-primary-filled-bg-hover); + border-color: var(--theme-button-primary-outline-hover); box-shadow: var(--theme-button-primary-shadow-hover); } -.ui-button--primary-fill:disabled, -.ui-button[variant="primary"][style="fill"]:disabled, -.ui-button--primary-fill-destructive:disabled { - opacity: 0.5; +.ui-button--primary-fill-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-primary-destructive-bg-hover); + box-shadow: var(--theme-button-primary-shadow-hover); } /* Primary outline */ @@ -188,19 +188,6 @@ border-color: var(--theme-button-primary-destructive-bg); } -.ui-button--primary-outline:hover, -.ui-button[variant="primary"][style="outline"]:hover, -.ui-button--primary-outline-destructive:hover { - background-color: var(--theme-button-primary-outline-bg-hover); - box-shadow: var(--theme-button-primary-shadow-hover); -} - -.ui-button--primary-outline:hover, -.ui-button[variant="primary"][style="outline"]:hover { - color: var(--theme-button-primary-outline-hover); - border-color: var(--theme-button-primary-outline-hover); -} - .ui-button--primary-outline:focus, .ui-button[variant="primary"][style="outline"]:focus, .ui-button--primary-outline-destructive:focus { @@ -220,6 +207,19 @@ opacity: 0.5; } +.ui-button--primary-outline:not(.ui-button--disable-hover):hover, +.ui-button[variant="primary"][style="outline"]:not(.ui-button--disable-hover):hover, +.ui-button--primary-outline-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-primary-outline-bg-hover); + box-shadow: var(--theme-button-primary-shadow-hover); +} + +.ui-button--primary-outline:not(.ui-button--disable-hover):hover, +.ui-button[variant="primary"][style="outline"]:not(.ui-button--disable-hover):hover { + color: var(--theme-button-primary-outline-hover); + border-color: var(--theme-button-primary-outline-hover); +} + /* Secondary fill */ .ui-button--secondary-fill, .ui-button[variant="secondary"][style="fill"], @@ -231,12 +231,10 @@ box-shadow: var(--theme-button-secondary-shadow); } -.ui-button--secondary-fill:hover, -.ui-button[variant="secondary"][style="fill"]:hover { - color: var(--theme-button-secondary-filled-text-hover); - background-color: var(--theme-button-secondary-filled-bg-hover); - border-color: var(--theme-button-secondary-outline-hover); - box-shadow: var(--theme-button-secondary-shadow-hover); +.ui-button--secondary-fill-destructive { + color: var(--theme-button-secondary-destructive-text); + background-color: var(--theme-button-secondary-destructive-bg); + border-color: var(--theme-button-secondary-destructive-outine); } .ui-button--secondary-fill:focus, @@ -247,16 +245,6 @@ box-shadow: var(--theme-button-secondary-shadow-hover); } -.ui-button--secondary-fill-destructive { - color: var(--theme-button-secondary-destructive-text); - background-color: var(--theme-button-secondary-destructive-bg); - border-color: var(--theme-button-secondary-destructive-outine); -} - -.ui-button--secondary-fill-destructive:hover { - background-color: var(--theme-button-secondary-destructive-bg-hover); -} - .ui-button--secondary-fill-destructive:focus { background-color: var(--theme-button-secondary-destructive-bg-active); } @@ -267,6 +255,18 @@ opacity: 0.5; } +.ui-button--secondary-fill:not(.ui-button--disable-hover):hover, +.ui-button[variant="secondary"][style="fill"]:not(.ui-button--disable-hover):hover { + color: var(--theme-button-secondary-filled-text-hover); + background-color: var(--theme-button-secondary-filled-bg-hover); + border-color: var(--theme-button-secondary-outline-hover); + box-shadow: var(--theme-button-secondary-shadow-hover); +} + +.ui-button--secondary-fill-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-secondary-destructive-bg-hover); +} + /* Secondary outline */ .ui-button--secondary-outline, .ui-button[variant="secondary"][style="outline"], @@ -284,17 +284,14 @@ border-color: var(--theme-button-secondary-destructive-outine); } -.ui-button--secondary-outline:hover, -.ui-button[variant="secondary"][style="outline"]:hover, -.ui-button--secondary-outline-destructive:hover { - background-color: var(--theme-button-secondary-outline-bg-hover); - box-shadow: var(--theme-button-secondary-shadow-hover); +.ui-button--secondary-outline-destructive:focus { + background-color: var(--theme-button-secondary-destructive-bg-active); } -.ui-button--secondary-outline:hover, -.ui-button[variant="secondary"][style="outline"]:hover { - color: var(--theme-button-secondary-outline-text-hover); - border-color: var(--theme-button-secondary-outline-hover); +.ui-button--secondary-outline:disabled, +.ui-button[variant="secondary"][style="outline"]:disabled, +.ui-button--secondary-outline-destructive:disabled { + opacity: 0.5; } .ui-button--secondary-outline:focus, @@ -310,18 +307,21 @@ border-color: var(--theme-button-secondary-outline-active); } -.ui-button--secondary-outline-destructive:hover { - background-color: var(--theme-button-secondary-destructive-bg-hover); +.ui-button--secondary-outline:not(.ui-button--disable-hover):hover, +.ui-button[variant="secondary"][style="outline"]:not(.ui-button--disable-hover):hover, +.ui-button--secondary-outline-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-secondary-outline-bg-hover); + box-shadow: var(--theme-button-secondary-shadow-hover); } -.ui-button--secondary-outline-destructive:focus { - background-color: var(--theme-button-secondary-destructive-bg-active); +.ui-button--secondary-outline:not(.ui-button--disable-hover):hover, +.ui-button[variant="secondary"][style="outline"]:not(.ui-button--disable-hover):hover { + color: var(--theme-button-secondary-outline-text-hover); + border-color: var(--theme-button-secondary-outline-hover); } -.ui-button--secondary-outline:disabled, -.ui-button[variant="secondary"][style="outline"]:disabled, -.ui-button--secondary-outline-destructive:disabled { - opacity: 0.5; +.ui-button--secondary-outline-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-secondary-destructive-bg-hover); } /* Neutral fill */ @@ -332,23 +332,14 @@ border-radius: var(--settings-button-primary-radius, var(--theme-border-radius-button)); } -.ui-button--neutral-fill:hover, -.ui-button[variant="neutral"][style="fill"]:hover { - background-color: var(--theme-button-neutral-fill-bg-hover); -} - -.ui-button--neutral-fill:focus, -.ui-button[variant="neutral"][style="fill"]:focus { - background-color: var(--theme-button-neutral-fill-bg-active); -} - .ui-button--neutral-fill-destructive { color: var(--theme-button-primary-destructive-text); background-color: var(--theme-button-primary-destructive-bg); } -.ui-button--neutral-fill-destructive:hover { - background-color: var(--theme-button-primary-destructive-bg-hover); +.ui-button--neutral-fill:focus, +.ui-button[variant="neutral"][style="fill"]:focus { + background-color: var(--theme-button-neutral-fill-bg-active); } .ui-button--neutral-fill-destructive:focus { @@ -361,6 +352,15 @@ opacity: 0.5; } +.ui-button--neutral-fill:not(.ui-button--disable-hover):hover, +.ui-button[variant="neutral"][style="fill"]:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-neutral-fill-bg-hover); +} + +.ui-button--neutral-fill-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-primary-destructive-bg-hover); +} + /* Neutral outline */ .ui-button--neutral-outline, .ui-button[variant="neutral"][style="outline"] { @@ -370,23 +370,14 @@ border-radius: var(--settings-button-secondary-radius, var(--theme-border-radius-button)); } -.ui-button--neutral-outline:hover, -.ui-button[variant="neutral"][style="outline"]:hover { - background-color: var(--theme-button-neutral-outline-bg-hover); -} - -.ui-button--neutral-outline:focus, -.ui-button[variant="neutral"][style="outline"]:focus { - background-color: var(--theme-button-neutral-outline-bg-active); -} - .ui-button--neutral-outline-destructive { color: var(--theme-button-secondary-destructive-text); background-color: var(--theme-button-secondary-destructive-bg); } -.ui-button--neutral-outline-destructive:hover { - background-color: var(--theme-button-secondary-destructive-bg-hover); +.ui-button--neutral-outline:focus, +.ui-button[variant="neutral"][style="outline"]:focus { + background-color: var(--theme-button-neutral-outline-bg-active); } .ui-button--neutral-outline-destructive:focus { @@ -399,18 +390,19 @@ opacity: 0.5; } -/* Tertiary */ -.ui-button--tertiary { - color: var(--theme-button-tertiary-text); - background-color: var(--theme-button-tertiary-bg); +.ui-button--neutral-outline:not(.ui-button--disable-hover):hover, +.ui-button[variant="neutral"][style="outline"]:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-neutral-outline-bg-hover); } -.ui-button--tertiary:hover { - background-color: var(--theme-button-tertiary-bg-hover); +.ui-button--neutral-outline-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-secondary-destructive-bg-hover); } -.ui-button--tertiary:focus { - background-color: var(--theme-button-tertiary-bg-active); +/* Tertiary */ +.ui-button--tertiary { + color: var(--theme-button-tertiary-text); + background-color: var(--theme-button-tertiary-bg); } .ui-button--tertiary-destructive { @@ -418,8 +410,8 @@ background-color: var(--theme-button-tertiary-destructive-bg); } -.ui-button--tertiary-destructive:hover { - background-color: var(--theme-button-tertiary-destructive-bg-hover); +.ui-button--tertiary:focus { + background-color: var(--theme-button-tertiary-bg-active); } .ui-button--tertiary-destructive:focus { @@ -431,6 +423,14 @@ opacity: 0.5; } +.ui-button--tertiary:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-tertiary-bg-hover); +} + +.ui-button--tertiary-destructive:not(.ui-button--disable-hover):hover { + background-color: var(--theme-button-tertiary-destructive-bg-hover); +} + /* Text */ .ui-button--text, .ui-button--text-destructive { diff --git a/theme/assets/css/ui/dialog.css b/theme/assets/css/ui/dialog.css index 9b5641b..544bef3 100644 --- a/theme/assets/css/ui/dialog.css +++ b/theme/assets/css/ui/dialog.css @@ -121,7 +121,8 @@ padding: 0; } -.ui-dialog__container-inner:not(.ui-dialog__container--header-visible, .ui-dialog__container--footer-visible) { +.ui-dialog__container-inner:not(.ui-dialog__container--header-visible, +.ui-dialog__container--footer-visible) { grid-template-rows: 100%; } diff --git a/theme/assets/css/ui/segmented-control.css b/theme/assets/css/ui/segmented-control.css new file mode 100644 index 0000000..602559b --- /dev/null +++ b/theme/assets/css/ui/segmented-control.css @@ -0,0 +1,10 @@ +.ui-segmented-control { + display: flex; + padding: var(--space-half); + background-color: var(--theme-color-neutral-05); + border-radius: var(--theme-border-radius-button); +} + +.ui-segmented-control--fullwidth { + width: 100%; +} diff --git a/theme/assets/js/components/fulfillment-and-scheduling/fulfillment-selection.js b/theme/assets/js/components/fulfillment-and-scheduling/fulfillment-selection.js new file mode 100644 index 0000000..88604e4 --- /dev/null +++ b/theme/assets/js/components/fulfillment-and-scheduling/fulfillment-selection.js @@ -0,0 +1,18 @@ +document.addEventListener('alpine:init', () => { + const createFulfillmentSelectionData = (dataId) => ({ + model: Alpine.store('global').fulfillment, + options: [], + init() { + Utils.loadJsonDataIntoComponent.call(this, dataId); + + this.$watch('model', (value) => { + Alpine.store('siteWideFulfillment').onFulfillmentSelected(value); + }); + }, + onFulfillmentButtonClick(value) { + this.model = value; + }, + }); + + Alpine.data('fulfillmentSelection', createFulfillmentSelectionData); +}); diff --git a/theme/assets/js/components/fulfillment-and-scheduling/scheduling-dialog.js b/theme/assets/js/components/fulfillment-and-scheduling/scheduling-dialog.js new file mode 100644 index 0000000..48e611e --- /dev/null +++ b/theme/assets/js/components/fulfillment-and-scheduling/scheduling-dialog.js @@ -0,0 +1,65 @@ +document.addEventListener('alpine:init', () => { + const createSchedulingDialogData = (dataId) => ({ + dates: [], + selectedDate: {}, + selectedTimeId: '', + selectedDateId: '', + times: [], + isLoadingAvailableTimes: false, + async init() { + Utils.loadJsonDataIntoComponent.call(this, dataId); + + this.selectedDate = this.dates.find((date) => date.key === this.selectedDateId); + await this.fetchAvailableTimes(this.selectedDate); + + this.$store.dialog.onClose = (isConfirmed) => { + if (isConfirmed) { + const selectedTime = this.times.find((time) => time.time_unix.toString() === this.selectedTimeId); + if (selectedTime) { + Alpine.store('siteWideFulfillment').setSelectedScheduleTime(selectedTime.time_formatted, selectedTime.time, selectedTime.time_unix, this.selectedDateId); + } + } + }; + }, + + async fetchAvailableTimes(scheduleDate) { + this.isLoadingAvailableTimes = true; + const globalStore = Alpine.store('global'); + await SquareWebSDK.resource.getResource({ + schedule: { + type: 'schedule-times', + filters: { + location_id: globalStore.locationId, + fulfillment: globalStore.fulfillment, + day: scheduleDate.key, + interval: '15m', + }, + }, + }).then(async (data) => { + this.times = data.schedule.available_times?.[scheduleDate.key].times ?? []; + if (Square.async.templates['schedule-selector']) { + await Square.async.refreshAsyncTemplate('schedule-selector', { + times: this.times, + }); + } + }).finally(() => { + this.isLoadingAvailableTimes = false; + }); + }, + }); + + const createDateSelectionData = (dataId) => ({ + async init() { + Utils.loadJsonDataIntoComponent.call(this, dataId); + + this.$watch('model', (value) => { + this.selectedDateId = value; + const scheduleDate = this.dates.find((date) => date.key === value); + this.fetchAvailableTimes(scheduleDate); + }); + }, + }); + + Alpine.data('schedulingDialog', createSchedulingDialogData); + Alpine.data('dateSelection', createDateSelectionData); +}); diff --git a/theme/assets/js/components/fulfillment-and-scheduling/scheduling.js b/theme/assets/js/components/fulfillment-and-scheduling/scheduling.js new file mode 100644 index 0000000..ceb0a96 --- /dev/null +++ b/theme/assets/js/components/fulfillment-and-scheduling/scheduling.js @@ -0,0 +1,80 @@ +document.addEventListener('alpine:init', () => { + const createSchedulingData = (dataId) => ({ + schedule: {}, + init() { + Utils.loadJsonDataIntoComponent.call(this, dataId); + }, + + /** + * Fetch latest Schedule resource data and open the scheduling selector dialog + * @returns {Promise} + */ + async openSchedulingDialog() { + const globalStore = Alpine.store('global'); + const weekdays = [ + SquareTranslations.shared.week.short.sunday, + SquareTranslations.shared.week.short.monday, + SquareTranslations.shared.week.short.tuesday, + SquareTranslations.shared.week.short.wednesday, + SquareTranslations.shared.week.short.thursday, + SquareTranslations.shared.week.short.friday, + SquareTranslations.shared.week.short.saturday, + ]; + + let filters = {}; + if (!SquareWebSDK.cart.getActiveId()) { + // We only provide these filters when there is no cart. When there is a cart, we use the cart schedule + // automatically. + filters = { + location_id: globalStore.locationId, + fulfillment: globalStore.fulfillment, + range: 365, + }; + } + await SquareWebSDK.resource.getResource({ + schedule: { + type: 'schedule-days', + filters, + }, + }).then(async (data) => { + this.schedule = data.schedule; + const dates = []; + Object.entries(this.schedule.available_times).forEach(([key, availableDate]) => { + const date = new Date(key); + const dayOfWeek = date.getUTCDay(); + const dayOfWeekLabel = weekdays[dayOfWeek]; + const dayOfMonthLabel = date.getUTCDate().toString(); + dates.push({ + key, + ...availableDate, + dayOfWeekLabel, + dayOfMonthLabel, + }); + }); + const selectedTime = Alpine.store('siteWideFulfillment').getSelectedScheduleTime(); + const templateProps = { + dates, + times: [], + selectedTimeId: selectedTime.timestamp.toString(), + selectedDateId: selectedTime.dateKey, + }; + const dialogAction = this.$store.dialog.isDialogOpen ? 'openSecondaryDialog' : 'openPrimaryDialog'; + this.$store.dialog[dialogAction]({ + templateUrl: 'templates/components/dialogs/scheduling-content', + dialogOptions: { + scrollable: false, + size: 'large', + showPrimaryButton: true, + showSecondaryButton: false, + disablePrimaryButton: true, + primaryButtonText: SquareTranslations.shared.buttons.update, + buttonPosition: 'footer', + }, + templateProps, + }); + }); + }, + }); + + Alpine.data('scheduling', createSchedulingData); +}); diff --git a/theme/assets/js/components/fulfillment-and-scheduling/site-wide-fulfillment.js b/theme/assets/js/components/fulfillment-and-scheduling/site-wide-fulfillment.js new file mode 100644 index 0000000..b8dd494 --- /dev/null +++ b/theme/assets/js/components/fulfillment-and-scheduling/site-wide-fulfillment.js @@ -0,0 +1,349 @@ +document.addEventListener('alpine:init', () => { + Alpine.store('siteWideFulfillment', { + asapTime: {}, + selectedScheduleTime: Alpine.$persist({}), + lastSelectedPickupLocationId: Alpine.$persist(''), + lastSelectedDeliveryLocationId: Alpine.$persist(''), + isLoadingItemList: false, + isLoadingLocationSelector: false, + isFailedToUpdateSchedule: false, + /** + * @param label This is a display such as "Today at 2:05pm" + * @param time This is time in RFC 3339 format such as "2024-02-02T21:55:00+00:00" + * @param timestamp This is a timestamp in seconds + * @param dateKey the date key value in yyyy-mm-dd format + */ + setSelectedScheduleTime(label, time, timestamp, dateKey) { + this.selectedScheduleTime = { + label, + time, + timestamp, + dateKey, + }; + }, + /** + * Keep track of last selected location id by fulfillment + * so we can restore to the correct location id when switching fulfillment option + * @param {String} fulfillment + * @param {String} locationId + */ + setLastSelectedLocationId(fulfillment, locationId) { + if (!locationId?.length) { + return; + } + if (fulfillment === Constants.FULFILLMENT_PICKUP) { + this.lastSelectedPickupLocationId = locationId; + } else if (fulfillment === Constants.FULFILLMENT_DELIVERY) { + this.lastSelectedDeliveryLocationId = locationId; + } + }, + /** + * When a new fulfillment is selected, fetch a new schedule and reset scheduled time to earliest + * @param fulfillment fulfillment constant such as PICKUP, SHIPMENT, DELIVERY + * @returns {Promise} + */ + async onFulfillmentSelected(fulfillment) { + const globalStore = Alpine.store('global'); + const locationsByFulfillment = globalStore.getHistory('locationsByFulfillment')?.[fulfillment] ?? []; + const lastSelectedLocationId = globalStore.buyerIntent[fulfillment] ?? globalStore.defaultLocation.id; + const promises = []; + + // Restore locations by fulfillment + globalStore.updateProperty('locations', locationsByFulfillment); + globalStore.updateProperty('fulfillment', fulfillment); + + if (fulfillment === Constants.FULFILLMENT_DELIVERY) { + if (globalStore.hasDeliveryAddress()) { + promises.push(this.updateCartFulfillment()); + } else { + this.refreshChooseLocation(''); + } + } else if (!globalStore.getCurrentLocationSupportsFulfillment(fulfillment)) { + // Only update to the closest location if current location does not support fulfillment + await globalStore.getLocationId(fulfillment); + } + + // Restore to the last selected location id + if (lastSelectedLocationId) { + globalStore.updateProperty('locationId', lastSelectedLocationId); + } + + // Refresh item list and load ASAP time if delivery address exists or fulfillment is not delivery + const shouldRefreshItemList = (globalStore.hasDeliveryAddress() && fulfillment === Constants.FULFILLMENT_DELIVERY) + || fulfillment !== Constants.FULFILLMENT_DELIVERY; + + if (shouldRefreshItemList) { + promises.push(this.loadASAPTime()); + promises.push(this.refreshItemList()); + } + + if (promises.length) { + await Promise.all(promises); + } + }, + /** + * Checks if we should show the scheduling selector + * @return {Boolean} + */ + shouldShowSchedulingSelector() { + const globalStore = Alpine.store('global'); + return ((globalStore.hasDeliveryAddress() && globalStore.fulfillment === Constants.FULFILLMENT_DELIVERY) + || globalStore.fulfillment !== Constants.FULFILLMENT_DELIVERY) && !this.isFailedToUpdateSchedule; + }, + /** + * Load the earliest possible time from schedule. This is used so that we can display the earliest available time + * as this is always changing in the ASAP case. + * @returns {Promise} + */ + async loadASAPTime() { + const globalStore = Alpine.store('global'); + let filters = {}; + this.selectedScheduleTime = {}; + + if (!SquareWebSDK.cart.getActiveId()) { + // We only provide these filters when there is no cart. When there is a cart, we use the cart schedule + // automatically. + filters = { + location_id: globalStore.locationId, + fulfillment: globalStore.fulfillment, + range: 365, + }; + } + await SquareWebSDK.resource.getResource({ + schedule: { + type: 'schedule-days', + filters, + }, + }).then(async (data) => { + if (data.schedule?.earliest_time) { + this.asapTime = { + label: data.schedule.earliest_time.time_formatted, + time: data.schedule.earliest_time.time, + timestamp: data.schedule.earliest_time.time_unix, + dateKey: Object.keys(data.schedule.available_times)[0], + }; + } + }); + }, + /** + * Returns the selected schedule time label + */ + selectedScheduleTimeLabel() { + return this.getSelectedScheduleTime().label; + }, + /** + * Returns the selected schedule time + */ + getSelectedScheduleTime() { + return this.selectedScheduleTime.time ? this.selectedScheduleTime : this.asapTime; + }, + /** + * Returns the scheduling type + */ + getScheduleType() { + return this.selectedScheduleTime.time ? Constants.SCHEDULE_TYPE_SCHEDULED : Constants.SCHEDULE_TYPE_ASAP; + }, + /** + * Opens the locations dialog + */ + openLocationsDialog() { + const globalStore = Alpine.store('global'); + const templateProps = { + fulfillment: globalStore.fulfillment, + locationId: globalStore.locationId, + locations: globalStore.locations, + alpine_store_name: 'global', + }; + + const dialogAction = Alpine.store('dialog').isDialogOpen ? 'openSecondaryDialog' : 'openPrimaryDialog'; + + Alpine.store('dialog')[dialogAction]({ + templateUrl: 'templates/components/dialogs/locations-content', + dialogOptions: { + id: `${templateProps.fulfillment}-locations`, + scrollable: false, + size: 'large', + showPrimaryButton: true, + showSecondaryButton: false, + disablePrimaryButton: true, + primaryButtonText: SquareTranslations.shared.buttons.update, + buttonPosition: 'footer', + }, + templateProps, + }); + }, + /** + * When a new location is selected, refresh the location template to reflect the new location + * @param locationId Square location id + */ + refreshChooseLocation(locationId) { + const chooseLocationSelector = document.querySelector('#chooseLocationSelector'); + if (chooseLocationSelector) { + this.isLoadingLocationSelector = true; + const location = Alpine.store('global').locations?.find((loc) => loc.id === locationId) ?? {}; + + Utils.refreshTemplate({ + template: 'partials/components/fulfillment-and-scheduling/choose-location', + props: { + location, + }, + el: chooseLocationSelector, + }).finally(() => { + this.isLoadingLocationSelector = false; + }); + } + }, + + /** + * Refresh the item list with latest fulfillment and location + */ + async refreshItemList() { + const orderItemList = document.querySelector('#orderItemList'); + if (orderItemList) { + this.isLoadingItemList = true; + const locationId = Alpine.store('global').locationId; + const filters = { + categories: { + type: 'category-list', + filters: { + location_id: locationId, + ...this.getAvailabilityFilter(), + }, + }, + }; + const { categories } = await SquareWebSDK.resource.getResource(filters); + Utils.refreshTemplate({ + template: 'partials/components/store/order/item-list', + props: { + categories, + fulfillment: Alpine.store('global').fulfillment, + }, + el: orderItemList, + }).finally(() => { + this.isLoadingItemList = false; + }); + } + }, + /** + * returns the availability filter + * @returns {{availability: {by: string, time: {from: (string|string)}}}} + */ + getAvailabilityFilter() { + let by = Constants.AVAILABILITY_ORDERING; + const currFulfillment = Alpine.store('global').fulfillment; + if (currFulfillment === Constants.FULFILLMENT_PICKUP) { + by = Constants.AVAILABILITY_READY; + } else if (currFulfillment === Constants.FULFILLMENT_DELIVERY) { + by = Constants.AVAILABILITY_DELIVER; + } + const from = this.selectedScheduleTime.time ? this.selectedScheduleTime.time.toString() : 'now'; + return { + availability: { + by, + time: { + from, + }, + }, + }; + }, + /** + * Updates the cart fulfillment (location, fulfillment type, fulfillment details) + */ + async updateCartFulfillment() { + const globalStore = Alpine.store('global'); + const patchFulfillmentRequest = { + fulfillment: { + fulfillmentType: globalStore.fulfillment, + }, + locationId: globalStore.locationId, + }; + if (this.selectedScheduleTime && this.selectedScheduleTime.timestamp <= this.asapTime.timestamp) { + this.selectedScheduleTime = {}; + } + if (globalStore.fulfillment === Constants.FULFILLMENT_DELIVERY) { + patchFulfillmentRequest.fulfillment.deliveryDetails = globalStore.getDeliveryDetails(); + } else { + patchFulfillmentRequest.fulfillment.pickupDetails = globalStore.getPickupDetails(); + } + // only update cart if it exists + if (SquareWebSDK.cart.getActiveId()) { + try { + await SquareWebSDK.cart.patchFulfillment(patchFulfillmentRequest); + } catch (e) { + this.isFailedToUpdateSchedule = true; + } + this.isFailedToUpdateSchedule = false; + } + }, + /** + * Pre-render dialog content so the dialog opens quicker. Fulfillment, locationId, and location params + * are optional, if not provided will fall back to the global store value + * @param {Object} payload + * @param {String} payload.fulfillment + * @param {String} payload.locationId + * @param {Array} payload.locations + * @returns {Promise} + */ + async preloadLocationsDialog({ fulfillment, locationId, locations } = {}) { + const props = { + fulfillment: fulfillment ?? Alpine.store('global').fulfillment, + locationId: locationId ?? Alpine.store('global').locationId, + locations: locations ?? Alpine.store('global').locations, + alpine_store_name: 'global', + }; + Utils.prerenderTemplate({ + template: 'templates/components/dialogs/locations-content', + props, + id: `${props.fulfillment}-locations`, + }); + }, + }); + + Alpine.data('siteWideFulfillment', () => ({ + async init() { + const globalStore = Alpine.store('global'); + const siteWideFulfillmentStore = Alpine.store('siteWideFulfillment'); + + siteWideFulfillmentStore.preloadLocationsDialog({ fulfillment: Constants.FULFILLMENT_PICKUP }); + siteWideFulfillmentStore.preloadLocationsDialog({ + fulfillment: Constants.FULFILLMENT_DELIVERY, + locationId: '', + locations: [], + }); + + this.$watch('$store.global.locationId', (locationId) => { + this.onLocationIdUpdate(locationId); + }); + + if (!siteWideFulfillmentStore.selectedScheduleTime?.time) { + await siteWideFulfillmentStore.loadASAPTime(); + } + + this.$watch('$store.siteWideFulfillment.selectedScheduleTime', () => { + siteWideFulfillmentStore.refreshItemList(); + siteWideFulfillmentStore.updateCartFulfillment(); + }); + + // if the current fulfillment is delivery and the customer address is not set, open the locations dialog + if (globalStore.fulfillment === Constants.FULFILLMENT_DELIVERY) { + if (!Object.values(globalStore.customerAddress).length) { + siteWideFulfillmentStore.openLocationsDialog(); + } + } + }, + /** + * Triggers when location id updates + * @param {String} locationId + */ + onLocationIdUpdate(locationId) { + const globalStore = Alpine.store('global'); + const siteWideFulfillmentStore = Alpine.store('siteWideFulfillment'); + siteWideFulfillmentStore.refreshChooseLocation(locationId); + siteWideFulfillmentStore.refreshItemList(); + siteWideFulfillmentStore.updateCartFulfillment(); + // Reload locations dialog content with new location id selected + siteWideFulfillmentStore.preloadLocationsDialog(); + siteWideFulfillmentStore.setLastSelectedLocationId(globalStore.fulfillment, locationId); + }, + })); +}); diff --git a/theme/assets/js/components/store/item/locations-dialog.js b/theme/assets/js/components/store/item/locations-dialog.js index 7603a5a..56af3ed 100644 --- a/theme/assets/js/components/store/item/locations-dialog.js +++ b/theme/assets/js/components/store/item/locations-dialog.js @@ -1,274 +1,361 @@ -document.addEventListener('alpine:init', () => { - const createLocationsDialogData = (dataId) => ({ - fulfillment: Constants.FULFILLMENT_PICKUP, - locationId: '', - alpineStoreName: 'product', - suggestions: [], - hasFetchedLocations: false, - /** - * Initial events - */ - init() { - Utils.loadJsonDataIntoComponent.call(this, dataId); +if (!window.onLocationsDialogReady) { + window.onLocationsDialogReady = () => { + const createLocationsDialogData = (dataId) => ({ + customerAddressLine2Model: Alpine.$persist(''), + fulfillment: Constants.FULFILLMENT_PICKUP, + locationId: '', + alpineStoreName: 'product', + suggestions: [], + hasFetchedLocations: false, + /** + * Initial events + */ + init() { + Utils.loadJsonDataIntoComponent.call(this, dataId); - this.$store.dialog.onClose = (isConfirmed) => { - if (isConfirmed) { - const location = this.getLocations().find((loc) => loc.id === this.locationId) ?? this.getLocations()[0]; - const hasFulfillmentDetail = Boolean(Square.async.templates['fulfillment-detail']); + this.$store.dialog.onClose = async (isConfirmed, isSecondaryDialogOpen) => { + if (isConfirmed) { + const location = this.getLocations().find((loc) => loc.id === this.locationId) ?? this.getLocations()[0]; + const hasFulfillmentDetail = Boolean(Square.async.templates['fulfillment-detail']); - if (hasFulfillmentDetail && this.fulfillment?.length) { - Square.async.refreshAsyncTemplate('fulfillment-detail', { - fulfillment: this.fulfillment, - location, - formatted_distance: location.formatted_distance, - }, { - loaded: { - location: 'location', - }, - }); - } + if (isSecondaryDialogOpen) { + // Wait until the primary dialog content is ready + await Utils.waitUntil(() => this.$store.dialog.isDialogContentReady, 600, 10); + } + + if (hasFulfillmentDetail && this.fulfillment?.length) { + Square.async.refreshAsyncTemplate('fulfillment-detail', { + fulfillment: this.fulfillment, + location, + formatted_distance: location.formatted_distance, + }, { + loaded: { + location: 'location', + }, + }); + } - if (this.locationId?.length && Alpine.store(this.alpineStoreName)) { - Alpine.store(this.alpineStoreName).updateProperty('locationId', this.locationId); + if (this.locationId?.length && Alpine.store(this.alpineStoreName)) { + const globalStore = Alpine.store('global'); + globalStore.updateProperty('buyerIntent', { + ...globalStore.buyerIntent, + [this.fulfillment]: this.locationId, + }); + Alpine.store(this.alpineStoreName).updateProperty('locationId', this.locationId); + } } - } - }; - }, - /** - * Get suggested place item from global store - * @return {Object} - */ - getSuggestedPlaceItem() { - return Alpine.store('global').suggestedPlaceItem; - }, - /** - * Updates suggested place item - * @param {Object} - */ - updateSuggestedPlaceItem(value) { - Alpine.store('global').updateProperty('suggestedPlaceItem', value); - }, - /** - * Checks if the user selected the autocomplete item - * @return {Boolean} - */ - hasSelectedItem() { - return Boolean(this.getSuggestedPlaceItem()?.place_id); - }, - /** - * Get locations from global store - * @return {Object} - */ - getLocations() { - return Alpine.store('global').locations; - }, - /** - * Get total locations - * @return {Number} - */ - getLocationsCount() { - return this.getLocations().length; - }, - /** - * Checks if the locations are available - */ - hasLocations() { - return this.getLocationsCount() > 0; - }, - /** - * Gets the locations by fulfillment - * @param {Boolean} shouldFocusLocations - focuses the locations list on complete - * @param {String} fulfillment - */ - async fetchLocations({ shouldFocusLocations = true, fulfillmentType = Constants.FULFILLMENT_PICKUP, sort } = {}) { - this.hasFetchedLocations = false; + }; - const input = { - filters: { - fulfillments: [fulfillmentType], - }, - limit: 10, - }; + this.$watch('customerAddressLine2Model', (value) => { + this.updateCustomerAddressLine2(value); + }); + }, + /** + * Get suggested place item from global store + * @return {Object} + */ + getSuggestedPlaceItem() { + return Alpine.store('global').suggestedPlaceItem; + }, + /** + * Updates suggested place item + * @param {Object} + */ + updateSuggestedPlaceItem(value) { + Alpine.store('global').updateProperty('suggestedPlaceItem', value); + }, + /** + * Updates customer address + * @param {Object} + */ + updateCustomerAddress(value) { + Alpine.store('global').updateProperty('customerAddress', value); + }, + /** + * Updates customer address line 2 + * @param {Object} + */ + updateCustomerAddressLine2(value) { + Alpine.store('global').updateProperty('customerAddressLine2', value); + }, + /** + * Checks if the user selected the autocomplete item + * @return {Boolean} + */ + hasSelectedItem() { + return Boolean(this.getSuggestedPlaceItem()?.place_id); + }, + /** + * Get locations from global store + * @return {Object} + */ + getLocations() { + return Alpine.store('global').locations; + }, + /** + * Get total locations + * @return {Number} + */ + getLocationsCount() { + return this.getLocations().length; + }, + /** + * Checks if the locations are available + */ + hasLocations() { + return this.getLocations() > 0; + }, + /** + * Gets the locations by fulfillment + * @param {Boolean} shouldFocusLocations - focuses the locations list on complete + * @param {String} fulfillment + */ + async fetchLocations({ shouldFocusLocations = true, fulfillmentType = Constants.FULFILLMENT_PICKUP, sort } = {}) { + this.hasFetchedLocations = false; - if (sort) { - input.sort = { - from: sort, + const input = { + filters: { + fulfillments: [fulfillmentType], + }, + limit: 10, }; - } - try { - await Alpine.store('global').getLocations(input).then(async (locations) => { - const formattedDistance = locations.map((loc) => loc.formatted_distance); + if (sort) { + input.sort = { + from: sort, + }; + } - await Square.async.refreshAsyncTemplate('location-selector', { - locations, - formatted_distance: formattedDistance, - }, { - loaded: { - locations: 'location-list', - }, + try { + await Alpine.store('global').getLocations(input).then(async (locations) => { + this.refreshLocationSelector(locations); + + const hasLocationSelected = this.getLocations().some((location) => location.id === this.locationId); + Alpine.store('dialog').updateDialogOptions('disablePrimaryButton', !hasLocationSelected); + + this.hasFetchedLocations = true; }); - const hasLocationSelected = this.hasLocations() && this.locationId?.length; - Alpine.store('dialog').updateDialogOptions('disablePrimaryButton', !hasLocationSelected); + this.$nextTick(() => { + if (shouldFocusLocations) { + this.focusLocationList(); + } + }); + } catch (e) { + // @todo: error alert + } + }, + /** + * Refresh location selector template + * @param {Array} locations + */ + async refreshLocationSelector(locations = []) { + const formattedDistance = locations.map((loc) => loc.formatted_distance); - this.hasFetchedLocations = true; - }); + if (this.$refs.locationSelector) { + await Utils.refreshTemplate({ + template: 'partials/components/location-selector', + props: { + locations, + formatted_distance: formattedDistance, + }, + el: this.$refs.locationSelector, + }); + } + }, + /** + * Focuses the locations list + */ + focusLocationList() { + const nextFocusableElement = this.$refs.locations?.querySelector('input:not(disabled)'); + if (nextFocusableElement) { + nextFocusableElement.focus({ focusVisible: true }); + } + }, + }); - this.$nextTick(() => { - if (shouldFocusLocations) { - this.focusLocationList(); + const createItemSuggestionsData = () => ({ + model: Alpine.$persist(''), + items: Alpine.$persist([]), + isInitialLoad: true, + /** + * Initial events + */ + async init() { + const globalStore = Alpine.store('global'); + const previousSuggestedPlace = this.getSuggestedPlaceItem(); + if (globalStore.fulfillment === Constants.FULFILLMENT_DELIVERY) { + await this.initForDelivery(previousSuggestedPlace); + } else { + await this.initForPickup(); + } + }, + /** + * Load the location from previously selected address, otherwise initialize with empty suggestion + * which will prompt buyer to enter a delivery address + * @returns {Promise} + */ + async initForDelivery() { + const previousSuggestedPlace = this.getSuggestedPlaceItem(); + if (Object.values(previousSuggestedPlace).length && !Utils.isPlaceTypeRegion(previousSuggestedPlace)) { + this.loadAutocompletePlace(previousSuggestedPlace); + } else { + this.updateSuggestedPlaceItem({}); + this.customerAddressLine2Model = ''; + this.model = ''; + this.items = []; + } + }, + /** + * Load location for delivery from previous selected address suggestion, otherwise initialize for first time + * @returns {Promise} + */ + async initForPickup() { + const previousSuggestedPlace = this.getSuggestedPlaceItem(); + this.$nextTick(async () => { + this.isLoadingAutocomplete = true; + if (!Object.values(previousSuggestedPlace).length && !this.items.length) { + await this.initForFirstTimer(); + } else { + await this.initForReturning(); } + this.isLoadingAutocomplete = false; }); - } catch (e) { - // @todo: error alert - } - }, - /** - * Focuses the locations list - */ - focusLocationList() { - const nextFocusableElement = this.$refs.locations.querySelector('input:not(disabled)'); - if (nextFocusableElement) { - nextFocusableElement.focus({ focusVisible: true }); - } - }, - }); + }, + /** + * Load the locations by the user's current location for first time visitor + */ + async initForFirstTimer() { + const globalStore = Alpine.store('global'); + await globalStore.getCustomerCoordinates(); + const { customerLocale } = globalStore; + let sort; - const createItemSuggestionsData = () => ({ - model: Alpine.$persist(''), - items: Alpine.$persist([]), - /** - * Initial events - */ - async init() { - // Pre-populate the input based on the history - this.$nextTick(async () => { - this.isLoadingAutocomplete = true; - if (!Object.values(this.getSuggestedPlaceItem()).length && !this.items.length) { - await this.initForFirstTimer(); - } else { - await this.initForReturning(); + if (customerLocale.postal_code) { + this.model = customerLocale.postal_code; + sort = { + lat: customerLocale.latitude, + lng: customerLocale.longitude, + }; } - this.isLoadingAutocomplete = false; - }); - }, - /** - * Load the locations by the user's current location for first time visitor - */ - async initForFirstTimer() { - const globalStore = Alpine.store('global'); - await globalStore.getCustomerCoordinates(); - const { customerLocale } = globalStore; - let sort; - if (customerLocale.postal_code) { - this.model = customerLocale.postal_code; - sort = { - lat: customerLocale.latitude, - lng: customerLocale.longitude, - }; - } - - await this.fetchLocations({ sort }); - }, - /** - * Load the locations by the user's last selection for returning visitor - */ - async initForReturning() { - if (this.items.length) { - await this.refreshDropdown(); - this.toggleDropdown(false); - } + const fulfillmentType = globalStore.fulfillment; + await this.fetchLocations({ sort, fulfillmentType }); + }, + /** + * Load the locations by the user's last selection for returning visitor + */ + async initForReturning() { + const globalStore = Alpine.store('global'); - const promises = []; + if (this.hasSelectedItem() && globalStore.locations.length) { + const formattedLocations = globalStore.formatLocationsWithDistance( + globalStore.locations, + { place_id: this.getSuggestedPlaceItem().place_id }, + ); + this.refreshLocationSelector(formattedLocations); + } else { + await this.fetchLocations(); + } - if (this.hasSelectedItem()) { - promises.push( - this.fetchLocations({ - sort: { - place_id: this.getSuggestedPlaceItem().place_id, - }, - }), - this.getPlaceDetails(this.getSuggestedPlaceItem().place_id), - ); - } else { - promises.push(this.fetchLocations()); - } + this.hasFetchedLocations = true; + }, + /** + * Get locations by autocomplete input + */ + loadSuggestions() { + return SquareWebSDK.places.autocompletePlaces({ + types: this.$store.global.fulfillment === Constants.FULFILLMENT_DELIVERY ? Constants.AUTOCOMPLETE_TYPE_ADDRESS : Constants.AUTOCOMPLETE_TYPE_GEOCODE, + address: this.model, + }) + .then(async ({ data }) => { + if (data) { + this.items = this.formatSuggestions(data); + } else { + this.items = []; + } + }); + }, + /** + * Formats the suggestions with label and value + * @return {Array} + */ + formatSuggestions(data) { + return data.map((suggestion) => ({ ...suggestion, label: suggestion.description, value: Utils.deepGet(suggestion, 'place_id') })); + }, + /** + * Gets input value to display to the user + * @return {String} + */ + getInputValue(value) { + return value?.description ?? this.model; + }, + /** + * Gets the place coordinates + */ + async getPlaceDetails(placeId) { + if (Utils.hasValidCoordinates(this.getSuggestedPlaceItem()) && this.getSuggestedPlaceItem().place_id === placeId) { + return; + } + await SquareWebSDK.places.getPlace({ placeId }) + .then(async ({ data }) => { + if (Utils.hasValidCoordinates(data)) { + this.updateCustomerAddress(data); + this.updateSuggestedPlaceItem({ + ...this.getSuggestedPlaceItem(), + latitude: data.latitude, + longitude: data.longitude, + }); + } + }); + }, + /** + * Input focus event + */ + async onInputFocus() { + if (this.isInitialLoad && this.items.length) { + // Don't show the dropdown on initial load + await this.refreshDropdown(); + } else { + this.toggleDropdown(true); + } - await Promise.all(promises); - this.hasFetchedLocations = true; - }, - /** - * Get locations by autocomplete input - */ - loadSuggestions() { - return SquareWebSDK.places.autocompletePlaces({ - address: this.model, - }) - .then(async ({ data }) => { - if (data) { - this.items = this.formatSuggestions(data); - } else { - this.items = []; - } - }); - }, - /** - * Formats the suggestions with label and value - * @return {Array} - */ - formatSuggestions(data) { - return data.map((suggestion) => ({ ...suggestion, label: suggestion.description, value: Utils.deepGet(suggestion, 'place_id') })); - }, - /** - * Gets input value to display to the user - * @return {String} - */ - getInputValue(value) { - return value?.description ?? this.model; - }, - /** - * Gets the place coordinates - */ - async getPlaceDetails(placeId) { - if (Utils.hasValidCoordinates(this.getSuggestedPlaceItem()) && this.getSuggestedPlaceItem().place_id === placeId) { - return; - } - await SquareWebSDK.places.getPlace({ placeId }) - .then(async ({ data }) => { - if (Utils.hasValidCoordinates(data)) { - this.updateSuggestedPlaceItem({ - ...this.getSuggestedPlaceItem(), - latitude: data.latitude, - longitude: data.longitude, + this.isInitialLoad = false; + }, + /** + * Updates the locations list and gets the place detail + * @param {Object} value + */ + onAutocompleteItemSelect(value) { + this.toggleDropdown(false); + this.loadAutocompletePlace(value); + }, + /** + * Load Autocomplete place + */ + loadAutocompletePlace(value) { + if (value?.place_id) { + this.isLoadingAutocomplete = true; + this.$nextTick(async () => { + this.updateSuggestedPlaceItem(value); + await this.getPlaceDetails(value.place_id); + await this.fetchLocations({ + sort: { + place_id: value.place_id, + }, + fulfillmentType: this.$store.global.fulfillment, }); - } - }); - }, - /** - * Updates the locations list and gets the place detail - * @param {Object} value - */ - onAutocompleteItemSelect(value) { - this.toggleDropdown(false); - - if (value?.place_id) { - this.isLoadingAutocomplete = true; - this.$nextTick(async () => { - this.updateSuggestedPlaceItem(value); - await this.getPlaceDetails(value.place_id); - await this.fetchLocations({ - sort: { - place_id: value.place_id, - }, + this.isLoadingAutocomplete = false; }); - this.isLoadingAutocomplete = false; - }); - } - }, - }); + } + }, + }); + + Alpine.data('locationsDialog', createLocationsDialogData); + Alpine.data('itemSuggestions', createItemSuggestionsData); + }; +} + +document.addEventListener('alpine:init', window.onLocationsDialogReady); - Alpine.data('locationsDialog', createLocationsDialogData); - Alpine.data('itemSuggestions', createItemSuggestionsData); -}); +document.addEventListener('async:alpine:init', window.onLocationsDialogReady); diff --git a/theme/assets/js/global.js b/theme/assets/js/global.js index 5373ded..67fb78b 100644 --- a/theme/assets/js/global.js +++ b/theme/assets/js/global.js @@ -173,11 +173,15 @@ document.addEventListener('alpine:init', () => { currencySymbolPosition: 'before', history: Alpine.$persist({}), locations: [], + defaultLocation: {}, isLoadingLocations: false, locationId: Alpine.$persist(''), fulfillment: Alpine.$persist(''), customerLocale: Alpine.$persist({}), suggestedPlaceItem: Alpine.$persist({}), + customerAddress: Alpine.$persist({}), + customerAddressLine2: Alpine.$persist(''), + buyerIntent: Alpine.$persist({}), isMobile: true, isTablet: false, isPageScrollDisabled: false, @@ -361,6 +365,18 @@ document.addEventListener('alpine:init', () => { this.currencySymbol = Utils.getCurrencySymbol(this.locale, this.currency); this.currencySymbolPosition = Utils.getCurrencySymbolPosition(this.locale, this.currency); }, + /** + * Load locations by default location id, fulfillment and/or buyerIntent + */ + async initLocations() { + const initialLocationId = this.buyerIntent[this.fulfillment] ?? this.defaultLocation.id; + + if (initialLocationId) { + await this.getLocationsByPlaceId().then(() => { + this.updateProperty('locationId', initialLocationId); + }); + } + }, /** * Get the user's current location coordinates */ @@ -461,10 +477,38 @@ document.addEventListener('alpine:init', () => { this.locations = this.formatLocationsWithDistance(locations, sort.from) ?? []; } + this.updateHistory('locationsByFulfillment', { + ...(this.getHistory('locationsByFulfillment') ?? {}), + [this.fulfillment]: this.locations, + }); + this.isLoadingLocations = false; return this.locations; }); }, + /** + * Load locations by current place id and fulfillment + * @return {Promise} + */ + async getLocationsByPlaceId() { + const fulfillment = Alpine.store('global').fulfillment; + let sort = {}; + + if (fulfillment === Constants.FULFILLMENT_DELIVERY) { + const placeId = Alpine.store('global').suggestedPlaceItem?.place_id; + sort = placeId ? { from: { place_id: placeId } } : {}; + } + if (!fulfillment) { + return Promise.resolve(); + } + return this.getLocations({ + sort, + filters: { + fulfillments: [fulfillment], + }, + limit: 10, + }); + }, /** * Formats the locations with distance * @param {Array} locations @@ -494,12 +538,60 @@ document.addEventListener('alpine:init', () => { await store.getCustomerCoordinates(); await store.getClosestLocation(fulfillment); }, + /** + * @returns Delivery details for customer + */ + getDeliveryDetails() { + const siteWideFulfillmentStore = Alpine.store('siteWideFulfillment'); + return { + scheduleType: siteWideFulfillmentStore.getScheduleType(), + deliverAt: siteWideFulfillmentStore.selectedScheduleTime.time, + recipient: { + address: { + addressLine1: this.customerAddress.street ?? '', + addressLine2: this.customerAddressLine2 ?? '', + locality: this.customerAddress.city ?? '', + postalCode: this.customerAddress.postal_code ?? '', + country: this.customerAddress.country_code ?? '', + administrativeDistrictLevel1: this.customerAddress.region_code ?? '', + }, + }, + noContactDelivery: false, + }; + }, + /** + * @returns {Boolean} returns true if the customer has entered a delivery address; + */ + hasDeliveryAddress() { + return this.customerAddress.street; + }, + /** + * @returns Pickup details for customer + */ + getPickupDetails() { + const siteWideFulfillmentStore = Alpine.store('siteWideFulfillment'); + return { + scheduleType: siteWideFulfillmentStore.getScheduleType(), + pickupAt: siteWideFulfillmentStore.selectedScheduleTime.time, + }; + }, + /** + * Currently selected location has pickup enabled + * @param {String} fulfillment ["PICKUP", "DELIVERY"] + * @returns {boolean} + */ + getCurrentLocationSupportsFulfillment(fulfillment) { + const location = this.locations.find((loc) => loc.id === this.locationId); + return location?.[(fulfillment)]?.enabled; + }, }); Alpine.data('global', (dataId) => ({ locale: Constants.DEFAULT_LOCALE, currency: Constants.DEFAULT_CURRENCY, defaultFulfillment: Constants.FULFILLMENT_SHIPPING, + defaultLocationId: '', + defaultLocation: {}, pageHeight: 0, pageWidth: 0, bodyStyles: {}, @@ -512,7 +604,10 @@ document.addEventListener('alpine:init', () => { const store = Alpine.store('global'); store.updateProperty('locale', this.locale.replace(/_/g, '-')); store.updateProperty('fulfillment', this.defaultFulfillment); + store.updateProperty('locationId', this.defaultLocationId); + store.updateProperty('defaultLocation', this.defaultLocation); store.setCurrency(this.currency); + store.initLocations(); // add whitespace at top to fit header this.$watch('$store.global.headerHeight', (height) => { diff --git a/theme/assets/js/helpers/utils.js b/theme/assets/js/helpers/utils.js index d2f5f0f..b75dc70 100644 --- a/theme/assets/js/helpers/utils.js +++ b/theme/assets/js/helpers/utils.js @@ -1,7 +1,16 @@ window.Constants = { FULFILLMENT_SHIPPING: 'SHIPMENT', FULFILLMENT_PICKUP: 'PICKUP', + FULFILLMENT_DELIVERY: 'DELIVERY', FULFILLMENT_MANUAL: 'MANUAL', + AVAILABILITY_READY: 'ready', + AVAILABILITY_DELIVER: 'deliver', + AVAILABILITY_ORDERING: 'ordering', + AVAILABILITY_NOW: 'now', + SCHEDULE_TYPE_ASAP: 'ASAP', + SCHEDULE_TYPE_SCHEDULED: 'SCHEDULED', + AUTOCOMPLETE_TYPE_GEOCODE: 'geocode', + AUTOCOMPLETE_TYPE_ADDRESS: 'address', DEFAULT_CURRENCY: 'USD', DEFAULT_CURRENCY_SYMBOL: '$', DEFAULT_LOCALE: 'en_US', @@ -200,6 +209,22 @@ window.Utils = { ? locations.find((location) => location.is_shipping_location) : locations.find((location) => location[fulfillment.toLowerCase()]?.enabled); }, + /** + * Checks if the place object type is non-specific location + * @param place + * @returns {boolean} + */ + isPlaceTypeRegion(place = {}) { + const regionTypes = [ + 'locality', + 'sublocality', + 'postal_code', + 'country', + 'administrative_area_level_1', + 'administrative_area_level_2', + ]; + return place.api_specific_data.types.some((type) => regionTypes.includes(type)); + }, /** * Loads script * @param {String} url @@ -393,4 +418,70 @@ window.Utils = { } } }, + /** + * Fetch template and update dom element + * @param {Object} payload + * @param {String} payload.template + * @param {Object} payload.props + * @param {Object} payload.el + * @return {Promise} + */ + async refreshTemplate({ template, props, el }) { + if (!template || !props || !el) { + return Promise.resolve(); + } + return SquareWebSDK.template.getTemplate({ + template, + props, + }).then((text) => { + // eslint-disable-next-line no-param-reassign + el.innerHTML = text; + }); + }, + /** + * Pre-render templates and append to the dom + * @param {Object} payload + * @param {String} payload.template + * @param {Object} payload.props + * @param {String} payload.id + * @return {Promise} + */ + async prerenderTemplate({ template, props, id }) { + if (!template || !props || !id) { + return Promise.resolve(); + } + return SquareWebSDK.template.getTemplate({ + template, + props, + }).then(async (text) => { + const newTemplate = document.createElement('template'); + const templateId = `template_${id}`; + const oldTemplate = document.querySelector(`#${templateId}`); + + if (oldTemplate) { + oldTemplate.remove(); + } + + newTemplate.setAttribute('id', templateId); + newTemplate.innerHTML = text; + document.body.appendChild(newTemplate); + + const templateContent = document.querySelector(`#${templateId}`)?.content; + const scripts = templateContent.querySelectorAll('script'); + const scriptPromises = []; + + scripts.forEach((script) => { + if (script.src) { + scriptPromises.push(Utils.loadScript(script.src)); + } + }); + + if (scriptPromises.length) { + // Load scripts + await Promise.all(scriptPromises); + // Initiate Alpine after all scripts are loaded + document.dispatchEvent(new CustomEvent('async:alpine:init')); + } + }); + }, }; diff --git a/theme/assets/square-online-web-sdk/lib/index.js b/theme/assets/square-online-web-sdk/lib/index.js index 148436c..bdf4723 100644 --- a/theme/assets/square-online-web-sdk/lib/index.js +++ b/theme/assets/square-online-web-sdk/lib/index.js @@ -1,777 +1,863 @@ -var U = Object.defineProperty; -var $ = (r, e, t) => e in r ? U(r, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : r[e] = t; -var y = (r, e, t) => ($(r, typeof e != "symbol" ? e + "" : e, t), t); +var q = Object.defineProperty; +var Z = (i, e, t) => e in i ? q(i, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : i[e] = t; +var y = (i, e, t) => (Z(i, typeof e != "symbol" ? e + "" : e, t), t), b = (i, e, t) => { + if (!e.has(i)) + throw TypeError("Cannot " + t); +}; +var j = (i, e, t) => (b(i, e, "read from private field"), t ? t.call(i) : e.get(i)), w = (i, e, t) => { + if (e.has(i)) + throw TypeError("Cannot add the same private member more than once"); + e instanceof WeakSet ? e.add(i) : e.set(i, t); +}, G = (i, e, t, r) => (b(i, e, "write to private field"), r ? r.call(i, t) : e.set(i, t), t); +var _ = (i, e, t) => (b(i, e, "access private method"), t); const M = { - SHIPMENT: "SHIPMENT", - PICKUP: "PICKUP", - MANUAL: "MANUAL" -}, j = { - ASAP: "ASAP" -}, v = { - CHOICE: "CHOICE", - TEXT: "TEXT", - GIFT_WRAP: "GIFT_WRAP", - GIFT_MESSAGE: "GIFT_MESSAGE" -}, x = () => { - var r; - return (r = document.querySelector('meta[name="csrf-token"]')) == null ? void 0 : r.content; -}, I = () => ({ - Accept: "application/json", - "content-type": "application/json; charset=UTF-8", - "X-CSRF-TOKEN": x() -}), O = "/s/api/v1/cart", N = "Something went wrong", A = (r, e) => { - const t = k(e.error || e.message || r.statusText), i = new Error(t); - if (e.errors) { - const n = {}; - Object.keys(e.errors).forEach((s) => { - const o = e.errors[s].map((a) => k(a)); - n[k(s)] = o; - }), i.errors = n; - } - return e.fields && (i.fields = e.fields), r.status && (i.status = r.status, i.status === 200 && (i.status = 500)), i; -}, C = async (r) => { - const e = await r.json(); - if (!r.ok) - throw A(r, e); - return { - response: r, - data: e.data - }; -}, L = async (r) => { - var e; - if (r.redirected) { - if (window.location.href === r.url) { - const t = await r.json(); - throw (e = t == null ? void 0 : t.response) != null && e.errors ? A(r, t.response.errors) : new Error(N); - } - window.location.href = r.url; - return; - } else if (!r.ok) { - const t = await r.json(); - throw A(r, t); - } - throw new Error(N); -}, k = (r) => r.replace(/[_][a-z0-9]/g, (e) => e.toUpperCase().replace("_", "")), P = (r) => r.replace(/[A-Z0-9]/g, (e) => `_${e.toLowerCase()}`), T = (r) => { - const e = {}; - return Object.keys(r).forEach((t) => { - const i = r[t]; - Array.isArray(i) ? e[P(t)] = F(i) : i && typeof i == "object" ? e[P(t)] = T(i) : e[P(t)] = i; - }), e; -}, F = (r) => { - const e = []; - return r.forEach((t) => { - Array.isArray(t) ? e.push(F(t)) : t && typeof t == "object" ? e.push(T(t)) : e.push(t); - }), e; -}, X = (r) => { - const e = r + "=", i = decodeURIComponent(document.cookie).split(";"); - for (let n = 0; n < i.length; n++) { - let s = i[n]; - for (; s.charAt(0) == " "; ) - s = s.substring(1); - if (s.indexOf(e) == 0) - return s.substring(e.length, s.length); - } - return null; -}, G = (r) => { - const e = V(r); - return delete e.order_id, e; -}, b = (r) => { - const e = JSON.parse(JSON.stringify(r)); - return e.fulfillmentType === "PICKUP" && (e.pickupDetails || (e.pickupDetails = {}), e.pickupDetails.scheduleType || (e.pickupDetails.scheduleType = "ASAP"), e.pickupDetails.curbsidePickupRequested == null && (e.pickupDetails.curbsidePickupRequested = !1), e.pickupDetails.curbsidePickupDetails || (e.pickupDetails.curbsidePickupDetails = { - curbsideDetails: "" - }), e.pickupDetails.pickupAt || (e.pickupDetails.pickupAt = (/* @__PURE__ */ new Date()).toISOString().split(".")[0] + "Z")), e; -}, D = (r) => { - var t; - const e = b(r.fulfillment); - return e.fulfillmentType === M.PICKUP && ((t = e.pickupDetails) == null ? void 0 : t.scheduleType) === j.ASAP; -}, V = (r) => { - var n; - const e = JSON.parse(JSON.stringify(r.lineItem)); - e.quantity || (e.quantity = 1); - const t = T(e); - if ((n = t.modifiers) != null && n.length) { - const s = {}; - t.modifiers.forEach((o) => { - if (o.type) { - s[o.type] || (s[o.type] = {}); - const a = JSON.parse(JSON.stringify(o)); - delete a.id, delete a.type, s[o.type][o.id] = a; - } - }), t.modifiers = s; - } else - t.modifiers && delete t.modifiers; - return { - line_item: t, - fulfillment: T(b(r.fulfillment)), - location_id: r.locationId, - // JSON.stringify will remove if undefined - order_id: E(r) - }; -}, E = (r) => r.orderId !== void 0 ? r.orderId : X("com_cart_id") || void 0; -class J { - /** - * Adds an item to your cart order. - * - * ```ts - * const addItemRequest = { - * lineItem: { - * itemId: '47HCEE6ZQUFFY3Y7X52CRVCO', - * variationId: '6YOTMYGOFTJR4PTTYRCLE7BH', - * quantity: 1, - * modifiers: [ - * { - * id: '6WVGAE3PKEHRWZHF54KR2PIN', - * type: 'CHOICE', - * choiceSelections: ['E3MWZ3PJ3VZDQWGW4G3KFZGW', 'GKCUYTB7ARN25J7BTRTOSVHO'] - * }, - * { - * id: '11ede91fbff63a3ab4dbde667deefb9b', - * type: 'TEXT', - * textEntry: 'my t-shirt-text' - * }, - * { - * id: '11ee185ca1cd3e98a25c9e3d692ffefb', - * type: 'GIFT_WRAP', - * choiceSelections: ['11ee185ca1cd7daebd029e3d692ffefb'] - * }, - * { - * id: '11ee185ca17973e490449e3d692ffefb', - * type: 'GIFT_MESSAGE', - * textEntry: 'happy bday' - * } - * ] - * }, - * fulfillment: { - * fulfillmentType: 'SHIPMENT' - * }, - * locationId: 'L36RW9ABXQTEE' - * }; - * try { - * const response = await sdk.cart.addItem(addItemRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async addItem(e) { - const t = V(e), i = await fetch(`${O}/add`, { - method: "POST", - body: JSON.stringify(t), - headers: I() - }), n = await C(i); - return D(e) && await this.patchAsapPickupTime(e), n; - } - /** - * Adds an item to a new order and redirects to checkout on success. - * - * ```ts - * const buyNowItemRequest = { - * lineItem: { - * itemId: '47HCEE6ZQUFFY3Y7X52CRVCO', - * variationId: '6YOTMYGOFTJR4PTTYRCLE7BH', - * quantity: 1, - * modifiers: [ - * { - * id: '6WVGAE3PKEHRWZHF54KR2PIN', - * type: 'CHOICE', - * choiceSelections: ['E3MWZ3PJ3VZDQWGW4G3KFZGW', 'GKCUYTB7ARN25J7BTRTOSVHO'] - * }, - * { - * id: '11ede91fbff63a3ab4dbde667deefb9b', - * type: 'TEXT', - * textEntry: 'my t-shirt-text' - * }, - * { - * id: '11ee185ca1cd3e98a25c9e3d692ffefb', - * type: 'GIFT_WRAP', - * choiceSelections: ['11ee185ca1cd7daebd029e3d692ffefb'] - * }, - * { - * id: '11ee185ca17973e490449e3d692ffefb', - * type: 'GIFT_MESSAGE', - * textEntry: 'happy bday' - * } - * ] - * }, - * fulfillment: { - * fulfillmentType: 'SHIPMENT' - * }, - * locationId: 'L36RW9ABXQTEE' - * }; - * try { - * await sdk.cart.buyNowItem(buyNowItemRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async buyNowItem(e) { - const t = G(e), i = await fetch(`${O}/buy`, { - method: "POST", - body: JSON.stringify(t), - headers: I() - }); - return !e.lineItem.subscriptionPlanVariationId && D(e) && await this.patchAsapPickupTime(e), L(i); - } - /** - * Updates the quantity of an item on an order. Quantity must be greater than 0. - * - * ```ts - * const updateItemQuantityRequest = { - * orderItemId: '11ee2722e42886d182fa089e019fd17a', - * quantity: 2 - * }; - * try { - * const response = await SDK.cart.updateItemQuantity(updateItemQuantityRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async updateItemQuantity(e) { - const t = await fetch(`${O}/update-quantity`, { - method: "POST", - body: JSON.stringify({ - order_item_id: e.orderItemId, - quantity: e.quantity, - order_id: E(e) - }), - headers: I() - }); - return C(t); - } - /** - * Removes a line item from an order. - * - * ```ts - * const removeItemRequest = { - * orderItemId: '11ee2722e42886d182fa089e019fd17a' - * }; - * try { - * const response = await SDK.cart.removeItem(removeItemRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async removeItem(e) { - const t = await fetch(`${O}/remove-item`, { - method: "POST", - body: JSON.stringify({ - order_item_id: e.orderItemId, - order_id: E(e) - }), - headers: I() - }); - return C(t); - } - /** - * Updates the fulfillment on an order. At the moment must update all properties as it acts like a POST. - * - * ```ts - * const patchFulfillmentRequest = { - * fulfillment: { - * fulfillmentType: 'PICKUP', - * pickupDetails: { - * curbsidePickupRequested: true, - * curbsidePickupDetails: { - * curbsideDetails: 'Contactless please' - * }, - * } - * } - * }; - * try { - * const response = await sdk.cart.patchFulfillment(patchFulfillmentRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async patchFulfillment(e) { - const t = await fetch(`${O}/${E(e)}/fulfillment`, { - method: "PATCH", - body: JSON.stringify({ - fulfillment: T(b(e.fulfillment)) - }), - headers: I() - }); - return C(t); - } - /** - * Updates your ASAP pickup order to the earliest available pickup time based on your order items and store settings (e.g. prep times). - * At the moment must provide all other existing fulfillment properties as it acts like a POST. Note that if - * you provide `fulfillment.pickupDetails.pickupAt`, it will just be ignored. - * - * ```ts - * const patchAsapPickupTimeRequest = { - * fulfillment: { - * fulfillmentType: 'PICKUP', - * pickupDetails: { - * curbsidePickupRequested: true, - * curbsidePickupDetails: { - * curbsideDetails: 'Contactless please' - * }, - * } - * } - * }; - * try { - * const response = await sdk.cart.patchAsapPickupTime(patchAsapPickupTimeRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link CartError} - */ - async patchAsapPickupTime(e) { - var t; - if (D(e)) { - const n = await (await fetch("/s/api/v1/resource", { - method: "POST", - headers: I(), - body: JSON.stringify({ - input: { - schedule: { - type: "schedule", - filters: { - location_id: null - } - } - } - }) - })).json(); - if ((t = n.schedule) != null && t.earliest_time.time_unix) { - const s = new Date(n.schedule.earliest_time.time_unix * 1e3).toISOString().split(".")[0] + "Z", o = { - orderId: E(e), - fulfillment: JSON.parse(JSON.stringify(e.fulfillment)) - }; - return o.fulfillment.pickupDetails || (o.fulfillment.pickupDetails = {}), o.fulfillment.pickupDetails.pickupAt = s, this.patchFulfillment(o); - } + SHIPMENT: "SHIPMENT", + PICKUP: "PICKUP", + DELIVERY: "DELIVERY", + MANUAL: "MANUAL" +}, X = { + ASAP: "ASAP", + SCHEDULED: "SCHEDULED" +}, x = { + CHOICE: "CHOICE", + TEXT: "TEXT", + GIFT_WRAP: "GIFT_WRAP", + GIFT_MESSAGE: "GIFT_MESSAGE" +}, ee = () => { + var i; + return (i = document.querySelector('meta[name="csrf-token"]')) == null ? void 0 : i.content; +}, E = () => ({ + Accept: "application/json", + "content-type": "application/json; charset=UTF-8", + "X-CSRF-TOKEN": ee() +}), N = (i) => { + const e = i + "=", r = decodeURIComponent(document.cookie).split(";"); + for (let n = 0; n < r.length; n++) { + let s = r[n]; + for (; s.charAt(0) == " "; ) + s = s.substring(1); + if (s.indexOf(e) == 0) + return s.substring(e.length, s.length); + } + return null; +}, T = "/s/api/v1/cart", K = "Something went wrong", V = (i, e) => { + const t = F(e.error || e.message || i.statusText), r = new Error(t); + if (e.errors) { + const n = {}; + Object.keys(e.errors).forEach((s) => { + const o = e.errors[s].map((a) => F(a)); + n[F(s)] = o; + }), r.errors = n; } + return e.fields && (r.fields = e.fields), i.status && (r.status = i.status, r.status === 200 && (r.status = 500)), r; +}, P = async (i) => { + const e = await i.json(); + if (!i.ok) + throw V(i, e); return { - data: { - cart: E(e) || "" - } + response: i, + data: e.data }; - } +}, te = async (i) => { + var e; + if (i.redirected) { + if (window.location.href === i.url) { + const t = await i.json(); + throw (e = t == null ? void 0 : t.response) != null && e.errors ? V(i, t.response.errors) : new Error(K); + } + window.location.href = i.url; + return; + } else if (!i.ok) { + const t = await i.json(); + throw V(i, t); + } + throw new Error(K); +}, F = (i) => i.replace(/[_][a-z0-9]/g, (e) => e.toUpperCase().replace("_", "")), U = (i) => i.replace(/[A-Z0-9]/g, (e) => `_${e.toLowerCase()}`), O = (i) => { + const e = {}; + return Object.keys(i).forEach((t) => { + const r = i[t]; + Array.isArray(r) ? e[U(t)] = Y(r) : r && typeof r == "object" ? e[U(t)] = O(r) : e[U(t)] = r; + }), e; +}, Y = (i) => { + const e = []; + return i.forEach((t) => { + Array.isArray(t) ? e.push(Y(t)) : t && typeof t == "object" ? e.push(O(t)) : e.push(t); + }), e; +}, re = (i) => { + const e = W(i); + return delete e.order_id, e; +}, Q = (i) => { + const e = JSON.parse(JSON.stringify(i)); + return e.fulfillmentType === M.PICKUP ? (e.pickupDetails || (e.pickupDetails = {}), e.pickupDetails.scheduleType || (e.pickupDetails.scheduleType = X.ASAP), e.pickupDetails.curbsidePickupRequested == null && (e.pickupDetails.curbsidePickupRequested = !1), e.pickupDetails.curbsidePickupDetails || (e.pickupDetails.curbsidePickupDetails = { + curbsideDetails: "" + })) : e.fulfillmentType === M.DELIVERY && e.deliveryDetails && (e.deliveryDetails.noContactDelivery == null && (e.deliveryDetails.noContactDelivery = !1), e.deliveryDetails.scheduleType || (e.deliveryDetails.scheduleType = X.ASAP)), e; +}, W = (i) => { + var n; + const e = JSON.parse(JSON.stringify(i.lineItem)); + e.quantity || (e.quantity = 1); + const t = O(e); + if ((n = t.modifiers) != null && n.length) { + const s = {}; + t.modifiers.forEach((o) => { + if (o.type) { + s[o.type] || (s[o.type] = {}); + const a = JSON.parse(JSON.stringify(o)); + delete a.id, delete a.type, s[o.type][o.id] = a; + } + }), t.modifiers = s; + } else + t.modifiers && delete t.modifiers; + return { + line_item: t, + fulfillment: O(Q(i.fulfillment)), + location_id: i.locationId, + // JSON.stringify will remove if undefined + order_id: k(i) + }; +}, k = (i) => i.orderId !== void 0 ? i.orderId : N("com_cart_id") || void 0; +class ie { + /** + * Retrieves the active cart id if it exists. + * + * ```ts + * const cartId = sdk.cart.getActiveId(); + * ``` + */ + getActiveId() { + return N("com_cart_id") || void 0; + } + /** + * Adds an item to your cart order. + * + * ```ts + * const addItemRequest = { + * lineItem: { + * itemId: '47HCEE6ZQUFFY3Y7X52CRVCO', + * variationId: '6YOTMYGOFTJR4PTTYRCLE7BH', + * quantity: 1, + * modifiers: [ + * { + * id: '6WVGAE3PKEHRWZHF54KR2PIN', + * type: 'CHOICE', + * choiceSelections: ['E3MWZ3PJ3VZDQWGW4G3KFZGW', 'GKCUYTB7ARN25J7BTRTOSVHO'] + * }, + * { + * id: '11ede91fbff63a3ab4dbde667deefb9b', + * type: 'TEXT', + * textEntry: 'my t-shirt-text' + * }, + * { + * id: '11ee185ca1cd3e98a25c9e3d692ffefb', + * type: 'GIFT_WRAP', + * choiceSelections: ['11ee185ca1cd7daebd029e3d692ffefb'] + * }, + * { + * id: '11ee185ca17973e490449e3d692ffefb', + * type: 'GIFT_MESSAGE', + * textEntry: 'happy bday' + * } + * ] + * }, + * fulfillment: { + * fulfillmentType: 'SHIPMENT' + * }, + * locationId: 'L36RW9ABXQTEE' + * }; + * try { + * const response = await sdk.cart.addItem(addItemRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link CartError} + */ + async addItem(e) { + const t = W(e), r = await fetch(`${T}/add`, { + method: "POST", + body: JSON.stringify(t), + headers: E() + }); + return await P(r); + } + /** + * Adds an item to a new order and redirects to checkout on success. + * + * ```ts + * const buyNowItemRequest = { + * lineItem: { + * itemId: '47HCEE6ZQUFFY3Y7X52CRVCO', + * variationId: '6YOTMYGOFTJR4PTTYRCLE7BH', + * quantity: 1, + * modifiers: [ + * { + * id: '6WVGAE3PKEHRWZHF54KR2PIN', + * type: 'CHOICE', + * choiceSelections: ['E3MWZ3PJ3VZDQWGW4G3KFZGW', 'GKCUYTB7ARN25J7BTRTOSVHO'] + * }, + * { + * id: '11ede91fbff63a3ab4dbde667deefb9b', + * type: 'TEXT', + * textEntry: 'my t-shirt-text' + * }, + * { + * id: '11ee185ca1cd3e98a25c9e3d692ffefb', + * type: 'GIFT_WRAP', + * choiceSelections: ['11ee185ca1cd7daebd029e3d692ffefb'] + * }, + * { + * id: '11ee185ca17973e490449e3d692ffefb', + * type: 'GIFT_MESSAGE', + * textEntry: 'happy bday' + * } + * ] + * }, + * fulfillment: { + * fulfillmentType: 'SHIPMENT' + * }, + * locationId: 'L36RW9ABXQTEE' + * }; + * try { + * await sdk.cart.buyNowItem(buyNowItemRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link CartError} + */ + async buyNowItem(e) { + const t = re(e), r = await fetch(`${T}/buy`, { + method: "POST", + body: JSON.stringify(t), + headers: E() + }); + return te(r); + } + /** + * Updates the quantity of an item on an order. Quantity must be greater than 0. + * + * ```ts + * const updateItemQuantityRequest = { + * orderItemId: '11ee2722e42886d182fa089e019fd17a', + * quantity: 2 + * }; + * try { + * const response = await SDK.cart.updateItemQuantity(updateItemQuantityRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link CartError} + */ + async updateItemQuantity(e) { + const t = await fetch(`${T}/update-quantity`, { + method: "POST", + body: JSON.stringify({ + order_item_id: e.orderItemId, + quantity: e.quantity, + order_id: k(e) + }), + headers: E() + }); + return P(t); + } + /** + * Removes a line item from an order. + * + * ```ts + * const removeItemRequest = { + * orderItemId: '11ee2722e42886d182fa089e019fd17a' + * }; + * try { + * const response = await SDK.cart.removeItem(removeItemRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link CartError} + */ + async removeItem(e) { + const t = await fetch(`${T}/remove-item`, { + method: "POST", + body: JSON.stringify({ + order_item_id: e.orderItemId, + order_id: k(e) + }), + headers: E() + }); + return P(t); + } + /** + * Updates the fulfillment on an order. At the moment must update all properties as it acts like a POST. + * + * ```ts + * const patchFulfillmentRequest = { + * fulfillment: { + * fulfillmentType: 'PICKUP', + * pickupDetails: { + * curbsidePickupRequested: true, + * curbsidePickupDetails: { + * curbsideDetails: 'Contactless please' + * }, + * } + * } + * }; + * try { + * const response = await sdk.cart.patchFulfillment(patchFulfillmentRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link CartError} + */ + async patchFulfillment(e) { + const t = await fetch(`${T}/${k(e)}/fulfillment`, { + method: "PATCH", + body: JSON.stringify({ + fulfillment: O(Q(e.fulfillment)), + location_id: e.locationId + }), + headers: E() + }); + return P(t); + } } -class K { - /** - * Used to load up to 5 resources. - * - * ```ts - * const resourceRequest = { - * 'categoryListResource': { - * type: 'category-list' - * }, - * 'categoryOptionsResource': { - * type: 'category-options', - * filters: { - * category_id: '2' - * } - * }, - * 'itemListResource': { - * type: 'item-list', - * filters: { - * 'option_choices': [ "11ee258c913644169c41a2491ad79fa8" ], - * 'square_online_id': true - * } - * }, - * 'cartResource': { - * type: 'cart', - * }, - * 'itemResource': { - * type: 'item', - * filters: { - * 'id': "47HCEE6ZQUFFY3Y7X52CRVCO" - * } - * } - * }; - * try { - * const resources = await sdk.resource.getResource(resourceRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link Error} - */ - async getResource(e) { - const t = {}; - for (const s in e) { - const o = e[s]; - t[s] = o; - } - return await (await fetch("/s/api/v1/resource", { - method: "POST", - body: JSON.stringify({ - input: t - }), - headers: I() - })).json(); - } +class se { + constructor(e) { + y(this, "initConfig"); + this.initConfig = e; + } + /** + * Fetches complete details about a past order using the jwt token associated with that order. + * + * ```ts + * const orderRequest = { + * jwtToken: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE...truncated', + * locationId: '11ecdbb1f3706d91a4ab2c601c83f953', + * fulfillments: ['shipping'] + * }; + * try { + * const response = await sdk.orders.getOrder(orderRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + */ + async getOrder(e) { + const t = e.jwtToken, r = e.locationId, n = e.fulfillments; + if (!t) + throw new Error("missing jwtToken"); + if (!r) + throw new Error("missing locationId"); + if (!n) + throw new Error("missing fulfillments"); + if (!this.initConfig.cmsSiteId) + throw new Error("missing cmsSiteId"); + if (!Array.isArray(n)) + throw new Error("fulfillments must be an array"); + const s = this.initConfig.cmsSiteId, o = ["shipping", "pickup", "delivery"]; + n.forEach((l) => { + if (!o.includes(l.toLowerCase())) + throw new Error("invalid value in fulfillments array: " + l); + }); + let a = `/app/cms/api/v1/sites/${s}/order-again/${t}?location=${r}`; + return n.forEach((l) => { + a += `&fulfillments[]=${l}`; + }), await (await fetch(a, { + method: "GET", + headers: E() + })).json(); + } } -class B { - constructor(e) { - y(this, "initConfig"); - this.initConfig = e; - } - /** - * Used to get a list of places autocompleted from an address (or partial address). - * - * ```ts - * const autocompletePlacesRequest = { - * address: '4 Pennsylvania Plaza' - * }; - * try { - * const response = await sdk.places.autocompletePlaces(autocompletePlacesRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link Error} - */ - async autocompletePlaces(e) { - const t = this.initConfig.userId, i = this.initConfig.siteId, n = e.address, s = `/app/store/api/v28/pub/users/${t}/sites/${i}/places?types=geocode&input=${n}`; - return await (await fetch(s, { - method: "GET", - headers: I() - })).json(); - } - /** - * Used to get the full details for a place using a `place_id` from autocompletePlaces. - * - * ```ts - * const getPlaceRequest = { - * placeId: 'G:ChIJFcXEG65ZwokRLH0n5pmtMIQ' - * }; - * try { - * const response = await sdk.places.getPlace(getPlaceRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link Error} - */ - async getPlace(e) { - const t = this.initConfig.userId, i = this.initConfig.siteId, n = e.placeId, s = `/app/store/api/v28/pub/users/${t}/sites/${i}/places/${n}`, a = await (await fetch(s, { - method: "GET", - headers: I() - })).json(); - return Array.isArray(a.data) && (a.data = {}), a; - } +class ne { + /** + * Used to load up to 5 resources. + * + * ```ts + * const resourceRequest = { + * 'categoryListResource': { + * type: 'category-list' + * }, + * 'categoryOptionsResource': { + * type: 'category-options', + * filters: { + * category_id: '2' + * } + * }, + * 'itemListResource': { + * type: 'item-list', + * filters: { + * 'option_choices': [ "11ee258c913644169c41a2491ad79fa8" ], + * 'square_online_id': true + * } + * }, + * 'cartResource': { + * type: 'cart', + * }, + * 'itemResource': { + * type: 'item', + * filters: { + * 'id': "47HCEE6ZQUFFY3Y7X52CRVCO" + * } + * } + * }; + * try { + * const resources = await sdk.resource.getResource(resourceRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link Error} + */ + async getResource(e) { + const t = {}; + for (const s in e) { + const o = e[s]; + t[s] = o; + } + return await (await fetch("/s/api/v1/resource", { + method: "POST", + body: JSON.stringify({ + input: t + }), + headers: E() + })).json(); + } } -class H extends Error { - constructor(t, i) { - super(t); - /** Provides the generic rendered HTML error template that would be rendered via the page on a failure. You can choose to use this to display a rendered error, or handle it how you see fit. */ - y(this, "template"); - this.template = i; - } +const oe = { + ADDRESS: "address", + GEOCODE: "geocode" +}; +class ae { + constructor(e) { + y(this, "initConfig"); + this.initConfig = e; + } + /** + * Used to get a list of places autocompleted from an address (or partial address). + * + * ```ts + * const autocompletePlacesRequest = { + * address: '4 Pennsylvania Plaza' + * types: 'address' + * }; + * try { + * const response = await sdk.places.autocompletePlaces(autocompletePlacesRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link Error} + */ + async autocompletePlaces(e) { + const t = this.initConfig.userId, r = this.initConfig.siteId, n = e.address, s = e.types ?? oe.GEOCODE, o = `/app/store/api/v28/pub/users/${t}/sites/${r}/places?types=${s}&input=${n}`; + return await (await fetch(o, { + method: "GET", + headers: E() + })).json(); + } + /** + * Used to get the full details for a place using a `place_id` from autocompletePlaces. + * + * ```ts + * const getPlaceRequest = { + * placeId: 'G:ChIJFcXEG65ZwokRLH0n5pmtMIQ' + * }; + * try { + * const response = await sdk.places.getPlace(getPlaceRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link Error} + */ + async getPlace(e) { + const t = this.initConfig.userId, r = this.initConfig.siteId, n = e.placeId, s = `/app/store/api/v28/pub/users/${t}/sites/${r}/places/${n}`, a = await (await fetch(s, { + method: "GET", + headers: E() + })).json(); + return Array.isArray(a.data) && (a.data = {}), a; + } +} +class ce extends Error { + constructor(t, r) { + super(t); + /** Provides the generic rendered HTML error template that would be rendered via the page on a failure. You can choose to use this to display a rendered error, or handle it how you see fit. */ + y(this, "template"); + this.template = r; + } } -class Q { - /** - * Used to load a Twig template via the API. - * - * ```ts - * const templateRequest = { - * template: 'sections/item-modal', - * props: { - * item: { - * filters: { - * id: item.id - * } - * } - * } - * }; - * try { - * const template = await sdk.template.getTemplate(templateRequest); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link TemplateError} - */ - async getTemplate(e) { - const t = await fetch("/s/api/v1/template", { - method: "POST", - body: JSON.stringify({ - template: e.template, - props: e.props - }), - headers: I() - }), i = await t.text(); - if (t.ok === !1) - throw new H("Unable to render template", i); - return i; - } +class le { + /** + * Used to load a Twig template via the API. + * + * ```ts + * const templateRequest = { + * template: 'sections/item-modal', + * props: { + * item: { + * filters: { + * id: item.id + * } + * } + * } + * }; + * try { + * const template = await sdk.template.getTemplate(templateRequest); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link TemplateError} + */ + async getTemplate(e) { + const t = await fetch("/s/api/v1/template", { + method: "POST", + body: JSON.stringify({ + template: e.template, + props: e.props + }), + headers: E() + }), r = await t.text(); + if (t.ok === !1) + throw new ce("Unable to render template", r); + return r; + } } -const w = { - INVALID_QUANTITY: "INVALID_QUANTITY", - SOLD_OUT: "SOLD_OUT", - STOCK_EXCEEDED: "STOCK_EXCEEDED", - PER_ORDER_MAX_EXCEEDED: "PER_ORDER_MAX_EXCEEDED" -}, R = (r) => { - const e = []; - return r.item_option_values && Object.keys(r.item_option_values).forEach((t) => { - e.push({ - itemOptionId: t, - choice: r.item_option_values[t].choice - }); - }), e; -}, W = (r) => { - const e = r.product_type_details.end_date, t = r.product_type_details.end_time; - let i = e + "T"; - const n = t.split(" "), s = n[0].split(":"); - let o = parseInt(s[0]) + (n[1] === "PM" ? 12 : 0); - o -= s[0] === "12" ? 12 : 0; - const a = s[1]; - return o.toString().length === 1 && (i += "0"), i += `${o}:${a}:00${r.product_type_details.timezone_info.utc_offset_string}`, new Date(i); +const C = { + INVALID_QUANTITY: "INVALID_QUANTITY", + SOLD_OUT: "SOLD_OUT", + STOCK_EXCEEDED: "STOCK_EXCEEDED", + PER_ORDER_MAX_EXCEEDED: "PER_ORDER_MAX_EXCEEDED" +}, H = (i) => { + const e = []; + return i.item_option_values && Object.keys(i.item_option_values).forEach((t) => { + e.push({ + itemOptionId: t, + choice: i.item_option_values[t].choice + }); + }), e; +}, de = (i) => { + const e = i.product_type_details.end_date, t = i.product_type_details.end_time; + let r = e + "T"; + const n = t.split(" "), s = n[0].split(":"); + let o = parseInt(s[0]) + (n[1] === "PM" ? 12 : 0); + o -= s[0] === "12" ? 12 : 0; + const a = s[1]; + return o.toString().length === 1 && (r += "0"), r += `${o}:${a}:00${i.product_type_details.timezone_info.utc_offset_string}`, new Date(r); }; -class Y { - /** - * Returns the variations for an item resource. - */ - getVariations(e) { - return e.variations; - } - /** - * Returns the item options for an item resource. - */ - getItemOptions(e) { - return e.item_options; - } - /** - * Returns the modifier lists for an item resource. - */ - getModifierLists(e) { - return e.modifier_lists; - } - /** - * Returns whether a particular variation is sold out. - */ - isVariationSoldOut(e) { - return e.sold_out || e.inventory_tracking_enabled && e.inventory === 0; - } - /** - * Returns the QuantityErrorType if there's an item quantity error with the item varation, otherwise null. - */ - getItemQuantityError(e, t, i) { - return i <= 0 ? w.INVALID_QUANTITY : this.isVariationSoldOut(t) ? w.SOLD_OUT : t.inventory_tracking_enabled && i > t.inventory ? w.STOCK_EXCEEDED : e.per_order_max && i > e.per_order_max ? w.PER_ORDER_MAX_EXCEEDED : null; - } - /** - * Returns whether all variations of an item are sold out. - */ - isItemSoldOut(e) { - return e.variations.every((t) => this.isVariationSoldOut(t)); - } - /** - * Returns all variations in stock for the selected options or variation. - */ - getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t = [], selectedVariationId: i = "", skipStockCheck: n = !1 }) { - return this.getVariations(e).reduce((s, o) => { - if (!i && o.item_option_values) { - const a = R(o); - if (!t.every((l) => a.find((d) => d.itemOptionId === l.itemOptionId && d.choice === l.choice))) - return s; - } else if (e.variations.length > 1 && o.id !== i) - return s; - return !n && this.isVariationSoldOut(o) || s.push(o), s; - }, []); - } - /** - * Returns whether an item's option choice is disabled based on the selected options. - */ - isOptionChoiceDisabledForSelectedOptions(e, t, i, n = !0) { - n && (i = i.filter((a) => a.itemOptionId !== t.itemOptionId)); - const s = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: i }); - let o = !1; - return s.forEach((a) => { - R(a).find((d) => d.itemOptionId === t.itemOptionId && d.choice === t.choice) && (o = !0); - }), !o; - } - /** - * Returns whether a modifier list is valid for the selected modifiers. - */ - isModifierListForSelectedModifiersValid(e, t) { - var a, l; - const i = t.find((d) => d.id == e.id), n = e.min_selected_modifiers, s = e.max_selected_modifiers; - let o = ((a = i == null ? void 0 : i.textEntry) == null ? void 0 : a.length) || 0; - if ((l = i == null ? void 0 : i.choiceSelections) != null && l.length) { - const d = i.choiceSelections.find((_) => { - var h; - return !((h = e.modifiers) != null && h.find((m) => m.id === _)); - }), p = i.choiceSelections.find((_) => { - var h, m; - return (m = (h = e.modifiers) == null ? void 0 : h.find((u) => u.id === _)) == null ? void 0 : m.sold_out; - }); - if (d || p) - return !1; - o = i.choiceSelections.length; - } - return n && s && n === s ? o === n : n && s ? o >= n && o <= s : s ? o <= s : n ? o >= n : !0; - } - /** - * Returns the disabled option choices for an item based on the selected options. - */ - getDisabledOptionChoicesForSelectedOptions(e, t, i, n = !0) { - const s = t.choices.map((a) => ({ - itemOptionId: t.id, - choice: a - })), o = []; - return n && (i = i.filter((a) => a.itemOptionId !== t.id)), s.forEach((a) => { - this.isOptionChoiceDisabledForSelectedOptions(e, a, i, n) && o.push(a.choice); - }), o; - } - /** - * Returns whether an item with any combination of selected options, modifiers, variationId, and quantity is valid. - * @throws {@link ValidateItemError} - */ - validateItem({ item: e, selectedOptions: t = [], selectedModifiers: i = [], selectedVariationId: n = "", quantity: s = void 0, skipStockCheck: o = !1, skipModifierCheck: a = !1 }) { - var S, g; - const l = []; - let d = !1, p = "", _ = w.SOLD_OUT; - const h = []; - (S = e.item_options) != null && S.length && !n ? e.item_options.forEach((c) => { - t != null && t.find((f) => f.itemOptionId === c.id && c.choices.includes(f.choice)) || l.push(c.id); - }) : !e.item_options && e.variations.length > 1 && !n && (d = !0); - let m = null; - if (l.length === 0 && !d) { - const c = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t, selectedVariationId: n, skipStockCheck: o }); - if (c.length === 0) { - const f = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t, selectedVariationId: n, skipStockCheck: !0 }); - f.length > 0 && (p = f[0].id); - } else if (m = c[0], s != null) { - const f = this.getItemQuantityError(e, m, s); - f && (_ = f, p = m.id); - } - } - if ((g = e.modifier_lists) != null && g.length && !a && e.modifier_lists.forEach((c) => { - this.isModifierListForSelectedModifiersValid(c, i) || h.push(c.id); - }), !m || l.length || p || h.length) { - const c = new Error("Failed to validate item."); - throw l.length && (c.itemOptionIds = l), d && (c.flatVariationSelectionMissing = !0), p && (c.variationId = p, c.quantityErrorType = _), h.length && (c.modifierListIds = h), c; - } - const u = { - itemId: e.id, - variationId: m.id, - modifiers: i - }; - return s && (u.quantity = s), u; - } - /** - * Returns the price of an item based on the selected options, modifiers, and/or variation id. - */ - getItemPrice({ item: e, selectedOptions: t = [], selectedVariationId: i = "", selectedModifiers: n = [], skipStockCheck: s = !1, skipModifierCheck: o = !1, formattedLocale: a = void 0 }) { - var d; - let l = null; - try { - l = this.validateItem({ item: e, selectedOptions: t, selectedVariationId: i, selectedModifiers: n, skipStockCheck: s, skipModifierCheck: o }); - } catch { - } - if (l) { - const p = e.variations.find((u) => u.id === l.variationId); - let _ = p.price.regular, h = p.price.sale; - (d = l.modifiers) == null || d.forEach((u) => { - var S, g; - if (u.type === v.CHOICE || u.type === v.GIFT_WRAP) { - const c = (S = e.modifier_lists) == null ? void 0 : S.find((f) => f.id === u.id); - c && ((g = c.modifiers) == null || g.forEach((f) => { - u.choiceSelections.includes(f.id) && f.price_money && (_ += f.price_money.amount, h += f.price_money.amount); - })); +class ue { + /** + * Returns the variations for an item resource. + */ + getVariations(e) { + return e.variations; + } + /** + * Returns the item options for an item resource. + */ + getItemOptions(e) { + return e.item_options; + } + /** + * Returns the modifier lists for an item resource. + */ + getModifierLists(e) { + return e.modifier_lists; + } + /** + * Returns whether a particular variation is sold out. + */ + isVariationSoldOut(e) { + return e.sold_out || e.inventory_tracking_enabled && e.inventory === 0; + } + /** + * Returns the QuantityErrorType if there's an item quantity error with the item varation, otherwise null. + */ + getItemQuantityError(e, t, r) { + return r <= 0 ? C.INVALID_QUANTITY : this.isVariationSoldOut(t) ? C.SOLD_OUT : t.inventory_tracking_enabled && r > t.inventory ? C.STOCK_EXCEEDED : e.per_order_max && r > e.per_order_max ? C.PER_ORDER_MAX_EXCEEDED : null; + } + /** + * Returns whether all variations of an item are sold out. + */ + isItemSoldOut(e) { + return e.variations.every((t) => this.isVariationSoldOut(t)); + } + /** + * Returns all variations in stock for the selected options or variation. + */ + getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t = [], selectedVariationId: r = "", skipStockCheck: n = !1 }) { + return this.getVariations(e).reduce((s, o) => { + if (!r && o.item_option_values) { + const a = H(o); + if (!t.every((c) => a.find((u) => u.itemOptionId === c.itemOptionId && u.choice === c.choice))) + return s; + } else if (e.variations.length > 1 && o.id !== r) + return s; + return !n && this.isVariationSoldOut(o) || s.push(o), s; + }, []); + } + /** + * Returns whether an item's option choice is disabled based on the selected options. + */ + isOptionChoiceDisabledForSelectedOptions(e, t, r, n = !0) { + n && (r = r.filter((a) => a.itemOptionId !== t.itemOptionId)); + const s = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: r }); + let o = !1; + return s.forEach((a) => { + H(a).find((u) => u.itemOptionId === t.itemOptionId && u.choice === t.choice) && (o = !0); + }), !o; + } + /** + * Returns whether a modifier list is valid for the selected modifiers. + */ + isModifierListForSelectedModifiersValid(e, t) { + var a, c; + const r = t.find((u) => u.id == e.id), n = e.min_selected_modifiers, s = e.max_selected_modifiers; + let o = ((a = r == null ? void 0 : r.textEntry) == null ? void 0 : a.length) || 0; + if ((c = r == null ? void 0 : r.choiceSelections) != null && c.length) { + const u = r.choiceSelections.find((I) => { + var h; + return !((h = e.modifiers) != null && h.find((m) => m.id === I)); + }), l = r.choiceSelections.find((I) => { + var h, m; + return (m = (h = e.modifiers) == null ? void 0 : h.find((f) => f.id === I)) == null ? void 0 : m.sold_out; + }); + if (u || l) + return !1; + o = r.choiceSelections.length; } - }); - const m = { - regular: _, - sale: h, - currency: p.price.currency - }; - if (a) { - let u; + return n && s && n === s ? o === n : n && s ? o >= n && o <= s : s ? o <= s : n ? o >= n : !0; + } + /** + * Returns the disabled option choices for an item based on the selected options. + */ + getDisabledOptionChoicesForSelectedOptions(e, t, r, n = !0) { + const s = t.choices.map((a) => ({ + itemOptionId: t.id, + choice: a + })), o = []; + return n && (r = r.filter((a) => a.itemOptionId !== t.id)), s.forEach((a) => { + this.isOptionChoiceDisabledForSelectedOptions(e, a, r, n) && o.push(a.choice); + }), o; + } + /** + * Returns whether an item with any combination of selected options, modifiers, variationId, and quantity is valid. + * @throws {@link ValidateItemError} + */ + validateItem({ item: e, selectedOptions: t = [], selectedModifiers: r = [], selectedVariationId: n = "", quantity: s = void 0, skipStockCheck: o = !1, skipModifierCheck: a = !1 }) { + var g, S; + const c = []; + let u = !1, l = "", I = C.SOLD_OUT; + const h = []; + (g = e.item_options) != null && g.length && !n ? e.item_options.forEach((d) => { + t != null && t.find((p) => p.itemOptionId === d.id && d.choices.includes(p.choice)) || c.push(d.id); + }) : !e.item_options && e.variations.length > 1 && !n && (u = !0); + let m = null; + if (c.length === 0 && !u) { + const d = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t, selectedVariationId: n, skipStockCheck: o }); + if (d.length === 0) { + const p = this.getInStockVariationsForSelectedOptionsOrVariation({ item: e, selectedOptions: t, selectedVariationId: n, skipStockCheck: !0 }); + p.length > 0 && (l = p[0].id); + } else if (m = d[0], s != null) { + const p = this.getItemQuantityError(e, m, s); + p && (I = p, l = m.id); + } + } + if ((S = e.modifier_lists) != null && S.length && !a && e.modifier_lists.forEach((d) => { + this.isModifierListForSelectedModifiersValid(d, r) || h.push(d.id); + }), !m || c.length || l || h.length) { + const d = new Error("Failed to validate item."); + throw c.length && (d.itemOptionIds = c), u && (d.flatVariationSelectionMissing = !0), l && (d.variationId = l, d.quantityErrorType = I), h.length && (d.modifierListIds = h), d; + } + const f = { + itemId: e.id, + variationId: m.id, + modifiers: r + }; + return s && (f.quantity = s), f; + } + /** + * Returns the price of an item based on the selected options, modifiers, and/or variation id. + */ + getItemPrice({ item: e, selectedOptions: t = [], selectedVariationId: r = "", selectedModifiers: n = [], skipStockCheck: s = !1, skipModifierCheck: o = !1, formattedLocale: a = void 0 }) { + var u; + let c = null; try { - u = new Intl.NumberFormat(a, { - style: "currency", - currency: p.price.currency - }); + c = this.validateItem({ item: e, selectedOptions: t, selectedVariationId: r, selectedModifiers: n, skipStockCheck: s, skipModifierCheck: o }); } catch { - u = new Intl.NumberFormat("en-US", { - style: "currency", - currency: p.price.currency - }); } - m.regularFormatted = u.format(_), m.saleFormatted = u.format(h); - } - return m; + if (c) { + const l = e.variations.find((f) => f.id === c.variationId); + let I = l.price.regular, h = l.price.sale; + (u = c.modifiers) == null || u.forEach((f) => { + var g, S; + if (f.type === x.CHOICE || f.type === x.GIFT_WRAP) { + const d = (g = e.modifier_lists) == null ? void 0 : g.find((p) => p.id === f.id); + d && ((S = d.modifiers) == null || S.forEach((p) => { + f.choiceSelections.includes(p.id) && p.price_money && (I += p.price_money.amount, h += p.price_money.amount); + })); + } + }); + const m = { + regular: I, + sale: h, + currency: l.price.currency + }; + if (a) { + let f; + try { + f = new Intl.NumberFormat(a, { + style: "currency", + currency: l.price.currency + }); + } catch { + f = new Intl.NumberFormat("en-US", { + style: "currency", + currency: l.price.currency + }); + } + m.regularFormatted = f.format(I), m.saleFormatted = f.format(h); + } + return m; + } + return null; + } + /** + * Returns whether an item is an event and has ended. + */ + isEventItemInThePast(e) { + return e.square_online_type !== "EVENT" ? !1 : de(e) <= /* @__PURE__ */ new Date(); + } + /** + * Returns whether an item is a preorder and the cutoff time has passed. + */ + isPreorderItemCutoffInThePast(e) { + if (!e.preordering.PICKUP) + return !1; + const t = e.fulfillment_availability.PICKUP[0].availability_cutoff_at; + return new Date(t) <= /* @__PURE__ */ new Date(); } - return null; - } - /** - * Returns whether an item is an event and has ended. - */ - isEventItemInThePast(e) { - return e.square_online_type !== "EVENT" ? !1 : W(e) <= /* @__PURE__ */ new Date(); - } - /** - * Returns whether an item is a preorder and the cutoff time has passed. - */ - isPreorderItemCutoffInThePast(e) { - if (!e.preordering.PICKUP) - return !1; - const t = e.fulfillment_availability.PICKUP[0].availability_cutoff_at; - return new Date(t) <= /* @__PURE__ */ new Date(); - } } -class Z { - constructor(e) { - y(this, "initConfig"); - this.initConfig = e; - } - /** - * Used to try and get the coordinates of the buyer based on their IP address. - * - * ```ts - * try { - * const coordinates = await sdk.customers.getCoordinates(); - * } catch (error) { - * // Handle errors - * } - * ``` - * @throws {@link Error} - */ - async getCoordinates() { - const t = `/app/website/cms/api/v1/users/${this.initConfig.userId}/customers/coordinates`; - let n = await (await fetch(t, { - method: "GET", - headers: I() - })).json(); - return Array.isArray(n) && (n = {}), n; - } +const J = "customer_xsrf", B = "/app/accounts/v1", fe = "/ping", pe = "/loyalty/account/search"; +var D, v, $, R, z, A, L; +class he { + constructor(e) { + w(this, v); + w(this, R); + /** + * Calling ping will set the session ID and XSRF token cookies needed for subsequent requests + */ + w(this, A); + w(this, D, void 0); + G(this, D, e); + } + async getLoyaltyAccount(e) { + const t = { + phone: e + }, r = await _(this, v, $).call(this, `${B}${pe}`, "POST", t); + return (r == null ? void 0 : r.data.loyalty_account) ?? null; + } } -class q { - constructor(e) { - y(this, "version", "4.4.3"); - y(this, "cart"); - y(this, "places"); - y(this, "resource"); - y(this, "template"); - y(this, "customers"); - y(this, "helpers"); - if (!e.userId) - throw new Error("missing user id"); - if (!e.siteId) - throw new Error("missing site id"); - if (!Number.isInteger(Number(e.userId))) - throw new Error("invalid user id"); - if (!Number.isInteger(Number(e.siteId))) - throw new Error("invalid site id"); - this.cart = new J(), this.places = new B(e), this.resource = new K(), this.template = new Q(), this.customers = new Z(e), this.helpers = { - item: new Y() +D = new WeakMap(), v = new WeakSet(), $ = async function(e, t, r = null, n = !0) { + let s = N(J); + s || (await _(this, A, L).call(this), s = N(J) ?? ""); + const o = { + method: t, + headers: _(this, R, z).call(this, s) + }; + r && (o.body = JSON.stringify(r)); + const a = await fetch(e, o); + if (!a.ok) { + if (a.status === 404) + return null; + if (a.status === 419 && n) + return await _(this, A, L).call(this), await _(this, v, $).call(this, e, t, r, !1); + throw new Error(`Error ${a.status}: ${a.statusText}`); + } + return await a.json(); +}, R = new WeakSet(), z = function(e) { + return { + Accept: "application/json", + "Content-Type": "application/json; charset=UTF-8", + "X-XSRF-TOKEN": e, + "Square-Merchant-Token": j(this, D) }; - } +}, A = new WeakSet(), L = async function() { + const e = `${B}${fe}`; + await fetch(e); +}; +class me { + constructor(e) { + y(this, "initConfig"); + y(this, "buyersServiceClient"); + this.initConfig = e, this.buyersServiceClient = new he(e.merchantId); + } + /** + * Used to try and get the coordinates of the buyer based on their IP address. + * If the coordinates can't be determined, this method returns an empty object. + * + * ```ts + * try { + * const coordinates = await sdk.customers.getCoordinates(); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link Error} + */ + async getCoordinates() { + const t = `/app/website/cms/api/v1/users/${this.initConfig.userId}/customers/coordinates`; + let n = await (await fetch(t, { + method: "GET", + headers: E() + })).json(); + return Array.isArray(n) && (n = {}), n; + } + /** + * Search for an existing customer loyalty account by phone number. + * If no loyalty account exists, this method returns an empty object. + * + * ```ts + * try { + * const loyaltyAccount = await sdk.customers.getLoyaltyAccount(); + * } catch (error) { + * // Handle errors + * } + * ``` + * @throws {@link Error} + */ + async getLoyaltyAccount(e) { + const t = e.phone, r = await this.buyersServiceClient.getLoyaltyAccount(t); + return r ? { + data: r + } : {}; + } +} +class Ee { + constructor(e) { + y(this, "version", "0.0.0-semantic-release"); + y(this, "cart"); + y(this, "orders"); + y(this, "places"); + y(this, "resource"); + y(this, "template"); + y(this, "customers"); + y(this, "helpers"); + if (!e.userId) + throw new Error("missing user id"); + if (!e.siteId) + throw new Error("missing site id"); + if (!e.merchantId) + throw new Error("missing merchant id"); + if (!Number.isInteger(Number(e.userId))) + throw new Error("invalid user id"); + if (!Number.isInteger(Number(e.siteId))) + throw new Error("invalid site id"); + this.cart = new ie(), this.orders = new se(e), this.places = new ae(e), this.resource = new ne(), this.template = new le(), this.customers = new me(e), this.helpers = { + item: new ue() + }; + } } export { - q as default + Ee as default }; diff --git a/theme/components/async/schedule-selector.html.twig b/theme/components/async/schedule-selector.html.twig new file mode 100644 index 0000000..f9efd82 --- /dev/null +++ b/theme/components/async/schedule-selector.html.twig @@ -0,0 +1,35 @@ +{% async:content %} + {% set options = [] %} + {% for time in times %} + {% set options = options|merge([{ label: time.label, value: time.time_unix }]) %} + {% endfor %} + + {% embed 'partials/form/radio' with { + options: options, + hideLabel: true, + showDivider: true, + variant: 'row', + isRequired: true, + parentModel: 'selectedTimeId', + } %} + {% block data %} + init() { + this.$watch('selectedTimeId', (value) => { + Alpine.store('dialog').updateDialogOptions('disablePrimaryButton', !value); + }); + }, + {% endblock %} + {% endembed %} +{% endasync %} + +{% async:loading %} +
{{ include('partials/ui/loader') }}
+{% endasync %} + +{% schema %} + { + "times": { + "type": "array" + } + } +{% endschema %} \ No newline at end of file diff --git a/theme/components/sections/store/item/main.html.twig b/theme/components/sections/store/item/main.html.twig index 189de30..5ab5d91 100644 --- a/theme/components/sections/store/item/main.html.twig +++ b/theme/components/sections/store/item/main.html.twig @@ -136,6 +136,7 @@ {{ include('partials/components/store/item/fulfillment', { fulfillments: product.fulfillment.methods, defaultFulfillment, + defaultLocation, }) }} {% endif %} diff --git a/theme/config/theme.json b/theme/config/theme.json index f440a11..5755932 100644 --- a/theme/config/theme.json +++ b/theme/config/theme.json @@ -13,5 +13,12 @@ "xxl": "shared.sizes.xx_large", "xxxl": "shared.sizes.xxx_large" }, - "breakpoints": [699, 700, 992, 1200, 1600] + "translations": "localize:", + "breakpoints": [ + 699, + 700, + 992, + 1200, + 1600 + ] } diff --git a/theme/layouts/theme.html.twig b/theme/layouts/theme.html.twig index 36874d9..5e5bdf0 100644 --- a/theme/layouts/theme.html.twig +++ b/theme/layouts/theme.html.twig @@ -30,8 +30,9 @@ @@ -53,7 +54,16 @@ {% endblock %} - {% set defaultFulfillment = (square.store.default_item_fulfillment|split(',')[0])|default('SHIPMENT') %} + {% set defaultFulfillment = (square.store.default_item_fulfillment|split(',')[0])|default('PICKUP') %} + + {# Set the default location from the query and fallback to the first location #} + {% set locations = location_list({}) %} + {% set queryLocation = locations|filter(location => location.id == request.query.location_id)|first %} + {% set defaultLocation = locations|filter(location => location[defaultFulfillment].enabled)|first %} + {% set firstLocation = locations|first %} + {% set queryOrDefaultLocation = queryLocation ? queryLocation : defaultLocation %} + {% set defaultLocation = queryOrDefaultLocation ? queryOrDefaultLocation : firstLocation %} + {% set defaultLocationId = defaultLocation.id %} {% if config.theme.shouldUseSiteStyles %} {% set themeContainerWidth = max(square.site.styles.spacing.site_width, 1200) %} @@ -74,6 +84,8 @@ locale: square.store.locale, currency: square.store.currency, defaultFulfillment, + defaultLocationId, + defaultLocation, }|json_encode }} diff --git a/theme/partials/components/fulfillment-and-scheduling/choose-location.html.twig b/theme/partials/components/fulfillment-and-scheduling/choose-location.html.twig new file mode 100644 index 0000000..7ead2b9 --- /dev/null +++ b/theme/partials/components/fulfillment-and-scheduling/choose-location.html.twig @@ -0,0 +1,27 @@ +{{ register_asset('css/components/fulfillment-and-scheduling/choose-location.css') }} + +
+ {% if location %} + {% set formattedLocation = location.address|address_format_multiline %} +

{{ formattedLocation|first }}

+ {% else %} +

{{ 'partials.components.fulfillment-and-scheduling.empty_label'|localize }}

+ {% endif %} + + {% set buttonLabel = location ? 'shared.buttons.change'|localize : 'shared.buttons.select'|localize %} + {{ include('partials/ui/button', { + label: buttonLabel, + size: 'medium', + variant: 'neutral', + action: '$store.siteWideFulfillment.openLocationsDialog()', + }) }} +
+ +
+ {{ include('partials/ui/loader') }} +
\ No newline at end of file diff --git a/theme/partials/components/fulfillment-and-scheduling/fulfillment-selection.html.twig b/theme/partials/components/fulfillment-and-scheduling/fulfillment-selection.html.twig new file mode 100644 index 0000000..2243704 --- /dev/null +++ b/theme/partials/components/fulfillment-and-scheduling/fulfillment-selection.html.twig @@ -0,0 +1,30 @@ +{{ register_asset('js/components/fulfillment-and-scheduling/fulfillment-selection.js', { defer: false }) }} + +{% set options = [] %} +{% if fulfillments.PICKUP %} + {% set options = options|merge([{ label: 'partials.components.store.item.fulfillment.buttons.pickup'|localize, value: 'PICKUP' }]) %} +{% endif %} +{% if fulfillments.DELIVERY %} + {% set options = options|merge([{ label: 'partials.components.store.item.fulfillment.buttons.delivery'|localize, value: 'DELIVERY' }]) %} +{% endif %} + +{% if options|length %} + {% set fulfillmentSelectionDataId = 'fulfillment-selection-data-' ~ random() %} + + +
+ {% embed 'partials/ui/segmented-control' with { + options, + fullWidth: true, + } %} + {% endembed %} +
+{% endif %} \ No newline at end of file diff --git a/theme/partials/components/fulfillment-and-scheduling/scheduling.html.twig b/theme/partials/components/fulfillment-and-scheduling/scheduling.html.twig new file mode 100644 index 0000000..522029a --- /dev/null +++ b/theme/partials/components/fulfillment-and-scheduling/scheduling.html.twig @@ -0,0 +1,17 @@ + + +
+

+ {{ include('partials/ui/button', { + variant: 'neutral', + size: 'medium', + label: 'shared.buttons.change'|localize, + action: 'openSchedulingDialog' + }) + }} +
diff --git a/theme/partials/components/fulfillment-and-scheduling/site-wide-fulfillment.html.twig b/theme/partials/components/fulfillment-and-scheduling/site-wide-fulfillment.html.twig new file mode 100644 index 0000000..002cad6 --- /dev/null +++ b/theme/partials/components/fulfillment-and-scheduling/site-wide-fulfillment.html.twig @@ -0,0 +1,47 @@ +{{ register_asset('css/components/fulfillment-and-scheduling/site-wide-fulfillment.css') }} +{{ register_asset('js/components/fulfillment-and-scheduling/site-wide-fulfillment.js', { defer: false }) }} + +{{ register_asset('css/components/fulfillment-and-scheduling/scheduling.css') }} +{{ register_asset('js/components/fulfillment-and-scheduling/scheduling.js', { defer: false }) }} + +{{ register_asset('js/components/gallery/dialog.js', { defer: false }) }} +{{ register_asset('css/components/gallery/dialog.css') }} + +{{ register_asset('js/components/fulfillment-and-scheduling/scheduling-dialog.js', { defer: false }) }} +{{ register_asset('css/components/fulfillment-and-scheduling/scheduling-dialog.css') }} + +{# TODO: Registering here as a workaround for css not loading properly in async templates for scheduling #} +{{ register_asset('css/form/radio.css') }} +{{ register_asset('js/form/radio.js', { defer: false }) }} + +{% set location = defaultLocation %} + + + +
+
+ {# Fulfillment selector section #} + {{ include('partials/components/fulfillment-and-scheduling/fulfillment-selection', { + fulfillments: square.store.fulfillment_support, + defaultFulfillment, + }) }} + +
+ {# Scheduling section #} +
+ {{ include('partials/components/fulfillment-and-scheduling/scheduling') }} +
+ + {# Choose location section #} +
+ {{ include('partials/components/fulfillment-and-scheduling/choose-location', { location: defaultLocation }) }} +
+
+
+
diff --git a/theme/partials/components/location-selector.html.twig b/theme/partials/components/location-selector.html.twig new file mode 100644 index 0000000..307acc6 --- /dev/null +++ b/theme/partials/components/location-selector.html.twig @@ -0,0 +1,39 @@ +{% set options = [] %} +{% for location in locations %} + {% set formattedAddress = location.address|address_format_multiline %} + {% set options = options|merge([{ label: location.name, sublabel: formattedAddress[0], value: location.id, distance: location_id_to_distance[location.id] }]) %} +{% endfor %} + +{% embed 'partials/form/radio' with { + options: options, + hideLabel: true, + showDivider: true, + variant: 'row', + isRequired: true, + parentModel: 'locationId', +} %} + {% block data %} + init() { + this.$watch('locationId', (value) => { + Alpine.store('dialog').updateDialogOptions('disablePrimaryButton', !value); + }); + }, + {% endblock %} + {% block side %} + {% set distance = formatted_distance[loop.index0] %} + {% if distance %} + {{ distance }} + {% endif %} + {% endblock %} +{% endembed %} + +{% schema %} +{ + "locations": { + "type": "array" + }, + "formatted_distance": { + "type": "array" + } +} +{% endschema %} \ No newline at end of file diff --git a/theme/partials/components/store/order/item-list.html.twig b/theme/partials/components/store/order/item-list.html.twig new file mode 100644 index 0000000..b0b7b35 --- /dev/null +++ b/theme/partials/components/store/order/item-list.html.twig @@ -0,0 +1,30 @@ +{{ register_asset('css/components/store/order/item-list.css') }} + +
+ {% for category in categories %} + {% if category.items|length %} + {% set availableCategoryItems = category.items|filter(item => item.fulfillment.methods[fulfillment]) %} + {% if availableCategoryItems|length %} +
+

{{ category.name }}

+ {% set orderingItems = [] %} + {% for item in availableCategoryItems %} + {% set orderingItems = orderingItems|merge([{ item }]) %} + {% endfor %} + {{ include('partials/components/collection/list', { items: orderingItems }) }} +
+ {% endif %} + {% endif %} + {% endfor %} +
+ +{% schema %} +{ + "categories": { + "type": "category-list" + }, + "fulfillment": { + "type": "string" + } +} +{% endschema %} \ No newline at end of file diff --git a/theme/partials/form/choice-carousel.html.twig b/theme/partials/form/choice-carousel.html.twig index 0f82b34..ac9d5ad 100644 --- a/theme/partials/form/choice-carousel.html.twig +++ b/theme/partials/form/choice-carousel.html.twig @@ -36,6 +36,11 @@ for="choice-{{ optionId }}" class="form-choice__label" > + {% if option.overheadLabel %} +
+ {{ option.overheadLabel }} +
+ {% endif %} {% if option.icon %} {{ include('partials/ui/icon', { name: option.icon, ariaHidden: true }) }} diff --git a/theme/partials/ui/button.html.twig b/theme/partials/ui/button.html.twig index 0b9068d..98545a7 100644 --- a/theme/partials/ui/button.html.twig +++ b/theme/partials/ui/button.html.twig @@ -30,8 +30,9 @@ ui-button ui-button--{{ size|default('medium') }} ui-button--{{ alignment|default('center') }} - ui-button--{{ buttonVariant }}{% if buttonVariant == 'primary' or buttonVariant == 'secondary' or buttonVariant == 'neutral' %}-{{ style|default(defaultStyle) }}{% endif %}{{ destructive ? '-destructive' : '' }} + ui-button--{{ buttonVariant }}{% if buttonVariant == 'primary' or buttonVariant == 'secondary' or buttonVariant == 'neutral' %}-{{ style|default(defaultStyle) }}{% endif %}{{ destructive ? '-destructive' : '' }} {{ fullWidth ? 'ui-button--fullwidth' : '' }} + {{ disableHoverStyles ? 'ui-button--disable-hover' : '' }} {{ buttonClasses }} " {% if buttonType %}{{ buttonType }} {% else %} type="button" {% endif %} diff --git a/theme/partials/ui/segmented-control.html.twig b/theme/partials/ui/segmented-control.html.twig new file mode 100644 index 0000000..c34cceb --- /dev/null +++ b/theme/partials/ui/segmented-control.html.twig @@ -0,0 +1,26 @@ +{% if request.async == true %} + +{% else %} + {{ register_asset('css/ui/segmented-control.css') }} +{% endif %} + +
+ {% for option in options %} + {% embed 'partials/ui/button' with { + label: option.label, + size: 'medium', + fullWidth: true, + disableHoverStyles: true, + buttonClasses: 'ui-segmented-control__button', + action: "$dispatch('segmented-control:click', '" ~ option.value ~ "')", + } %} + {% block attributes %} + :variant="model === '{{ option.value }}' ? 'primary' : 'neutral'" + style="fill" + {% endblock %} + {% endembed %} + {% endfor %} +
\ No newline at end of file diff --git a/theme/templates/components/dialogs/locations-content.html.twig b/theme/templates/components/dialogs/locations-content.html.twig index 0b5b58e..f79a512 100644 --- a/theme/templates/components/dialogs/locations-content.html.twig +++ b/theme/templates/components/dialogs/locations-content.html.twig @@ -7,34 +7,50 @@ }|json_encode }} + + +
-

+ {% set fulfillmentKey = fulfillment|lower %} +

{% embed 'partials/form/autocomplete' with { - label: 'templates.components.dialogs.locations.input.label'|localize, - placeholder: 'templates.components.dialogs.locations.input.placeholder'|localize, + label: ('templates.components.dialogs.locations.' ~ fulfillmentKey ~ '.input.label')|localize, + placeholder: ('templates.components.dialogs.locations.' ~ fulfillmentKey ~ '.input.placeholder')|localize, } %} {% block data %} ...itemSuggestions(), {% endblock %} {% endembed %} + {% if fulfillment == 'DELIVERY' %} + + {% endif %}

- {{ 'templates.components.dialogs.locations.results.label'|localize }}: + {{ ('templates.components.dialogs.locations.' ~ fulfillmentKey ~ '.results.label')|localize }}: +

-
- {{ async('location-selector', 'location-selector', { props: { locations, formatted_distance: [] } }) }} +
+ {{ include('partials/components/location-selector', { locations, formatted_distance: [] }) }}
diff --git a/theme/templates/components/dialogs/scheduling-content.html.twig b/theme/templates/components/dialogs/scheduling-content.html.twig new file mode 100644 index 0000000..7aa0e64 --- /dev/null +++ b/theme/templates/components/dialogs/scheduling-content.html.twig @@ -0,0 +1,59 @@ + + +
+ +
+
+
+ + {{ async('schedule-selector', 'schedule-selector', { props: { times } }) }} +
+
+
+
+ +{% schema %} + { + "dates": { + "type": "array" + }, + "selectedTimeId": { + "type": "string", + "optional": true + }, + "selectedDateId": { + "type": "string", + "optional": true + }, + "times": { + "type": "array" + } + } +{% endschema %} \ No newline at end of file diff --git a/theme/templates/store/item.html.twig b/theme/templates/store/item.html.twig index 390f331..2b6fd55 100644 --- a/theme/templates/store/item.html.twig +++ b/theme/templates/store/item.html.twig @@ -17,7 +17,7 @@ {# Sets default location by fulfillment #} {% if locations|length %} - {% set defaultLocation = locations|filter(location => location[defaultFulfillment|lower].enabled)|first %} + {% set defaultLocation = locations|filter(location => location[defaultFulfillment].enabled)|first %} {% if defaultFulfillment == 'SHIPMENT' %} {% set defaultLocation = locations|filter(location => location.is_shipping_location)|first %} {% endif %} @@ -30,7 +30,7 @@ class="container page item-page" x-data="productPage('{{ defaultFulfillment }}', '{{ defaultLocationId }}')" > - {{ include('components/sections/store/item/main', { product: item, locations }) }} + {{ include('components/sections/store/item/main', { product: item, locations, defaultFulfillment, defaultLocation }) }} {# retrieve similar items, filter out the currently viewed item #} {% set similarItems = similar_items(item) | filter(sim_item => sim_item.id != item.id ) %} diff --git a/theme/templates/store/order.html.twig b/theme/templates/store/order.html.twig new file mode 100644 index 0000000..6cc1dcd --- /dev/null +++ b/theme/templates/store/order.html.twig @@ -0,0 +1,30 @@ +{% extends "layouts/theme" %} + +{% block main_content %} + +{{ register_asset('css/templates/store/order.css') }} + +
+ {{ include('partials/components/fulfillment-and-scheduling/site-wide-fulfillment', { defaultLocation }) }} + +
+ {{ include('partials/components/store/order/item-list', { categories, fulfillment: defaultFulfillment }) }} +
+ +
+ {{ include('partials/ui/loader') }} +
+
+ +{% endblock %} + +{% schema %} +{ + "categories": { + "type": "category-list" + } +} +{% endschema %} diff --git a/theme/translations/en.json b/theme/translations/en.json index 0e10d26..c393505 100644 --- a/theme/translations/en.json +++ b/theme/translations/en.json @@ -13,7 +13,9 @@ "next": "Next", "prev": "Previous", "apply": "Apply", - "reset": "Reset" + "reset": "Reset", + "change": "Change", + "select": "Select" }, "errors": { "general": "Something went wrong! Please try again." @@ -27,6 +29,26 @@ "x_large": "XL", "xx_large": "XXL", "xxx_large": "XXXL" + }, + "week": { + "short": { + "sunday": "Sun", + "monday": "Mon", + "tuesday": "Tue", + "wednesday": "Wed", + "thursday": "Thu", + "friday": "Fri", + "saturday": "Sat" + }, + "long": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + } } }, "components": { @@ -111,6 +133,9 @@ "quick_add.status.success": "Added to cart!", "image.alt": "{{name}} image" }, + "components.fulfillment-and-scheduling": { + "empty_label": "Choose location" + }, "components.store.filters": { "buttons": { "filters": "Filters", @@ -172,7 +197,8 @@ "fulfillment": { "label": "How to get it", "buttons.shipping": "Ship", - "buttons.pickup": "Pickup" + "buttons.pickup": "Pickup", + "buttons.delivery": "Delivery" }, "modifiers": { "label": "Modifiers", @@ -289,7 +315,42 @@ "empty_locations.label": "Check zip code", "input.label": "City, street, or zip code", "input.placeholder": "Zip code", - "results.label": "Nearest stores" + "results.label": "Nearest stores", + "pickup": { + "has_locations": { + "label": "Pickup location" + }, + "empty_locations": { + "label": "Check zip code" + }, + "input": { + "label": "City, street, or zip code", + "placeholder": "Zip code" + }, + "results": { + "label": "Nearest stores" + } + }, + "delivery": { + "has_locations": { + "label": "Delivery address" + }, + "empty_locations": { + "label": "Check address" + }, + "input": { + "label": "We need your address to deliver your order", + "placeholder": "Enter your address" + }, + "address_second_line": { + "input": { + "placeholder": "Apt or unit number" + } + }, + "results": { + "label": "Nearest stores" + } + } }, "preorder": { "heading": "Preorder available",