diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 0f114ed000..6d243c83f0 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -306,6 +306,8 @@ export type IStoreSession = { b2b?: Maybe; /** Session input channel. */ channel?: Maybe; + /** Session input city. */ + city?: Maybe; /** Session input country. */ country: Scalars['String']; /** Session input currency. */ @@ -466,6 +468,12 @@ export type PickupStoreInfo = { isPickupStore?: Maybe; }; +export type ProductCountResult = { + __typename?: 'ProductCountResult'; + /** Total product count. */ + total: Scalars['Int']; +}; + export type Profile = { __typename?: 'Profile'; /** Collection of user's address */ @@ -512,6 +520,8 @@ export type Query = { collection: StoreCollection; /** Returns the details of a product based on the specified locator. */ product: StoreProduct; + /** Returns the total product count information based on a specific location accessible through the VTEX segment cookie. */ + productCount?: Maybe; /** Returns information about the profile. */ profile?: Maybe; /** Returns if there's a redirect for a search. */ @@ -547,6 +557,11 @@ export type QueryProductArgs = { }; +export type QueryProductCountArgs = { + term?: Maybe; +}; + + export type QueryProfileArgs = { id: Scalars['String']; }; @@ -1198,6 +1213,8 @@ export type StoreSession = { b2b?: Maybe; /** Session channel. */ channel?: Maybe; + /** Session city. */ + city?: Maybe; /** Session country. */ country: Scalars['String']; /** Session currency. */ diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index adf2071c20..4e13b3a626 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -357,17 +357,19 @@ export const VtexCommerce = ( params.set( 'items', - 'profile.id,profile.email,profile.firstName,profile.lastName,store.channel,store.countryCode,store.cultureInfo,store.currencyCode,store.currencySymbol,authentication.customerId,' + 'profile.id,profile.email,profile.firstName,profile.lastName,store.channel,store.countryCode,store.cultureInfo,store.currencyCode,store.currencySymbol,authentication.customerId,checkout.regionId,' ) const headers: HeadersInit = withCookie({ 'content-type': 'application/json', }) + const sessionCookie = parse(ctx?.headers?.cookie ?? '')?.vtex_session + return fetchAPI( `${base}/api/sessions?${params.toString()}`, { - method: 'POST', + method: sessionCookie ? 'PATCH' : 'POST', headers, body: '{}', }, diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/Address.ts b/packages/api/src/platforms/vtex/clients/commerce/types/Address.ts index ee6d03c08d..8d6e833927 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/types/Address.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/types/Address.ts @@ -13,5 +13,5 @@ export interface Address { neighborhood: string complement: string reference: string - geoCoordinates: [number] + geoCoordinates: [number, number] // [longitude, latitude] } diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/Session.ts b/packages/api/src/platforms/vtex/clients/commerce/types/Session.ts index 9d2236e54a..ad3f5c1c0c 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/types/Session.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/types/Session.ts @@ -36,6 +36,7 @@ export interface Profile { export interface Checkout { orderFormId?: Value + regionId?: Value } export interface Public { diff --git a/packages/api/src/platforms/vtex/clients/search/index.ts b/packages/api/src/platforms/vtex/clients/search/index.ts index b48e796cec..c0a369f8f1 100644 --- a/packages/api/src/platforms/vtex/clients/search/index.ts +++ b/packages/api/src/platforms/vtex/clients/search/index.ts @@ -16,6 +16,7 @@ import type { ProductSearchResult, Suggestion, } from './types/ProductSearchResult' +import type { ProductCountResult } from './types/ProductCountResult' export type Sort = | 'price:desc' @@ -265,10 +266,26 @@ export const IntelligentSearch = ( const facets = (args: Omit) => search({ ...args, type: 'facets' }) + const productCount = ( + args: Pick + ): Promise => { + const params = new URLSearchParams() + + if (args?.query) { + params.append('query', args.query.toString()) + } + + return fetchAPI( + `${base}/_v/api/intelligent-search/catalog_count?${params.toString()}`, + { headers } + ) + } + return { facets, products, suggestedTerms, topSearches, + productCount, } } diff --git a/packages/api/src/platforms/vtex/clients/search/types/ProductCountResult.ts b/packages/api/src/platforms/vtex/clients/search/types/ProductCountResult.ts new file mode 100644 index 0000000000..6d94af6a6a --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/search/types/ProductCountResult.ts @@ -0,0 +1,6 @@ +export interface ProductCountResult { + /** + * @description Total product count. + */ + total: number +} diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index ab8c4d4361..66dfc2671d 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -8,6 +8,7 @@ import type { QuerySearchArgs, QuerySellersArgs, QueryShippingArgs, + QueryProductCountArgs, } from '../../../__generated__/schema' import { BadRequestError, NotFoundError } from '../../errors' import type { CategoryTree } from '../clients/commerce/types/CategoryTree' @@ -361,4 +362,19 @@ export const Query = { return { addresses: parsedAddresses } }, + productCount: async ( + _: unknown, + { term }: QueryProductCountArgs, + ctx: Context + ) => { + const { + clients: { search }, + } = ctx + + const result = await search.productCount({ + query: term ?? undefined, + }) + + return result + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/validateSession.ts b/packages/api/src/platforms/vtex/resolvers/validateSession.ts index 9f210e5240..661df7e385 100644 --- a/packages/api/src/platforms/vtex/resolvers/validateSession.ts +++ b/packages/api/src/platforms/vtex/resolvers/validateSession.ts @@ -1,12 +1,34 @@ import deepEquals from 'fast-deep-equal' -import ChannelMarshal from '../utils/channel' import type { Context } from '..' import type { MutationValidateSessionArgs, StoreMarketingData, StoreSession, } from '../../../__generated__/schema' +import ChannelMarshal from '../utils/channel' + +async function getPreciseLocationData( + clients: Context['clients'], + country: string, + postalCode: string +) { + try { + const address = await clients.commerce.checkout.address({ + postalCode, + country, + }) + + const [longitude, latitude] = address.geoCoordinates + return { city: address.city, geoCoordinates: { latitude, longitude } } + } catch (err) { + console.error( + `Error while getting geo coordinates for the current postal code (${postalCode}) and country (${country}).\n` + ) + + throw err + } +} export const validateSession = async ( _: any, @@ -15,15 +37,45 @@ export const validateSession = async ( ): Promise => { const channel = ChannelMarshal.parse(oldSession.channel ?? '') const postalCode = String(oldSession.postalCode ?? '') - const geoCoordinates = oldSession.geoCoordinates ?? null - const country = oldSession.country ?? '' + let city = oldSession.city ?? null + let geoCoordinates = oldSession.geoCoordinates ?? null + + // Update location data if postal code and country are provided + const shouldGetPreciseLocation = !city || !geoCoordinates + if (shouldGetPreciseLocation && postalCode !== '' && country !== '') { + const preciseLocation = await getPreciseLocationData( + clients, + country, + postalCode + ) + city = preciseLocation.city + geoCoordinates = preciseLocation.geoCoordinates + } + /** + * The Session Manager API (https://developers.vtex.com/docs/api-reference/session-manager-api#patch-/api/sessions) adds the query params to the session public namespace. + * This is used by Checkout (checkout-session) and Intelligent Search (search-session) + */ const params = new URLSearchParams(search) const salesChannel = params.get('sc') ?? channel.salesChannel - params.set('sc', salesChannel) + if (!!postalCode) { + params.set('postalCode', postalCode) + } + + if (!!country) { + params.set('country', country) + } + + if (!!geoCoordinates) { + params.set( + 'geoCoordinates', + `${geoCoordinates.latitude},${geoCoordinates.longitude}` + ) + } + const { marketingData: oldMarketingData } = oldSession const marketingData: StoreMarketingData = { @@ -36,24 +88,27 @@ export const validateSession = async ( utmiPart: params.get('utmi_pc') ?? oldMarketingData?.utmiPart ?? '', } - const [regionData, sessionData] = await Promise.all([ - postalCode || geoCoordinates - ? clients.commerce.checkout.region({ - postalCode, - geoCoordinates, - country, - salesChannel, - }) - : Promise.resolve(null), - clients.commerce.session(params.toString()).catch(() => null), - ]) + const sessionData = await clients.commerce + .session(params.toString()) + .catch(() => null) const profile = sessionData?.namespaces.profile ?? null const store = sessionData?.namespaces.store ?? null const authentication = sessionData?.namespaces.authentication ?? null - const region = regionData?.[0] + const checkout = sessionData?.namespaces.checkout ?? null + // Set seller only if it's inside a region - const seller = region?.sellers.find((seller) => channel.seller === seller.id) + let seller + if (!!channel.seller && (postalCode || geoCoordinates)) { + const regionData = await clients.commerce.checkout.region({ + postalCode, + geoCoordinates, + country, + salesChannel, + }) + const region = regionData?.[0] + seller = region?.sellers.find((seller) => channel.seller === seller.id) + } const newSession = { ...oldSession, @@ -64,7 +119,7 @@ export const validateSession = async ( country: store?.countryCode?.value ?? oldSession.country, channel: ChannelMarshal.stringify({ salesChannel: store?.channel?.value ?? channel.salesChannel, - regionId: region?.id ?? channel.regionId, + regionId: checkout?.regionId?.value ?? channel.regionId, seller: seller?.id, hasOnlyDefaultSalesChannel: !store?.channel?.value, }), @@ -80,6 +135,8 @@ export const validateSession = async ( familyName: profile.lastName?.value ?? '', } : null, + geoCoordinates, + city, } if (deepEquals(oldSession, newSession)) { diff --git a/packages/api/src/typeDefs/query.graphql b/packages/api/src/typeDefs/query.graphql index 8712ea6869..225e9e3916 100644 --- a/packages/api/src/typeDefs/query.graphql +++ b/packages/api/src/typeDefs/query.graphql @@ -199,6 +199,13 @@ input IGeoCoordinates { longitude: Float! } +type ProductCountResult { + """ + Total product count. + """ + total: Int! +} + type Query { """ Returns the details of a product based on the specified locator. @@ -349,6 +356,17 @@ type Query { id: String! ): Profile @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) + + """ + Returns the total product count information based on a specific location accessible through the VTEX segment cookie. + """ + productCount( + """ + Search term. + """ + term: String + ): ProductCountResult + @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) } """ diff --git a/packages/api/src/typeDefs/session.graphql b/packages/api/src/typeDefs/session.graphql index 65aca9ebc5..8a869e930a 100644 --- a/packages/api/src/typeDefs/session.graphql +++ b/packages/api/src/typeDefs/session.graphql @@ -159,6 +159,10 @@ type StoreSession { """ addressType: String """ + Session city. + """ + city: String + """ Session postal code. """ postalCode: String @@ -217,6 +221,10 @@ input IStoreSession { """ addressType: String """ + Session input city. + """ + city: String + """ Session input postal code. """ postalCode: String diff --git a/packages/api/test/integration/schema.test.ts b/packages/api/test/integration/schema.test.ts index 1bc1858594..64268bfa4f 100644 --- a/packages/api/test/integration/schema.test.ts +++ b/packages/api/test/integration/schema.test.ts @@ -68,6 +68,7 @@ const QUERIES = [ 'redirect', 'sellers', 'profile', + 'productCount', ] const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] diff --git a/packages/components/src/hooks/UIProvider.tsx b/packages/components/src/hooks/UIProvider.tsx index 9e72fe8ff8..c3c4e8b624 100644 --- a/packages/components/src/hooks/UIProvider.tsx +++ b/packages/components/src/hooks/UIProvider.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactNode } from 'react' +import type { PropsWithChildren, ReactNode, RefObject } from 'react' import React, { createContext, useContext, useMemo, useReducer } from 'react' export interface Toast { @@ -8,6 +8,11 @@ export interface Toast { icon?: ReactNode } +export interface Popover { + isOpen: boolean + triggerRef?: RefObject +} + interface State { /** Cart sidebar */ cart: boolean @@ -17,7 +22,10 @@ interface State { navbar: boolean /** Search page filter slider */ filter: boolean + /** Toast notifications */ toasts: Toast[] + /** Region Popover */ + popover: Popover } type UIElement = 'navbar' | 'cart' | 'modal' | 'filter' @@ -38,6 +46,16 @@ type Action = | { type: 'popToast' } + | { + type: 'openPopover' + payload: { + isOpen: boolean + triggerRef?: RefObject + } + } + | { + type: 'closePopover' + } const reducer = (state: State, action: Action): State => { const { type } = action @@ -89,6 +107,26 @@ const reducer = (state: State, action: Action): State => { } } + case 'openPopover': { + return { + ...state, + popover: { + isOpen: true, + triggerRef: action.payload.triggerRef, + }, + } + } + + case 'closePopover': { + return { + ...state, + popover: { + isOpen: false, + triggerRef: undefined, + }, + } + } + default: throw new Error(`Action ${type} not implemented`) } @@ -100,6 +138,10 @@ const initializer = (): State => ({ navbar: false, filter: false, toasts: [], + popover: { + isOpen: false, + triggerRef: undefined, + }, }) interface Context extends State { @@ -113,6 +155,8 @@ interface Context extends State { closeModal: () => void pushToast: (data: Toast) => void popToast: () => void + openPopover: (popover: Popover) => void + closePopover: () => void } const UIContext = createContext(undefined) @@ -133,6 +177,9 @@ function UIProvider({ children }: PropsWithChildren) { pushToast: (toast: Toast) => dispatch({ type: 'pushToast', payload: toast }), popToast: () => dispatch({ type: 'popToast' }), + openPopover: (popover: Popover) => + dispatch({ type: 'openPopover', payload: popover }), + closePopover: () => dispatch({ type: 'closePopover' }), }), [] ) diff --git a/packages/components/src/hooks/index.ts b/packages/components/src/hooks/index.ts index 302d67bd2f..f216c91896 100644 --- a/packages/components/src/hooks/index.ts +++ b/packages/components/src/hooks/index.ts @@ -1,6 +1,6 @@ export { default as UIProvider, Toast as ToastProps, useUI } from './UIProvider' export { useFadeEffect } from './useFadeEffect' -export { useTrapFocus } from './useTrapFocus' +export { useOnClickOutside } from './useOnClickOutside' export { useSearch } from './useSearch' export { useSKUMatrix } from './useSKUMatrix' export { useScrollDirection } from './useScrollDirection' @@ -12,3 +12,4 @@ export type { SlideDirection, } from './useSlider' export { useSlideVisibility } from './useSlideVisibility' +export { useTrapFocus } from './useTrapFocus' diff --git a/packages/components/src/hooks/useOnClickOutside.ts b/packages/components/src/hooks/useOnClickOutside.ts new file mode 100644 index 0000000000..a9d497a802 --- /dev/null +++ b/packages/components/src/hooks/useOnClickOutside.ts @@ -0,0 +1,40 @@ +import type { RefObject } from 'react' +import { useEffect } from 'react' + +type Handler = (event: any) => void + +export function useOnClickOutside( + ref: RefObject | undefined, + handler: Handler +) { + useEffect( + () => { + if (!ref?.current) return + + const listener: Handler = (event) => { + if (!ref?.current || ref.current.contains(event.target)) { + return + } + + handler(event) + } + + document.addEventListener('mousedown', listener) + document.addEventListener('touchstart', listener) + + return () => { + document.removeEventListener('mousedown', listener) + document.removeEventListener('touchstart', listener) + } + }, + /** + * Add ref and handler to effect dependencies. + * It's worth noting that because passed in handler is a new + * function on every render that will cause this effect + * callback/cleanup to run every render. It's not a big deal + * but to optimize you can wrap handler in useCallback before + * passing it into this hook. + */ + [ref, handler] + ) +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index c0cbd3ee2c..2b5f5bb235 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -131,6 +131,8 @@ export type { } from './molecules/NavbarLinks' export { default as OrderSummary } from './molecules/OrderSummary' export type { OrderSummaryProps } from './molecules/OrderSummary' +export { default as Popover } from './molecules/Popover' +export type { PopoverProps } from './molecules/Popover' export { default as ProductCard, ProductCardImage, diff --git a/packages/components/src/molecules/Modal/Modal.tsx b/packages/components/src/molecules/Modal/Modal.tsx index 606cdcfa74..8fae227ed6 100644 --- a/packages/components/src/molecules/Modal/Modal.tsx +++ b/packages/components/src/molecules/Modal/Modal.tsx @@ -20,7 +20,8 @@ export type ModalChildrenProps = { type ModalChildrenFunction = (props: ModalChildrenProps) => ReactNode -export interface ModalProps extends Omit { +export interface ModalProps + extends Omit { /** * ID to find this component in testing tools (e.g.: cypress, testing library, and jest). */ @@ -31,21 +32,29 @@ export interface ModalProps extends Omit { */ 'aria-labelledby'?: AriaAttributes['aria-label'] /** - * A boolean value that represents the state of the Modal + * A boolean value that represents the state of the Modal. */ isOpen?: boolean /** - * Event emitted when the modal is closed + * Event emitted when the modal is closed. */ onDismiss?: () => void /** - * Props forwarded to the `Overlay` component + * Callback function when the modal is opened. + */ + onEntered?: () => void + /** + * Props forwarded to the `Overlay` component. */ overlayProps?: OverlayProps /** - * Children or function as a children + * Children or function as a children. */ children: ModalChildrenFunction | ReactNode + /** + * Disable being closed using the Escape key. + */ + disableEscapeKeyDown?: boolean } /* @@ -60,13 +69,15 @@ const Modal = ({ isOpen = true, onDismiss, overlayProps, + disableEscapeKeyDown = false, + onEntered, ...otherProps }: ModalProps) => { const { closeModal } = useUI() const { fade, fadeOut, fadeIn } = useFadeEffect() const handleBackdropClick = (event: MouseEvent) => { - if (event.defaultPrevented) { + if (disableEscapeKeyDown || event.defaultPrevented) { return } @@ -76,7 +87,11 @@ const Modal = ({ } const handleBackdropKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) { + if ( + disableEscapeKeyDown || + event.key !== 'Escape' || + event.defaultPrevented + ) { return } @@ -93,7 +108,13 @@ const Modal = ({ {...overlayProps} > fade === 'out' && closeModal()} + onTransitionEnd={() => { + if (fade === 'out') { + closeModal() + } else if (fade === 'in' && onEntered) { + onEntered() + } + }} data-fs-modal data-fs-modal-state={fade} testId={testId} diff --git a/packages/components/src/molecules/Popover/Popover.tsx b/packages/components/src/molecules/Popover/Popover.tsx new file mode 100644 index 0000000000..f98102e94c --- /dev/null +++ b/packages/components/src/molecules/Popover/Popover.tsx @@ -0,0 +1,209 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useRef, + useState, + type HTMLAttributes, + type KeyboardEvent, + type ReactNode, + type RefObject, +} from 'react' +import Icon from '../../atoms/Icon' +import IconButton from '../IconButton' + +import { useOnClickOutside, useUI } from '../../hooks' + +/** + * Specifies Popover position. + */ +export type Side = 'bottom' | 'top' + +/** + * Specifies tooltip alignment. + */ +export type Alignment = 'start' | 'center' | 'end' + +/** + * Combines side + alignment (e.g., "top-start"). + */ +export type Placement = `${Side}-${Alignment}` + +export interface PopoverProps + extends Omit, 'content'> { + /** + * The Popover header's title. + */ + title?: string + /** + * Content of the Popover. + */ + content: ReactNode + /** + * Defines the side or side-alignment (e.g., "bottom-start", "bottom-center") of the Popover. + */ + placement?: Placement + /** + * If the Popover can be closed by a button. + */ + dismissible?: boolean + /** + * Called when the Popover is dismissed. + */ + onDismiss?: () => void + /** + * Callback when the Popover is fully rendered and positioned. + */ + onEntered?: () => void + /** + * Close button aria-label. + */ + closeButtonAriaLabel?: string + /** + * Controls whether the Popover is open. + */ + isOpen: boolean + /** + * ID to find this component in testing tools (e.g.: cypress, testing library, and jest). + */ + testId?: string + /** + * Offset value for top position (e.g.: 12). + * @default '8' + */ + offsetTop?: number + /** + * Offset value for left position (e.g.: 12). + * @default '0' + */ + offsetLeft?: number + /** + * Reference to the trigger element that opens the Popover. + */ + triggerRef?: RefObject +} + +const calculatePosition = ( + rect: DOMRect, + placement: Placement, + offsetTop: number, + offsetLeft: number +) => { + const { top, left, height } = rect + + switch (true) { + case placement.startsWith('top'): + return { + top: top + height + window.scrollY - offsetTop, + left: left + window.scrollX + offsetLeft, + } + case placement.startsWith('bottom'): + return { + top: top + height + window.scrollY + offsetTop, + left: left + window.scrollX + offsetLeft, + } + default: + return { top: 0, left: 0 } + } +} + +const Popover = forwardRef(function Popover( + { + title, + content, + placement = 'bottom-start', + dismissible = false, + onDismiss, + isOpen, + triggerRef: propTriggerRef, + offsetTop = 8, + offsetLeft = 0, + closeButtonAriaLabel = 'Close Popover', + testId = 'fs-popover', + style, + onEntered, + ...otherProps + }, + ref +) { + // Use forwarded ref or internal ref for fallback + const popoverRef = ref || useRef(null) + + const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 }) + const { popover, closePopover } = useUI() + + const contextTriggerRef = popover.triggerRef + + // Use the propTriggerRef if provided, otherwise fallback to contextTriggerRef + const triggerRef = propTriggerRef || contextTriggerRef + + useEffect(() => { + if (!isOpen || !triggerRef?.current) return + + // Set the position according to the trigger element and placement + const rect = triggerRef.current.getBoundingClientRect() + + setPopoverPosition( + calculatePosition(rect, placement, offsetTop, offsetLeft) + ) + + // Trigger the onEntered callback after positioning + if (onEntered) { + onEntered() + } + }, [isOpen, triggerRef, offsetTop, offsetLeft, placement]) + + const handleDismiss = useCallback(() => { + closePopover() + onDismiss?.() + }, [closePopover, onDismiss]) + + useOnClickOutside( + isOpen ? (popoverRef as RefObject) : undefined, + handleDismiss + ) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleDismiss() + } + }, + [handleDismiss] + ) + + if (!isOpen) { + return null + } + + return ( +
+
+ {title &&

{title}

} + {dismissible && ( + } + aria-label={closeButtonAriaLabel} + onClick={handleDismiss} + /> + )} +
+
{content}
+
+ ) +}) + +export default Popover diff --git a/packages/components/src/molecules/Popover/index.tsx b/packages/components/src/molecules/Popover/index.tsx new file mode 100644 index 0000000000..9e461b54c6 --- /dev/null +++ b/packages/components/src/molecules/Popover/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Popover' +export type { PopoverProps } from './Popover' diff --git a/packages/components/src/molecules/RegionBar/RegionBar.tsx b/packages/components/src/molecules/RegionBar/RegionBar.tsx index d30c32c262..7734839d1c 100644 --- a/packages/components/src/molecules/RegionBar/RegionBar.tsx +++ b/packages/components/src/molecules/RegionBar/RegionBar.tsx @@ -1,11 +1,17 @@ -import type { HTMLAttributes, ReactNode } from 'react' +import type { HTMLAttributes, ReactNode, RefAttributes } from 'react' import React, { forwardRef } from 'react' import { Button } from '../../' -export interface RegionBarProps extends HTMLAttributes { +export interface RegionBarProps + extends HTMLAttributes, + RefAttributes { /** - * Postal code string to be display in the component + * City to be displayed in the component. + */ + city?: string + /** + * Postal code string to be display in the component. */ postalCode?: string /** @@ -28,16 +34,23 @@ export interface RegionBarProps extends HTMLAttributes { * A React component that will be rendered as an icon. */ buttonIcon?: ReactNode + /** + * Boolean to control whether postal code should be visible or not. + * @default true + */ + shouldDisplayPostalCode?: boolean } const RegionBar = forwardRef(function RegionBar( { + city, postalCode, icon, label, editLabel, buttonIcon, onButtonClick, + shouldDisplayPostalCode = true, ...otherProps }, ref @@ -51,9 +64,12 @@ const RegionBar = forwardRef(function RegionBar( icon={buttonIcon} > {!!icon && icon} - {postalCode ? ( + {city && postalCode ? ( <> - {postalCode} + + {city} + {shouldDisplayPostalCode && `, ${postalCode}`} + {!!editLabel && {editLabel}} ) : ( diff --git a/packages/components/src/organisms/RegionModal/RegionModal.tsx b/packages/components/src/organisms/RegionModal/RegionModal.tsx index 4c66312161..9b8175e170 100644 --- a/packages/components/src/organisms/RegionModal/RegionModal.tsx +++ b/packages/components/src/organisms/RegionModal/RegionModal.tsx @@ -51,6 +51,10 @@ export interface RegionModalProps extends Omit { * Postal code input's label. */ inputLabel?: string + /** + * The text displayed on the InputField Button. Suggestion: maximum 9 characters. + */ + inputButtonActionText?: string /** * Enables fadeOut effect on modal after onSubmit function */ @@ -75,24 +79,31 @@ export interface RegionModalProps extends Omit { * Callback function when the input clear button is clicked. */ onClear?: () => void + /** + * Determines if the modal can be dismissed using the close button or the Escape key. + * @default true + */ + dismissible?: boolean } function RegionModal({ testId = 'fs-region-modal', title = 'Set your location', - description = 'Prices, offers and availability may vary according to your location.', + description = 'Offers and availability vary by location.', closeButtonAriaLabel = 'Close Region Modal', idkPostalCodeLinkProps, errorMessage, inputRef, inputValue, inputLabel = 'Postal Code', + inputButtonActionText = 'Apply', fadeOutOnSubmit, overlayProps, onClose, onInput, onSubmit, onClear, + dismissible = true, ...otherProps }: RegionModalProps) { return ( @@ -102,15 +113,24 @@ function RegionModal({ overlayProps={overlayProps} title="Region modal" aria-label="Region modal" + disableEscapeKeyDown={!dismissible} + onEntered={() => { + if (inputRef?.current) { + inputRef.current.focus() + } + }} {...otherProps} > {({ fadeOut }) => ( <> { - fadeOut() - onClose?.() - }} + {...(dismissible && { + onClose: () => { + fadeOut() + onClear?.() + onClose?.() + }, + })} title={title} description={description} closeBtnProps={{ @@ -125,6 +145,7 @@ function RegionModal({ label={inputLabel} actionable value={inputValue} + buttonActionText={inputButtonActionText} onInput={(event) => onInput?.(event)} onSubmit={() => { onSubmit?.() @@ -133,8 +154,9 @@ function RegionModal({ onClear={() => onClear?.()} error={errorMessage} /> - - + {idkPostalCodeLinkProps && ( + + )} )} diff --git a/packages/components/src/organisms/ShippingSimulation/ShippingSimulation.tsx b/packages/components/src/organisms/ShippingSimulation/ShippingSimulation.tsx index 63005f0dee..48fd270aae 100644 --- a/packages/components/src/organisms/ShippingSimulation/ShippingSimulation.tsx +++ b/packages/components/src/organisms/ShippingSimulation/ShippingSimulation.tsx @@ -69,9 +69,9 @@ interface Address { */ reference?: string /** - * Address geoCoordinates + * Address geoCoordinates. [longitude, latitude] */ - geoCoordinates?: [number] + geoCoordinates?: [number, number] } export interface ShippingSimulationProps diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 2e47fc0483..7468c9c511 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -44,6 +44,8 @@ const documents = { types.ValidateCartMutationDocument, '\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n': types.SubscribeToNewsletterDocument, + '\n query ClientProductCountQuery($term: String) {\n productCount(term: $term) {\n total\n }\n }\n': + types.ClientProductCountQueryDocument, '\n query ClientAllVariantProductsQuery($locator: [IStoreSelectedFacet!]!) {\n product(locator: $locator) {\n ...ProductSKUMatrixSidebarFragment_product\n }\n }\n': types.ClientAllVariantProductsQueryDocument, '\n query ClientManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n }\n }\n': @@ -52,11 +54,13 @@ const documents = { types.ClientProductGalleryQueryDocument, '\n query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ClientProduct\n product(locator: $locator) {\n ...ProductDetailsFragment_product\n }\n }\n': types.ClientProductQueryDocument, + '\n query ClientProfileQuery($id: String!) {\n profile(id: $id) {\n addresses {\n country\n postalCode\n geoCoordinate\n city\n }\n }\n }\n': + types.ClientProfileQueryDocument, '\n query ClientSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n products {\n ...ProductSummary_product\n }\n }\n products {\n pageInfo {\n totalCount\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n': types.ClientSearchSuggestionsQueryDocument, '\n query ClientTopSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientTopSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n': types.ClientTopSearchSuggestionsQueryDocument, - '\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n }\n }\n': + '\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n city\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n }\n }\n': types.ValidateSessionDocument, '\n query ClientShippingSimulationQuery(\n $postalCode: String!\n $country: String!\n $items: [IShippingItem!]!\n ) {\n ...ClientShippingSimulation\n shipping(items: $items, postalCode: $postalCode, country: $country) {\n logisticsInfo {\n slas {\n carrier\n price\n availableDeliveryWindows {\n startDateUtc\n endDateUtc\n price\n listPrice\n }\n shippingEstimate\n localizedEstimates\n deliveryChannel\n }\n }\n address {\n city\n neighborhood\n state\n }\n }\n }\n': types.ClientShippingSimulationQueryDocument, @@ -160,6 +164,12 @@ export function gql( export function gql( source: '\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n' ): typeof import('./graphql').SubscribeToNewsletterDocument +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n query ClientProductCountQuery($term: String) {\n productCount(term: $term) {\n total\n }\n }\n' +): typeof import('./graphql').ClientProductCountQueryDocument /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -184,6 +194,12 @@ export function gql( export function gql( source: '\n query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ClientProduct\n product(locator: $locator) {\n ...ProductDetailsFragment_product\n }\n }\n' ): typeof import('./graphql').ClientProductQueryDocument +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n query ClientProfileQuery($id: String!) {\n profile(id: $id) {\n addresses {\n country\n postalCode\n geoCoordinate\n city\n }\n }\n }\n' +): typeof import('./graphql').ClientProfileQueryDocument /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -200,7 +216,7 @@ export function gql( * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql( - source: '\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n }\n }\n' + source: '\n mutation ValidateSession($session: IStoreSession!, $search: String!) {\n validateSession(session: $session, search: $search) {\n locale\n channel\n country\n addressType\n postalCode\n city\n deliveryMode {\n deliveryChannel\n deliveryMethod\n deliveryWindow {\n startDate\n endDate\n }\n }\n geoCoordinates {\n latitude\n longitude\n }\n currency {\n code\n symbol\n }\n person {\n id\n email\n givenName\n familyName\n }\n b2b {\n customerId\n }\n marketingData {\n utmCampaign\n utmMedium\n utmSource\n utmiCampaign\n utmiPage\n utmiPart\n }\n }\n }\n' ): typeof import('./graphql').ValidateSessionDocument /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 476e6865d1..d04230fd8b 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -320,6 +320,8 @@ export type IStoreSession = { b2b: InputMaybe /** Session input channel. */ channel: InputMaybe + /** Session input city. */ + city: InputMaybe /** Session input country. */ country: Scalars['String']['input'] /** Session input currency. */ @@ -469,6 +471,11 @@ export type PickupStoreInfo = { isPickupStore: Maybe } +export type ProductCountResult = { + /** Total product count. */ + total: Scalars['Int']['output'] +} + export type Profile = { /** Collection of user's address */ addresses: Maybe>> @@ -512,6 +519,8 @@ export type Query = { collection: StoreCollection /** Returns the details of a product based on the specified locator. */ product: StoreProduct + /** Returns the total product count information based on a specific location accessible through the VTEX segment cookie. */ + productCount: Maybe /** Returns information about the profile. */ profile: Maybe /** Returns if there's a redirect for a search. */ @@ -542,6 +551,10 @@ export type QueryProductArgs = { locator: Array } +export type QueryProductCountArgs = { + term: InputMaybe +} + export type QueryProfileArgs = { id: Scalars['String']['input'] } @@ -1138,6 +1151,8 @@ export type StoreSession = { b2b: Maybe /** Session channel. */ channel: Maybe + /** Session city. */ + city: Maybe /** Session country. */ country: Scalars['String']['output'] /** Session currency. */ @@ -1547,6 +1562,14 @@ export type SubscribeToNewsletterMutation = { subscribeToNewsletter: { id: string } | null } +export type ClientProductCountQueryQueryVariables = Exact<{ + term: InputMaybe +}> + +export type ClientProductCountQueryQuery = { + productCount: { total: number } | null +} + export type ClientAllVariantProductsQueryQueryVariables = Exact<{ locator: Array | IStoreSelectedFacet }> @@ -1745,6 +1768,21 @@ export type ClientProductQueryQuery = { } } +export type ClientProfileQueryQueryVariables = Exact<{ + id: Scalars['String']['input'] +}> + +export type ClientProfileQueryQuery = { + profile: { + addresses: Array<{ + country: string | null + postalCode: string | null + geoCoordinate: Array | null + city: string | null + } | null> | null + } | null +} + export type ClientSearchSuggestionsQueryQueryVariables = Exact<{ term: Scalars['String']['input'] selectedFacets: InputMaybe | IStoreSelectedFacet> @@ -1825,6 +1863,7 @@ export type ValidateSessionMutation = { country: string addressType: string | null postalCode: string | null + city: string | null deliveryMode: { deliveryChannel: string deliveryMethod: string @@ -2427,6 +2466,15 @@ export const SubscribeToNewsletterDocument = { SubscribeToNewsletterMutation, SubscribeToNewsletterMutationVariables > +export const ClientProductCountQueryDocument = { + __meta__: { + operationName: 'ClientProductCountQuery', + operationHash: 'dc912e7272e3d9f5ced206837df87f544d39d0a5', + }, +} as unknown as TypedDocumentString< + ClientProductCountQueryQuery, + ClientProductCountQueryQueryVariables +> export const ClientAllVariantProductsQueryDocument = { __meta__: { operationName: 'ClientAllVariantProductsQuery', @@ -2463,6 +2511,15 @@ export const ClientProductQueryDocument = { ClientProductQueryQuery, ClientProductQueryQueryVariables > +export const ClientProfileQueryDocument = { + __meta__: { + operationName: 'ClientProfileQuery', + operationHash: '34ea14c0d4a57ddf9bc11e4be0cd2b5a6506d3d4', + }, +} as unknown as TypedDocumentString< + ClientProfileQueryQuery, + ClientProfileQueryQueryVariables +> export const ClientSearchSuggestionsQueryDocument = { __meta__: { operationName: 'ClientSearchSuggestionsQuery', @@ -2484,7 +2541,7 @@ export const ClientTopSearchSuggestionsQueryDocument = { export const ValidateSessionDocument = { __meta__: { operationName: 'ValidateSession', - operationHash: '2c6e94b978eb50647873082daebcc5b332154cb1', + operationHash: '6189ed611a20d9d5fe8ebebf61c87c9c29a5cef4', }, } as unknown as TypedDocumentString< ValidateSessionMutation, diff --git a/packages/core/cms/faststore/sections.json b/packages/core/cms/faststore/sections.json index 5f6a156430..c241b373fb 100644 --- a/packages/core/cms/faststore/sections.json +++ b/packages/core/cms/faststore/sections.json @@ -2206,7 +2206,7 @@ "description": { "title": "Description", "type": "string", - "default": "Prices, offers and availability may vary according to your location." + "default": "Offers and availability vary by location" }, "closeButtonAriaLabel": { "title": "Close modal aria-label", @@ -2226,6 +2226,17 @@ "title": "Input field error message", "type": "string", "default": "You entered an invalid Postal Code" + }, + "noProductsAvailableErrorMessage": { + "title": "Input field error message for the scenario of no products available for a given location", + "type": "string", + "default": "There are no products available for %s.", + "description": "The '%s' will be replaced by the postal code value." + }, + "buttonActionText": { + "title": "Input field action button label", + "type": "string", + "default": "Apply" } } }, @@ -2265,6 +2276,104 @@ } } }, + { + "name": "RegionPopover", + "requiredScopes": [], + "schema": { + "title": "Region Popover", + "type": "object", + "description": "Region Popover configuration", + "properties": { + "title": { + "title": "Title", + "type": "string", + "default": "Set your location" + }, + "closeButtonAriaLabel": { + "title": "Close popover aria-label", + "type": "string", + "default": "Close Region Popover" + }, + "inputField": { + "title": "Input Field", + "type": "object", + "properties": { + "label": { + "title": "Input field label", + "type": "string", + "default": "Postal Code" + }, + "errorMessage": { + "title": "Input field error message", + "type": "string", + "default": "You entered an invalid Postal Code" + }, + "noProductsAvailableErrorMessage": { + "title": "Input field error message for the scenario of no products available for a given location", + "type": "string", + "default": "There are no products available for %s.", + "description": "The '%s' will be replaced by the postal code value." + }, + "buttonActionText": { + "title": "Input field action button label", + "type": "string", + "default": "Apply" + } + } + }, + "textBeforeLocation": { + "title": "Text before location", + "type": "string", + "default": "Your current location is", + "description": "If location is available, this text will be shown before the location" + }, + "textAfterLocation": { + "title": "Text After location", + "type": "string", + "default": "Use the field below to change it.", + "description": "If location is available, this text will be shown after the location" + }, + "description": { + "title": "Description", + "type": "string", + "default": "Offers and availability vary by location" + }, + "idkPostalCodeLink": { + "title": "I don't know my postal code link", + "type": "object", + "properties": { + "text": { + "type": "string", + "title": "Link Text", + "default": "I don't know my Postal Code" + }, + "to": { + "type": "string", + "title": "Action link" + }, + "icon": { + "title": "Icon", + "type": "object", + "properties": { + "icon": { + "title": "Icon", + "type": "string", + "enumNames": ["Arrow Square Out"], + "enum": ["ArrowSquareOut"], + "default": "ArrowSquareOut" + }, + "alt": { + "title": "Alternative Label", + "type": "string", + "default": "Arrow Square Out icon" + } + } + } + } + } + } + } + }, { "name": "EmptyState", "schema": { diff --git a/packages/core/discovery.config.default.js b/packages/core/discovery.config.default.js index c4c5dfaeaa..195881eb51 100644 --- a/packages/core/discovery.config.default.js +++ b/packages/core/discovery.config.default.js @@ -52,6 +52,7 @@ module.exports = { country: 'USA', deliveryMode: null, addressType: null, + city: null, postalCode: null, geoCoordinates: null, b2b: null, @@ -128,6 +129,11 @@ module.exports = { type: 'CMS', }, + deliveryPromise: { + enabled: false, + mandatory: false, + }, + experimental: { cypressVersion: 12, enableCypressExtension: false, diff --git a/packages/core/src/Layout.tsx b/packages/core/src/Layout.tsx index ace71ccb18..b8aeb5e799 100644 --- a/packages/core/src/Layout.tsx +++ b/packages/core/src/Layout.tsx @@ -1,10 +1,12 @@ import { useMemo, type PropsWithChildren, type ReactElement } from 'react' +import { useRegionModal } from './components/region/RegionModal/useRegionModal' import { usePageViewEvent } from './sdk/analytics/hooks/usePageViewEvent' function Layout({ children }: PropsWithChildren) { const props = useMemo(() => (children as ReactElement)?.props, []) usePageViewEvent(props) + useRegionModal() return <>{children} } diff --git a/packages/core/src/components/cms/global/Components.ts b/packages/core/src/components/cms/global/Components.ts index f0358a3e7f..e7bc182cac 100644 --- a/packages/core/src/components/cms/global/Components.ts +++ b/packages/core/src/components/cms/global/Components.ts @@ -1,6 +1,7 @@ import dynamic from 'next/dynamic' import type { ComponentType } from 'react' +import RegionPopover from 'src/components/region/RegionPopover' import { OverriddenDefaultAlert as Alert } from 'src/components/sections/Alert/OverriddenDefaultAlert' import { OverriddenDefaultNavbar as Navbar } from 'src/components/sections/Navbar/OverriddenDefaultNavbar' import { OverriddenDefaultRegionBar as RegionBar } from 'src/components/sections/RegionBar/OverriddenDefaultRegionBar' @@ -32,6 +33,7 @@ const COMPONENTS: Record> = { Alert, Navbar, RegionBar, + RegionPopover, CartSidebar, // out of viewport RegionModal, // out of viewport Footer, // out of viewport diff --git a/packages/core/src/components/region/RegionBar/RegionBar.tsx b/packages/core/src/components/region/RegionBar/RegionBar.tsx index 94ae24002a..d9a11af373 100644 --- a/packages/core/src/components/region/RegionBar/RegionBar.tsx +++ b/packages/core/src/components/region/RegionBar/RegionBar.tsx @@ -1,9 +1,14 @@ import type { RegionBarProps as UIRegionBarProps } from '@faststore/ui' +import { useEffect, useRef } from 'react' import { useUI } from '@faststore/ui' import { useSession } from 'src/sdk/session' +import { deliveryPromise, session as initialSession } from 'discovery.config' import { useOverrideComponents } from 'src/sdk/overrides/OverrideContext' +import { textToTitleCase } from 'src/utils/utilities' + +import { useRegionModal } from '../RegionModal/useRegionModal' export interface RegionBarProps { /** @@ -43,8 +48,34 @@ function RegionBar({ ButtonIcon, } = useOverrideComponents<'RegionBar'>() - const { openModal } = useUI() - const { postalCode } = useSession() + const { openModal, openPopover } = useUI() + const { city, postalCode } = useSession() + const { isValidationComplete } = useRegionModal() + const regionBarRef = useRef(null) + + const defaultPostalCode = + !!initialSession?.postalCode && postalCode === initialSession.postalCode + + // If location is not mandatory, and default zipCode is provided or if the user has not set a zipCode, show the popover. + const displayRegionPopover = + defaultPostalCode || (!postalCode && !deliveryPromise.mandatory) + + useEffect(() => { + if (!deliveryPromise.enabled) { + return + } + + if (!isValidationComplete) { + return + } + + if (isValidationComplete && displayRegionPopover && regionBarRef.current) { + openPopover({ + isOpen: true, + triggerRef: regionBarRef, + }) + } + }, [isValidationComplete]) return ( ) } diff --git a/packages/core/src/components/region/RegionButton/RegionButton.tsx b/packages/core/src/components/region/RegionButton/RegionButton.tsx index 6c4cfd64ae..278c6531b1 100644 --- a/packages/core/src/components/region/RegionButton/RegionButton.tsx +++ b/packages/core/src/components/region/RegionButton/RegionButton.tsx @@ -1,21 +1,56 @@ -import { Button as UIButton } from '@faststore/ui' +import { useEffect, useRef } from 'react' -import { Icon, useUI } from '@faststore/ui' +import { Button as UIButton, Icon as UIIcon, useUI } from '@faststore/ui' +import { deliveryPromise, session as initialSession } from 'discovery.config' import { useSession } from 'src/sdk/session' +import { textToTitleCase } from 'src/utils/utilities' + +import { useRegionModal } from '../RegionModal/useRegionModal' function RegionButton({ icon, label }: { icon: string; label: string }) { - const { openModal } = useUI() - const { postalCode } = useSession() + const { openModal, openPopover } = useUI() + const { city, postalCode } = useSession() + const { isValidationComplete } = useRegionModal() + const regionButtonRef = useRef(null) + + const defaultPostalCode = + !!initialSession?.postalCode && postalCode === initialSession.postalCode + + // If location is not mandatory, and default zipCode is provided or if the user has not set a zipCode, show the popover. + const displayRegionPopover = + defaultPostalCode || (!postalCode && !deliveryPromise.mandatory) + + useEffect(() => { + if (!deliveryPromise.enabled) { + return + } + + if (!isValidationComplete) { + return + } + + if ( + isValidationComplete && + displayRegionPopover && + regionButtonRef.current + ) { + openPopover({ + isOpen: true, + triggerRef: regionButtonRef, + }) + } + }, [isValidationComplete]) return ( } + icon={} iconPosition="left" onClick={openModal} + ref={regionButtonRef} > - {postalCode ?? label} + {city && postalCode ? `${textToTitleCase(city)}, ${postalCode}` : label} ) } diff --git a/packages/core/src/components/region/RegionModal/RegionModal.tsx b/packages/core/src/components/region/RegionModal/RegionModal.tsx index a7908adad1..204ebab1e6 100644 --- a/packages/core/src/components/region/RegionModal/RegionModal.tsx +++ b/packages/core/src/components/region/RegionModal/RegionModal.tsx @@ -1,13 +1,14 @@ -import { - Icon, - type RegionModalProps as UIRegionModalProps, - useUI, -} from '@faststore/ui' +import dynamic from 'next/dynamic' import { useRef, useState } from 'react' -import { sessionStore, useSession, validateSession } from 'src/sdk/session' +import type { RegionModalProps as UIRegionModalProps } from '@faststore/ui' +import { Icon, useUI } from '@faststore/ui' + +import { deliveryPromise } from 'discovery.config' +import { useSession } from 'src/sdk/session' + +import useRegion from './useRegion' -import dynamic from 'next/dynamic' import styles from './section.module.scss' const UIRegionModal = dynamic( @@ -17,7 +18,6 @@ const UIRegionModal = dynamic( ), { ssr: false } ) - interface RegionModalProps { title?: UIRegionModalProps['title'] description?: UIRegionModalProps['description'] @@ -25,6 +25,8 @@ interface RegionModalProps { inputField?: { label?: UIRegionModalProps['inputLabel'] errorMessage?: UIRegionModalProps['errorMessage'] + noProductsAvailableErrorMessage?: UIRegionModalProps['errorMessage'] + buttonActionText?: UIRegionModalProps['inputButtonActionText'] } idkPostalCodeLink?: { text?: string @@ -40,7 +42,12 @@ function RegionModal({ title, description, closeButtonAriaLabel, - inputField: { label: inputFieldLabel, errorMessage: inputFieldErrorMessage }, + inputField: { + label: inputFieldLabel, + errorMessage: inputFieldErrorMessage, + noProductsAvailableErrorMessage: inputFieldNoProductsAvailableErrorMessage, + buttonActionText: inputButtonActionText, + }, idkPostalCodeLink: { text: idkPostalCodeLinkText, to: idkPostalCodeLinkTo, @@ -49,35 +56,33 @@ function RegionModal({ }: RegionModalProps) { const inputRef = useRef(null) const { isValidating, ...session } = useSession() - const [errorMessage, setErrorMessage] = useState('') + const { modal: displayModal, closeModal } = useUI() + const [input, setInput] = useState('') - const { modal: displayModal } = useUI() - const handleSubmit = async () => { - const postalCode = inputRef.current?.value + const { loading, setRegion, regionError, setRegionError } = useRegion() - if (typeof postalCode !== 'string') { + const handleSubmit = async () => { + if (isValidating) { return } - setErrorMessage('') - - try { - const newSession = { - ...session, - postalCode, - } - - const validatedSession = await validateSession(newSession) - - sessionStore.set(validatedSession ?? newSession) - } catch (error) { - setErrorMessage(inputFieldErrorMessage) - } + await setRegion({ + session, + onSuccess: () => { + setInput('') + closeModal() + }, + postalCode: input, + errorMessage: inputFieldErrorMessage, + noProductsAvailableErrorMessage: + inputFieldNoProductsAvailableErrorMessage, + }) } + const isDismissible = !!(!deliveryPromise?.mandatory || session.postalCode) const idkPostalCodeLinkProps: UIRegionModalProps['idkPostalCodeLinkProps'] = { - href: idkPostalCodeLinkTo ?? '#', + href: idkPostalCodeLinkTo, children: ( <> {idkPostalCodeLinkText} @@ -106,15 +111,22 @@ function RegionModal({ inputRef={inputRef} inputValue={input} inputLabel={inputFieldLabel} - errorMessage={errorMessage} - idkPostalCodeLinkProps={idkPostalCodeLinkProps} + errorMessage={regionError} + idkPostalCodeLinkProps={ + idkPostalCodeLinkTo ? idkPostalCodeLinkProps : null + } onInput={(e) => { - errorMessage !== '' && setErrorMessage('') + regionError !== '' && setRegionError('') setInput(e.currentTarget.value) }} onSubmit={handleSubmit} - fadeOutOnSubmit={true} - onClear={() => setInput('')} + fadeOutOnSubmit={false} + onClear={() => { + setInput('') + setRegionError('') + }} + inputButtonActionText={loading ? '...' : inputButtonActionText} + dismissible={isDismissible} /> )} diff --git a/packages/core/src/components/region/RegionModal/useRegion.ts b/packages/core/src/components/region/RegionModal/useRegion.ts new file mode 100644 index 0000000000..d2f69d6403 --- /dev/null +++ b/packages/core/src/components/region/RegionModal/useRegion.ts @@ -0,0 +1,79 @@ +import { useCallback, useState } from 'react' + +import type { Session } from '@faststore/sdk' +import { sessionStore, validateSession } from 'src/sdk/session' +import { getProductCount } from 'src/sdk/product' +import { deliveryPromise } from 'discovery.config' + +type SetRegionProps = { + session: Session + postalCode: string | undefined + onSuccess?: () => void + errorMessage: string + noProductsAvailableErrorMessage?: string +} + +type UseRegionValues = { + loading: boolean + regionError: string + setRegion: (props: SetRegionProps) => Promise + setRegionError: (value: string) => void +} + +export default function useRegion(): UseRegionValues { + const [loading, setLoading] = useState(false) + const [regionError, setRegionError] = useState('') + + const setRegion = async ({ + postalCode, + errorMessage, + session, + onSuccess, + noProductsAvailableErrorMessage, + }: SetRegionProps) => { + if (typeof postalCode !== 'string') { + return + } + + setLoading(true) + + try { + const newSession = { + ...session, + postalCode, + geoCoordinates: null, // Revalidate geo coordinates in API when users set a new postal code + } as Session + + const validatedSession = await validateSession(newSession) + + if (deliveryPromise.enabled) { + // Check product availability for specific postal code + const productCount = await getProductCount() + if (productCount === 0) { + const errorFallback = `There are no products available for ${postalCode}.` + const noProductsAvailableError = + noProductsAvailableErrorMessage?.replace(/%s/g, () => postalCode) + + setRegionError(noProductsAvailableError ?? errorFallback) + setLoading(false) + return + } + } + + sessionStore.set(validatedSession ?? newSession) + setRegionError('') + onSuccess?.() // Execute the post-validation action (close modal, etc.) + } catch (error) { + setRegionError(errorMessage) + } finally { + setLoading(false) // Reset loading to false when validation is complete + } + } + + return { + loading, + setRegion, + regionError, + setRegionError, + } +} diff --git a/packages/core/src/components/region/RegionModal/useRegionModal.ts b/packages/core/src/components/region/RegionModal/useRegionModal.ts new file mode 100644 index 0000000000..51358c76c4 --- /dev/null +++ b/packages/core/src/components/region/RegionModal/useRegionModal.ts @@ -0,0 +1,44 @@ +import { useUI } from '@faststore/ui' +import { deliveryPromise } from 'discovery.config' +import { useEffect, useRef, useState } from 'react' +import { sessionStore, useSession } from 'src/sdk/session' + +export function useRegionModal() { + const { openModal: displayRegionModal } = useUI() + const { isValidating } = useSession() + + // Ref to track the previous value of isValidating + const prevIsValidating = useRef(isValidating) + + // State to track if validation is complete + const [isValidationComplete, setValidationComplete] = useState(false) + + const openRegionModal = () => { + const { postalCode } = sessionStore.read() + if (!postalCode) { + displayRegionModal() + } + } + + // Effect to handle when isValidating changes from true to false + useEffect(() => { + if (!deliveryPromise.enabled) { + return + } + + // Check if validation has completed (isValidating changed from true to false) + if (prevIsValidating.current && !isValidating) { + setValidationComplete(true) + + // If the postal code is not set and is mandatory, open the region modal + if (deliveryPromise.mandatory) { + openRegionModal() + } + } + + // Update the previous value of isValidating + prevIsValidating.current = isValidating + }, [openRegionModal]) + + return { openRegionModal, isValidationComplete } +} diff --git a/packages/core/src/components/region/RegionPopover/RegionPopover.tsx b/packages/core/src/components/region/RegionPopover/RegionPopover.tsx new file mode 100644 index 0000000000..6ca77428cc --- /dev/null +++ b/packages/core/src/components/region/RegionPopover/RegionPopover.tsx @@ -0,0 +1,174 @@ +import type { PopoverProps as UIPopoverProps } from '@faststore/ui' +import { + Icon as UIIcon, + InputField as UIInputField, + Link as UILink, + Popover as UIPopover, + useUI, +} from '@faststore/ui' +import { useRef, useState } from 'react' + +import useRegion from '../RegionModal/useRegion' + +import { sessionStore, useSession } from 'src/sdk/session' +import { textToTitleCase } from 'src/utils/utilities' +import styles from './section.module.scss' + +interface RegionPopoverProps { + title?: UIPopoverProps['title'] + closeButtonAriaLabel?: UIPopoverProps['closeButtonAriaLabel'] + inputField?: { + label?: string + errorMessage?: string + noProductsAvailableErrorMessage?: string + buttonActionText?: string + } + idkPostalCodeLink?: { + text?: string + to?: string + icon?: { + icon?: string + alt?: string + } + } + textBeforeLocation?: string + textAfterLocation?: string + description?: string + triggerRef?: UIPopoverProps['triggerRef'] + onDismiss: UIPopoverProps['onDismiss'] + offsetTop?: UIPopoverProps['offsetTop'] + offsetLeft?: UIPopoverProps['offsetLeft'] + placement?: UIPopoverProps['placement'] +} + +function RegionPopover({ + title = 'Set your location', + closeButtonAriaLabel, + inputField: { + label: inputFieldLabel, + errorMessage: inputFieldErrorMessage, + noProductsAvailableErrorMessage: inputFieldNoProductsAvailableErrorMessage, + buttonActionText: inputButtonActionText, + }, + idkPostalCodeLink: { + text: idkPostalCodeLinkText, + to: idkPostalCodeLinkTo, + icon: { icon: idkPostalCodeLinkIcon, alt: idkPostalCodeLinkIconAlt }, + }, + textBeforeLocation = 'Your current location is:', + textAfterLocation = 'Use the field below to change it.', + description = 'Offers and availability vary by location.', + triggerRef, + offsetTop = 6, + offsetLeft, + placement = 'bottom-start', +}: RegionPopoverProps) { + const inputRef = useRef(null) + const { isValidating, ...session } = useSession() + const { popover: displayPopover, closePopover } = useUI() + const { city, postalCode } = sessionStore.read() + const location = city ? `${textToTitleCase(city)}, ${postalCode}` : postalCode + + const [input, setInput] = useState('') + + const { loading, setRegion, regionError, setRegionError } = useRegion() + + const handleSubmit = async () => { + if (isValidating) { + return + } + + await setRegion({ + session, + onSuccess: () => { + setInput('') + closePopover() + }, + postalCode: input, + errorMessage: inputFieldErrorMessage, + noProductsAvailableErrorMessage: + inputFieldNoProductsAvailableErrorMessage, + }) + } + + const idkPostalCodeLinkProps = { + href: idkPostalCodeLinkTo, + children: ( + <> + {idkPostalCodeLinkText} + {!!idkPostalCodeLinkIcon && ( + + )} + + ), + } + + const RegionPopoverContent = ( + <> + + {postalCode ? ( + <> + {textBeforeLocation} {location}. {textAfterLocation} + + ) : ( + <>{description} + )} + + { + regionError !== '' && setRegionError('') + setInput(e.currentTarget.value) + }} + onSubmit={handleSubmit} + onClear={() => { + setInput('') + setRegionError('') + }} + buttonActionText={loading ? '...' : inputButtonActionText} + error={regionError} + /> + {idkPostalCodeLinkTo && ( + + )} + + ) + + return ( + <> + {displayPopover.isOpen && ( +
+ { + if (!postalCode && inputRef.current) { + inputRef.current.focus() + } + }} + /> +
+ )} + + ) +} + +export default RegionPopover diff --git a/packages/core/src/components/region/RegionPopover/index.ts b/packages/core/src/components/region/RegionPopover/index.ts new file mode 100644 index 0000000000..610039213c --- /dev/null +++ b/packages/core/src/components/region/RegionPopover/index.ts @@ -0,0 +1 @@ +export { default } from './RegionPopover' diff --git a/packages/core/src/components/region/RegionPopover/section.module.scss b/packages/core/src/components/region/RegionPopover/section.module.scss new file mode 100644 index 0000000000..7a20075f73 --- /dev/null +++ b/packages/core/src/components/region/RegionPopover/section.module.scss @@ -0,0 +1,9 @@ +.section { + @import "@faststore/ui/src/components/atoms/Icon/styles.scss"; + @import "@faststore/ui/src/components/atoms/Input/styles.scss"; + @import "@faststore/ui/src/components/atoms/Button/styles.scss"; + @import "@faststore/ui/src/components/atoms/Link/styles.scss"; + @import "@faststore/ui/src/components/molecules/InputField/styles.scss"; + @import "@faststore/ui/src/components/molecules/Popover/styles.scss"; + @import "@faststore/ui/src/components/organisms/RegionPopover/styles.scss"; +} diff --git a/packages/core/src/components/search/SearchInput/SearchInput.tsx b/packages/core/src/components/search/SearchInput/SearchInput.tsx index c3fddabbbf..308e16b01d 100644 --- a/packages/core/src/components/search/SearchInput/SearchInput.tsx +++ b/packages/core/src/components/search/SearchInput/SearchInput.tsx @@ -18,6 +18,7 @@ import { Icon as UIIcon, IconButton as UIIconButton, SearchInput as UISearchInput, + useOnClickOutside, } from '@faststore/ui' import type { @@ -27,10 +28,9 @@ import type { import type { SearchProviderContextValue } from '@faststore/ui' +import type { NavbarProps } from 'src/components/sections/Navbar' import useSearchHistory from 'src/sdk/search/useSearchHistory' import useSuggestions from 'src/sdk/search/useSuggestions' -import useOnClickOutside from 'src/sdk/ui/useOnClickOutside' -import type { NavbarProps } from 'src/components/sections/Navbar' import { formatSearchPath } from 'src/sdk/search/formatSearchPath' diff --git a/packages/core/src/components/sections/Navbar/section.module.scss b/packages/core/src/components/sections/Navbar/section.module.scss index bbb7e93eec..8fe2ad1fa7 100644 --- a/packages/core/src/components/sections/Navbar/section.module.scss +++ b/packages/core/src/components/sections/Navbar/section.module.scss @@ -14,10 +14,13 @@ @import "@faststore/ui/src/components/atoms/Logo/styles.scss"; @import "@faststore/ui/src/components/atoms/Overlay/styles.scss"; @import "@faststore/ui/src/components/atoms/Price/styles.scss"; + @import "@faststore/ui/src/components/molecules/InputField/styles.scss"; @import "@faststore/ui/src/components/molecules/LinkButton/styles.scss"; @import "@faststore/ui/src/components/molecules/QuantitySelector/styles.scss"; @import "@faststore/ui/src/components/molecules/NavbarLinks/styles.scss"; + @import "@faststore/ui/src/components/molecules/Popover/styles.scss"; @import "@faststore/ui/src/components/molecules/ProductPrice/styles.scss"; + @import "@faststore/ui/src/components/organisms/RegionPopover/styles.scss"; @import "@faststore/ui/src/components/molecules/SearchAutoComplete/styles.scss"; @import "@faststore/ui/src/components/molecules/SearchDropdown/styles.scss"; @import "@faststore/ui/src/components/molecules/SearchHistory/styles.scss"; diff --git a/packages/core/src/components/sections/RegionBar/RegionBar.tsx b/packages/core/src/components/sections/RegionBar/RegionBar.tsx index b450dc934a..ebe14b7fac 100644 --- a/packages/core/src/components/sections/RegionBar/RegionBar.tsx +++ b/packages/core/src/components/sections/RegionBar/RegionBar.tsx @@ -1,3 +1,4 @@ +import useScreenResize from 'src/sdk/ui/useScreenResize' import { getOverridableSection } from '../../..//sdk/overrides/getOverriddenSection' import RegionBar, { type RegionBarProps, @@ -30,10 +31,14 @@ type RegionBarSectionProps = { } function RegionBarSection({ ...otherProps }: RegionBarSectionProps) { + const { isDesktop } = useScreenResize() + return ( -
- -
+ !isDesktop && ( +
+ +
+ ) ) } diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 139f1c0811..1adbee38cb 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1,2 +1,3 @@ export const ITEMS_PER_PAGE = 12 export const ITEMS_PER_SECTION = 5 +export const TIME_TO_VALIDATE_SESSION = 3000 diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index d72049a6e4..1366e70545 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -91,8 +91,9 @@ function Page({ offers, meta, }: Props) { - const { product } = server const { currency } = useSession() + + const { product } = server const { seo: { pdp: pdpSeo, ...storeSeo }, } = storeConfig diff --git a/packages/core/src/pages/_app.tsx b/packages/core/src/pages/_app.tsx index f206f5329b..24e5f0a430 100644 --- a/packages/core/src/pages/_app.tsx +++ b/packages/core/src/pages/_app.tsx @@ -3,17 +3,19 @@ import type { AppProps } from 'next/app' import Layout from 'src/Layout' import AnalyticsHandler from 'src/sdk/analytics' import ErrorBoundary from 'src/sdk/error/ErrorBoundary' +import useGeolocation from 'src/sdk/geolocation/useGeolocation' import SEO from '../../next-seo.config' // FastStore UI's base styles -import '../styles/global/index.scss' -import '../plugins/index.scss' import '../customizations/src/themes/index.scss' +import '../plugins/index.scss' +import '../styles/global/index.scss' import { DefaultSeo } from 'next-seo' function App({ Component, pageProps }: AppProps) { const { key } = pageProps + useGeolocation() return ( diff --git a/packages/core/src/sdk/geolocation/useGeolocation.ts b/packages/core/src/sdk/geolocation/useGeolocation.ts new file mode 100644 index 0000000000..f20d95ad97 --- /dev/null +++ b/packages/core/src/sdk/geolocation/useGeolocation.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react' + +import { deliveryPromise } from 'discovery.config' +import { TIME_TO_VALIDATE_SESSION } from 'src/constants' +import { sessionStore, validateSession } from 'src/sdk/session' + +async function askGeolocationConsent() { + const { postalCode: stalePostalCode, geoCoordinates: staleGeoCoordinates } = + sessionStore.read() + + if (navigator?.geolocation && (!stalePostalCode || !staleGeoCoordinates)) { + navigator.geolocation.getCurrentPosition( + async ({ coords: { latitude, longitude } }) => { + // Revalidate the session because users can set a zip code while granting consent. + const revalidatedSession = sessionStore.read() + if ( + revalidatedSession.postalCode || + revalidatedSession.geoCoordinates + ) { + return + } + + const newSession = { + ...revalidatedSession, + geoCoordinates: { latitude, longitude }, + } + const validatedSession = await validateSession(newSession) + sessionStore.set(validatedSession ?? newSession) + } + ) + } +} + +export default function useGeolocation() { + useEffect(() => { + if (!deliveryPromise.enabled) { + return + } + + setTimeout(() => askGeolocationConsent(), TIME_TO_VALIDATE_SESSION) + }, []) +} diff --git a/packages/core/src/sdk/product/index.ts b/packages/core/src/sdk/product/index.ts new file mode 100644 index 0000000000..38e6621cfb --- /dev/null +++ b/packages/core/src/sdk/product/index.ts @@ -0,0 +1,21 @@ +import { gql } from '@generated' + +import type { + ClientProductCountQueryQuery as Query, + ClientProductCountQueryQueryVariables as Variables, +} from '@generated/graphql' +import { request } from '../graphql/request' + +export const query = gql(` + query ClientProductCountQuery($term: String) { + productCount(term: $term) { + total + } + } +`) + +export const getProductCount = async (term?: string) => { + const { productCount } = await request(query, { term }) + + return productCount.total +} diff --git a/packages/core/src/sdk/profile/index.ts b/packages/core/src/sdk/profile/index.ts new file mode 100644 index 0000000000..3477c9b153 --- /dev/null +++ b/packages/core/src/sdk/profile/index.ts @@ -0,0 +1,31 @@ +import { gql } from '@generated' + +import type { + ClientProfileQueryQuery as Query, + ClientProfileQueryQueryVariables as Variables, +} from '@generated/graphql' +import { request } from '../graphql/request' + +export const query = gql(` + query ClientProfileQuery($id: String!) { + profile(id: $id) { + addresses { + country + postalCode + geoCoordinate + city + } + } + } +`) + +export const getAddresses = async (id: string) => { + const data = await request(query, { id }) + return data.profile.addresses +} + +export const getSavedAddress = async (id: string) => { + const addresses = await getAddresses(id) + // returning the first address of the list because there is no favorite address feature + return addresses ? addresses[0] : null +} diff --git a/packages/core/src/sdk/session/index.ts b/packages/core/src/sdk/session/index.ts index ec4b110429..d074f25998 100644 --- a/packages/core/src/sdk/session/index.ts +++ b/packages/core/src/sdk/session/index.ts @@ -10,6 +10,7 @@ import type { import storeConfig from '../../../discovery.config' import { cartStore } from '../cart' import { request } from '../graphql/request' +import { getSavedAddress } from '../profile' import { createValidationStore, useStore } from '../useStore' export const mutation = gql(` @@ -20,6 +21,7 @@ export const mutation = gql(` country addressType postalCode + city deliveryMode { deliveryChannel deliveryMethod @@ -58,6 +60,41 @@ export const mutation = gql(` `) export const validateSession = async (session: Session) => { + // If deliveryPromise is enabled and there is no postalCode in the session + if (storeConfig.deliveryPromise?.enabled && !session.postalCode) { + const isLoggedIn = !!session.person?.id + + // If user is logged try to get the location (postalCode / geoCoordinates / country) from the user's address + if (isLoggedIn) { + const userId = session.person?.id + const address = await getSavedAddress(userId) + + // Save the location in the session + if (address) { + sessionStore.set({ + ...session, + city: address?.city, + postalCode: address?.postalCode, + geoCoordinates: { + // the values come in the reverse expected order + latitude: address?.geoCoordinate ? address?.geoCoordinate[1] : null, + longitude: address?.geoCoordinate + ? address?.geoCoordinate[0] + : null, + }, + country: address?.country, + }) + } + } else { + // Use the initial postalCode defined in discovery.config.js + const initialPostalCode = defaultStore.readInitial().postalCode + + if (!!initialPostalCode) { + sessionStore.set({ ...session, postalCode: initialPostalCode }) + } + } + } + const data = await request< ValidateSessionMutation, ValidateSessionMutationVariables diff --git a/packages/core/src/sdk/ui/useOnClickOutside.ts b/packages/core/src/sdk/ui/useOnClickOutside.ts index 6c822562d2..0daa2f0b0d 100644 --- a/packages/core/src/sdk/ui/useOnClickOutside.ts +++ b/packages/core/src/sdk/ui/useOnClickOutside.ts @@ -1,38 +1,3 @@ -import type { RefObject } from 'react' -import { useEffect } from 'react' - -type Handler = (event: any) => void - -export default function useOnClickOutside( - ref: RefObject, - handler: Handler -) { - useEffect( - () => { - const listener: Handler = (event) => { - if (!ref.current || ref.current.contains(event.target)) { - return - } - - handler(event) - } - - document.addEventListener('mousedown', listener) - document.addEventListener('touchstart', listener) - - return () => { - document.removeEventListener('mousedown', listener) - document.removeEventListener('touchstart', listener) - } - }, - /** - * Add ref and handler to effect dependencies. - * It's worth noting that because passed in handler is a new - * function on every render that will cause this effect - * callback/cleanup to run every render. It's not a big deal - * but to optimize you can wrap handler in useCallback before - * passing it into this hook. - */ - [ref, handler] - ) -} +// This hook was moved to the UI package, this export is to avoid breaking changes +// TODO: remove in the next major release +export { useOnClickOutside as default } from '@faststore/ui' diff --git a/packages/core/src/utils/utilities.ts b/packages/core/src/utils/utilities.ts index 390fa39cdb..937e96f871 100644 --- a/packages/core/src/utils/utilities.ts +++ b/packages/core/src/utils/utilities.ts @@ -1,4 +1,4 @@ -//Input "Example Text!". Output: example-text +// Input "Example Text!". Output: example-text export function textToKebabCase(text: string): string { // Replace spaces and special characters with hyphens let kebabCase = text.replace(/[^\w\s]/gi, '-') @@ -11,3 +11,11 @@ export function textToKebabCase(text: string): string { return kebabCase ?? '' } + +// Input "EXAMPLE text!". Output: "Example Text!" +export function textToTitleCase(text: string): string { + return text.replace( + /\S+/g, + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ) +} diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 8fc91ceb43..7dbe7eca0a 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -72,6 +72,7 @@ const QUERIES = [ 'redirect', 'sellers', 'profile', + 'productCount', ] const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] diff --git a/packages/sdk/src/session/index.ts b/packages/sdk/src/session/index.ts index c64de34d95..d34b83f8d4 100644 --- a/packages/sdk/src/session/index.ts +++ b/packages/sdk/src/session/index.ts @@ -48,6 +48,7 @@ export interface Session { channel: string | null deliveryMode: DeliveryMode | null addressType: string | null + city: string | null postalCode: string | null geoCoordinates: GeoCoordinates | null person: Person | null diff --git a/packages/sdk/test/session/index.test.tsx b/packages/sdk/test/session/index.test.tsx index 580f2c79c6..9085ed169a 100644 --- a/packages/sdk/test/session/index.test.tsx +++ b/packages/sdk/test/session/index.test.tsx @@ -13,6 +13,7 @@ const initialSession: Session = { channel: 'test-channel', deliveryMode: null, addressType: null, + city: null, postalCode: null, geoCoordinates: null, person: null, diff --git a/packages/ui/src/components/molecules/Popover/styles.scss b/packages/ui/src/components/molecules/Popover/styles.scss new file mode 100644 index 0000000000..48be6eed0f --- /dev/null +++ b/packages/ui/src/components/molecules/Popover/styles.scss @@ -0,0 +1,143 @@ +[data-fs-popover] { + // -------------------------------------------------------- + // Design Tokens for Popover + // -------------------------------------------------------- + + // Default properties + --fs-popover-margin : 0 var(--fs-spacing-3); + --fs-popover-padding : var(--fs-spacing-3) var(--fs-spacing-4) var(--fs-spacing-4); + --fs-popover-border-radius : var(--fs-border-radius); + --fs-popover-bkg-color : var(--fs-color-body-bkg); + --fs-popover-box-shadow : var(--fs-shadow-darker); + --fs-popover-z-index : var(--fs-z-index-top); + + // Indicator + --fs-popover-indicator-size : var(--fs-spacing-1); + --fs-popover-indicator-distance-edge : var(--fs-spacing-3); + --fs-popover-indicator-distance-base : var(--fs-spacing-1); + --fs-popover-indicator-translate : calc(var(--fs-popover-indicator-size) + var(--fs-popover-indicator-distance-base)); + + // -------------------------------------------------------- + // Structural Styles + // -------------------------------------------------------- + + position: absolute; + z-index: var(--fs-popover-z-index); + display: flex; + flex-direction: column; + height: fit-content; + padding: var(--fs-popover-padding); + margin: var(--fs-popover-margin); + background-color: var(--fs-popover-bkg-color); + border-radius: var(--fs-popover-border-radius); + box-shadow: var(--fs-popover-box-shadow); + + [data-fs-popover-header] { + display: flex; + justify-content: space-between; + } + + [data-fs-popover-header-title] { + font-weight: var(--fs-text-weight-medium); + } + + [data-fs-popover-header-dismiss-button] { + margin-top: calc(-1 * var(--fs-spacing-3)); + margin-right: calc(-1 * var(--fs-spacing-3)); + } + + [data-fs-popover-indicator] { + position: absolute; + width: 0; + height: 0; + border: var(--fs-popover-indicator-size) solid transparent; + } + + // -------------------------------------------------------- + // Variants Styles + // -------------------------------------------------------- + + /* TOP */ + &[data-fs-popover-placement^="top"] { + bottom: 100%; + transform: translateY(calc(-1 * var(--fs-popover-indicator-translate))); + } + + &[data-fs-popover-placement^="top"] [data-fs-popover-indicator] { + top: 100%; + border-top-color: var(--fs-popover-bkg-color); + } + + /* TOP-CENTER */ + &[data-fs-popover-placement="top-center"] { + left: 50%; + transform: + translateX(-50%) + translateY(calc(-1 * var(--fs-popover-indicator-translate))); + } + + &[data-fs-popover-placement="top-center"] [data-fs-popover-indicator] { + left: 50%; + transform: translateX(-50%); + } + + /* TOP-START */ + &[data-fs-popover-placement="top-start"] { + left: 0; + } + + &[data-fs-popover-placement="top-start"] [data-fs-popover-indicator] { + left: var(--fs-spacing-3); + } + + /* TOP-END */ + &[data-fs-popover-placement="top-end"] { + right: 0; + } + + &[data-fs-popover-placement="top-end"] [data-fs-popover-indicator] { + right: var(--fs-spacing-3); + } + + /* BOTTOM */ + &[data-fs-popover-placement^="bottom"] { + top: 100%; + transform: translateY(var(--fs-popover-indicator-translate)); + } + + &[data-fs-popover-placement^="bottom"] [data-fs-popover-indicator] { + bottom: 100%; + border-bottom-color: var(--fs-popover-bkg-color); + } + + /* BOTTOM-CENTER */ + &[data-fs-popover-placement="bottom-center"] { + left: 50%; + transform: + translateX(-50%) + translateY(var(--fs-popover-indicator-translate)); + } + + &[data-fs-popover-placement="bottom-center"] [data-fs-popover-indicator] { + left: 50%; + transform: translateX(-50%); + } + + /* BOTTOM-START */ + &[data-fs-popover-placement="bottom-start"] { + left: 0; + } + + &[data-fs-popover-placement="bottom-start"] [data-fs-popover-indicator] { + left: var(--fs-spacing-3); + } + + /* BOTTOM-END */ + &[data-fs-popover-placement="bottom-end"] { + right: 0; + } + + &[data-fs-popover-placement="bottom-end"] [data-fs-popover-indicator] { + right: var(--fs-spacing-3); + } +} diff --git a/packages/ui/src/components/organisms/RegionModal/styles.scss b/packages/ui/src/components/organisms/RegionModal/styles.scss index f8dd402d71..ac284fe0fe 100644 --- a/packages/ui/src/components/organisms/RegionModal/styles.scss +++ b/packages/ui/src/components/organisms/RegionModal/styles.scss @@ -15,17 +15,23 @@ // Structural Styles // -------------------------------------------------------- [data-fs-input-field] { + flex: 1; margin-bottom: var(--fs-region-modal-margin-bottom); } [data-fs-region-modal-link] { display: flex; flex-direction: row; + column-gap: var(--fs-region-modal-link-column-gap); align-content: flex-start; align-items: center; justify-content: flex-start; - column-gap: var(--fs-region-modal-link-column-gap); padding: var(--fs-region-modal-link-padding); color: var(--fs-region-modal-link-color); } + + [data-fs-modal-body] { + display: flex; + flex-wrap: wrap; + } } diff --git a/packages/ui/src/components/organisms/RegionPopover/styles.scss b/packages/ui/src/components/organisms/RegionPopover/styles.scss new file mode 100644 index 0000000000..78ebe4e515 --- /dev/null +++ b/packages/ui/src/components/organisms/RegionPopover/styles.scss @@ -0,0 +1,54 @@ +[data-fs-region-popover] { + // -------------------------------------------------------- + // Design Tokens for Region Popover + // -------------------------------------------------------- + + // Default properties + --fs-region-popover-width : 406px; + --fs-region-popover-row-gap : var(--fs-spacing-2); + + // Description + --fs-region-popover-description-text-size : var(--fs-text-size-legend); + + // Link + --fs-region-popover-link-padding : 0; + --fs-region-popover-link-column-gap : var(--fs-spacing-0); + --fs-region-popover-link-color : var(--fs-color-link); + + // -------------------------------------------------------- + // Structural Styles + // -------------------------------------------------------- + + width: auto; + + @include media(">=tablet") { + width: var(--fs-region-popover-width); + } + + [data-fs-popover-content] { + display: flex; + flex-direction: column; + row-gap: var(--fs-region-popover-row-gap); + } + + [data-fs-region-popover-description] { + font-size: var(--fs-region-popover-description-text-size); + + span { + font-weight: var(--fs-text-weight-bold); + color: var(--fs-color-text-light); + } + } + + // Duplicate from data-fs-region-modal-link + [data-fs-region-popover-link] { + display: flex; + flex-direction: row; + column-gap: var(--fs-region-popover-link-column-gap); + align-content: flex-start; + align-items: center; + justify-content: flex-start; + padding: var(--fs-region-popover-link-padding); + color: var(--fs-region-popover-link-color); + } +} diff --git a/packages/ui/src/styles/components.scss b/packages/ui/src/styles/components.scss index 6f70a4fd91..6189da3ea0 100644 --- a/packages/ui/src/styles/components.scss +++ b/packages/ui/src/styles/components.scss @@ -35,6 +35,7 @@ @import "../components/molecules/Modal/styles"; @import "../components/molecules/NavbarLinks/styles"; @import "../components/molecules/OrderSummary/styles"; +@import "../components/molecules/Popover/styles"; @import "../components/molecules/ProductCard/styles"; @import "../components/molecules/ProductCardSkeleton/styles"; @import "../components/molecules/ProductPrice/styles"; @@ -83,6 +84,7 @@ @import "../components/organisms/ProductGrid/styles"; @import "../components/organisms/ProductShelf/styles"; @import "../components/organisms/RegionModal/styles"; +@import "../components/organisms/RegionPopover/styles"; @import "../components/organisms/SearchInput/styles"; @import "../components/organisms/SKUMatrix/styles"; @import "../components/organisms/ShippingSimulation/styles";