diff --git a/src/apps/accounts/src/lib/assets/tools/index.ts b/src/apps/accounts/src/lib/assets/tools/index.ts index 160c83175..176aaa7f5 100644 --- a/src/apps/accounts/src/lib/assets/tools/index.ts +++ b/src/apps/accounts/src/lib/assets/tools/index.ts @@ -9,7 +9,7 @@ import { ReactComponent as WearableIcon } from './wearable.svg' import { ReactComponent as FinancialInstitutionIcon } from './Financial Institution.svg' import { ReactComponent as OtherServiceProviderIcon } from './other_service_provider.svg' import { ReactComponent as TelevisionServiceProviderIcon } from './Television.svg' -import { ReactComponent as MobileCarierServiceProviderIcon } from './Mobile Carrier.svg' +import { ReactComponent as MobileCarrierServiceProviderIcon } from './Mobile Carrier.svg' import { ReactComponent as InternetServiceProviderIcon } from './Internet Service Provider.svg' import { ReactComponent as SubscriptionsIcon } from './subscription.svg' @@ -18,7 +18,7 @@ export { DesktopIncon, FinancialInstitutionIcon, InternetServiceProviderIcon, - MobileCarierServiceProviderIcon, + MobileCarrierServiceProviderIcon, LaptopIcon, OtherDeviceIcon, OtherServiceProviderIcon, diff --git a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.module.scss b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.module.scss index fb9e41691..38f99856b 100644 --- a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.module.scss +++ b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.module.scss @@ -16,6 +16,10 @@ align-self: flex-start; width: 64px; height: 64px; + + svg { + margin: auto; + } } .actionElements { @@ -36,10 +40,37 @@ } } - .deviceForm { + .formWrap { display: grid; grid-template-columns: 1fr 1fr; - margin: $sp-4 0; + margin: $sp-13 0 $sp-4; + + @include ltelg { + grid-template-columns: 1fr; + + p { + margin-bottom: $sp-4; + } + } + + &.formNoTop { + margin-top: 0; + } + + .formCTAs { + display: flex; + align-items: center; + + svg { + width: 14px; + height: 14px; + margin-right: $sp-1; + } + + .ctaBtnCancel { + margin-left: $sp-8; + } + } } } } \ No newline at end of file diff --git a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx index f84bdb9a0..eeb1ccb27 100644 --- a/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/devices/Devices.tsx @@ -1,8 +1,19 @@ -import { FC } from 'react' -import { bind, compact } from 'lodash' +/* eslint-disable complexity */ +import { Dispatch, FC, MutableRefObject, SetStateAction, useEffect, useRef, useState } from 'react' +import { bind, compact, isEmpty, reject, uniqBy } from 'lodash' +import { KeyedMutator } from 'swr' +import { toast } from 'react-toastify' +import classNames from 'classnames' -import { UserProfile, UserTrait } from '~/libs/core' -import { Button, Collapsible, IconOutline } from '~/libs/ui' +import { + createMemberTraitsAsync, + updateMemberTraitsAsync, + useMemberDevicesLookup, + useMemberTraits, + UserProfile, + UserTrait, +} from '~/libs/core' +import { Button, Collapsible, ConfirmModal, IconOutline, InputSelect } from '~/libs/ui' import { ConsoleIcon, DesktopIncon, @@ -21,15 +32,105 @@ interface DevicesProps { profile: UserProfile } +const methodsMap: { [key: string]: any } = { + create: createMemberTraitsAsync, + update: updateMemberTraitsAsync, +} + const Devices: FC = (props: DevicesProps) => { - console.log('Devices', props.devicesTrait, props.profile) + const formElRef: MutableRefObject = useRef() + + const deviceTypes: any = useMemberDevicesLookup('/types') + + const [deviceTypesData, setDeviceTypesData]: [ + UserTrait[] | undefined, + Dispatch> + ] = useState() + + const [formErrors, setFormErrors]: [ + { [key: string]: string }, + Dispatch> + ] + = useState<{ [key: string]: string }>({}) + + const [isEditMode, setIsEditMode]: [boolean, Dispatch>] = useState(false) + + const [removeConfirmationOpen, setRemoveConfirmationOpen]: [boolean, Dispatch>] + = useState(false) + + const [itemToUpdate, setItemToUpdate]: [UserTrait | undefined, Dispatch>] + = useState() + + const [itemToRemove, setItemToRemove]: [UserTrait | undefined, Dispatch>] + = useState() + + const { mutate: mutateTraits }: { mutate: KeyedMutator } = useMemberTraits(props.profile.handle) + + const [selectedDeviceType, setSelectedDeviceType]: [ + string | undefined, + Dispatch> + ] + = useState() + + const deviceTypeManufacturers: any + = useMemberDevicesLookup(selectedDeviceType ? `/manufacturers?type=${selectedDeviceType}` : undefined) + + const [selectedDeviceManufacturerType, setSelectedDeviceManufacturerType]: [ + string | undefined, + Dispatch> + ] + = useState() + + const deviceTypeManufacturerModels: any + = useMemberDevicesLookup( + selectedDeviceType && selectedDeviceManufacturerType + ? `?type=${selectedDeviceType}&manufacturer=${selectedDeviceManufacturerType}&page=1&perPage=100` + : undefined, + ) + + const [selectedDeviceManufacturerModelType, setSelectedDeviceManufacturerModelType]: [ + any, + Dispatch> + ] + = useState() + + const deviceTypeManufacturerModelOS: any + = useMemberDevicesLookup( + selectedDeviceType && selectedDeviceManufacturerType && selectedDeviceManufacturerModelType?.model + // eslint-disable-next-line max-len + ? `?type=${selectedDeviceType}&manufacturer=${selectedDeviceManufacturerType}&model=${selectedDeviceManufacturerModelType.model}&page=1&perPage=100` + : undefined, + ) + + const [selectedDeviceManufacturerModelOSType, setSelectedDeviceManufacturerModelOSType]: [ + any, + Dispatch> + ] + = useState() + + useEffect(() => { + setDeviceTypesData(props.devicesTrait?.traits.data) + }, [props.devicesTrait]) + + function toggleRemoveConfirmation(): void { + setRemoveConfirmationOpen(!removeConfirmationOpen) + setItemToRemove(undefined) + } function handleEditBtnClick(trait: UserTrait): void { - console.log('handleCTABtnClick', trait) + setItemToUpdate(trait) + setIsEditMode(true) + setSelectedDeviceType(trait.deviceType) + setSelectedDeviceManufacturerType(trait.manufacturer) + setSelectedDeviceManufacturerModelType({ + model: trait.model, + }) + setFormErrors({}) } function handleTrashBtnClick(trait: UserTrait): void { - console.log('handleTrashBtnClick', trait) + setRemoveConfirmationOpen(true) + setItemToRemove(trait) } function renderDeviceImage(trait: UserTrait): JSX.Element { @@ -44,6 +145,185 @@ const Devices: FC = (props: DevicesProps) => { } } + function resetForm(): void { + setSelectedDeviceType(undefined) + setSelectedDeviceManufacturerType(undefined) + formElRef.current.reset() + setIsEditMode(false) + } + + function onRemoveItemConfirm(): void { + const updatedDeviceTypesData: UserTrait[] = reject(deviceTypesData, (trait: UserTrait) => ( + trait.model === itemToRemove?.model + && trait.deviceType === itemToRemove?.deviceType + && trait.operatingSystem === itemToRemove?.operatingSystem + )) || [] + + resetForm() + + updateMemberTraitsAsync( + props.profile.handle, + [{ + categoryName: 'Device', + traitId: 'device', + traits: { + data: updatedDeviceTypesData, + }, + }], + ) + .then(() => { + toast.success('Device deleted successfully') + setDeviceTypesData(updatedDeviceTypesData) + mutateTraits() + }) + .catch(() => { + toast.error('Error deleting Device') + }) + .finally(() => { + toggleRemoveConfirmation() + }) + } + + function handleSelectedDeviceTypeChange(event: React.ChangeEvent): void { + setSelectedDeviceType(event.target.value) + } + + function handleCancelEditMode(): void { + setIsEditMode(false) + resetForm() + setFormErrors({}) + } + + function handleSelectedDeviceManufacturerTypeChange(event: React.ChangeEvent): void { + setSelectedDeviceManufacturerType(event.target.value) + } + + function handleSelectedDeviceManufacturerModelTypeChange(event: React.ChangeEvent): void { + setSelectedDeviceManufacturerModelType({ + model: event.target.value, + }) + } + + function handleSelectedDeviceManufacturerModelOSTypeChange(event: React.ChangeEvent): void { + setSelectedDeviceManufacturerModelOSType({ + operatingSystem: event.target.value, + }) + } + + function handleFormAction(): void { + const updatedFormErrors: { [key: string]: string } = {} + const deviceUpdate: UserTrait = { + deviceType: selectedDeviceType, + manufacturer: selectedDeviceManufacturerType, + model: selectedDeviceManufacturerModelType?.model, + operatingSystem: selectedDeviceManufacturerModelOSType?.operatingSystem, + } + + if (deviceTypesData?.find( + (trait: UserTrait) => + // eslint-disable-next-line implicit-arrow-linebreak + trait.manufacturer === selectedDeviceManufacturerType + && trait.deviceType === selectedDeviceType + && trait.model === selectedDeviceManufacturerModelType?.model + && trait.operatingSystem === selectedDeviceManufacturerModelOSType?.operatingSystem, + )) { + toast.success('Look like you\'ve already entered this device.') + resetForm() + return + } + + if (!selectedDeviceType) { + updatedFormErrors.deviceType = 'Device type is required' + } + + if (!selectedDeviceManufacturerType) { + updatedFormErrors.deviceManufacturerType = 'Device Manufacturer is required' + } + + if (!selectedDeviceManufacturerModelType?.model) { + updatedFormErrors.deviceManufacturerModelType = 'Device Model type is required' + } + + if (!selectedDeviceManufacturerModelOSType?.operatingSystem) { + updatedFormErrors.deviceManufacturerModelOSType = 'Device Operating System is required' + } + + if (isEmpty(updatedFormErrors)) { + // call the API to update the trait based on action type + if (isEditMode) { + const updatedDeviceTypesData: UserTrait[] = reject( + deviceTypesData, + (trait: UserTrait) => ( + trait.deviceType === itemToUpdate?.deviceType + && trait.manufacturer === itemToUpdate?.manufacturer + && trait.model === itemToUpdate?.model + && trait.operatingSystem === itemToUpdate?.operatingSystem + ), + ) || [] + + updateMemberTraitsAsync( + props.profile.handle, + [{ + categoryName: 'Device', + traitId: 'device', + traits: { + data: [ + ...updatedDeviceTypesData || [], + deviceUpdate, + ], + }, + }], + ) + .then(() => { + toast.success('Device updated successfully') + setDeviceTypesData([ + ...updatedDeviceTypesData || [], + deviceUpdate, + ]) + mutateTraits() + }) + .catch(() => { + toast.error('Error updating Device') + }) + .finally(() => { + resetForm() + setIsEditMode(false) + }) + } else { + methodsMap[!deviceTypesData || !deviceTypesData.length ? 'create' : 'update']( + props.profile.handle, + [{ + categoryName: 'Device', + traitId: 'device', + traits: { + data: [ + ...deviceTypesData || [], + deviceUpdate, + ], + }, + }], + ) + .then(() => { + toast.success('Device added successfully') + setDeviceTypesData([ + ...deviceTypesData || [], + deviceUpdate, + ]) + mutateTraits() + }) + .catch(() => { + toast.error('Error adding new Device') + }) + .finally(() => { + resetForm() + setIsEditMode(false) + }) + } + } + + setFormErrors(updatedFormErrors) + } + return ( YOUR DEVICES} @@ -51,7 +331,7 @@ const Devices: FC = (props: DevicesProps) => { contentClass={styles.content} > { - props.devicesTrait?.traits.data.map((trait: UserTrait) => ( + deviceTypesData?.map((trait: UserTrait) => ( = (props: DevicesProps) => { )) } -
+ +
+ Are you sure you want to delete + {' '} + {itemToRemove?.name} + ? + {' '} + This action cannot be undone. +
+
+ +

Add a new device to your devices list

+
+ ({ label: type, value: type })) || []} + value={selectedDeviceType} + onChange={handleSelectedDeviceTypeChange} + name='memberDeviceTypes' + label='Device Type *' + error={formErrors.deviceType} + dirty + placeholder='Select a Device Type' + /> + + ({ label: type, value: type })) || []} + value={selectedDeviceManufacturerType} + onChange={handleSelectedDeviceManufacturerTypeChange} + name='memberDeviceManufacturerTypes' + label='Manufacturer *' + error={formErrors.deviceManufacturerType} + dirty + placeholder='Select a Device Manufacturer' + /> + + ({ label: type.model, value: type.model }), + ) || [] + } + value={selectedDeviceManufacturerModelType?.model} + onChange={handleSelectedDeviceManufacturerModelTypeChange} + name='memberDeviceManufacturerModelTypes' + label='Model *' + error={formErrors.deviceManufacturerModelType} + dirty + placeholder='Select a Device Manufacturer Model' + /> -
+ ({ label: type.operatingSystem, value: type.operatingSystem }), + ) || [] + } + value={selectedDeviceManufacturerModelOSType?.operatingSystem} + onChange={handleSelectedDeviceManufacturerModelOSTypeChange} + name='memberDeviceManufacturerModelOSTypes' + label='Operating System *' + error={formErrors.deviceManufacturerModelOSType} + dirty + placeholder='Select a Device Manufacturer Model Operating System' + /> + +
+ {!isEditMode && } +
+
+
) } diff --git a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.module.scss b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.module.scss index 791843c2d..95f0ad421 100644 --- a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.module.scss +++ b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.module.scss @@ -41,6 +41,14 @@ grid-template-columns: 1fr 1fr; margin: $sp-13 0 $sp-4; + @include ltelg { + grid-template-columns: 1fr; + + p { + margin-bottom: $sp-4; + } + } + &.formNoTop { margin-top: 0; } diff --git a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx index e2ec75b68..192d3315e 100644 --- a/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx +++ b/src/apps/accounts/src/settings/tabs/tools/service-provider/ServiceProvider.tsx @@ -9,7 +9,7 @@ import { Button, Collapsible, ConfirmModal, IconOutline, InputSelect, InputText import { FinancialInstitutionIcon, InternetServiceProviderIcon, - MobileCarierServiceProviderIcon, + MobileCarrierServiceProviderIcon, OtherServiceProviderIcon, SettingSection, TelevisionServiceProviderIcon, @@ -241,7 +241,7 @@ const ServiceProvider: FC = (props: ServiceProviderProps) switch (trait.serviceProviderType) { case 'Financial Institution': return case 'Internet Service Provider': return - case 'MobileCarierServiceProviderIcon': return + case 'Mobile Carrier': return case 'Television': return default: return } diff --git a/src/apps/accounts/src/settings/tabs/tools/software/Software.module.scss b/src/apps/accounts/src/settings/tabs/tools/software/Software.module.scss index 791843c2d..95f0ad421 100644 --- a/src/apps/accounts/src/settings/tabs/tools/software/Software.module.scss +++ b/src/apps/accounts/src/settings/tabs/tools/software/Software.module.scss @@ -41,6 +41,14 @@ grid-template-columns: 1fr 1fr; margin: $sp-13 0 $sp-4; + @include ltelg { + grid-template-columns: 1fr; + + p { + margin-bottom: $sp-4; + } + } + &.formNoTop { margin-top: 0; } diff --git a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.module.scss b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.module.scss index 791843c2d..95f0ad421 100644 --- a/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.module.scss +++ b/src/apps/accounts/src/settings/tabs/tools/subscriptions/Subscriptions.module.scss @@ -41,6 +41,14 @@ grid-template-columns: 1fr 1fr; margin: $sp-13 0 $sp-4; + @include ltelg { + grid-template-columns: 1fr; + + p { + margin-bottom: $sp-4; + } + } + &.formNoTop { margin-top: 0; } diff --git a/src/libs/core/lib/profile/data-providers/index.ts b/src/libs/core/lib/profile/data-providers/index.ts index 152d2f55d..fbb7d6d0d 100644 --- a/src/libs/core/lib/profile/data-providers/index.ts +++ b/src/libs/core/lib/profile/data-providers/index.ts @@ -9,3 +9,4 @@ export * from './useMemberEmailPreferences' export * from './useMemberMFAStatus' export * from './useDiceIdConnection' export * from './useMemberTraits' +export * from './useMemberDevicesLookup' diff --git a/src/libs/core/lib/profile/data-providers/useMemberDevicesLookup.ts b/src/libs/core/lib/profile/data-providers/useMemberDevicesLookup.ts new file mode 100644 index 000000000..fe22fb95c --- /dev/null +++ b/src/libs/core/lib/profile/data-providers/useMemberDevicesLookup.ts @@ -0,0 +1,11 @@ +import { SWRResponse } from 'swr' +import useSWRImmutable from 'swr/immutable' + +import { EnvironmentConfig } from '~/config' + +export function useMemberDevicesLookup(query: string | undefined): string[] | { [key: string]: string } | undefined { + const { data }: SWRResponse + = useSWRImmutable(!!query ? `${EnvironmentConfig.API.V5}/lookups/devices${query}` : undefined) + + return data +}