diff --git a/blocks/commerce-checkout-address-form/commerce-checkout.css b/blocks/commerce-checkout-address-form/commerce-checkout-address-form.css similarity index 100% rename from blocks/commerce-checkout-address-form/commerce-checkout.css rename to blocks/commerce-checkout-address-form/commerce-checkout-address-form.css diff --git a/blocks/commerce-checkout-address-form/commerce-checkout.js b/blocks/commerce-checkout-address-form/commerce-checkout-address-form.js similarity index 100% rename from blocks/commerce-checkout-address-form/commerce-checkout.js rename to blocks/commerce-checkout-address-form/commerce-checkout-address-form.js diff --git a/blocks/commerce-checkout-address-lookup/README.md b/blocks/commerce-checkout-address-lookup/README.md new file mode 100644 index 000000000..04cdae2d2 --- /dev/null +++ b/blocks/commerce-checkout-address-lookup/README.md @@ -0,0 +1,297 @@ +# Adding 3rd Party Address Lookup Integration + +This guide explains how to override any field in address forms on checkout to extend it and integrate 3rd party services. As an example, we demonstrate how to replace the default address field with a custom one and integrate Google API for Address Lookup functionality. + +## Steps to Integrate 3rd Party Address Lookup + +### 1. Identify the Container and Override the Default Field + +Locate the required container in the `commerce-checkout.js` block. The same approach works for both `Addresses` and `AddressForm` containers: +- **Addresses container**: Used when customers have saved addresses. +- **AddressForm container**: Used when no addresses are saved. + +To override the `street` field in the `AddressForm` container for the shipping address, pass the `AddressFormInput_street` slot parameter. Here’s how you remove the default field: + +```js +shippingForm = await AccountProvider.render(AddressForm, { + // Other parameters + slots: { + AddressFormInput_street: async (ctx) => { + }, + }, +}); +``` + +Use a function to create and render a custom input field. Below is an example using the Input component from the @dropins/tools package: + +### 2. Define a Function to Generate the Custom Field Markup + +This is plain JS code used to generate markup for new input. In this example we using Input component provided by dropin tools package (make sure to add appropriate import - `import { Input } from '@dropins/tools/components.js';`). +Callback destructure from `context` passed into slot from container allowing to make custom input fully functional and integrated with form. +As a result of these changes default input will be replcaed by custom one matching general look & feel but not functional yet. +```js +const generateMarkup = async (context) => { + const { inputName, handleOnChange, handleOnBlur, handleOnFocus, config } = context; + + const wrapper = document.createElement('div'); + const errorContainer = document.createElement('div'); + errorContainer.classList.add('dropin-field__hint', 'dropin-field__hint--medium', 'dropin-field__hint--error'); + errorContainer.style.display = 'none'; + + const inputComponent = await UI.render(Input, { + name: inputName, + onChange: handleOnChange, + onBlur: handleOnBlur, + onFocus: handleOnFocus, + floatingLabel: `${config.label} *`, + placeholder: config.label, + })(wrapper); + + wrapper.appendChild(errorContainer); + ctx.appendChild(wrapper); + + return { inputElement: wrapper.querySelector('input'), inputComponent, errorContainer }; +}; + +const markupElements = await generateMarkup(ctx); +``` + +This replaces the default input field with a custom one that matches the design but lacks functionality. + +### 3. Replicate Default Functionality for the Custom Input + +Make the custom input fully functional by using the onChange callback provided by the slot context. This enables validation and integration with the form: + +```js +const handleStateChange = (next, { inputElement, inputComponent, errorContainer }) => { + const { errorMessage, errors, handleOnChange, handleOnBlur } = next; + + const getNextProps = (prev, error) => ({ + ...prev, + error, + onChange: (e) => handleOnChange(e, errors), + onBlur: (e) => handleOnBlur(e, errors), + }); + + if (errorMessage) { + errorContainer.innerText = errorMessage; + errorContainer.style.display = 'block'; + inputComponent.setProps((prev) => getNextProps(prev, true)); + } else { + errorContainer.innerText = ''; + errorContainer.style.display = 'none'; + inputComponent.setProps((prev) => getNextProps(prev, false)); + } + + if (document.activeElement === inputElement) { + setTimeout(() => inputElement.focus(), 0); + } +}; + +ctx.onChange((nextState) => handleStateChange(nextState, markupElements)); +``` + +### 4. Enable Google Address Lookup Integration + +Add the Google Places API to the checkout page: + +```js +const scriptUrl = 'https://maps.googleapis.com/maps/api/js?key={GOOGLE_API_KEY}&loading=async&libraries=places'; + + +if (!document.querySelector(`script[src="${scriptUrl}"]`)) { + const script = document.createElement('script'); + script.src = scriptUrl; + script.async = true; + document.head.appendChild(script); +} +``` + +Initialize the Google Autocomplete API for the custom field: + +```js +const initAutocomplete = (inputElement) => { + const autocompleteEl = new google.maps.places.Autocomplete(inputElement, { + types: ['address'], + fields: ['address_components'], + }); + + autocompleteEl.addListener('place_changed', () => { + const place = autocompleteEl.getPlace(); + const addressComponents = place.address_components; + + let street = '', city = '', countryCode = ''; + + addressComponents.forEach((component) => { + if (component.types.includes('route')) street = component.long_name; + if (component.types.includes('locality') || component.types.includes('sublocality')) city = component.long_name; + if (component.types.includes('country')) countryCode = component.short_name; + }); + + document.getElementById('country_code').value = countryCode; + document.getElementById('street').value = street; + document.getElementById('city').value = city; + }); +}; + +initAutocomplete(markupElements.inputElement); +``` + +### 5. Final fully functional code + +```js +shippingForm = await AccountProvider.render(AddressForm, { + addressesFormTitle: 'Shipping address', + className: 'checkout-shipping-form__address-form', + formName: SHIPPING_FORM_NAME, + forwardFormRef: shippingFormRef, + hideActionFormButtons: true, + inputsDefaultValueSet: cartShippingAddress ?? { + countryCode: storeConfig.defaultCountry, + }, + isOpen: true, + onChange: (values) => { + const syncAddress = !isFirstRenderShipping || !hasCartShippingAddress; + if (syncAddress) setShippingAddressOnCart(values); + if (!hasCartShippingAddress) estimateShippingCostOnCart(values); + if (isFirstRenderShipping) isFirstRenderShipping = false; + }, + showBillingCheckBox: false, + showFormLoader: false, + showShippingCheckBox: false, + slots: { + AddressFormInput_street: async (ctx) => { + const generateMarkup = async (context) => { + const { + inputName, + handleOnChange, + handleOnBlur, + handleOnFocus, + config, + } = context; + + const wrapper = document.createElement('div'); + + const errorContainer = document.createElement('div'); + errorContainer.classList.add(...['dropin-field__hint', 'dropin-field__hint--medium', 'dropin-field__hint--error']); + errorContainer.style.display = 'none'; + + const inputComponent = await UI.render(Input, { + name: inputName, + onChange: handleOnChange, + onBlur: handleOnBlur, + onFocus: handleOnFocus, + floatingLabel: `${config.label} *`, + placeholder: config.label, + })(wrapper); + const inputElement = wrapper.querySelector('input'); + wrapper.appendChild(errorContainer); + ctx.appendChild(wrapper); + + return { inputElement, inputComponent, errorContainer }; + }; + + const markupElements = await generateMarkup(ctx); + + const handleStateChange = (next, { inputElement, inputComponent, errorContainer }) => { + const { + errorMessage, + errors, + handleOnChange, + handleOnBlur, + } = next; + + const getNextProps = ({ value, ...prev }, error) => ({ + ...prev, + error, + onChange: (e) => handleOnChange(e, errors), + onBlur: (e) => handleOnBlur(e, errors), + }); + + if (errorMessage) { + errorContainer.innerText = errorMessage; + errorContainer.style.display = 'block'; + inputComponent.setProps((prev) => getNextProps(prev, true)); + } else { + errorContainer.innerText = ''; + errorContainer.style.display = 'none'; + inputComponent.setProps((prev) => getNextProps(prev, false)); + } + + if (document.activeElement === inputElement) { + setTimeout(() => { + inputElement.focus(); + }, 0); + } + }; + + ctx.onChange((nextState) => handleStateChange(nextState, markupElements)); + + const initAutocomplete = (inputElement) => { + const autocompleteEl = new google.maps.places.Autocomplete(inputElement, { + types: ['address'], + fields: ['address_components'], + }); + + let streetInput = null; + let cityInput = null; + let countrySelect = null; + + function onPlaceChanged() { + const place = autocompleteEl.getPlace(); + const addressComponents = place.address_components; + + // Initialize variables for street, city, and country code + let street = ''; + let city = ''; + let countryCode = ''; + + addressComponents.forEach((component) => { + if (component.types.find((type) => type === 'route')) { + street = component.long_name; + } else if (component.types.find((type) => type === 'locality' || type === 'sublocality')) { + city = component.long_name; + } else if (component.types.find((type) => type === 'country')) { + countryCode = component.short_name; + } + }); + + if (!countrySelect) { + countrySelect = document.getElementById('country_code'); + } + + countrySelect.value = countryCode; + countrySelect.dispatchEvent(new Event('change')); + + setTimeout(() => { + if (!streetInput) { + streetInput = document.getElementById('street'); + } + + if (!cityInput) { + cityInput = document.getElementById('city'); + } + + streetInput.value = street; + streetInput.dispatchEvent(new Event('change')); + + cityInput.value = city; + cityInput.dispatchEvent(new Event('change')); + }, 2000); + } + + autocompleteEl.addListener('place_changed', onPlaceChanged); + }; + + initAutocomplete(markupElements.inputElement); + }, + }, +})($shippingForm); +``` + +### Notes + +- Replace `{GOOGLE_API_KEY}` with your actual API key. Refer to [Google API Documentation](https://developers.google.com/maps/documentation/javascript/get-api-key) for details. +- The implementation supports backend-configurable validation and full form submission integration. + +By following these steps, you can successfully override fields and integrate 3rd party address lookup functionality into your checkout process. \ No newline at end of file diff --git a/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.css b/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.css new file mode 100644 index 000000000..ccbeda69a --- /dev/null +++ b/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.css @@ -0,0 +1,330 @@ +/* stylelint-disable selector-class-pattern */ +.checkout__content { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-big) 0; +} + +.checkout__main { + display: grid; + row-gap: var(--spacing-xbig); + margin-top: var(--spacing-medium); +} + +.checkout__aside { + display: grid; + gap: var(--spacing-xbig); +} + +/* Block dividers */ +.checkout__block.checkout__heading .dropin-header-container { + gap: var(--spacing-xsmall); +} + +.checkout__shipping-form { + padding-top: var(--spacing-xbig); + border-top: var(--shape-border-width-3) solid var(--color-neutral-400); +} + +.checkout__payment-methods { + padding-top: var(--spacing-xbig); + border-top: var(--shape-border-width-3) solid var(--color-neutral-400); +} + +/* Hide empty blocks */ +.checkout__block:empty { + display: none; +} + +/* Hide blocks with empty divs */ +.checkout__server-error:has(> :empty), +.checkout__out-of-stock:has(> :empty), +.checkout__delivery:has(> :empty), +.checkout__bill-to-shipping:has(> :empty) { + display: none; +} + +/* Hide main containers when the cart is empty or there is a server error */ +.checkout__content--error .checkout__out-of-stock, +.checkout__content--error .checkout__login, +.checkout__content--error .checkout__shipping-form, +.checkout__content--error .checkout__bill-to-shipping, +.checkout__content--error .checkout__delivery, +.checkout__content--error .checkout__payment-methods, +.checkout__content--error .checkout__billing-form, +.checkout__content--empty .checkout__server-error, +.checkout__content--empty .checkout__out-of-stock, +.checkout__content--empty .checkout__login, +.checkout__content--empty .checkout__shipping-form, +.checkout__content--empty .checkout__bill-to-shipping, +.checkout__content--empty .checkout__delivery, +.checkout__content--empty .checkout__payment-methods, +.checkout__content--empty .checkout__billing-form { + display: none !important; +} + +/* Hide aside containers when the cart is empty or there is a server error */ +.checkout__content--error .checkout__aside, +.checkout__content--empty .checkout__aside { + display: none; +} + +/* Integrate place order button into Order Summary - mobile */ +.checkout__place-order { + grid-column: unset; + justify-items: unset; + margin-top: calc(var(--spacing-big) * -1); +} + +/* Hide the place order button when the cart is empty or there is a server error */ +.checkout__content--error .checkout__place-order, +.checkout__content--empty .checkout__place-order { + display: none; +} + +.checkout__loader { + align-items: center; + background: var(--color-neutral-50); + display: flex; + height: 100vh; + justify-content: center; + left: 0; + opacity: 0.5; + position: fixed; + top: 0; + width: 100%; + z-index: 9999; +} + +.checkout__loader:empty { + display: none; +} + +.checkout__error-banner, +.checkout__merged-cart-banner { + grid-column: 1; +} + +/* remove margin from the heading divider */ +.checkout__heading .dropin-divider { + margin: 0; +} + +/* Cart Summary */ +.checkout__block .cart-cart-summary-list { + padding: var(--spacing-medium); +} + +/* Order Summary Coupon */ +.dropin-accordion-section__heading { + margin: var(--spacing-medium) auto; +} + +.cart-coupons__accordion { + margin-top: var(--spacing-xsmall); +} + +/* temporary fix to hide the default cart heading */ +[data-testid='default-cart-heading'] { + display: none; +} + +.cart-summary-list__heading { + display: flex; + justify-content: space-between; +} + +.cart-summary-list__heading-text { + font: var(--type-headline-2-strong-font); + letter-spacing: var(--type-headline-2-strong-letter-spacing); + color: var(--color-neutral-800); +} + +.cart-cart-summary-list__heading { + row-gap: var(--spacing-small); + padding-top: 0; +} + +.cart-cart-summary-list__heading-text { + font: var(--type-headline-2-strong-font); + letter-spacing: var(--type-headline-2-strong-letter-spacing); + color: var(--color-neutral-800); +} + +.cart-summary-list__edit { + font: var(--type-body-2-strong-font); + letter-spacing: var(--type-body-2-strong-letter-spacing); +} + +.checkout__block + .cart-cart-summary-list + .cart-cart-summary-list__footer-divider { + margin: var(--spacing-small) 0; +} + +/* Sign-in modal */ +#modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgb(0 0 0 / 50%); + display: flex; + justify-content: center; + align-items: center; + z-index: 2; +} + +#modal-form { + width: 800px; +} + +/* Address form */ +.checkout__shipping-form .account-address-form-wrapper__title, +.checkout__shipping-form .dropin-header-container__title, +.checkout__billing-form .account-address-form-wrapper__title, +.checkout__billing-form .dropin-header-container__title { + font: var(--type-headline-2-default-font); + letter-spacing: var(--type-headline-2-default-letter-spacing); + color: var(--color-neutral-800); + margin: 0 0 var(--spacing-medium) 0; +} + +.checkout__shipping-form .dropin-header-container .dropin-divider, +.checkout__billing-form .dropin-header-container .dropin-divider { + display: none; +} + +/* Order confirmation */ +.order-confirmation { + display: grid; + align-items: start; + grid-template-columns: repeat(var(--grid-4-columns), 1fr); + grid-template-areas: 'main aside'; + grid-column-gap: var(--grid-4-gutters); + margin-bottom: var(--spacing-xbig); + padding-top: var(--spacing-xxlarge); +} + +.order-confirmation__main { + display: grid; + grid-row-gap: var(--spacing-xbig); + grid-column: 1 / span 7; +} + +.order-confirmation__aside { + display: grid; + grid-row-gap: var(--spacing-xbig); + grid-column: 9 / span 4; +} + +.order-confirmation__footer { + display: grid; + gap: var(--spacing-small); + text-align: center; +} + +.order-confirmation__footer p { + margin: 0; +} + +.order-confirmation__footer .order-confirmation-footer__continue-button { + margin: 0 auto; + text-align: center; + display: inline-block; +} + +.order-confirmation-footer__contact-support { + font: var(--type-body-2-default-font); + letter-spacing: var(--type-body-2-default-letter-spacing); + color: var(--color-neutral-700); +} + +.order-confirmation-footer__contact-support a { + font: var(--type-body-2-strong-font); + letter-spacing: var(--type-body-2-strong-letter-spacing); + color: var(--color-brand-500); + cursor: pointer; +} + +/* Hide empty blocks */ +.order-confirmation__block:empty { + display: none; +} + +@media only screen and (width >= 320px) and (width <= 768px) { + .checkout__main, + .checkout__aside { + display: contents; + } + + .checkout__block { + order: 3; + } + + .checkout__heading { + order: 1; + } + + .checkout__cart-summary { + order: 2; + } + + .checkout__place-order { + order: 4; + } + + .order-confirmation { + grid-template-columns: repeat(var(--grid-1-columns), 1fr); + padding-top: 0; + } + + .order-confirmation__main, + .order-confirmation__aside { + grid-row-gap: var(--spacing-medium); + } + + .order-confirmation > div { + grid-column: 1 / span 4; + } + + .order-confirmation__block .dropin-card { + border: 0; + } +} + +@media only screen and (width >= 768px) { + .checkout__content { + display: grid; + align-items: start; + grid-template-columns: repeat(var(--grid-4-columns), 1fr); + gap: var(--spacing-big) var(--grid-4-gutters); + } + + .checkout__content--error, + .checkout__content--empty { + display: grid; + grid-template-columns: 1fr; + } + + .checkout__main { + grid-column: 1 / span 7; + row-gap: var(--spacing-xbig); + } + + .checkout__aside { + grid-column: 9 / span 4; + gap: var(--spacing-xbig); + } + + .checkout__error-banner, + .checkout__merged-cart-banner { + display: grid; + grid-column: 1 / span 12; + } + + .checkout__place-order { + margin-top: 0; + } +} diff --git a/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.js b/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.js new file mode 100644 index 000000000..99044f6f4 --- /dev/null +++ b/blocks/commerce-checkout-address-lookup/commerce-checkout-address-lookup.js @@ -0,0 +1,979 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable no-unused-vars */ +/* eslint-disable no-shadow */ +/* eslint-disable no-use-before-define */ +/* eslint-disable prefer-const */ + +// Dropin Tools +import { events } from '@dropins/tools/event-bus.js'; +import { initializers } from '@dropins/tools/initializer.js'; +import { Input } from '@dropins/tools/components.js'; + +// Dropin Components +import { + Button, + Header, + ProgressSpinner, + provider as UI, +} from '@dropins/tools/components.js'; + +// Auth Dropin +import * as authApi from '@dropins/storefront-auth/api.js'; +import AuthCombine from '@dropins/storefront-auth/containers/AuthCombine.js'; +import SignUp from '@dropins/storefront-auth/containers/SignUp.js'; +import { render as AuthProvider } from '@dropins/storefront-auth/render.js'; + +// Account Dropin +import Addresses from '@dropins/storefront-account/containers/Addresses.js'; +import AddressForm from '@dropins/storefront-account/containers/AddressForm.js'; +import { render as AccountProvider } from '@dropins/storefront-account/render.js'; + +// Cart Dropin +import * as cartApi from '@dropins/storefront-cart/api.js'; +import CartSummaryList from '@dropins/storefront-cart/containers/CartSummaryList.js'; +import Coupons from '@dropins/storefront-cart/containers/Coupons.js'; +import EmptyCart from '@dropins/storefront-cart/containers/EmptyCart.js'; +import OrderSummary from '@dropins/storefront-cart/containers/OrderSummary.js'; +import { render as CartProvider } from '@dropins/storefront-cart/render.js'; + +// Checkout Dropin +import * as checkoutApi from '@dropins/storefront-checkout/api.js'; +import BillToShippingAddress from '@dropins/storefront-checkout/containers/BillToShippingAddress.js'; +import EstimateShipping from '@dropins/storefront-checkout/containers/EstimateShipping.js'; +import LoginForm from '@dropins/storefront-checkout/containers/LoginForm.js'; +import MergedCartBanner from '@dropins/storefront-checkout/containers/MergedCartBanner.js'; +import OutOfStock from '@dropins/storefront-checkout/containers/OutOfStock.js'; +import PaymentMethods from '@dropins/storefront-checkout/containers/PaymentMethods.js'; +import PlaceOrder from '@dropins/storefront-checkout/containers/PlaceOrder.js'; +import ServerError from '@dropins/storefront-checkout/containers/ServerError.js'; +import ShippingMethods from '@dropins/storefront-checkout/containers/ShippingMethods.js'; + +import { render as CheckoutProvider } from '@dropins/storefront-checkout/render.js'; + +// Order Dropin Modules +import * as orderApi from '@dropins/storefront-order/api.js'; +import CustomerDetails from '@dropins/storefront-order/containers/CustomerDetails.js'; +import OrderCostSummary from '@dropins/storefront-order/containers/OrderCostSummary.js'; +import OrderHeader from '@dropins/storefront-order/containers/OrderHeader.js'; +import OrderProductList from '@dropins/storefront-order/containers/OrderProductList.js'; +import OrderStatus from '@dropins/storefront-order/containers/OrderStatus.js'; +import ShippingStatus from '@dropins/storefront-order/containers/ShippingStatus.js'; +import { render as OrderProvider } from '@dropins/storefront-order/render.js'; +import { getUserTokenCookie } from '../../scripts/initializers/index.js'; + +// Block-level +import createModal from '../modal/modal.js'; + +import { + estimateShippingCost, getCartAddress, + isCartEmpty, + isCheckoutEmpty, + scrollToElement, + setAddressOnCart, +} from '../../scripts/checkout.js'; + +function createMetaTag(property, content, type) { + if (!property || !type) { + return; + } + let meta = document.head.querySelector(`meta[${type}="${property}"]`); + if (meta) { + if (!content) { + meta.remove(); + return; + } + meta.setAttribute(type, property); + meta.setAttribute('content', content); + return; + } + if (!content) { + return; + } + meta = document.createElement('meta'); + meta.setAttribute(type, property); + meta.setAttribute('content', content); + document.head.appendChild(meta); +} + +function setMetaTags(dropin) { + createMetaTag('title', dropin); + createMetaTag('description', dropin); + createMetaTag('keywords', dropin); + + createMetaTag('og:description', dropin); + createMetaTag('og:title', dropin); + createMetaTag('og:url', window.location.href, 'property'); +} + +export default async function decorate(block) { + const GOOGLE_API_KEY = 'API_KEY'; + const scriptUrl = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&loading=async&libraries=places`; + + if (!document.querySelector(`script[src="${scriptUrl}"]`)) { + const script = document.createElement('script'); + script.src = scriptUrl; + script.async = true; + document.head.appendChild(script); + } + + // Initializers + import('../../scripts/initializers/account.js'); + import('../../scripts/initializers/checkout.js'); + + setMetaTags('Checkout'); + document.title = 'Checkout'; + + events.on('checkout/order', () => { + setMetaTags('Order Confirmation'); + document.title = 'Order Confirmation'; + }); + + const DEBOUNCE_TIME = 1000; + const LOGIN_FORM_NAME = 'login-form'; + const SHIPPING_FORM_NAME = 'selectedShippingAddress'; + const BILLING_FORM_NAME = 'selectedBillingAddress'; + const SHIPPING_ADDRESS_DATA_KEY = `${SHIPPING_FORM_NAME}_addressData`; + const BILLING_ADDRESS_DATA_KEY = `${BILLING_FORM_NAME}_addressData`; + + // Define the Layout for the Checkout + const checkoutFragment = document.createRange().createContextualFragment(` +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ `); + + const $content = checkoutFragment.querySelector('.checkout__content'); + const $loader = checkoutFragment.querySelector('.checkout__loader'); + const $mergedCartBanner = checkoutFragment.querySelector( + '.checkout__merged-cart-banner', + ); + + const $heading = checkoutFragment.querySelector('.checkout__heading'); + const $emptyCart = checkoutFragment.querySelector('.checkout__empty-cart'); + const $serverError = checkoutFragment.querySelector( + '.checkout__server-error', + ); + const $outOfStock = checkoutFragment.querySelector('.checkout__out-of-stock'); + const $login = checkoutFragment.querySelector('.checkout__login'); + const $shippingForm = checkoutFragment.querySelector( + '.checkout__shipping-form', + ); + const $billToShipping = checkoutFragment.querySelector( + '.checkout__bill-to-shipping', + ); + const $delivery = checkoutFragment.querySelector('.checkout__delivery'); + const $paymentMethods = checkoutFragment.querySelector( + '.checkout__payment-methods', + ); + const $billingForm = checkoutFragment.querySelector( + '.checkout__billing-form', + ); + const $orderSummary = checkoutFragment.querySelector( + '.checkout__order-summary', + ); + const $cartSummary = checkoutFragment.querySelector( + '.checkout__cart-summary', + ); + const $placeOrder = checkoutFragment.querySelector('.checkout__place-order'); + + block.appendChild(checkoutFragment); + + // Global state + let initialized = false; + + // Container and component references + let loader; + let modal; + let emptyCart; + let shippingFormRef = { current: null }; + let billingFormRef = { current: null }; + let shippingForm; + let billingForm; + let shippingAddresses; + let billingAddresses; + + // Render the initial containers + const [ + _mergedCartBanner, + _header, + _serverError, + _outOfStock, + _loginForm, + shippingFormSkeleton, + _billToShipping, + _shippingMethods, + _paymentMethods, + billingFormSkeleton, + _orderSummary, + _cartSummary, + placeOrder, + ] = await Promise.all([ + CheckoutProvider.render(MergedCartBanner)($mergedCartBanner), + + UI.render(Header, { title: 'Checkout', size: 'large', divider: true })($heading), + + CheckoutProvider.render(ServerError, { + onRetry: () => { + $content.classList.remove('checkout__content--error'); + }, + onServerError: () => { + $content.classList.add('checkout__content--error'); + }, + })($serverError), + + CheckoutProvider.render(OutOfStock, { + routeCart: () => '/cart', + onCartProductsUpdate: (items) => { + cartApi.updateProductsFromCart(items).catch(console.error); + }, + })($outOfStock), + + CheckoutProvider.render(LoginForm, { + name: LOGIN_FORM_NAME, + onSignInClick: async (initialEmailValue) => { + const signInForm = document.createElement('div'); + + AuthProvider.render(AuthCombine, { + signInFormConfig: { + renderSignUpLink: true, + initialEmailValue, + onSuccessCallback: () => { + displayOverlaySpinner(); + }, + }, + signUpFormConfig: {}, + resetPasswordFormConfig: {}, + })(signInForm); + + showModal(signInForm); + }, + onSignOutClick: () => { + authApi.revokeCustomerToken(); + }, + })($login), + + AccountProvider.render(AddressForm, { + isOpen: true, + showFormLoader: true, + })($shippingForm), + + CheckoutProvider.render(BillToShippingAddress, { + hideOnVirtualCart: true, + onChange: (checked) => { + $billingForm.style.display = checked ? 'none' : 'block'; + if (!checked && billingFormRef?.current) { + const { formData, isDataValid } = billingFormRef.current; + + setAddressOnCart({ + api: checkoutApi.setBillingAddress, + debounceMs: DEBOUNCE_TIME, + placeOrderBtn: placeOrder, + })({ data: formData, isDataValid }); + } + }, + })($billToShipping), + + CheckoutProvider.render(ShippingMethods, { + hideOnVirtualCart: true, + onCheckoutDataUpdate: () => { + cartApi.refreshCart().catch(console.error); + }, + })($delivery), + + CheckoutProvider.render(PaymentMethods)($paymentMethods), + + AccountProvider.render(AddressForm, { + isOpen: true, + showFormLoader: true, + })($billingForm), + + CartProvider.render(OrderSummary, { + slots: { + EstimateShipping: (esCtx) => { + const estimateShippingForm = document.createElement('div'); + CheckoutProvider.render(EstimateShipping)(estimateShippingForm); + esCtx.appendChild(estimateShippingForm); + }, + Coupons: (ctx) => { + const coupons = document.createElement('div'); + + CartProvider.render(Coupons)(coupons); + + ctx.appendChild(coupons); + }, + }, + })($orderSummary), + + CartProvider.render(CartSummaryList, { + variant: 'secondary', + slots: { + Heading: (headingCtx) => { + const title = 'Your Cart ({count})'; + + const cartSummaryListHeading = document.createElement('div'); + cartSummaryListHeading.classList.add('cart-summary-list__heading'); + + const cartSummaryListHeadingText = document.createElement('div'); + cartSummaryListHeadingText.classList.add( + 'cart-summary-list__heading-text', + ); + + cartSummaryListHeadingText.innerText = title.replace( + '({count})', + headingCtx.count ? `(${headingCtx.count})` : '', + ); + const editCartLink = document.createElement('a'); + editCartLink.classList.add('cart-summary-list__edit'); + editCartLink.href = '/cart'; + editCartLink.rel = 'noreferrer'; + editCartLink.innerText = 'Edit'; + + cartSummaryListHeading.appendChild(cartSummaryListHeadingText); + cartSummaryListHeading.appendChild(editCartLink); + headingCtx.appendChild(cartSummaryListHeading); + + headingCtx.onChange((nextHeadingCtx) => { + cartSummaryListHeadingText.innerText = title.replace( + '({count})', + nextHeadingCtx.count ? `(${nextHeadingCtx.count})` : '', + ); + }); + }, + }, + })($cartSummary), + + CheckoutProvider.render(PlaceOrder, { + handleValidation: () => { + let success = true; + const { forms } = document; + + const loginForm = forms[LOGIN_FORM_NAME]; + + if (loginForm) { + success = loginForm.checkValidity(); + if (!success) scrollToElement($login); + } + + const shippingForm = forms[SHIPPING_FORM_NAME]; + + if ( + success + && shippingFormRef.current + && shippingForm + && shippingForm.checkVisibility() + ) { + success = shippingFormRef.current.handleValidationSubmit(false); + } + + const billingForm = forms[BILLING_FORM_NAME]; + + if ( + success + && billingFormRef.current + && billingForm + && billingForm.checkVisibility() + ) { + success = billingFormRef.current.handleValidationSubmit(false); + } + + return success; + }, + handlePlaceOrder: async ({ cartId }) => { + await displayOverlaySpinner(); + + await orderApi.placeOrder(cartId).finally(removeOverlaySpinner); + }, + })($placeOrder), + ]); + + // Dynamic containers and components + const showModal = async (content) => { + modal = await createModal([content]); + modal.showModal(); + }; + + const removeModal = () => { + if (!modal) return; + modal.removeModal(); + modal = null; + }; + + const displayEmptyCart = async () => { + if (emptyCart) return; + + emptyCart = await CartProvider.render(EmptyCart, { + routeCTA: () => '/', + })($emptyCart); + + $content.classList.add('checkout__content--empty'); + }; + + const removeEmptyCart = () => { + if (!emptyCart) return; + + emptyCart.remove(); + emptyCart = null; + $emptyCart.innerHTML = ''; + + $content.classList.remove('checkout__content--empty'); + }; + + const displayOverlaySpinner = async () => { + if (loader) return; + + loader = await UI.render(ProgressSpinner, { + className: '.checkout__overlay-spinner', + })($loader); + }; + + const removeOverlaySpinner = () => { + if (!loader) return; + + loader.remove(); + loader = null; + $loader.innerHTML = ''; + }; + + const initializeCheckout = async (data) => { + if (initialized) return; + removeEmptyCart(); + if (data.isGuest) await displayGuestAddressForms(data); + else { + removeOverlaySpinner(); + await displayCustomerAddressForms(data); + } + }; + + const displayGuestAddressForms = async (data) => { + if (data.isVirtual) { + shippingForm?.remove(); + shippingForm = null; + $shippingForm.innerHTML = ''; + } else if (!shippingForm) { + const cartShippingAddress = getCartAddress(data, 'shipping'); + + const shippingAddressCache = sessionStorage.getItem( + SHIPPING_ADDRESS_DATA_KEY, + ); + + if (cartShippingAddress && shippingAddressCache) { + sessionStorage.removeItem(SHIPPING_ADDRESS_DATA_KEY); + } + + shippingFormSkeleton.remove(); + + let isFirstRenderShipping = true; + const hasCartShippingAddress = Boolean(data.shippingAddresses?.[0]); + + const setShippingAddressOnCart = setAddressOnCart({ + api: checkoutApi.setShippingAddress, + debounceMs: DEBOUNCE_TIME, + placeOrderBtn: placeOrder, + }); + + const estimateShippingCostOnCart = estimateShippingCost({ + api: checkoutApi.estimateShippingMethods, + debounceMs: DEBOUNCE_TIME, + }); + + const storeConfig = checkoutApi.getStoreConfigCache(); + + shippingForm = await AccountProvider.render(AddressForm, { + addressesFormTitle: 'Shipping address', + className: 'checkout-shipping-form__address-form', + formName: SHIPPING_FORM_NAME, + forwardFormRef: shippingFormRef, + hideActionFormButtons: true, + inputsDefaultValueSet: cartShippingAddress ?? { + countryCode: storeConfig.defaultCountry, + }, + isOpen: true, + onChange: (values) => { + const syncAddress = !isFirstRenderShipping || !hasCartShippingAddress; + if (syncAddress) setShippingAddressOnCart(values); + if (!hasCartShippingAddress) estimateShippingCostOnCart(values); + if (isFirstRenderShipping) isFirstRenderShipping = false; + }, + showBillingCheckBox: false, + showFormLoader: false, + showShippingCheckBox: false, + slots: { + AddressFormInput_street: async (ctx) => { + const generateMarkup = async (context) => { + const { + inputName, + handleOnChange, + handleOnBlur, + handleOnFocus, + config, + } = context; + + const wrapper = document.createElement('div'); + + const errorContainer = document.createElement('div'); + errorContainer.classList.add(...['dropin-field__hint', 'dropin-field__hint--medium', 'dropin-field__hint--error']); + errorContainer.style.display = 'none'; + + const inputComponent = await UI.render(Input, { + name: inputName, + onChange: handleOnChange, + onBlur: handleOnBlur, + onFocus: handleOnFocus, + floatingLabel: `${config.label} *`, + placeholder: config.label, + })(wrapper); + const inputElement = wrapper.querySelector('input'); + wrapper.appendChild(errorContainer); + ctx.appendChild(wrapper); + + return { inputElement, inputComponent, errorContainer }; + }; + + const markupElements = await generateMarkup(ctx); + + const handleStateChange = (next, { inputElement, inputComponent, errorContainer }) => { + const { + errorMessage, + errors, + handleOnChange, + handleOnBlur, + } = next; + + const getNextProps = ({ value, ...prev }, error) => ({ + ...prev, + error, + onChange: (e) => handleOnChange(e, errors), + onBlur: (e) => handleOnBlur(e, errors), + }); + + if (errorMessage) { + errorContainer.innerText = errorMessage; + errorContainer.style.display = 'block'; + inputComponent.setProps((prev) => getNextProps(prev, true)); + } else { + errorContainer.innerText = ''; + errorContainer.style.display = 'none'; + inputComponent.setProps((prev) => getNextProps(prev, false)); + } + + if (document.activeElement === inputElement) { + setTimeout(() => { + inputElement.focus(); + }, 0); + } + }; + + ctx.onChange((nextState) => handleStateChange(nextState, markupElements)); + + const initAutocomplete = (inputElement) => { + const autocompleteEl = new google.maps.places.Autocomplete(inputElement, { + types: ['address'], + fields: ['address_components'], + }); + + let streetInput = null; + let cityInput = null; + let countrySelect = null; + + function onPlaceChanged() { + const place = autocompleteEl.getPlace(); + const addressComponents = place.address_components; + + // Initialize variables for street, city, and country code + let street = ''; + let city = ''; + let countryCode = ''; + + addressComponents.forEach((component) => { + if (component.types.find((type) => type === 'route')) { + street = component.long_name; + } else if (component.types.find((type) => type === 'locality' || type === 'sublocality')) { + city = component.long_name; + } else if (component.types.find((type) => type === 'country')) { + countryCode = component.short_name; + } + }); + + if (!countrySelect) { + countrySelect = document.getElementById('country_code'); + } + + countrySelect.value = countryCode; + countrySelect.dispatchEvent(new Event('change')); + + setTimeout(() => { + if (!streetInput) { + streetInput = document.getElementById('street'); + } + + if (!cityInput) { + cityInput = document.getElementById('city'); + } + + streetInput.value = street; + streetInput.dispatchEvent(new Event('change')); + + cityInput.value = city; + cityInput.dispatchEvent(new Event('change')); + }, 2000); + } + + autocompleteEl.addListener('place_changed', onPlaceChanged); + }; + + initAutocomplete(markupElements.inputElement); + }, + }, + })($shippingForm); + } + + if (!billingForm) { + const cartBillingAddress = getCartAddress(data, 'billing'); + + const billingAddressCache = sessionStorage.getItem( + BILLING_ADDRESS_DATA_KEY, + ); + + if (cartBillingAddress && billingAddressCache) { + sessionStorage.removeItem(BILLING_ADDRESS_DATA_KEY); + } + + billingFormSkeleton.remove(); + + let isFirstRenderBilling = true; + const hasCartBillingAddress = Boolean(data.billingAddress); + + const setBillingAddressOnCart = setAddressOnCart({ + api: checkoutApi.setBillingAddress, + debounceMs: DEBOUNCE_TIME, + placeOrderBtn: placeOrder, + }); + + const storeConfig = checkoutApi.getStoreConfigCache(); + + billingForm = await AccountProvider.render(AddressForm, { + addressesFormTitle: 'Billing address', + className: 'checkout-billing-form__address-form', + formName: BILLING_FORM_NAME, + forwardFormRef: billingFormRef, + hideActionFormButtons: true, + inputsDefaultValueSet: cartBillingAddress ?? { + countryCode: storeConfig.defaultCountry, + }, + isOpen: true, + onChange: (values) => { + const canSetBillingAddressOnCart = !isFirstRenderBilling || !hasCartBillingAddress; + if (canSetBillingAddressOnCart) setBillingAddressOnCart(values); + if (isFirstRenderBilling) isFirstRenderBilling = false; + }, + showBillingCheckBox: false, + showFormLoader: false, + showShippingCheckBox: false, + })($billingForm); + } + }; + + const displayCustomerAddressForms = async (data) => { + if (data.isVirtual) { + shippingAddresses?.remove(); + shippingAddresses = null; + $shippingForm.innerHTML = ''; + } else if (!shippingAddresses) { + shippingForm?.remove(); + shippingForm = null; + shippingFormRef = { current: null }; + + const cartShippingAddress = getCartAddress(data, 'shipping'); + + const shippingAddressId = cartShippingAddress + ? (cartShippingAddress?.id ?? 0) + : undefined; + + const shippingAddressCache = sessionStorage.getItem( + SHIPPING_ADDRESS_DATA_KEY, + ); + + // clear persisted shipping address if cart has a shipping address + if (cartShippingAddress && shippingAddressCache) { + sessionStorage.removeItem(SHIPPING_ADDRESS_DATA_KEY); + } + + // when shipping address form is empty + if (!cartShippingAddress) { + checkoutApi.estimateShippingMethods(); + events.emit('checkout/estimate-shipping-address', { + address: {}, + isValid: false, + }); + } + + const storeConfig = checkoutApi.getStoreConfigCache(); + + const inputsDefaultValueSet = cartShippingAddress && cartShippingAddress.id === undefined + ? cartShippingAddress + : { countryCode: storeConfig.defaultCountry }; + + const hasCartShippingAddress = Boolean(data.shippingAddresses?.[0]); + let isFirstRenderShipping = true; + + const setShippingAddressOnCart = setAddressOnCart({ + api: checkoutApi.setShippingAddress, + debounceMs: DEBOUNCE_TIME, + placeOrderBtn: placeOrder, + }); + + shippingAddresses = await AccountProvider.render(Addresses, { + addressFormTitle: 'Deliver to new address', + defaultSelectAddressId: shippingAddressId, + formName: SHIPPING_FORM_NAME, + forwardFormRef: shippingFormRef, + inputsDefaultValueSet, + minifiedView: false, + onAddressData: (values) => { + const canSetShippingAddressOnCart = !isFirstRenderShipping || !hasCartShippingAddress; + if (canSetShippingAddressOnCart) setShippingAddressOnCart(values); + if (isFirstRenderShipping) isFirstRenderShipping = false; + }, + selectable: true, + selectShipping: true, + showBillingCheckBox: false, + showSaveCheckBox: true, + showShippingCheckBox: false, + title: 'Shipping address', + })($shippingForm); + } + + if (!billingAddresses) { + billingForm?.remove(); + billingForm = null; + billingFormRef = { current: null }; + + const cartBillingAddress = getCartAddress(data, 'billing'); + + const billingAddressId = cartBillingAddress + ? (cartBillingAddress?.id ?? 0) + : undefined; + + const billingAddressCache = sessionStorage.getItem( + BILLING_ADDRESS_DATA_KEY, + ); + + // clear persisted billing address if cart has a billing address + if (cartBillingAddress && billingAddressCache) { + sessionStorage.removeItem(BILLING_ADDRESS_DATA_KEY); + } + + const storeConfig = checkoutApi.getStoreConfigCache(); + + const inputsDefaultValueSet = cartBillingAddress && cartBillingAddress.id === undefined + ? cartBillingAddress + : { countryCode: storeConfig.defaultCountry }; + + const hasCartBillingAddress = Boolean(data.billingAddress); + let isFirstRenderBilling = true; + + const setBillingAddressOnCart = setAddressOnCart({ + api: checkoutApi.setBillingAddress, + debounceMs: DEBOUNCE_TIME, + placeOrderBtn: placeOrder, + }); + + billingAddresses = await AccountProvider.render(Addresses, { + addressFormTitle: 'Bill to new address', + defaultSelectAddressId: billingAddressId, + formName: BILLING_FORM_NAME, + forwardFormRef: billingFormRef, + inputsDefaultValueSet, + minifiedView: false, + onAddressData: (values) => { + const canSetBillingAddressOnCart = !isFirstRenderBilling || !hasCartBillingAddress; + if (canSetBillingAddressOnCart) setBillingAddressOnCart(values); + if (isFirstRenderBilling) isFirstRenderBilling = false; + }, + selectable: true, + selectBilling: true, + showBillingCheckBox: false, + showSaveCheckBox: true, + showShippingCheckBox: false, + title: 'Billing address', + })($billingForm); + } + }; + + // Define the Layout for the Order Confirmation + const displayOrderConfirmation = async (orderData) => { + // Scroll to the top of the page + window.scrollTo(0, 0); + + const orderConfirmationFragment = document.createRange() + .createContextualFragment(` +
+
+
+
+
+
+
+
+
+
+ +
+
+ `); + + // Order confirmation elements + const $orderConfirmationHeader = orderConfirmationFragment.querySelector( + '.order-confirmation__header', + ); + const $orderStatus = orderConfirmationFragment.querySelector( + '.order-confirmation__order-status', + ); + const $shippingStatus = orderConfirmationFragment.querySelector( + '.order-confirmation__shipping-status', + ); + const $customerDetails = orderConfirmationFragment.querySelector( + '.order-confirmation__customer-details', + ); + const $orderCostSummary = orderConfirmationFragment.querySelector( + '.order-confirmation__order-cost-summary', + ); + const $orderProductList = orderConfirmationFragment.querySelector( + '.order-confirmation__order-product-list', + ); + const $orderConfirmationFooter = orderConfirmationFragment.querySelector( + '.order-confirmation__footer', + ); + + await initializers.mountImmediately(orderApi.initialize, { orderData }); + + block.replaceChildren(orderConfirmationFragment); + + const handleSignUpClick = async ({ inputsDefaultValueSet, addressesData }) => { + const signUpForm = document.createElement('div'); + AuthProvider.render(SignUp, { + routeSignIn: () => '/customer/login', + routeRedirectOnEmailConfirmationClose: () => '/customer/account', + inputsDefaultValueSet, + addressesData, + })(signUpForm); + + await showModal(signUpForm); + }; + + OrderProvider.render(OrderHeader, { + handleEmailAvailability: checkoutApi.isEmailAvailable, + handleSignUpClick, + orderData, + })($orderConfirmationHeader); + + OrderProvider.render(OrderStatus, { slots: { OrderActions: () => null } })( + $orderStatus, + ); + OrderProvider.render(ShippingStatus)($shippingStatus); + OrderProvider.render(CustomerDetails)($customerDetails); + OrderProvider.render(OrderCostSummary)($orderCostSummary); + OrderProvider.render(OrderProductList)($orderProductList); + + $orderConfirmationFooter.innerHTML = ` + + + `; + + const $orderConfirmationFooterContinueBtn = $orderConfirmationFooter.querySelector( + '.order-confirmation-footer__continue-button', + ); + + UI.render(Button, { + children: 'Continue shopping', + 'data-testid': 'order-confirmation-footer__continue-button', + className: 'order-confirmation-footer__continue-button', + size: 'medium', + variant: 'primary', + type: 'submit', + href: '/', + })($orderConfirmationFooterContinueBtn); + }; + + // Define the event handlers + const handleCartInitialized = async (data) => { + if (isCartEmpty(data)) await displayEmptyCart(); + }; + + const handleCheckoutInitialized = async (data) => { + if (!data || isCheckoutEmpty(data)) return; + initializeCheckout(data); + }; + + const handleCheckoutUpdated = async (data) => { + if (isCheckoutEmpty(data)) { + await displayEmptyCart(); + } else if (!initialized) { + await initializeCheckout(data); + } + }; + + const handleAuthenticated = (authenticated) => { + if (!authenticated) return; + removeModal(); + }; + + const handleOrderPlaced = async (orderData) => { + // Clear address form data + sessionStorage.removeItem(SHIPPING_ADDRESS_DATA_KEY); + sessionStorage.removeItem(BILLING_ADDRESS_DATA_KEY); + + const token = getUserTokenCookie(); + const orderRef = token ? orderData.number : orderData.token; + const orderNumber = orderData.number; + const encodedOrderRef = encodeURIComponent(orderRef); + const encodedOrderNumber = encodeURIComponent(orderNumber); + + const url = token + ? `/order-details?orderRef=${encodedOrderRef}` + : `/order-details?orderRef=${encodedOrderRef}&orderNumber=${encodedOrderNumber}`; + + window.history.pushState({}, '', url); + + // TODO cleanup checkout containers + await displayOrderConfirmation(orderData); + }; + + events.on('authenticated', handleAuthenticated); + events.on('cart/initialized', handleCartInitialized, { eager: true }); + events.on('checkout/initialized', handleCheckoutInitialized, { eager: true }); + events.on('checkout/updated', handleCheckoutUpdated); + events.on('order/placed', handleOrderPlaced); +}