From 048da0e2512ea9b7555595d1ba1baae73cc66df2 Mon Sep 17 00:00:00 2001 From: Alexandr SaVBaD Waltz Date: Mon, 9 Mar 2026 20:32:36 +0300 Subject: [PATCH] feat(addresses): add address management feature with UI and logic integration Introduced address management functionality, including address creation, deletion, and display with full localization support. Integrated GraphQL queries, mutations, and reusable composables for backend communication. - Added `addresses.vue` to profile for managing user addresses. - Implemented `useAddressCreate`, `useAddressDelete`, and `useAddressAutocomplete` composables. - Created reusable components: `forms/address.vue` and `cards/address.vue`. - Updated `navigation.vue` to include addresses in profile navigation. - Enhanced localization files for address-related translations. This improves user experience by enabling comprehensive address management in the profile section. No breaking changes. --- storefront/app/assets/styles/ui/select.scss | 22 ++- storefront/app/components/cards/address.vue | 113 +++++++++++ storefront/app/components/forms/address.vue | 181 ++++++++++++++++++ .../app/components/profile/navigation.vue | 12 ++ .../skeletons/ui/addresses-complete.vue | 36 ++++ .../skeletons/{header => ui}/search.vue | 0 storefront/app/components/ui/input.vue | 7 + storefront/app/components/ui/search.vue | 2 +- storefront/app/composables/adresses/index.ts | 4 + .../adresses/useAddressAutocomplete.ts | 63 ++++++ .../composables/adresses/useAddressCreate.ts | 47 +++++ .../composables/adresses/useAddressDelete.ts | 41 ++++ .../app/composables/adresses/useAddresses.ts | 20 ++ .../app/composables/user/useUserBaseData.ts | 4 + .../app/graphql/fragments/address.fragment.ts | 12 ++ storefront/app/graphql/mutations/addresses.ts | 41 ++++ .../graphql/queries/combined/userBaseData.ts | 6 +- .../graphql/queries/standalone/addresses.ts | 27 +++ storefront/app/pages/profile/addresses.vue | 85 ++++++++ storefront/app/stores/addresses.ts | 13 ++ storefront/i18n/locales/en-gb.json | 21 +- storefront/i18n/locales/ru-ru.json | 21 +- storefront/types/api/addresses.ts | 32 ++++ storefront/types/app/addresses.ts | 27 +++ storefront/types/index.ts | 2 + 25 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 storefront/app/components/cards/address.vue create mode 100644 storefront/app/components/forms/address.vue create mode 100644 storefront/app/components/skeletons/ui/addresses-complete.vue rename storefront/app/components/skeletons/{header => ui}/search.vue (100%) create mode 100644 storefront/app/composables/adresses/index.ts create mode 100644 storefront/app/composables/adresses/useAddressAutocomplete.ts create mode 100644 storefront/app/composables/adresses/useAddressCreate.ts create mode 100644 storefront/app/composables/adresses/useAddressDelete.ts create mode 100644 storefront/app/composables/adresses/useAddresses.ts create mode 100644 storefront/app/graphql/fragments/address.fragment.ts create mode 100644 storefront/app/graphql/mutations/addresses.ts create mode 100644 storefront/app/graphql/queries/standalone/addresses.ts create mode 100644 storefront/app/pages/profile/addresses.vue create mode 100644 storefront/app/stores/addresses.ts create mode 100644 storefront/types/api/addresses.ts create mode 100644 storefront/types/app/addresses.ts diff --git a/storefront/app/assets/styles/ui/select.scss b/storefront/app/assets/styles/ui/select.scss index 6fb32a07..526c9851 100644 --- a/storefront/app/assets/styles/ui/select.scss +++ b/storefront/app/assets/styles/ui/select.scss @@ -2,6 +2,26 @@ .el-select__wrapper { height: 36px !important; - min-height: 36px !important; + min-height: 50px !important; background-color: transparent !important; +} +.el-select--large .el-select__wrapper { + border-radius: $default_border_radius !important; + box-shadow: none !important; + border: 1px solid $border !important; + font-size: 16px !important; +} +.el-select__placeholder.is-transparent { + color: $disabled_secondary !important; +} + +.el-select__popper.el-popper { + background-color: $main_hover !important; +} +.el-select-dropdown__item { + color: $primary_dark !important; +} +.el-select-dropdown__item.is-hovering { + color: $primary !important; + background-color: $main !important; } \ No newline at end of file diff --git a/storefront/app/components/cards/address.vue b/storefront/app/components/cards/address.vue new file mode 100644 index 00000000..1386711d --- /dev/null +++ b/storefront/app/components/cards/address.vue @@ -0,0 +1,113 @@ + + + + + \ No newline at end of file diff --git a/storefront/app/components/forms/address.vue b/storefront/app/components/forms/address.vue new file mode 100644 index 00000000..2e21e8f2 --- /dev/null +++ b/storefront/app/components/forms/address.vue @@ -0,0 +1,181 @@ + + + + + \ No newline at end of file diff --git a/storefront/app/components/profile/navigation.vue b/storefront/app/components/profile/navigation.vue index 4e9b65fb..59b39d1e 100644 --- a/storefront/app/components/profile/navigation.vue +++ b/storefront/app/components/profile/navigation.vue @@ -33,6 +33,14 @@ {{ t('profile.promocodes.title') }} + + + {{ t('profile.addresses.title') }} +
- +
(null); + + const { mutate, loading, error } = useMutation(AUTOCOMPLETE_ADDRESS); + + async function complete(query: string) { + completeResults.value = null; + const result = await mutate({ + limit: 5, + q: query, + }); + + if (result?.data?.autocompleteAddress) { + completeResults.value = result.data.autocompleteAddress.suggestions + + return { + results: result.data.autocompleteAddress.suggestions, + }; + } + } + + const debouncedComplete = useDebounceFn(async (val: string) => { + if (!val) { + completeResults.value = null; + return; + } + + await complete(val); + }, 400); + + async function handleSearch(val: string) { + await debouncedComplete(val); + } + + watch(error, (err) => { + if (!err) return; + console.error('useAddressAutocomplete error:', err); + let message = t('popup.errors.defaultError'); + if (isGraphQLError(err)) { + message = err.graphQLErrors?.[0]?.message || message; + } else { + message = err.message; + } + $notify({ + message, + type: 'error', + title: t('popup.errors.main'), + }); + }); + + return { + query, + completeResults, + loading, + handleSearch + }; +} \ No newline at end of file diff --git a/storefront/app/composables/adresses/useAddressCreate.ts b/storefront/app/composables/adresses/useAddressCreate.ts new file mode 100644 index 00000000..8d685895 --- /dev/null +++ b/storefront/app/composables/adresses/useAddressCreate.ts @@ -0,0 +1,47 @@ +import {CREATE_ADDRESS} from "@graphql/mutations/addresses"; +import type {IAddressCreateResponse} from "@types"; + +interface ICreateAddressArgs { + rawData: string; + addressLine1: string; + addressLine2?: string; +} + +export function useAddressCreate() { + const { t } = useI18n(); + const { $notify } = useNuxtApp(); + + const { mutate, loading, error } = useMutation(CREATE_ADDRESS); + + async function createAddress(args: ICreateAddressArgs) { + const result = await mutate(args); + + if (result?.data?.createAddress?.address?.uuid) { + $notify({ + message: t('popup.success.createAddress'), + type: 'success', + }); + } + } + + watch(error, (err) => { + if (!err) return; + console.error('useAddressCreate error:', err); + let message = t('popup.errors.defaultError'); + if (isGraphQLError(err)) { + message = err.graphQLErrors?.[0]?.message || message; + } else { + message = err.message; + } + $notify({ + message, + type: 'error', + title: t('popup.errors.main'), + }); + }); + + return { + createAddress, + loading + }; +} \ No newline at end of file diff --git a/storefront/app/composables/adresses/useAddressDelete.ts b/storefront/app/composables/adresses/useAddressDelete.ts new file mode 100644 index 00000000..7bfba4ef --- /dev/null +++ b/storefront/app/composables/adresses/useAddressDelete.ts @@ -0,0 +1,41 @@ +import {DELETE_ADDRESS} from "@graphql/mutations/addresses"; +import type {IAddressDeleteResponse} from "@types"; + +export function useAddressDelete() { + const { t } = useI18n(); + const { $notify } = useNuxtApp(); + + const { mutate, loading, error } = useMutation(DELETE_ADDRESS); + + async function deleteAddress(uuid: string) { + const result = await mutate({ uuid }); + + if (result?.data?.deleteAddress?.success) { + $notify({ + message: t('popup.success.deleteAddress'), + type: 'success', + }); + } + } + + watch(error, (err) => { + if (!err) return; + console.error('useAddressDelete error:', err); + let message = t('popup.errors.defaultError'); + if (isGraphQLError(err)) { + message = err.graphQLErrors?.[0]?.message || message; + } else { + message = err.message; + } + $notify({ + message, + type: 'error', + title: t('popup.errors.main'), + }); + }); + + return { + deleteAddress, + loading + }; +} \ No newline at end of file diff --git a/storefront/app/composables/adresses/useAddresses.ts b/storefront/app/composables/adresses/useAddresses.ts new file mode 100644 index 00000000..5d822da2 --- /dev/null +++ b/storefront/app/composables/adresses/useAddresses.ts @@ -0,0 +1,20 @@ +import {GET_ADDRESSES} from "@graphql/queries/standalone/addresses"; +import type { IAddressesResponse } from '@types'; + +export async function useAddresses() { + const addressesStore = useAddressesStore(); + + const { data, error } = await useAsyncQuery(GET_ADDRESSES); + + if (!error.value && data.value?.addresses.edges[0]) { + addressesStore.setAddresses(data.value.addresses.edges[0].node); + } + + watch(error, (err) => { + if (err) { + console.error('useAddresses error:', err); + } + }); + + return {}; +} diff --git a/storefront/app/composables/user/useUserBaseData.ts b/storefront/app/composables/user/useUserBaseData.ts index d1ca7d94..a02b43e6 100644 --- a/storefront/app/composables/user/useUserBaseData.ts +++ b/storefront/app/composables/user/useUserBaseData.ts @@ -8,6 +8,7 @@ export function useUserBaseData() { const wishlistStore = useWishlistStore(); const cartStore = useCartStore(); const promocodeStore = usePromocodeStore(); + const addressesStore = useAddressesStore(); const { syncWishlist } = useWishlistSync(); const { syncOrder } = useOrderSync(); @@ -40,6 +41,9 @@ export function useUserBaseData() { if (data?.promocodes.edges) { promocodeStore.setPromocodes(data.promocodes.edges); } + if (data?.addresses.edges) { + addressesStore.setAddresses(data.addresses.edges); + } } return { diff --git a/storefront/app/graphql/fragments/address.fragment.ts b/storefront/app/graphql/fragments/address.fragment.ts new file mode 100644 index 00000000..20fb38e2 --- /dev/null +++ b/storefront/app/graphql/fragments/address.fragment.ts @@ -0,0 +1,12 @@ +export const ADDRESS_FRAGMENT = gql` + fragment Address on AddressType { + uuid + city + addressLine + country + region + district + postalCode + street + } +`; diff --git a/storefront/app/graphql/mutations/addresses.ts b/storefront/app/graphql/mutations/addresses.ts new file mode 100644 index 00000000..76069c7b --- /dev/null +++ b/storefront/app/graphql/mutations/addresses.ts @@ -0,0 +1,41 @@ +export const CREATE_ADDRESS = gql` + mutation createAddress( + $rawData: String! + $addressLine1: String!, + $addressLine2: String + ) { + createAddress( + rawData: $rawData + addressLine1: $addressLine1, + addressLine2: $addressLine2 + ) { + uuid + } + } +`; + +export const DELETE_ADDRESS = gql` + mutation deleteAddress( + $uuid: UUID! + ) { + deleteAddress( + uuid: $uuid + ) { + success + } + } +`; + +export const AUTOCOMPLETE_ADDRESS = gql` + mutation autocompleteAddress( + $limit: Int, + $q: String + ) { + autocompleteAddress( + limit: $limit, + q: $q + ) { + suggestions + } + } +`; diff --git a/storefront/app/graphql/queries/combined/userBaseData.ts b/storefront/app/graphql/queries/combined/userBaseData.ts index 15a0fbf1..12772d7b 100644 --- a/storefront/app/graphql/queries/combined/userBaseData.ts +++ b/storefront/app/graphql/queries/combined/userBaseData.ts @@ -1,13 +1,15 @@ +import {GET_ADDRESSES} from "@graphql/queries/standalone/addresses"; import { GET_ORDERS } from '@graphql/queries/standalone/orders'; import { GET_PROMOCODES } from '@graphql/queries/standalone/promocodes'; import { GET_WISHLIST } from '@graphql/queries/standalone/wishlist'; import combineQuery from 'graphql-combine-query'; -export const getUserBaseData = (orderVariables?: { userEmail?: string; status?: string }) => { +export const getUserBaseData = (orderVariables?: { userEmail?: string; status?: string; }) => { const { document, variables } = combineQuery('getUserBaseData') .add(GET_WISHLIST) .add(GET_PROMOCODES) - .add(GET_ORDERS, orderVariables || {}); + .add(GET_ORDERS, orderVariables || {}) + .add(GET_ADDRESSES); return { document, diff --git a/storefront/app/graphql/queries/standalone/addresses.ts b/storefront/app/graphql/queries/standalone/addresses.ts new file mode 100644 index 00000000..6f60103f --- /dev/null +++ b/storefront/app/graphql/queries/standalone/addresses.ts @@ -0,0 +1,27 @@ +import { ADDRESS_FRAGMENT } from '@graphql/fragments/address.fragment'; + +export const GET_ADDRESSES = gql` + query getAddresses( + $addressesAfter: String, + $addressesFirst: Int, + $addressesOrderBy: String, + + ) { + addresses( + after: $addressesAfter, + first: $addressesFirst, + orderBy: $addressesOrderBy, + ) { + edges { + node { + ...Address + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + ${ADDRESS_FRAGMENT} +`; diff --git a/storefront/app/pages/profile/addresses.vue b/storefront/app/pages/profile/addresses.vue new file mode 100644 index 00000000..be5782f1 --- /dev/null +++ b/storefront/app/pages/profile/addresses.vue @@ -0,0 +1,85 @@ + + + + + \ No newline at end of file diff --git a/storefront/app/stores/addresses.ts b/storefront/app/stores/addresses.ts new file mode 100644 index 00000000..3e618399 --- /dev/null +++ b/storefront/app/stores/addresses.ts @@ -0,0 +1,13 @@ +import type { IAddress } from '@types'; + +export const useAddressesStore = defineStore('addresses', () => { + const addresses = ref<{ node: IAddress }[] | null>(null); + const setAddresses = (payload: { node: IAddress }[] | null) => { + addresses.value = payload; + }; + + return { + addresses, + setAddresses, + }; +}); diff --git a/storefront/i18n/locales/en-gb.json b/storefront/i18n/locales/en-gb.json index e99dc933..a7227804 100644 --- a/storefront/i18n/locales/en-gb.json +++ b/storefront/i18n/locales/en-gb.json @@ -22,7 +22,8 @@ "saveChanges": "Save Changes", "clearAll": "Clear All", "buyNow": "Buy Now", - "backToHome": "Back To Home" + "backToHome": "Back To Home", + "add": "Add" }, "errors": { "required": "This field is required!", @@ -55,13 +56,24 @@ "confirmNewPassword": "Confirm new password", "brandsSearch": "Search brands by name...", "promocode": "Enter promocode", - "address": "Start typing the address" + "address": "Start typing the address", + "addressName": "Name", + "country": "Country", + "region": "Region", + "district": "District", + "postalCode": "Postal code", + "street": "Street", + "city": "City", + "entrance": "Entrance", + "floor": "Floor", + "apartNumber": "Apart. number" }, "checkboxes": { "remember": "Remember me", "chooseAll": "Choose all", "agree": "I agree to the {terms} and {policy}", - "subscribe": "Subscribe to newsletter for exclusive offers and updates" + "subscribe": "Subscribe to newsletter for exclusive offers and updates", + "isHouse": "Private house" }, "popup": { "errors": { @@ -173,6 +185,9 @@ }, "newPassword": { "title": "New password" + }, + "address": { + "moscow": "Moscow" } }, "cards": { diff --git a/storefront/i18n/locales/ru-ru.json b/storefront/i18n/locales/ru-ru.json index ee0d402e..fce0b4a2 100644 --- a/storefront/i18n/locales/ru-ru.json +++ b/storefront/i18n/locales/ru-ru.json @@ -22,7 +22,8 @@ "saveChanges": "Сохранить изменения", "clearAll": "Очистить всё", "buyNow": "Купить Сейчас", - "backToHome": "Обратно на Главную" + "backToHome": "Обратно на Главную", + "add": "Добавить" }, "errors": { "required": "Это поле обязательно!", @@ -55,13 +56,24 @@ "confirmNewPassword": "Подтвердите новый пароль", "brandsSearch": "Поиск брендов по названию...", "promocode": "Введите промокод", - "address": "Начните вводить адрес" + "address": "Начните вводить адрес", + "addressName": "Название", + "country": "Страна", + "region": "Регион", + "district": "Регион", + "postalCode": "Индекс", + "street": "Улица", + "city": "Город", + "entrance": "Подъезд", + "floor": "Этаж", + "apartNumber": "Квартира" }, "checkboxes": { "remember": "Запомнить меня", "chooseAll": "Выбрать все", "agree": "Я согласен с {terms} и {policy}", - "subscribe": "Подписаться на рассылку новостей об эксклюзивных предложениях и обновлениях" + "subscribe": "Подписаться на рассылку новостей об эксклюзивных предложениях и обновлениях", + "isHouse": "Частный дом" }, "popup": { "errors": { @@ -173,6 +185,9 @@ }, "newPassword": { "title": "Новый пароль" + }, + "address": { + "moscow": "Москва" } }, "cards": { diff --git a/storefront/types/api/addresses.ts b/storefront/types/api/addresses.ts new file mode 100644 index 00000000..5b92853d --- /dev/null +++ b/storefront/types/api/addresses.ts @@ -0,0 +1,32 @@ +import type {IAddress, IAddressCompleteResult} from '@types'; + +export interface IAddressesResponse { + addresses: { + edges: { + cursor: string; + node: IAddress; + }[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string; + }; + }; +} + +export interface IAddressCreateResponse { + createAddress: { + address: IAddress; + }; +} + +export interface IAddressDeleteResponse { + deleteAddress: { + success: boolean; + }; +} + +export interface IAddressAutocompleteResponse { + autocompleteAddress: { + suggestions: IAddressCompleteResult[]; + } +} diff --git a/storefront/types/app/addresses.ts b/storefront/types/app/addresses.ts new file mode 100644 index 00000000..ed22c4dc --- /dev/null +++ b/storefront/types/app/addresses.ts @@ -0,0 +1,27 @@ +export interface IAddress { + uuid: string, + city: string, + addressLine: string, + country: string, + region: string, + district: string, + postalCode: string, + street: string +} + +export interface IAddressCompleteResult { + display_name: string, + lat: string, + lon: string, + address: { + hamlet: string, + village: string, + municipality: string, + county: string, + state: string, + region: string, + postcode: string, + country: string, + country_code: string + } +} diff --git a/storefront/types/index.ts b/storefront/types/index.ts index 08d2b905..48d18849 100644 --- a/storefront/types/index.ts +++ b/storefront/types/index.ts @@ -1,5 +1,6 @@ // APP +export * from './app/addresses'; export * from './app/brand'; export * from './app/category'; export * from './app/company'; @@ -17,6 +18,7 @@ export * from './app/wishlist'; // API +export * from './api/addresses'; export * from './api/auth'; export * from './api/brands'; export * from './api/categories';