diff --git a/package.json b/package.json index cef56ead9a..89c34c01ad 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepare": "node ./husky-prepare.js" }, "validate-branch-name": { - "pattern": "^(main|develop){1}$|^(feature|hotfix|projects-redesign)/[a-z0-9-_]+$", + "pattern": "^(main|develop){1}$|^(feature|hotfix|address-mgmt)/[a-z0-9-_]+$", "errorMsg": "Invalid branch name. \n 1.Branch names can contain lowercase characters, numbers, hyphen and underscore. \n 2.Except for 'main' and 'develop', branch names must begin with 'feature/' or 'hotfix/' " }, "engines": { diff --git a/pages/sites/[slug]/[locale]/profile/edit.tsx b/pages/sites/[slug]/[locale]/profile/edit.tsx index d9fa25135b..0a171a832e 100644 --- a/pages/sites/[slug]/[locale]/profile/edit.tsx +++ b/pages/sites/[slug]/[locale]/profile/edit.tsx @@ -1,18 +1,21 @@ -import Head from 'next/head'; -import React, { ReactElement } from 'react'; -import UserLayout from '../../../../../src/features/common/Layout/UserLayout/UserLayout'; -import { AbstractIntlMessages, useTranslations } from 'next-intl'; -import EditProfile from '../../../../../src/features/user/Settings/EditProfile'; -import { +import type { AbstractIntlMessages } from 'next-intl'; +import type { GetStaticProps, GetStaticPropsContext, GetStaticPropsResult, } from 'next'; +import type { Tenant } from '@planet-sdk/common/build/types/tenant'; +import type { ReactElement } from 'react'; + +import { useEffect } from 'react'; +import Head from 'next/head'; +import { useTranslations } from 'next-intl'; +import UserLayout from '../../../../../src/features/common/Layout/UserLayout/UserLayout'; +import EditProfile from '../../../../../src/features/user/Settings/EditProfile'; import { constructPathsForTenantSlug, getTenantConfig, } from '../../../../../src/utils/multiTenancy/helpers'; -import { Tenant } from '@planet-sdk/common/build/types/tenant'; import { defaultTenant } from '../../../../../tenant.config'; import { useRouter } from 'next/router'; import { useTenant } from '../../../../../src/features/common/Layout/TenantContext'; @@ -27,7 +30,7 @@ function EditProfilePage({ pageProps: { tenantConfig } }: Props): ReactElement { const router = useRouter(); const { setTenantConfig } = useTenant(); - React.useEffect(() => { + useEffect(() => { if (router.isReady) { setTenantConfig(tenantConfig); } @@ -50,7 +53,7 @@ export default EditProfilePage; export const getStaticPaths = async () => { const subDomainPaths = await constructPathsForTenantSlug(); - const paths = subDomainPaths.map((path) => { + const paths = subDomainPaths?.map((path) => { return { params: { slug: path.params.slug, @@ -78,7 +81,7 @@ export const getStaticProps: GetStaticProps = async ( const messages = await getMessagesForPage({ locale: context.params?.locale as string, - filenames: ['common', 'me', 'country', 'editProfile'], + filenames: ['common', 'me', 'country', 'editProfile', 'profile'], }); return { diff --git a/public/assets/images/icons/KebabMenuIcon.tsx b/public/assets/images/icons/KebabMenuIcon.tsx new file mode 100644 index 0000000000..06232b4e03 --- /dev/null +++ b/public/assets/images/icons/KebabMenuIcon.tsx @@ -0,0 +1,11 @@ +const KebabMenuIcon = () => { + return ( + + + + + + ); +}; + +export default KebabMenuIcon; diff --git a/public/static/locales/en/editProfile.json b/public/static/locales/en/editProfile.json index 3848a5420e..51fad4556c 100644 --- a/public/static/locales/en/editProfile.json +++ b/public/static/locales/en/editProfile.json @@ -39,6 +39,7 @@ "email": "Email", "address": "Address", "city": "City", + "state": "State", "zipCode": "Postal/Zip Code", "country": "Country", "bio": "Your Description", @@ -61,11 +62,48 @@ "addressInvalid": "Address is invalid. Only these special characters are allowed: space . , # - /", "cityRequired": "City is required", "cityInvalid": "City is invalid. Only letters and these special characters are allowed: space . , ( ) -", + "stateInvalid": "State is invalid. Only letters and these special characters are allowed: space . , ( ) -", "zipCodeRequired": "Zip Code is required", "zipCodeInvalid": "Zip Code is invalid", "websiteInvalid": "Please enter valid Website URL", "countryRequired": "Country is required", "companyRequired": "Company Name is required" + }, + "addressManagement": { + "labels": { + "actionMenu": "Action menu" + }, + "addressManagementTitle": "Address", + "addressType": { + "primary": "Primary Address", + "mailing": "Billing Address" + }, + "actions": { + "edit": "Edit", + "delete": "Delete", + "setAsPrimaryAddress": "Set as Primary Address", + "setAsBillingAddress": "Set as Billing Address", + "unsetBillingAddress": "Unset Billing Address", + "addAddress": "Add New Address" + }, + "deleteAction": { + "title": "Delete Address", + "deleteButton": "Delete", + "deleteAddressConfirmationMessage": "Are you sure you want to delete this address? If you want to use it again, please add it as a new address." + }, + "updateAddressType": { + "setAddressConfirmation": "Are you sure you want to set this address as your {addressType}?", + "replaceAddressWarning": "This will replace your current {addressType}.", + "confirmButton": "Confirm", + "unsetBillingAddressMessage": "Do you want to unset your billing address? Your primary address will be used as billing address." + }, + "maxAddressesMessage": "You have reached the maximum number of addresses! Remove one to add a new address.", + "addressForm": { + "addAddress": "Add Address", + "editAddress": "Edit Address", + "saveChanges": "Save Changes", + "address2": "Address 2(optional)" + } } } } diff --git a/src/features/common/InputTypes/AutoCompleteCountry.tsx b/src/features/common/InputTypes/AutoCompleteCountry.tsx index 7b7e7264ba..1f57498062 100644 --- a/src/features/common/InputTypes/AutoCompleteCountry.tsx +++ b/src/features/common/InputTypes/AutoCompleteCountry.tsx @@ -1,12 +1,14 @@ /* eslint-disable no-use-before-define */ -import { useState, ReactElement, useEffect, ReactNode } from 'react'; +import type { CountryType, ExtendedCountryCode } from '../types/country'; +import type { SetState } from '../types/common'; +import type { ReactNode, ReactElement } from 'react'; +import type { CountryCode } from '@planet-sdk/common'; + +import { useState, useEffect } from 'react'; import { TextField } from '@mui/material'; -import React from 'react'; import { useTranslations } from 'next-intl'; import { MuiAutoComplete, StyledAutoCompleteOption } from './MuiAutoComplete'; -import { CountryType, ExtendedCountryCode } from '../types/country'; import { allCountries } from '../../../utils/constants/countries'; -import { SetState } from '../types/common'; // ISO 3166-1 alpha-2 // ⚠️ No support for IE 11 @@ -66,8 +68,8 @@ export default function CountrySelect({ useEffect(() => { countries.sort((a, b) => { - const nameA = t(a.code.toLowerCase()); - const nameB = t(b.code.toLowerCase()); + const nameA = t(a.code.toLowerCase() as Lowercase); + const nameB = t(b.code.toLowerCase() as Lowercase); //Automatic Selection option is always at first position (if present) if (a.code === 'auto') return -1; @@ -91,7 +93,8 @@ export default function CountrySelect({ getOptionLabel={(option) => { const { code: countryCode, currency } = option as CountryType; const label = - (currency ? `(${currency}) ` : '') + t(countryCode.toLowerCase()); + (currency ? `(${currency}) ` : '') + + t(countryCode.toLowerCase() as Lowercase); return label; }} isOptionEqualToValue={(option, value) => @@ -101,7 +104,7 @@ export default function CountrySelect({ const { code: countryCode, currency } = option as CountryType; const displayedOption = (currency ? `(${currency}) ` : '') + - t(countryCode.toLowerCase()) + + t(countryCode.toLowerCase() as Lowercase) + (!(name == 'editProfile' || countryCode === 'auto') ? ` ${countryCode}` : ''); diff --git a/src/features/common/types/profile.d.ts b/src/features/common/types/profile.d.ts index 1ae18f5c07..532583abdc 100644 --- a/src/features/common/types/profile.d.ts +++ b/src/features/common/types/profile.d.ts @@ -1,6 +1,7 @@ -import { User, UserPublicProfile } from '@planet-sdk/common'; -import { SetState } from './common'; -import { PublicUser } from './user'; +import type { User, UserPublicProfile } from '@planet-sdk/common'; +import type { SetState } from './common'; +import type { PublicUser } from './user'; +import type { ADDRESS_ACTIONS } from '../../../utils/addressManagement'; export interface UserFeaturesProps { handleShare: () => void; @@ -25,3 +26,8 @@ export type PublicProfileV2Props = { }; export type ProfileV2Props = PrivateProfileV2Props | PublicProfileV2Props; + +// address management + +export type AddressAction = + (typeof ADDRESS_ACTIONS)[keyof typeof ADDRESS_ACTIONS]; diff --git a/src/features/user/Profile/ForestProgress/ForestProgress.module.scss b/src/features/user/Profile/ForestProgress/ForestProgress.module.scss index a789a4fd23..30c3854736 100644 --- a/src/features/user/Profile/ForestProgress/ForestProgress.module.scss +++ b/src/features/user/Profile/ForestProgress/ForestProgress.module.scss @@ -114,10 +114,10 @@ position: relative; left: 50%; top: 50%; + transform: translate(-50%, -50%); width: 398px; height: 524px; background: #fff; - transform: translate(-50%, -50%); border-radius: 16px; padding: 18px; display: flex; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/AddAddress.tsx b/src/features/user/Settings/EditProfile/AddressManagement/AddAddress.tsx new file mode 100644 index 0000000000..bada5fb106 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/AddAddress.tsx @@ -0,0 +1,114 @@ +import type { ExtendedCountryCode } from '../../../../common/types/country'; +import type { SetState } from '../../../../common/types/common'; +import type { Address, APIError } from '@planet-sdk/common'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useState, useContext, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { handleError } from '@planet-sdk/common'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import { postAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import AddressForm from './microComponents/AddressForm'; +import { ADDRESS_TYPE } from '../../../../../utils/addressManagement'; +import AddressFormLayout from './microComponents/AddressFormLayout'; +import { getStoredConfig } from '../../../../../utils/storeConfig'; + +export type FormData = { + address: string | undefined; + address2: string | null; + city: string | undefined; + zipCode: string | undefined; + state: string | null; +}; + +interface Props { + setIsModalOpen: SetState; + setUserAddresses: SetState; + setAddressAction: SetState; +} + +const defaultAddressDetail = { + address: '', + address2: '', + city: '', + zipCode: '', + state: '', +}; + +const AddAddress = ({ + setIsModalOpen, + setUserAddresses, + setAddressAction, +}: Props) => { + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const { contextLoaded, user, token, logoutUser } = useUserProps(); + const configCountry = getStoredConfig('country'); + const defaultCountry = user?.country || configCountry || 'DE'; + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const [country, setCountry] = useState( + defaultCountry + ); + const [isLoading, setIsLoading] = useState(false); + + const addAddress = useCallback( + async (data: FormData) => { + if (!contextLoaded || !user || !token) return; + setIsLoading(true); + const bodyToSend = { + ...data, + country, + type: ADDRESS_TYPE.OTHER, + }; + try { + const res = await postAuthenticatedRequest
( + tenantConfig.id, + '/app/addresses', + bodyToSend, + token, + logoutUser + ); + if (res && setUserAddresses) { + setUserAddresses((prevAddresses) => [...prevAddresses, res]); + } + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsLoading(false); + setIsModalOpen(false); + setAddressAction(null); + } + }, + [ + contextLoaded, + user, + token, + country, + logoutUser, + setUserAddresses, + handleError, + setIsLoading, + setIsModalOpen, + postAuthenticatedRequest, + ] + ); + + return ( + + + + ); +}; + +export default AddAddress; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/AddressManagement.module.scss b/src/features/user/Settings/EditProfile/AddressManagement/AddressManagement.module.scss new file mode 100644 index 0000000000..6407a604f7 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/AddressManagement.module.scss @@ -0,0 +1,205 @@ +@import '../../../../../theme/theme'; + +@mixin flex-container($direction: row, $justify: space-between) { + display: flex; + flex-direction: $direction; + justify-content: $justify; +} + +@mixin modal-container { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 20px; + border-radius: 16px; + background: rgba(255, 255, 255, 1); +} + +// Base Components +.addressManagement { + @include flex-container(column); + margin-top: 40px; + gap: 20px; + + .maxAddress { + font-size: $fontXSmall; + color: #6c757d; + } +} + +.addressManagementTitle { + font-size: $fontLarge; + font-weight: 600; +} + +.addressListContainer { + @include flex-container(column); + gap: 16px; + width: 100%; +} + +.singleAddressContainer { + @include flex-container; + width: 100%; + background: rgba(242, 242, 242, 0.5); + border-radius: 8px; + font-size: $fontSmall; + font-weight: 400; + padding: 24px 10px 24px 24px; + + .kebabMenuButton { + display: flex; + justify-content: center; + align-items: center; + height: 34px; + width: 34px; + border-radius: 50%; + background-color: transparent; + cursor: pointer; + + svg { + height: 20px; + width: 12px; + } + + &:hover { + background-color: rgba(120, 120, 120, 0.1); + } + } +} + +.addressDetails { + @include flex-container(column, center); + gap: 16px; + + span { + padding: 5px 10px; + max-width: fit-content; + border-radius: 6px; + text-align: center; + color: #fff; + font-size: $fontXSmall; + line-height: 16px; + font-weight: 600; + } + + p { + margin-top: 2px; + } +} + +// Address Types +.primary { + background: $primaryDarkColor; +} + +.mailing { + background: $mediumBlueColor; +} + +// Address Actions +.addressActions { + min-width: 184px; + cursor: pointer; + padding: 2px 12px; + list-style: none; + + .action { + font-size: $fontXSmall; + font-weight: 400; + } + + li { + padding: 8px 0; + + &:not(:last-child) { + border-bottom: 1px solid rgba(189, 189, 189, 1); + } + } +} + +// Modal Forms +.header { + font-weight: 700; + font-size: $fontSixteen; +} + +.addressFormLayout { + @include flex-container(column); + @include modal-container; + min-width: 565px; + gap: 40px; + + .addressForm { + @include flex-container(column); + gap: 23px; + } + + .buttonContainer { + @include flex-container; + gap: 8px; + margin-top: 25px; + + button { + flex: 1 1; + } + } +} + +.addressActionContainer { + @include flex-container(column); + @include modal-container; + width: 468px; + gap: 10px; + + .address { + width: 100%; + background: rgba(242, 242, 242, 0.5); + border-radius: 8px; + font-size: $fontSmall; + font-weight: 400; + padding: 24px; + } + + .buttonContainer { + @include flex-container; + gap: 8px; + margin-top: 40px; + + button { + flex: 1 1; + } + } +} + +// Mobile Style +@include xsPhoneView { + .addressFormLayout { + min-width: fit-content; + width: 95%; + + .buttonContainer { + @include flex-container(column); + + button { + width: 100%; + } + } + } + + .addressActionContainer { + width: 95%; + min-width: fit-content; + + .buttonContainer { + flex-direction: column; + gap: 8px; + } + } +} + +// Form Spinner +.addressMgmtSpinner { + @include flex-container(row, center); +} diff --git a/src/features/user/Settings/EditProfile/AddressManagement/DeleteAddress.tsx b/src/features/user/Settings/EditProfile/AddressManagement/DeleteAddress.tsx new file mode 100644 index 0000000000..11402f07b7 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/DeleteAddress.tsx @@ -0,0 +1,90 @@ +import type { SetState } from '../../../../common/types/common'; +import type { APIError } from '@planet-sdk/common'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useContext, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { handleError } from '@planet-sdk/common'; +import { CircularProgress } from '@mui/material'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import WebappButton from '../../../../common/WebappButton'; +import styles from './AddressManagement.module.scss'; +import { deleteAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; + +interface Props { + setIsModalOpen: SetState; + addressId: string; + updateUserAddresses: () => Promise; + setAddressAction: SetState; +} + +const DeleteAddress = ({ + setIsModalOpen, + addressId, + updateUserAddresses, + setAddressAction, +}: Props) => { + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const tCommon = useTranslations('Common'); + const { contextLoaded, user, token, logoutUser } = useUserProps(); + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const [isLoading, setIsLoading] = useState(false); + + const deleteAddress = async () => { + if (!contextLoaded || !user || !token) return; + try { + setIsLoading(true); + await deleteAuthenticatedRequest( + tenantConfig.id, + `/app/addresses/${addressId}`, + token, + logoutUser + ); + updateUserAddresses(); + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsModalOpen(false); + setIsLoading(false); + setAddressAction(null); + } + }; + const handleCancel = () => { + setIsModalOpen(false); + setAddressAction(null); + }; + return ( +
+

+ {tAddressManagement('deleteAction.title')} +

+

+ {tAddressManagement('deleteAction.deleteAddressConfirmationMessage')} +

+ {!isLoading ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ ); +}; +export default DeleteAddress; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/EditAddress.tsx b/src/features/user/Settings/EditProfile/AddressManagement/EditAddress.tsx new file mode 100644 index 0000000000..25ab975bcc --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/EditAddress.tsx @@ -0,0 +1,104 @@ +import type { ExtendedCountryCode } from '../../../../common/types/country'; +import type { SetState } from '../../../../common/types/common'; +import type { Address, APIError } from '@planet-sdk/common'; +import type { FormData } from './AddAddress'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useState, useContext, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { handleError } from '@planet-sdk/common'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import { putAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import AddressForm from './microComponents/AddressForm'; +import AddressFormLayout from './microComponents/AddressFormLayout'; + +interface Props { + setIsModalOpen: SetState; + selectedAddressForAction: Address; + updateUserAddresses: () => Promise; + setAddressAction: SetState; +} + +const EditAddress = ({ + setIsModalOpen, + selectedAddressForAction, + updateUserAddresses, + setAddressAction, +}: Props) => { + const defaultAddressDetail = { + address: selectedAddressForAction.address, + address2: selectedAddressForAction.address2, + city: selectedAddressForAction.city, + zipCode: selectedAddressForAction.zipCode, + state: selectedAddressForAction.state, + }; + + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const { contextLoaded, user, token, logoutUser } = useUserProps(); + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const [country, setCountry] = useState( + selectedAddressForAction?.country ?? 'DE' + ); + const [isLoading, setIsLoading] = useState(false); + + const updateAddress = useCallback( + async (data: FormData) => { + if (!contextLoaded || !user || !token) return; + setIsLoading(true); + const bodyToSend = { + ...data, + country, + type: selectedAddressForAction?.type, + }; + try { + const res = await putAuthenticatedRequest
( + tenantConfig.id, + `/app/addresses/${selectedAddressForAction?.id}`, + bodyToSend, + token, + logoutUser + ); + if (res && updateUserAddresses) updateUserAddresses(); + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsLoading(false); + setIsModalOpen(false); + setAddressAction(null); + } + }, + [ + contextLoaded, + user, + token, + country, + selectedAddressForAction?.type, + selectedAddressForAction?.id, + tenantConfig.id, + logoutUser, + updateUserAddresses, + handleError, + putAuthenticatedRequest, + ] + ); + + return ( + + + + ); +}; + +export default EditAddress; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/UnsetBillingAddress.tsx b/src/features/user/Settings/EditProfile/AddressManagement/UnsetBillingAddress.tsx new file mode 100644 index 0000000000..f83b4dae4e --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/UnsetBillingAddress.tsx @@ -0,0 +1,99 @@ +import type { Address, APIError } from '@planet-sdk/common'; +import type { SetState } from '../../../../common/types/common'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useContext, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { CircularProgress } from '@mui/material'; +import { handleError } from '@planet-sdk/common'; +import styles from './AddressManagement.module.scss'; +import WebappButton from '../../../../common/WebappButton'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import { putAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { ADDRESS_TYPE } from '../../../../../utils/addressManagement'; + +interface Props { + addressType: 'mailing'; + setIsModalOpen: SetState; + setAddressAction: SetState; + updateUserAddresses: () => Promise; + selectedAddressForAction: Address; +} + +const UnsetBillingAddress = ({ + addressType, + setIsModalOpen, + setAddressAction, + updateUserAddresses, + selectedAddressForAction, +}: Props) => { + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const tCommon = useTranslations('Common'); + const { contextLoaded, user, token, logoutUser } = useUserProps(); + const { setErrors } = useContext(ErrorHandlingContext); + const { tenantConfig } = useTenant(); + const [isLoading, setIsLoading] = useState(false); + + const unsetAddress = async () => { + if (!contextLoaded || !user || !token) return; + setIsLoading(true); + const bodyToSend = { + type: ADDRESS_TYPE.OTHER, + }; + try { + const res = await putAuthenticatedRequest
( + tenantConfig.id, + `/app/addresses/${selectedAddressForAction.id}`, + bodyToSend, + token, + logoutUser + ); + if (res) updateUserAddresses(); + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsLoading(false); + setIsModalOpen(false); + setAddressAction(null); + } + }; + + const handleCancel = () => { + setIsModalOpen(false); + setAddressAction(null); + }; + return ( +
+

+ {tAddressManagement(`addressType.${addressType}`)} +

+

+ {tAddressManagement('updateAddressType.unsetBillingAddressMessage')} +

+ {!isLoading ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default UnsetBillingAddress; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/UpdateAddressType.tsx b/src/features/user/Settings/EditProfile/AddressManagement/UpdateAddressType.tsx new file mode 100644 index 0000000000..e3e92a9f47 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/UpdateAddressType.tsx @@ -0,0 +1,111 @@ +import type { SetState } from '../../../../common/types/common'; +import type { APIError, Address } from '@planet-sdk/common'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useContext, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { handleError } from '@planet-sdk/common'; +import { CircularProgress } from '@mui/material'; +import styles from './AddressManagement.module.scss'; +import WebappButton from '../../../../common/WebappButton'; +import { putAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import FormattedAddressBlock from './microComponents/FormattedAddressBlock'; + +interface Props { + addressType: 'primary' | 'mailing'; + setIsModalOpen: SetState; + userAddress: Address | undefined; + selectedAddressForAction: Address; + updateUserAddresses: () => Promise; + setAddressAction: SetState; +} + +const UpdateAddressType = ({ + addressType, + setIsModalOpen, + userAddress, + selectedAddressForAction, + updateUserAddresses, + setAddressAction, +}: Props) => { + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const tCommon = useTranslations('Common'); + const { contextLoaded, user, token, logoutUser } = useUserProps(); + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const [isUploadingData, setIsUploadingData] = useState(false); + + const updateAddress = async (addressType: 'primary' | 'mailing') => { + if (!contextLoaded || !user || !token) return; + setIsUploadingData(true); + const bodyToSend = { + type: addressType, + }; + try { + const res = await putAuthenticatedRequest
( + tenantConfig.id, + `/app/addresses/${selectedAddressForAction.id}`, + bodyToSend, + token, + logoutUser + ); + if (res) updateUserAddresses(); + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsUploadingData(false); + setIsModalOpen(false); + setAddressAction(null); + } + }; + const handleCancel = () => { + setIsModalOpen(false); + setAddressAction(null); + }; + return ( +
+

+ {tAddressManagement(`addressType.${addressType}`)} +

+

+ {tAddressManagement('updateAddressType.setAddressConfirmation', { + addressType: tAddressManagement(`addressType.${addressType}`), + })} + {userAddress && + tAddressManagement('updateAddressType.replaceAddressWarning', { + addressType: tAddressManagement(`addressType.${addressType}`), + })} +

+ {userAddress !== undefined && ( +
+ +
+ )} + {!isUploadingData ? ( +
+ + updateAddress(addressType)} + /> +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default UpdateAddressType; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/index.tsx b/src/features/user/Settings/EditProfile/AddressManagement/index.tsx new file mode 100644 index 0000000000..6cbc44f0be --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/index.tsx @@ -0,0 +1,197 @@ +import type { Address, APIError } from '@planet-sdk/common'; +import type { AddressAction } from '../../../../common/types/profile'; + +import { useContext, useMemo, useState, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { Modal } from '@mui/material'; +import { handleError } from '@planet-sdk/common'; +import AddressList from './microComponents/AddressList'; +import { useUserProps } from '../../../../common/Layout/UserPropsContext'; +import WebappButton from '../../../../common/WebappButton'; +import styles from './AddressManagement.module.scss'; +import { getAuthenticatedRequest } from '../../../../../utils/apiRequests/api'; +import { useTenant } from '../../../../common/Layout/TenantContext'; +import { ErrorHandlingContext } from '../../../../common/Layout/ErrorHandlingContext'; +import { + ADDRESS_ACTIONS, + ADDRESS_TYPE, + addressTypeOrder, + findAddressByType, + MAX_ADDRESS_LIMIT, +} from '../../../../../utils/addressManagement'; +import CenteredContainer from '../../../../common/Layout/CenteredContainer'; +import UpdateAddressType from './UpdateAddressType'; +import DeleteAddress from './DeleteAddress'; +import EditAddress from './EditAddress'; +import AddAddress from './AddAddress'; +import UnsetBillingAddress from './UnsetBillingAddress'; + +const AddressManagement = () => { + const { user, contextLoaded, token, logoutUser } = useUserProps(); + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const [userAddresses, setUserAddresses] = useState( + user?.addresses ?? [] + ); + const [addressAction, setAddressAction] = useState( + null + ); + const [selectedAddressForAction, setSelectedAddressForAction] = + useState
(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const sortedAddresses = useMemo(() => { + return [...userAddresses].sort((a, b) => { + return ( + addressTypeOrder.indexOf(a.type) - addressTypeOrder.indexOf(b.type) + ); + }); + }, [userAddresses]); + + const updateUserAddresses = useCallback(async () => { + if (!user || !token || !contextLoaded) return; + try { + const res = await getAuthenticatedRequest( + tenantConfig.id, + '/app/addresses', + token, + logoutUser + ); + if (res) setUserAddresses(res); + } catch (error) { + setErrors(handleError(error as APIError)); + } + }, [user, token, contextLoaded, tenantConfig.id, logoutUser, setErrors]); + + const toggleAddAddressModal = () => { + setIsModalOpen(true); + setAddressAction(ADDRESS_ACTIONS.ADD); + }; + const primaryAddress = useMemo( + () => findAddressByType(userAddresses, ADDRESS_TYPE.PRIMARY), + [userAddresses] + ); + const billingAddress = useMemo( + () => findAddressByType(userAddresses, ADDRESS_TYPE.MAILING), + [userAddresses] + ); + + const renderModalContent = useMemo(() => { + switch (addressAction) { + case ADDRESS_ACTIONS.ADD: + return ( + + ); + case ADDRESS_ACTIONS.EDIT: + if (!selectedAddressForAction) return <>; + return ( + + ); + case ADDRESS_ACTIONS.DELETE: + if (!selectedAddressForAction) return <>; + return ( + + ); + case ADDRESS_ACTIONS.SET_PRIMARY: + if (!selectedAddressForAction) return <>; + return ( + + ); + case ADDRESS_ACTIONS.SET_BILLING: + if (!selectedAddressForAction) return <>; + return ( + + ); + case ADDRESS_ACTIONS.UNSET_BILLING: + if (!selectedAddressForAction) return <>; + return ( + + ); + default: + return <>; + } + }, [ + setIsModalOpen, + setUserAddresses, + selectedAddressForAction, + updateUserAddresses, + primaryAddress, + billingAddress, + addressAction, + setAddressAction, + ]); + + const canAddMoreAddresses = userAddresses.length < MAX_ADDRESS_LIMIT; + const shouldRenderAddressList = + user?.addresses !== undefined && user.addresses.length > 0; + return ( +
+

+ {tAddressManagement('addressManagementTitle')} +

+ + {shouldRenderAddressList && ( + + )} + {canAddMoreAddresses ? ( + + ) : ( +

+ {tAddressManagement('maxAddressesMessage')} +

+ )} +
+ + {renderModalContent} + +
+ ); +}; + +export default AddressManagement; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressActionMenu.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressActionMenu.tsx new file mode 100644 index 0000000000..3aa037381a --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressActionMenu.tsx @@ -0,0 +1,144 @@ +import type { SetState } from '../../../../../common/types/common'; +import type { AddressAction } from '../../../../../common/types/profile'; +import type { Address } from '@planet-sdk/common'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Popover } from '@mui/material'; +import KebabMenuIcon from '../../../../../../../public/assets/images/icons/KebabMenuIcon'; +import styles from '../AddressManagement.module.scss'; +import { + ADDRESS_ACTIONS, + ADDRESS_TYPE, +} from '../../../../../../utils/addressManagement'; + +export interface AddressActionItem { + label: string; + action: AddressAction; + shouldRender: boolean; +} +interface Props { + addressCount: number; + setAddressAction: SetState; + setIsModalOpen: SetState; + setSelectedAddressForAction: SetState
; + userAddress: Address; +} + +const AddressActionsMenu = ({ + addressCount, + setAddressAction, + setIsModalOpen, + setSelectedAddressForAction, + userAddress, +}: Props) => { + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + const [popoverAnchor, setPopoverAnchor] = useState( + null + ); + const { type } = userAddress; + const addressActionConfig: AddressActionItem[] = [ + { + label: tAddressManagement(`actions.edit`), + action: ADDRESS_ACTIONS.EDIT, + shouldRender: true, + }, + { + label: tAddressManagement(`actions.delete`), + action: ADDRESS_ACTIONS.DELETE, + shouldRender: addressCount > 1 && type !== ADDRESS_TYPE.PRIMARY, + }, + { + label: tAddressManagement('actions.setAsPrimaryAddress'), + action: ADDRESS_ACTIONS.SET_PRIMARY, + shouldRender: !( + type === ADDRESS_TYPE.MAILING || type === ADDRESS_TYPE.PRIMARY + ), + }, + { + label: tAddressManagement('actions.setAsBillingAddress'), + action: ADDRESS_ACTIONS.SET_BILLING, + shouldRender: !( + type === ADDRESS_TYPE.MAILING || type === ADDRESS_TYPE.PRIMARY + ), + }, + { + label: tAddressManagement('actions.unsetBillingAddress'), + action: ADDRESS_ACTIONS.UNSET_BILLING, + shouldRender: type === ADDRESS_TYPE.MAILING, + }, + ]; + + const openPopover = (event: React.MouseEvent) => { + setPopoverAnchor(event.currentTarget); + }; + + const closePopover = () => { + setPopoverAnchor(null); + }; + + const handleActionClick = (action: AddressAction) => { + setSelectedAddressForAction(userAddress); + setIsModalOpen(true); + setAddressAction(action); + setPopoverAnchor(null); + }; + + const open = Boolean(popoverAnchor); + const id = open ? 'address-action-popOver' : undefined; + + return ( +
+ + +
    + {addressActionConfig.map((item, key) => { + if (!item.shouldRender) return; + return ( +
  • handleActionClick(item.action)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActionClick(item.action); + } + }} + tabIndex={0} + role="button" + aria-label={item.label} + > + {item.label} +
  • + ); + })} +
+
+
+ ); +}; + +export default AddressActionsMenu; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressDetails.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressDetails.tsx new file mode 100644 index 0000000000..3245cca8f1 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressDetails.tsx @@ -0,0 +1,26 @@ +import type { Address } from '@planet-sdk/common'; + +import { useTranslations } from 'next-intl'; +import styles from '../AddressManagement.module.scss'; +import FormattedAddressBlock from './FormattedAddressBlock'; + +interface Props { + userAddress: Address; +} +const AddressDetails = ({ userAddress }: Props) => { + const { type } = userAddress; + const tAddressManagement = useTranslations('EditProfile.addressManagement'); + + return ( +
+ {type !== 'other' && ( + + {tAddressManagement(`addressType.${type}`)} + + )} + +
+ ); +}; + +export default AddressDetails; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressForm.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressForm.tsx new file mode 100644 index 0000000000..8c8c5a97fd --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressForm.tsx @@ -0,0 +1,242 @@ +import type { AddressSuggestionsType } from '../../../../../common/types/geocoder'; +import type { ExtendedCountryCode } from '../../../../../common/types/country'; +import type { SetState } from '../../../../../common/types/common'; +import type { Nullable } from '@planet-sdk/common/build/types/util'; +import type { FormData } from '../AddAddress'; +import type { AddressAction } from '../../../../../common/types/profile'; + +import { useCallback, useMemo, useState } from 'react'; +import { CircularProgress, TextField } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { Controller, useForm } from 'react-hook-form'; +import { + fetchAddressDetails, + geocoder, + getPostalRegex, + suggestAddress, + validationPattern, +} from '../../../../../../utils/addressManagement'; +import InlineFormDisplayGroup from '../../../../../common/Layout/Forms/InlineFormDisplayGroup'; +import styles from '../AddressManagement.module.scss'; +import AddressInput from './AddressInput'; +import CountrySelect from '../../../../../common/InputTypes/AutoCompleteCountry'; +import { allCountries } from '../../../../../../utils/constants/countries'; +import AddressFormButtons from './AddressFormButtons'; +import { useDebouncedEffect } from '../../../../../../utils/useDebouncedEffect'; + +interface Props { + country: ExtendedCountryCode | ''; + setCountry: SetState; + label: string; + processFormData: (data: FormData) => Promise; + defaultAddressDetail: { + address: string | undefined; + address2: Nullable | undefined; + city: string | undefined; + zipCode: string | undefined; + state: Nullable | undefined; + }; + setIsModalOpen: SetState; + isLoading: boolean; + setAddressAction: SetState; +} + +const AddressForm = ({ + country, + setCountry, + defaultAddressDetail, + setIsModalOpen, + label, + processFormData, + isLoading, + setAddressAction, +}: Props) => { + const t = useTranslations('EditProfile'); + const [addressSuggestions, setAddressSuggestions] = useState< + AddressSuggestionsType[] + >([]); + const { + control, + handleSubmit, + setValue, + reset, + formState: { errors }, + } = useForm({ + mode: 'onBlur', + defaultValues: defaultAddressDetail, + }); + const [inputValue, setInputValue] = useState(''); + const postalRegex = useMemo(() => getPostalRegex(country), [country]); + const handleInputChange = (value: string) => { + setInputValue(value); + }; + + const handleSuggestAddress = useCallback( + async (value: string) => { + try { + const suggestions = await suggestAddress(value, country); + setAddressSuggestions(suggestions); + } catch (error) { + console.error('Failed to fetch address suggestions:', error); + setAddressSuggestions([]); + } + }, + [geocoder, country] + ); + + useDebouncedEffect( + () => { + if (inputValue) { + handleSuggestAddress(inputValue); + } + }, + 700, + [inputValue] + ); + + const handleGetAddress = useCallback( + async (value: string) => { + try { + const details = await fetchAddressDetails(value); + if (details) { + setValue('address', details.address, { shouldValidate: true }); + setValue('city', details.city, { shouldValidate: true }); + setValue('zipCode', details.zipCode, { shouldValidate: true }); + } + setAddressSuggestions([]); + } catch (error) { + console.error('Failed to fetch address details:', error); + } + }, + [geocoder, setValue] + ); + const handleAddressSelect = (address: string) => { + handleGetAddress(address); + }; + + const resetForm = () => { + reset(defaultAddressDetail); + setAddressSuggestions([]); + }; + const handleCancel = () => { + setIsModalOpen(false); + setAddressAction(null); + resetForm(); + }; + return ( +
+ + + + ( + + )} + /> + ( + + )} + /> + + + ( + + )} + /> + + + {isLoading ? ( +
+ +
+ ) : ( + + )} + + ); +}; + +export default AddressForm; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormButtons.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormButtons.tsx new file mode 100644 index 0000000000..60efbc5b50 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormButtons.tsx @@ -0,0 +1,33 @@ +import styles from '../AddressManagement.module.scss'; +import WebappButton from '../../../../../common/WebappButton'; +import { useTranslations } from 'next-intl'; + +interface Props { + text: string; + handleSubmit: () => void; + handleCancel: () => void; +} + +const AddressFormButtons = ({ text, handleSubmit, handleCancel }: Props) => { + const tCommon = useTranslations('Common'); + return ( +
+ + +
+ ); +}; + +export default AddressFormButtons; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormLayout.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormLayout.tsx new file mode 100644 index 0000000000..e5a971f8b5 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressFormLayout.tsx @@ -0,0 +1,17 @@ +import type { JSX } from 'react'; +import styles from '../AddressManagement.module.scss'; + +interface Props { + children: JSX.Element; + label: string; +} +const AddressFormLayout = ({ children, label }: Props) => { + return ( +
+

{label}

+ {children} +
+ ); +}; + +export default AddressFormLayout; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressInput.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressInput.tsx new file mode 100644 index 0000000000..233943407d --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressInput.tsx @@ -0,0 +1,91 @@ +import type { Control } from 'react-hook-form'; + +import { Autocomplete, TextField } from '@mui/material'; +import { Controller } from 'react-hook-form'; + +interface AddressOption { + text: string; + [key: string]: any; +} + +interface AddressInputProps { + name: string; + control: Control; + label: string; + required?: boolean; + validationPattern?: RegExp; + validationMessages: { + required: string; + invalid: string; + }; + suggestions: AddressOption[]; + onInputChange?: (value: string) => void; + onAddressSelect?: (address: string) => void; +} + +const AddressInput: React.FC = ({ + name, + control, + label, + required = false, + validationPattern, + validationMessages, + suggestions, + onInputChange, + onAddressSelect, +}) => { + const validationRules = { + ...(required && { required: validationMessages.required }), + ...(validationPattern && { + pattern: { + value: validationPattern, + message: validationMessages.invalid, + }, + }), + }; + + const getOptionLabel = (option: string | AddressOption): string => { + if (typeof option === 'string') { + return option; + } + return 'text' in option ? option.text : ''; + }; + + return ( + ( + { + field.onChange(newValue); + onInputChange?.(newValue); + }} + onChange={(_, newValue) => { + const value = + typeof newValue === 'string' ? newValue : newValue?.text || ''; + field.onChange(value); + onAddressSelect?.(value); + }} + value={field.value} + getOptionLabel={getOptionLabel} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +}; + +export default AddressInput; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressList.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressList.tsx new file mode 100644 index 0000000000..184c4ad095 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/AddressList.tsx @@ -0,0 +1,39 @@ +import type { AddressAction } from '../../../../../common/types/profile'; +import type { SetState } from '../../../../../common/types/common'; +import type { Address } from '@planet-sdk/common'; + +import SingleAddress from './SingleAddress'; +import styles from '../AddressManagement.module.scss'; + +interface Props { + addresses: Address[]; + setAddressAction: SetState; + setSelectedAddressForAction: SetState
; + setIsModalOpen: SetState; +} + +const AddressList = ({ + addresses, + setAddressAction, + setSelectedAddressForAction, + setIsModalOpen, +}: Props) => { + const addressCount = addresses.length; + + return ( +
+ {addresses.map((address) => ( + + ))} +
+ ); +}; + +export default AddressList; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/FormattedAddressBlock.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/FormattedAddressBlock.tsx new file mode 100644 index 0000000000..4eee3873fe --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/FormattedAddressBlock.tsx @@ -0,0 +1,28 @@ +import type { Address, CountryCode } from '@planet-sdk/common'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { getFormattedAddress } from '../../../../../../utils/addressManagement'; + +interface Props { + userAddress: Address | undefined; +} + +const FormattedAddressBlock = ({ userAddress }: Props) => { + if (!userAddress) return null; + const tCountry = useTranslations('Country'); + const { zipCode, city, state, country, address, address2 } = userAddress; + const countryName = tCountry(country.toLowerCase() as Lowercase); + const cityStatePostalString = useMemo( + () => getFormattedAddress(zipCode, city, state, countryName), + [zipCode, city, state, countryName] + ); + return ( +
+

{address}

+ {address2 !== null &&

{address2}

} +

{cityStatePostalString}

+
+ ); +}; +export default FormattedAddressBlock; diff --git a/src/features/user/Settings/EditProfile/AddressManagement/microComponents/SingleAddress.tsx b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/SingleAddress.tsx new file mode 100644 index 0000000000..cf9d660ca5 --- /dev/null +++ b/src/features/user/Settings/EditProfile/AddressManagement/microComponents/SingleAddress.tsx @@ -0,0 +1,38 @@ +import type { SetState } from '../../../../../common/types/common'; +import type { AddressAction } from '../../../../../common/types/profile'; +import type { Address } from '@planet-sdk/common'; + +import styles from '../AddressManagement.module.scss'; +import AddressDetails from './AddressDetails'; +import AddressActionsMenu from './AddressActionMenu'; + +interface Props { + userAddress: Address; + addressCount: number; + setIsModalOpen: SetState; + setAddressAction: SetState; + setSelectedAddressForAction: SetState
; +} + +const SingleAddress = ({ + userAddress, + addressCount, + setIsModalOpen, + setAddressAction, + setSelectedAddressForAction, +}: Props) => { + return ( +
+ + +
+ ); +}; + +export default SingleAddress; diff --git a/src/features/user/Settings/EditProfile/EditProfileForm.tsx b/src/features/user/Settings/EditProfile/EditProfileForm.tsx index a662636fbb..424aefe17c 100644 --- a/src/features/user/Settings/EditProfile/EditProfileForm.tsx +++ b/src/features/user/Settings/EditProfile/EditProfileForm.tsx @@ -7,30 +7,21 @@ import { Controller, useForm } from 'react-hook-form'; import { User } from '@planet-sdk/common/build/types/user'; import Camera from '../../../../../public/assets/images/icons/userProfileIcons/Camera'; import { putAuthenticatedRequest } from '../../../../utils/apiRequests/api'; -import COUNTRY_ADDRESS_POSTALS from '../../../../utils/countryZipCode'; import getImageUrl from '../../../../utils/getImageURL'; import { selectUserType } from '../../../../utils/selectUserType'; -import AutoCompleteCountry from '../../../common/InputTypes/AutoCompleteCountry'; import { useUserProps } from '../../../common/Layout/UserPropsContext'; import styles from './EditProfile.module.scss'; -import GeocoderArcGIS from 'geocoder-arcgis'; import { ErrorHandlingContext } from '../../../common/Layout/ErrorHandlingContext'; import { useLocale, useTranslations } from 'next-intl'; -import { allCountries } from '../../../../utils/constants/countries'; import InlineFormDisplayGroup from '../../../common/Layout/Forms/InlineFormDisplayGroup'; import { MuiAutoComplete, StyledAutoCompleteOption, } from '../../../common/InputTypes/MuiAutoComplete'; import StyledForm from '../../../common/Layout/StyledForm'; -import { - AddressSuggestionsType, - AddressType, -} from '../../../common/types/geocoder'; import { AlertColor } from '@mui/lab'; import { APIError, handleError } from '@planet-sdk/common'; import { useTenant } from '../../../common/Layout/TenantContext'; -import { ExtendedCountryCode } from '../../../common/types/country'; import Delete from '../../../../../public/assets/images/icons/manageProjects/Delete'; import CustomTooltip from '../../../common/Layout/CustomTooltip'; import NewToggleSwitch from '../../../common/InputTypes/NewToggleSwitch'; @@ -97,7 +88,6 @@ export default function EditProfileForm() { handleSubmit, control, reset, - setValue, formState: { errors }, } = useForm({ mode: 'onBlur', @@ -117,71 +107,12 @@ export default function EditProfileForm() { setSnackbarOpen(false); }; - const [country, setCountry] = React.useState( - user?.country || 'DE' - ); - React.useEffect(() => { reset(defaultProfileDetails); }, [defaultProfileDetails]); const [updatingPic, setUpdatingPic] = React.useState(false); - const [addressSugggestions, setaddressSugggestions] = React.useState< - AddressSuggestionsType[] - >([]); - const geocoder = new GeocoderArcGIS( - process.env.ESRI_CLIENT_SECRET - ? { - client_id: process.env.ESRI_CLIENT_ID, - client_secret: process.env.ESRI_CLIENT_SECRET, - } - : {} - ); - - const suggestAddress = (value: string) => { - if (value.length > 3) { - geocoder - .suggest(value, { category: 'Address', countryCode: country }) - .then((result: { suggestions: AddressSuggestionsType[] }) => { - const filterdSuggestions = result.suggestions.filter( - (suggestion: AddressSuggestionsType) => { - return !suggestion.isCollection; - } - ); - setaddressSugggestions(filterdSuggestions); - }) - .catch(console.log); - } - }; - const getAddress = (value: string) => { - geocoder - .findAddressCandidates(value, { outfields: '*' }) - .then((result: AddressType) => { - setValue('address', result.candidates[0].attributes.ShortLabel, { - shouldValidate: true, - }); - setValue('city', result.candidates[0].attributes.City, { - shouldValidate: true, - }); - setValue('zipCode', result.candidates[0].attributes.Postal, { - shouldValidate: true, - }); - setaddressSugggestions([]); - }) - .catch(console.log); - }; - - const [postalRegex, setPostalRegex] = React.useState( - COUNTRY_ADDRESS_POSTALS.filter((item) => item.abbrev === country)[0]?.postal - ); - React.useEffect(() => { - const fiteredCountry = COUNTRY_ADDRESS_POSTALS.filter( - (item) => item.abbrev === country - ); - setPostalRegex(fiteredCountry[0]?.postal); - }, [country]); - // the form values const [severity, setSeverity] = useState('success'); const [snackbarMessage, setSnackbarMessage] = useState('OK'); @@ -296,7 +227,6 @@ export default function EditProfileForm() { const bodyToSend = { ...otherData, - country: country, isPrivate: !isPublic, ...(type !== 'tpo' ? { type: type } : {}), }; @@ -321,7 +251,6 @@ export default function EditProfileForm() { } } }; - let suggestion_counter = 0; return ( @@ -514,109 +443,6 @@ export default function EditProfileForm() { )} /> )} - ( - { - suggestAddress(event.target.value); - handleChange(event); - }} - onBlur={() => { - setaddressSugggestions([]); - handleBlur(); - }} - value={value} - error={errors.address !== undefined} - helperText={ - errors.address !== undefined && errors.address.message - } - /> - )} - /> - {addressSugggestions - ? addressSugggestions.length > 0 && ( -
- {addressSugggestions.map((suggestion) => { - return ( -
{ - getAddress(suggestion['text']); - }} - className="suggestion" - > - {suggestion['text']} -
- ); - })} -
- ) - : null} - - ( - - )} - /> - ( - - )} - /> - - + ); diff --git a/src/utils/addressManagement.ts b/src/utils/addressManagement.ts new file mode 100644 index 0000000000..c974b00739 --- /dev/null +++ b/src/utils/addressManagement.ts @@ -0,0 +1,154 @@ +import type { Address } from '@planet-sdk/common'; +import type { ExtendedCountryCode } from '../features/common/types/country'; +import type { + AddressSuggestionsType, + AddressType, +} from '../features/common/types/geocoder'; + +import GeocoderArcGIs from 'geocoder-arcgis'; +import COUNTRY_ADDRESS_POSTALS from './countryZipCode'; + +export const ADDRESS_TYPE = { + PRIMARY: 'primary', + MAILING: 'mailing', + OTHER: 'other', +} as const; + +export const ADDRESS_ACTIONS = { + ADD: 'add', + EDIT: 'edit', + DELETE: 'delete', + SET_PRIMARY: 'setPrimary', + SET_BILLING: 'setBilling', + UNSET_BILLING: 'unsetBilling', +} as const; + +export const ADDRESS_FORM_TYPE = { + ADD_ADDRESS: 'add', + EDIT_ADDRESS: 'edit', +} as const; +export const addressTypeOrder = ['primary', 'mailing', 'other']; + +export const MAX_ADDRESS_LIMIT = 5; + +export const getFormattedAddress = ( + zipCode: string | undefined, + city: string | undefined, + state: string | null | undefined, + countryName: string +) => { + const cleanAddress = [zipCode, city, state, countryName] + .filter(Boolean) + .join(', ') + .replace(/\s+/g, ' ') + .trim(); + return cleanAddress; +}; + +export const validationPattern = { + address: /^[\p{L}\p{N}\sß.,#/-]+$/u, + cityState: /^[\p{L}\sß.,()-]+$/u, +}; + +export const findAddressByType = ( + addresses: Address[], + addressType: 'primary' | 'mailing' +) => { + return addresses.find((address) => address.type === addressType); +}; + +export const geocoder = new GeocoderArcGIs( + process.env.ESRI_CLIENT_SECRET + ? { + client_id: process.env.ESRI_CLIENT_ID, + client_secret: process.env.ESRI_CLIENT_SECRET, + } + : {} +); + +/** + * Suggests address options based on user input. + * + * This function queries the geocoder's `suggest` method with the provided input value + * and optional country code to fetch address suggestions categorized as "Address". + * It filters out suggestions marked as collections (`isCollection`) to ensure only + * individual address suggestions are returned. + * + * @param value - The input string to search for address suggestions. + * @param country - The optional country code to narrow down the address suggestions. + * @returns A promise that resolves to an array of valid address suggestions or an empty array. + */ + +export const suggestAddress = async ( + value: string, + country: ExtendedCountryCode | '' +): Promise => { + if (value.length > 3) { + try { + const result = await geocoder.suggest(value, { + category: 'Address', + countryCode: country, + }); + return result.suggestions.filter( + (suggestion: AddressSuggestionsType) => !suggestion.isCollection + ); + } catch (error) { + console.log(error); + return []; + } + } + return []; +}; + +/** + * Fetches detailed address information based on the input value. + * + * This function uses the geocoder's `findAddressCandidates` method to search for + * address candidates and retrieves key information such as formatted address, city, + * and postal code (ZIP code). If no candidates are found, it returns `null`. + * + * @param value - The input string to search for address details. + * @returns A promise that resolves to an object containing address details (address, city, zipCode) + * or `null` if no candidates are found or an error occurs. + */ +export const fetchAddressDetails = async ( + value: string +): Promise<{ + address: string; + city: string; + zipCode: string; +} | null> => { + try { + const result: AddressType = await geocoder.findAddressCandidates(value, { + outfields: '*', + }); + if (result.candidates.length > 0) { + const { ShortLabel, City, Postal } = result.candidates[0].attributes; + return { + address: ShortLabel, + city: City, + zipCode: Postal, + }; + } + return null; + } catch (error) { + console.log(error); + return null; + } +}; + +/** + * Retrieves the postal regex for a given country code. + * + * This function searches the `COUNTRY_ADDRESS_POSTALS` array for the country matching the provided + * `country` code and returns the associated postal regex pattern. If no match is found, it returns `undefined`. + * + * @param country - The country code for which to retrieve the postal regex. + * @returns The postal regex pattern for the given country, or `undefined` if no match is found. + */ +export const getPostalRegex = (country: ExtendedCountryCode | '') => { + const filteredCountry = COUNTRY_ADDRESS_POSTALS.find( + (item) => item.abbrev === country + ); + return filteredCountry?.postal; +};