From 7353781d2bdbd0d129e17b7010a41e5eeee6970b Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sat, 11 May 2024 15:30:06 +0300 Subject: [PATCH 01/10] feat: expand loder options --- src/shared/Loader/model/LoaderModel.ts | 8 ++++-- src/shared/Loader/view/LoaderView.ts | 27 +++++++++++++------ src/shared/Loader/view/loaderView.module.scss | 14 ++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/shared/Loader/model/LoaderModel.ts b/src/shared/Loader/model/LoaderModel.ts index 28a2f0a2..d061aab7 100644 --- a/src/shared/Loader/model/LoaderModel.ts +++ b/src/shared/Loader/model/LoaderModel.ts @@ -1,17 +1,21 @@ -import type { SizesType } from '@/shared/constants/sizes.ts'; +import type { LoaderSizeType } from '@/shared/constants/sizes.ts'; import LoaderView from '../view/LoaderView.ts'; class LoaderModel { private view: LoaderView; - constructor(size: SizesType) { + constructor(size: LoaderSizeType) { this.view = new LoaderView(size); } public getHTML(): HTMLDivElement { return this.view.getHTML(); } + + public setAbsolutePosition(): void { + this.view.setAbsolutePosition(); + } } export default LoaderModel; diff --git a/src/shared/Loader/view/LoaderView.ts b/src/shared/Loader/view/LoaderView.ts index b0cf276d..6388276c 100644 --- a/src/shared/Loader/view/LoaderView.ts +++ b/src/shared/Loader/view/LoaderView.ts @@ -1,6 +1,6 @@ -import type { SizesType } from '@/shared/constants/sizes.ts'; +import type { LoaderSizeType } from '@/shared/constants/sizes.ts'; -import { SIZES } from '@/shared/constants/sizes.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import styles from './loaderView.module.scss'; @@ -8,11 +8,11 @@ import styles from './loaderView.module.scss'; class LoaderView { private loader: HTMLDivElement; - constructor(size: SizesType) { + constructor(size: LoaderSizeType) { this.loader = this.createHTML(size); } - private createHTML(size: SizesType): HTMLDivElement { + private createHTML(size: LoaderSizeType): HTMLDivElement { this.loader = createBaseElement({ cssClasses: [styles.loader], tag: 'div', @@ -23,23 +23,30 @@ class LoaderView { return this.loader; } - private selectSize(size: SizesType): void { + private selectSize(size: LoaderSizeType): void { switch (size) { - case SIZES.SMALL: + case LOADER_SIZE.SMALL: this.setSmallStyle(); break; - case SIZES.MEDIUM: + case LOADER_SIZE.MEDIUM: this.setMediumStyle(); break; - case SIZES.LARGE: + case LOADER_SIZE.LARGE: this.setLargeStyle(); break; + case LOADER_SIZE.EXTRA_LARGE: + this.setExtraLargeStyle(); + break; default: this.setSmallStyle(); break; } } + private setExtraLargeStyle(): void { + this.loader.classList.add(styles.extraLarge); + } + private setLargeStyle(): void { this.loader.classList.add(styles.large); } @@ -55,6 +62,10 @@ class LoaderView { public getHTML(): HTMLDivElement { return this.loader; } + + public setAbsolutePosition(): void { + this.loader.classList.add(styles.absolute); + } } export default LoaderView; diff --git a/src/shared/Loader/view/loaderView.module.scss b/src/shared/Loader/view/loaderView.module.scss index 84018ff7..d46e3966 100644 --- a/src/shared/Loader/view/loaderView.module.scss +++ b/src/shared/Loader/view/loaderView.module.scss @@ -27,6 +27,13 @@ height: 40px; } +.extraLarge { + border: 10px solid var(--noble-gray-200); + border-top: 10px solid var(--steam-green-800); + width: 80px; + height: 80px; +} + @keyframes spin { 0% { transform: rotate(0deg); @@ -36,3 +43,10 @@ transform: rotate(360deg); } } + +.absolute { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} From b0822a113094f0e976a175da3de1a8d1c59856bf Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sat, 11 May 2024 15:30:39 +0300 Subject: [PATCH 02/10] fix: styles product card --- .../ProductCard/model/ProductCardModel.ts | 2 +- .../ProductCard/view/ProductCardView.ts | 51 ++++++++++++++----- .../view/productCardView.module.scss | 6 ++- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/entities/ProductCard/model/ProductCardModel.ts b/src/entities/ProductCard/model/ProductCardModel.ts index 634a4e74..2f4f016d 100644 --- a/src/entities/ProductCard/model/ProductCardModel.ts +++ b/src/entities/ProductCard/model/ProductCardModel.ts @@ -6,7 +6,7 @@ import ProductCardView from '../view/ProductCardView.ts'; class ProductCardModel { private view: ProductCardView; - constructor(params: ProductCardParams, size: SizeType) { + constructor(params: ProductCardParams, size: SizeType | null) { this.view = new ProductCardView(params, size); } diff --git a/src/entities/ProductCard/view/ProductCardView.ts b/src/entities/ProductCard/view/ProductCardView.ts index 3bc69dfe..643e7c5f 100644 --- a/src/entities/ProductCard/view/ProductCardView.ts +++ b/src/entities/ProductCard/view/ProductCardView.ts @@ -6,8 +6,9 @@ import LinkModel from '@/shared/Link/model/LinkModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; -import { MORE_TEXT } from '@/shared/constants/buttons.ts'; -import { SIZES } from '@/shared/constants/sizes.ts'; +import { LANGUAGE_CHOICE, MORE_TEXT } from '@/shared/constants/buttons.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import styles from './productCardView.module.scss'; @@ -35,9 +36,9 @@ class ProductCardView { private productShortDescription: HTMLParagraphElement; - private size: SizeType; + private size: SizeType | null; - constructor(params: ProductCardParams, size: SizeType) { + constructor(params: ProductCardParams, size: SizeType | null) { this.size = size; this.params = params; this.productImage = this.createProductImage(); @@ -62,10 +63,13 @@ class ProductCardView { } private createBasicPrice(): HTMLSpanElement { - const { discount, price } = this.params.variant.find(({ size }) => size === this.size) ?? {}; + const { discount, price } = this.size + ? this.params.variant.find(({ size }) => size === this.size) ?? {} + : this.params.variant[0]; + const innerContent = discount ? `$${discount.toFixed(2)}` : `$${price?.toFixed(2)}`; this.basicPrice = createBaseElement({ cssClasses: [styles.basicPrice], - innerContent: discount ? `$${discount.toFixed(2)}` : `$${price?.toFixed(2)}`, + innerContent, tag: 'span', }); @@ -114,10 +118,13 @@ class ProductCardView { } private createOldPrice(): HTMLSpanElement { - const { discount, price } = this.params.variant.find(({ size }) => size === this.size) ?? {}; + const { discount, price } = this.size + ? this.params.variant.find(({ size }) => size === this.size) ?? {} + : this.params.variant[0]; + const innerContent = discount ? `$${price?.toFixed(2)}` : ''; this.oldPrice = createBaseElement({ cssClasses: [styles.oldPrice], - innerContent: discount ? `$${price?.toFixed(2)}` : '', + innerContent, tag: 'span', }); @@ -137,6 +144,7 @@ class ProductCardView { private createProductImage(): HTMLImageElement { const productImage = createBaseElement({ attributes: { + alt: this.params.name[0].value, src: this.params.images[0], }, cssClasses: [styles.productImage], @@ -151,7 +159,7 @@ class ProductCardView { tag: 'div', }); - const loader = new LoaderModel(SIZES.MEDIUM).getHTML(); + const loader = new LoaderModel(LOADER_SIZE.MEDIUM).getHTML(); this.productImageWrapper.append(this.productImage, loader); this.productImage.classList.add(styles.hidden); @@ -172,28 +180,47 @@ class ProductCardView { this.productLink.getHTML().addEventListener('click', (event) => { event.preventDefault(); - window.location.href = this.params.key; + // TBD: fix href on product page + window.location.href = `${PAGE_ID.CATALOG_PAGE}/${this.params.key}`; }); return this.productLink; } private createProductName(): HTMLHeadingElement { + // TBD: replace on locale + const innerContent = this.params.name[getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? 0 : 1].value; const productName = createBaseElement({ cssClasses: [styles.productName], - innerContent: this.params.name[0].value, + innerContent, tag: 'h3', }); + + observeStore(selectCurrentLanguage, () => { + // TBD: replace on locale + const textContent = this.params.name[getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? 0 : 1].value; + productName.textContent = textContent; + }); return productName; } private createProductShortDescription(): HTMLParagraphElement { + // TBD: replace on locale + const innerContent = + this.params.description[getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? 0 : 1].value; this.productShortDescription = createBaseElement({ cssClasses: [styles.productShortDescription], - innerContent: this.params.description[0].value, + innerContent, tag: 'p', }); + observeStore(selectCurrentLanguage, () => { + // TBD: replace on locale + const textContent = + this.params.description[getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? 0 : 1].value; + this.productShortDescription.textContent = textContent; + }); + return this.productShortDescription; } diff --git a/src/entities/ProductCard/view/productCardView.module.scss b/src/entities/ProductCard/view/productCardView.module.scss index f67bdf04..5d6cde81 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -3,9 +3,11 @@ display: flex; flex-direction: column; align-items: center; + justify-self: center; outline: 2px solid var(--noble-gray-300); border-radius: var(--medium-br); min-height: 350px; + max-width: 270px; transition: outline 0.2s, transform 0.2s; @@ -58,7 +60,7 @@ max-width: 90%; font: var(--regular-font); letter-spacing: 1px; - text-align: center; + text-align: left; text-overflow: ellipsis; color: var(--noble-gray-400); -webkit-box-orient: vertical; @@ -79,7 +81,7 @@ position: relative; z-index: 1; align-self: flex-end; - margin-top: calc(var(--extra-small-offset) * -1); + margin-top: calc(var(--extra-small-offset) * (-0.2)); margin-right: 5px; font: var(--regular-font); letter-spacing: 1px; From 55f70ea9af892d3d6a2ef653783c9a5a15cfcadf Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sat, 11 May 2024 23:02:25 +0300 Subject: [PATCH 03/10] feat: implement saving Set into LS Co-authored-by: Meg G. --- src/shared/Store/Store.ts | 4 +-- src/shared/Store/actions.ts | 17 +++++++-- src/shared/Store/observer.ts | 36 +++++++++++++++++++ src/shared/Store/reducer.ts | 13 +++++-- src/shared/Store/test.spec.ts | 3 ++ src/shared/constants/initialState.ts | 3 ++ src/shared/constants/pages.ts | 2 ++ src/shared/constants/sizes.ts | 4 +-- src/shared/services/helper.ts | 42 +++++++++++++++++++++++ src/shared/services/localStorage.ts | 4 ++- src/shared/types/productFilters.ts | 12 +++++++ src/shared/types/validation/state.ts | 31 +++++++++++++++++ src/shared/utils/showBadRequestMessage.ts | 11 ++++++ 13 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/shared/services/helper.ts create mode 100644 src/shared/types/productFilters.ts create mode 100644 src/shared/types/validation/state.ts create mode 100644 src/shared/utils/showBadRequestMessage.ts diff --git a/src/shared/Store/Store.ts b/src/shared/Store/Store.ts index 12b9440f..0f344c47 100644 --- a/src/shared/Store/Store.ts +++ b/src/shared/Store/Store.ts @@ -1,8 +1,8 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ import type { Action, State } from './reducer.ts'; import type { Reducer, ReduxStore } from './types'; import initialState from '../constants/initialState.ts'; +import { parseToLoad } from '../services/helper.ts'; import { STORAGE_KEY, saveCurrentStateToLocalStorage } from '../services/localStorage.ts'; import { rootReducer } from './reducer.ts'; @@ -19,7 +19,7 @@ export class Store implements ReduxStore { let stateToSet: S; if (storedData) { - stateToSet = structuredClone(JSON.parse(storedData) as S); + stateToSet = structuredClone(parseToLoad(storedData)); } else { stateToSet = initialData; } diff --git a/src/shared/Store/actions.ts b/src/shared/Store/actions.ts index c95df970..931a4a16 100644 --- a/src/shared/Store/actions.ts +++ b/src/shared/Store/actions.ts @@ -1,4 +1,7 @@ +import type { LanguageChoiceType } from '../constants/buttons.ts'; +import type { PageIdType } from '../constants/pages.ts'; import type { Category, Product } from '../types/product'; +import type { SelectedFilters } from '../types/productFilters'; import type { User } from '../types/user'; const ACTION = { @@ -8,6 +11,7 @@ const ACTION = { SET_CURRENT_PAGE: 'setCurrentPage', SET_CURRENT_USER: 'setCurrentUser', SET_PRODUCTS: 'setProducts', + SET_SELECTED_FILTERS: 'setSelectedFilters', SET_SHIPPING_COUNTRY: 'setShippingCountry', SWITCH_APP_THEME: 'switchAppTheme', SWITCH_IS_USER_LOGGED_IN: 'switchIsUserLoggedIn', @@ -50,8 +54,8 @@ export const setShippingCountry = (value: string): ActionWithPayload => ({ + value: LanguageChoiceType, +): ActionWithPayload => ({ payload: value, type: ACTION.SET_CURRENT_LANGUAGE, }); @@ -63,7 +67,7 @@ export const switchIsUserLoggedIn = ( type: ACTION.SWITCH_IS_USER_LOGGED_IN, }); -export const setCurrentPage = (value: string): ActionWithPayload => ({ +export const setCurrentPage = (value: PageIdType): ActionWithPayload => ({ payload: value, type: ACTION.SET_CURRENT_PAGE, }); @@ -71,3 +75,10 @@ export const setCurrentPage = (value: string): ActionWithPayload => ({ type: ACTION.SWITCH_APP_THEME, }); + +export const setSelectedFilters = ( + value: SelectedFilters | null, +): ActionWithPayload => ({ + payload: value, + type: ACTION.SET_SELECTED_FILTERS, +}); diff --git a/src/shared/Store/observer.ts b/src/shared/Store/observer.ts index 2fe20901..34e35b0c 100644 --- a/src/shared/Store/observer.ts +++ b/src/shared/Store/observer.ts @@ -18,6 +18,35 @@ function observeStore(select: (state: State) => T, onChange: (selectedState: return unsubscribe; } +export function observeSetInStore(select: (state: State) => T, onChange: (selectedState: T) => void): VoidFunction { + let currentState = select(getStore().getState()); + function handleChange(): void { + const nextState = select(getStore().getState()); + if (isSet(currentState) && isSet(nextState) && !setsHaveEqualContent(currentState, nextState)) { + currentState = nextState; + onChange(currentState); + } + } + + const unsubscribe = getStore().subscribe(handleChange); + return unsubscribe; +} + +function isSet(value: unknown): value is Set { + return value instanceof Set; +} + +function setsHaveEqualContent(setA: Set, setB: Set): boolean { + if (setA.size !== setB.size) { + return false; + } + + const sortedA = Array.from(setA).sort(); + const sortedB = Array.from(setB).sort(); + + return sortedA.every((value, index) => value === sortedB[index]); +} + export const selectCurrentUser = (state: State): User | null => state.currentUser; export const selectBillingCountry = (state: State): string => state.billingCountry; @@ -30,4 +59,11 @@ export const selectIsUserLoggedIn = (state: State): boolean => state.isUserLogge export const selectCurrentPage = (state: State): string => state.currentPage; +export const selectSelectedFilters = (state: State): Set | null => { + if (state.selectedFilters) { + return state.selectedFilters.category; + } + return null; +}; + export default observeStore; diff --git a/src/shared/Store/reducer.ts b/src/shared/Store/reducer.ts index c9186dd1..69062267 100644 --- a/src/shared/Store/reducer.ts +++ b/src/shared/Store/reducer.ts @@ -1,5 +1,8 @@ /* eslint-disable max-lines-per-function */ +import type { LanguageChoiceType } from '../constants/buttons.ts'; +import type { PageIdType } from '../constants/pages.ts'; import type { Category, Product } from '../types/product.ts'; +import type { SelectedFilters } from '../types/productFilters.ts'; import type { User } from '../types/user.ts'; import type * as actions from './actions.ts'; import type { Reducer } from './types.ts'; @@ -7,12 +10,13 @@ import type { Reducer } from './types.ts'; export interface State { billingCountry: string; categories: Category[]; - currentLanguage: 'en' | 'ru'; - currentPage: string; // TBD Specify type + currentLanguage: LanguageChoiceType; + currentPage: PageIdType; currentUser: User | null; isAppThemeLight: boolean; isUserLoggedIn: boolean; products: Product[]; + selectedFilters: SelectedFilters | null; shippingCountry: string; } @@ -68,6 +72,11 @@ export const rootReducer: Reducer = (state: State, action: Action ...state, isAppThemeLight: !state.isAppThemeLight, }; + case 'setSelectedFilters': + return { + ...state, + selectedFilters: action.payload, + }; default: return state; } diff --git a/src/shared/Store/test.spec.ts b/src/shared/Store/test.spec.ts index ff727490..44de5608 100644 --- a/src/shared/Store/test.spec.ts +++ b/src/shared/Store/test.spec.ts @@ -96,6 +96,7 @@ vi.mock('./Store.ts', async (importOriginal) => { isAppThemeLight: true, isUserLoggedIn: false, products: [], + selectedFilters: null, shippingCountry: '', }), }; @@ -143,6 +144,7 @@ it('observeStore should call select and onChange when state changes', () => { isAppThemeLight: true, isUserLoggedIn: false, products: [], + selectedFilters: null, shippingCountry: '', }; @@ -172,6 +174,7 @@ describe('rootReducer', () => { isAppThemeLight: true, isUserLoggedIn: false, products: [], + selectedFilters: null, shippingCountry: '', }; }); diff --git a/src/shared/constants/initialState.ts b/src/shared/constants/initialState.ts index eabf69a0..5a95b53e 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/constants/initialState.ts @@ -9,6 +9,9 @@ const initialState: State = { isAppThemeLight: true, isUserLoggedIn: false, products: [], + selectedFilters: { + category: new Set(), + }, shippingCountry: '', }; diff --git a/src/shared/constants/pages.ts b/src/shared/constants/pages.ts index 87f4d57c..dac6be8f 100644 --- a/src/shared/constants/pages.ts +++ b/src/shared/constants/pages.ts @@ -75,3 +75,5 @@ export const PAGE_ID = { REGISTRATION_PAGE: 'register', USER_PROFILE_PAGE: 'profile', } as const; + +export type PageIdType = (typeof PAGE_ID)[keyof typeof PAGE_ID]; diff --git a/src/shared/constants/sizes.ts b/src/shared/constants/sizes.ts index 59ff07b3..3733c847 100644 --- a/src/shared/constants/sizes.ts +++ b/src/shared/constants/sizes.ts @@ -1,4 +1,4 @@ -export const SIZES = { +export const LOADER_SIZE = { EXTRA_LARGE: 'xl', EXTRA_SMALL: 'xs', LARGE: 'l', @@ -6,4 +6,4 @@ export const SIZES = { SMALL: 's', } as const; -export type SizesType = (typeof SIZES)[keyof typeof SIZES]; +export type LoaderSizeType = (typeof LOADER_SIZE)[keyof typeof LOADER_SIZE]; diff --git a/src/shared/services/helper.ts b/src/shared/services/helper.ts new file mode 100644 index 00000000..8abf423e --- /dev/null +++ b/src/shared/services/helper.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +const COLLECTION_FIELD_KEY = { + selectedFilters: 'selectedFilters', +} as const; + +const COLLECTION_FIELD_VALUE = { + category: 'category', +} as const; + +export default function stringifyToSave(state: S): string { + return JSON.stringify(state, (key, value) => (hasKey(COLLECTION_FIELD_KEY, key) ? collectionToArray(value) : value)); +} + +function hasKey(obj: T, key: K): key is K { + return key in obj; +} + +function collectionToArray(obj: Record): Record { + const newObj: Record = { ...obj }; + Object.entries(obj).forEach(([key, value]) => { + if (hasKey(COLLECTION_FIELD_VALUE, key) && value instanceof Set) { + newObj[key] = [...value]; + } + }); + return newObj; +} + +export function parseToLoad(state: string): S { + return JSON.parse(state, (key, value) => (hasKey(COLLECTION_FIELD_KEY, key) ? arrayToCollection(value) : value)) as S; +} + +function arrayToCollection(obj: Record): Record { + const newObj: Record = { ...obj }; + Object.entries(obj).forEach(([key, value]) => { + if (hasKey(COLLECTION_FIELD_VALUE, key) && value instanceof Array) { + newObj[key] = new Set(value); + } + }); + return newObj; +} diff --git a/src/shared/services/localStorage.ts b/src/shared/services/localStorage.ts index c4eae09b..f58dfede 100644 --- a/src/shared/services/localStorage.ts +++ b/src/shared/services/localStorage.ts @@ -1,7 +1,9 @@ +import stringifyToSave from './helper.ts'; + export const STORAGE_KEY = '3a981d01-2a98-4e10-8869-8b5839202195'; export function saveCurrentStateToLocalStorage(state: S): S { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + localStorage.setItem(STORAGE_KEY, stringifyToSave(state)); return state; } diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts new file mode 100644 index 00000000..b6e487d3 --- /dev/null +++ b/src/shared/types/productFilters.ts @@ -0,0 +1,12 @@ +import type { Category, Product } from './product.ts'; + +interface ProductFiltersParams { + categories: Category[]; + products: Product[]; +} + +export interface SelectedFilters { + category: Set; +} + +export default ProductFiltersParams; diff --git a/src/shared/types/validation/state.ts b/src/shared/types/validation/state.ts new file mode 100644 index 00000000..ba2f314b --- /dev/null +++ b/src/shared/types/validation/state.ts @@ -0,0 +1,31 @@ +import type { State } from '../../Store/reducer.ts'; + +import { isUser } from './user.ts'; + +const isValidState = (state: unknown): state is State => + typeof state === 'object' && + state !== null && + 'billingCountry' in state && + 'categories' in state && + 'currentLanguage' in state && + 'currentPage' in state && + 'currentUser' in state && + 'isAppThemeLight' in state && + 'isUserLoggedIn' in state && + 'products' in state && + 'selectedFilters' in state && + 'shippingCountry' in state && + typeof state.billingCountry === 'string' && + Array.isArray(state.categories) && + state.categories.every((category: unknown) => typeof category === 'object') && + (state.currentLanguage === 'en' || state.currentLanguage === 'ru') && + typeof state.currentPage === 'string' && + isUser(state.currentUser) && + typeof state.isAppThemeLight === 'boolean' && + typeof state.isUserLoggedIn === 'boolean' && + Array.isArray(state.products) && + state.products.every((product: unknown) => typeof product === 'object') && + (state.selectedFilters === null || typeof state.selectedFilters === 'object') && + typeof state.shippingCountry === 'string'; + +export default isValidState; diff --git a/src/shared/utils/showBadRequestMessage.ts b/src/shared/utils/showBadRequestMessage.ts new file mode 100644 index 00000000..434a565b --- /dev/null +++ b/src/shared/utils/showBadRequestMessage.ts @@ -0,0 +1,11 @@ +import serverMessageModel from '../ServerMessage/model/ServerMessageModel.ts'; +import getStore from '../Store/Store.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE } from '../constants/messages.ts'; + +const showBadRequestMessage = (): boolean => + serverMessageModel.showServerMessage( + SERVER_MESSAGE[getStore().getState().currentLanguage].BAD_REQUEST, + MESSAGE_STATUS.ERROR, + ); + +export default showBadRequestMessage; From 3589cd6aae426c95e55d71be8568e525ad92c766 Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sat, 11 May 2024 23:17:26 +0300 Subject: [PATCH 04/10] feat: get and draw product items --- src/widgets/Catalog/model/CatalogModel.ts | 105 +++++++++++++----- .../Catalog/view/catalogView.module.scss | 19 +++- src/widgets/LoginForm/model/LoginFormModel.ts | 6 +- .../model/RegistrationFormModel.ts | 6 +- 4 files changed, 98 insertions(+), 38 deletions(-) diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index 23ce57d2..fb3d01f0 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -1,49 +1,100 @@ +import type { Category, Product } from '@/shared/types/product.ts'; + import ProductCardModel from '@/entities/ProductCard/model/ProductCardModel.ts'; +import ProductFiltersModel from '@/features/ProductFilters/model/ProductFiltersModel.ts'; import getProductModel from '@/shared/API/product/model/ProductModel.ts'; import addFilter from '@/shared/API/product/utils/filter.ts'; import { FilterFields, type OptionsRequest } from '@/shared/API/types/type.ts'; -import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE } from '@/shared/constants/messages.ts'; -import { SIZE } from '@/shared/types/product.ts'; +import { observeSetInStore, selectSelectedFilters } from '@/shared/Store/observer.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import showBadRequestMessage from '@/shared/utils/showBadRequestMessage.ts'; import CatalogView from '../view/CatalogView.ts'; class CatalogModel { + private productFilters: ProductFiltersModel | null = null; + private view = new CatalogView(); constructor() { - this.getProductItems(); + this.init(); } - private getProductItems(): void { + private async getProductItems( + options: OptionsRequest, + ): Promise<{ categories: Category[] | null; products: Product[] | null } | null> { const productList = this.view.getItemsList(); + const loader = new LoaderModel(LOADER_SIZE.EXTRA_LARGE); + loader.setAbsolutePosition(); + productList.append(loader.getHTML()); + + try { + const categories = await getProductModel().getCategories(); + try { + const products = await getProductModel().getProducts(options); + return { categories, products }; + } catch { + showBadRequestMessage(); + } + } catch { + showBadRequestMessage(); + } finally { + loader.getHTML().remove(); + } + + return null; + } + + private getSelectedFilters(): OptionsRequest { + const filter: OptionsRequest['filter'] = []; + getStore() + .getState() + .selectedFilters?.category.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); + return { filter, limit: 100, sort: { direction: 'desc', field: 'name', locale: 'en' } }; + } - const options: OptionsRequest = { - filter: [addFilter(FilterFields.SIZE, 'S')], - limit: 5, - sort: { - direction: 'asc', - field: 'name', - locale: 'en', - }, - }; - - getProductModel() - .getProducts(options) + private init(): void { + const productList = this.view.getItemsList(); + this.getProductItems(this.getSelectedFilters()) + .then((data) => { + if (!data?.products?.length) { + productList.textContent = 'Ничего не найдено'; + } + data?.products?.forEach((productData) => productList.append(new ProductCardModel(productData, null).getHTML())); + this.productFilters = new ProductFiltersModel({ + categories: data?.categories ?? [], + products: data?.products ?? [], + }); + this.getHTML().append(this.productFilters.getHTML()); + }) + .catch(() => { + showBadRequestMessage(); + }); + + observeSetInStore(selectSelectedFilters, () => { + this.redrawProductList(this.getSelectedFilters()); + }); + } + + private redrawProductList(options?: OptionsRequest): void { + const productList = this.view.getItemsList(); + productList.innerHTML = ''; + this.getProductItems(options ?? {}) .then((data) => { - if (data) { - data.forEach((productData) => { - productList.append(new ProductCardModel(productData, SIZE.S).getHTML()); - }); + if (data?.products?.length) { + data?.products?.forEach((productData) => + productList.append(new ProductCardModel(productData, null).getHTML()), + ); + this.productFilters?.updateParams({ categories: data?.categories ?? [], products: data?.products ?? [] }); + } else { + productList.textContent = 'Ничего не найдено'; } }) - .catch(() => - serverMessageModel.showServerMessage( - SERVER_MESSAGE[getStore().getState().currentLanguage].BAD_REQUEST, - MESSAGE_STATUS.ERROR, - ), - ); + .catch(() => { + showBadRequestMessage(); + }); } public getHTML(): HTMLDivElement { diff --git a/src/widgets/Catalog/view/catalogView.module.scss b/src/widgets/Catalog/view/catalogView.module.scss index fcc90211..a24c2e82 100644 --- a/src/widgets/Catalog/view/catalogView.module.scss +++ b/src/widgets/Catalog/view/catalogView.module.scss @@ -1,12 +1,21 @@ +.catalog { + position: relative; + display: flex; + justify-content: space-between; + gap: var(--small-offset); +} + .itemsList { + position: relative; display: grid; - grid-template-columns: repeat(4, 1fr); + align-items: stretch; + justify-content: center; + order: 2; + grid-template-columns: repeat(3, 1fr); + width: 80%; + min-height: 500px; gap: var(--small-offset); - @media (max-width: 1140px) { - grid-template-columns: repeat(3, 1fr); - } - @media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); } diff --git a/src/widgets/LoginForm/model/LoginFormModel.ts b/src/widgets/LoginForm/model/LoginFormModel.ts index d5b7f336..bccccc4b 100644 --- a/src/widgets/LoginForm/model/LoginFormModel.ts +++ b/src/widgets/LoginForm/model/LoginFormModel.ts @@ -8,7 +8,7 @@ import getStore from '@/shared/Store/Store.ts'; import { setCurrentUser } from '@/shared/Store/actions.ts'; import { INPUT_TYPE, PASSWORD_TEXT } from '@/shared/constants/forms.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE } from '@/shared/constants/messages.ts'; -import { SIZES } from '@/shared/constants/sizes.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import { createGreetingMessage } from '@/shared/utils/messageTemplate.ts'; import LoginFormView from '../view/LoginFormView.ts'; @@ -38,7 +38,7 @@ class LoginFormModel { private loginUser(userLoginData: UserCredentials): void { this.view.getSubmitFormButton().setDisabled(); - const loader = new LoaderModel(SIZES.SMALL).getHTML(); + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); this.view.getSubmitFormButton().getHTML().append(loader); getCustomerModel() .hasEmail(userLoginData.email) @@ -62,7 +62,7 @@ class LoginFormModel { } private loginUserHandler(userLoginData: UserCredentials): void { - const loader = new LoaderModel(SIZES.SMALL).getHTML(); + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); this.view.getSubmitFormButton().getHTML().append(loader); getCustomerModel() .authCustomer(userLoginData) diff --git a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts index 4c54e0a9..4409d9f9 100644 --- a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts +++ b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts @@ -10,7 +10,7 @@ import getStore from '@/shared/Store/Store.ts'; import { setBillingCountry, setCurrentUser, switchIsUserLoggedIn } from '@/shared/Store/actions.ts'; import { INPUT_TYPE, PASSWORD_TEXT } from '@/shared/constants/forms.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE } from '@/shared/constants/messages.ts'; -import { SIZES } from '@/shared/constants/sizes.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import { ADDRESS_TYPE } from '@/shared/types/address.ts'; import formattedText from '@/shared/utils/formattedText.ts'; @@ -118,7 +118,7 @@ class RegisterFormModel { } private registerUser(): void { - const loader = new LoaderModel(SIZES.SMALL).getHTML(); + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); this.view.getSubmitFormButton().getHTML().append(loader); getCustomerModel() .registerNewCustomer(this.getFormUserData()) @@ -206,7 +206,7 @@ class RegisterFormModel { } private successfulUserRegistration(newUserData: User): void { - const loader = new LoaderModel(SIZES.SMALL).getHTML(); + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); this.view.getSubmitFormButton().getHTML().append(loader); this.updateUserData(newUserData) .then(() => getStore().dispatch(switchIsUserLoggedIn(true))) From fe63913d074a09f401064760e0179ec4943b1d74 Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sat, 11 May 2024 23:18:04 +0300 Subject: [PATCH 05/10] feat: create ProductFilters component --- src/app/App/model/AppModel.ts | 8 +- .../model/ProductFiltersModel.ts | 72 +++++++++ .../ProductFilters/view/ProductFiltersView.ts | 151 ++++++++++++++++++ .../view/productFiltersView.module.scss | 59 +++++++ 4 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 src/features/ProductFilters/model/ProductFiltersModel.ts create mode 100644 src/features/ProductFilters/view/ProductFiltersView.ts create mode 100644 src/features/ProductFilters/view/productFiltersView.module.scss diff --git a/src/app/App/model/AppModel.ts b/src/app/App/model/AppModel.ts index 5afc6d2a..2dcf3a4c 100644 --- a/src/app/App/model/AppModel.ts +++ b/src/app/App/model/AppModel.ts @@ -14,11 +14,9 @@ class AppModel { private router = new RouterModel(); constructor() { - this.initialize() - .then() - .catch(() => { - throw new Error('AppModel initialization error'); - }); + this.initialize().catch(() => { + throw new Error('AppModel initialization error'); + }); } private createRoutes(): Promise Promise>> { diff --git a/src/features/ProductFilters/model/ProductFiltersModel.ts b/src/features/ProductFilters/model/ProductFiltersModel.ts new file mode 100644 index 00000000..7fd09786 --- /dev/null +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -0,0 +1,72 @@ +import type LinkModel from '@/shared/Link/model/LinkModel.ts'; +import type ProductFiltersParams from '@/shared/types/productFilters.ts'; +import type { SelectedFilters } from '@/shared/types/productFilters.ts'; + +import getStore from '@/shared/Store/Store.ts'; +import { setSelectedFilters } from '@/shared/Store/actions.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; + +import ProductFiltersView from '../view/ProductFiltersView.ts'; + +class ProductFiltersModel { + private selectedFilters: SelectedFilters = { + category: new Set(), + }; + + private view: ProductFiltersView; + + constructor(params: ProductFiltersParams) { + this.view = new ProductFiltersView(params); + this.init(); + } + + private init(): void { + this.initCategoryFilters(); + + observeStore(selectCurrentLanguage, () => { + this.initCategoryFilters(); + }); + } + + private initCategoryFilters(): void { + this.setCategoryLinksHandlers(); + this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; + const categoryLinks = this.view.getCategoryLinks(); + this.selectedFilters.category.forEach((categoryID) => { + const currentLink = categoryLinks.find((link) => link.getHTML().id === categoryID); + if (currentLink) { + this.view.switchSelectCategory(currentLink); + } + }); + } + + private setCategoryLinksHandlers(): void { + this.view.getCategoryLinks().forEach((categoryLink) => { + categoryLink.getHTML().addEventListener('click', () => { + this.view.switchSelectCategory(categoryLink); + const categoryID = categoryLink.getHTML().id; + if (this.selectedFilters.category.has(categoryID)) { + this.selectedFilters.category.delete(categoryID); + } else { + this.selectedFilters.category.add(categoryID); + } + + getStore().dispatch(setSelectedFilters(this.selectedFilters)); + }); + }); + } + + public getCategoryLinks(): LinkModel[] { + return this.view.getCategoryLinks(); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } + + public updateParams(params: ProductFiltersParams): void { + this.view.updateParams(params); + } +} + +export default ProductFiltersModel; diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts new file mode 100644 index 00000000..81697c4c --- /dev/null +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -0,0 +1,151 @@ +import type { Category } from '@/shared/types/product'; +import type ProductFiltersParams from '@/shared/types/productFilters'; + +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/buttons.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import styles from './productFiltersView.module.scss'; + +const BASE_CATEGORY_LINK_COUNT = '(0)'; +const TEXT_RADIX = 10; + +class ProductFiltersView { + private categoryCountSpan: HTMLSpanElement[] = []; + + private categoryLinks: LinkModel[] = []; + + private categoryList: HTMLUListElement; + + private filters: HTMLDivElement; + + private params: ProductFiltersParams; + + constructor(params: ProductFiltersParams) { + this.params = params; + this.categoryList = this.createCategoryList(); + this.filters = this.createHTML(); + } + + private createCategoryLink(category: Category): LinkModel { + const text = category.name[getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? 0 : 1].value; + const categoryLink = new LinkModel({ + attrs: { + href: category.key, + id: category.id, + }, + classes: [styles.categoryLink], + text, + }); + + categoryLink.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + }); + + const span = createBaseElement({ + attributes: { + id: category.id, + }, + cssClasses: [styles.categoryLinkCount], + innerContent: BASE_CATEGORY_LINK_COUNT, + tag: 'span', + }); + + this.categoryCountSpan.push(span); + categoryLink.getHTML().append(span); + + this.params.products.forEach((product) => { + product.category.forEach((categoryEl) => { + if (category.key === categoryEl.key) { + const text = span.innerText.match(/\d+/); + const updatedText = text ? `(${Number(parseInt(text[0], TEXT_RADIX)) + 1})` : BASE_CATEGORY_LINK_COUNT; + span.innerText = updatedText; + } + }); + }); + this.categoryLinks.push(categoryLink); + return categoryLink; + } + + private createCategoryList(): HTMLUListElement { + this.categoryList = createBaseElement({ + cssClasses: [styles.categoryList], + tag: 'ul', + }); + + const title = createBaseElement({ + cssClasses: [styles.categoryTitle], + innerContent: 'Categories', + tag: 'h3', + }); + this.categoryList.append(title); + + this.params.categories.forEach((category) => { + const li = createBaseElement({ + cssClasses: [styles.categoryItem], + tag: 'li', + }); + + li.append(this.createCategoryLink(category).getHTML()); + this.categoryList.append(li); + + observeStore(selectCurrentLanguage, () => { + this.categoryCountSpan.length = 0; + this.categoryLinks.length = 0; + this.categoryList.innerHTML = ''; + this.categoryList.append(title); + this.params.categories.forEach((category) => { + this.categoryList.append(this.createCategoryLink(category).getHTML()); + }); + }); + }); + + return this.categoryList; + } + + private createHTML(): HTMLDivElement { + this.filters = createBaseElement({ + cssClasses: [styles.filters], + tag: 'div', + }); + + this.filters.append(this.categoryList); + + return this.filters; + } + + public getCategoryLinks(): LinkModel[] { + return this.categoryLinks; + } + + public getHTML(): HTMLDivElement { + return this.filters; + } + + public switchSelectCategory(categoryLink: LinkModel): void { + categoryLink.getHTML().classList.toggle(styles.activeLink); + } + + public updateParams(params: ProductFiltersParams): void { + this.params = params; + this.params.categories.forEach((category) => { + const currentSpan = this.categoryCountSpan.find((span) => span.id === category.id) ?? null; + if (currentSpan) { + currentSpan.innerText = BASE_CATEGORY_LINK_COUNT; + } + this.params.products.forEach((product) => { + product.category.forEach((categoryEl) => { + if (category.id === categoryEl.id && currentSpan) { + const text = currentSpan?.innerText.match(/\d+/); + const updatedText = text ? `(${Number(parseInt(text[0], TEXT_RADIX)) + 1})` : BASE_CATEGORY_LINK_COUNT; + currentSpan.innerText = updatedText; + } + }); + }); + }); + } +} + +export default ProductFiltersView; diff --git a/src/features/ProductFilters/view/productFiltersView.module.scss b/src/features/ProductFilters/view/productFiltersView.module.scss new file mode 100644 index 00000000..597cd8b1 --- /dev/null +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -0,0 +1,59 @@ +.filters { + position: sticky; + top: 100px; + order: 1; + border-radius: var(--medium-br); + width: 20%; + height: max-content; + background-color: var(--noble-white-200); +} + +.categoryList { + display: flex; + flex-direction: column; + align-items: center; + border-radius: var(--medium-br); + width: 100%; + height: 70%; + background-color: var(--noble-white-200); +} + +.categoryTitle { + padding: calc(var(--extra-small-offset)); + width: 100%; + font: var(--medium-font); + letter-spacing: 1px; + text-align: left; + color: var(--steam-green-500); +} + +.categoryItem { + width: 100%; +} + +.categoryLink { + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(var(--small-offset) / 2) var(--extra-small-offset); + width: 100%; + font: var(--regular-font); + letter-spacing: 1px; + color: var(--noble-gray-800); + transition: + color 0.2s, + background-color 0.2s; + + @media (hover: hover) { + &:hover { + color: var(--steam-green-400); + background-color: var(--noble-white-100); + } + } + + &.activeLink { + color: var(--steam-green-400); + background-color: var(--noble-white-100); + opacity: 1; + } +} From 1f681602a5d6d2ca6a9ba129ac14be5a1fbdd46c Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sun, 12 May 2024 04:13:19 +0300 Subject: [PATCH 06/10] feat: implement price filter --- package.json | 1 + src/app/styles/noUiSlider.scss | 100 ++++++++++++++++ .../model/ProductFiltersModel.ts | 36 ++++++ .../ProductFilters/view/ProductFiltersView.ts | 109 +++++++++++++++--- .../view/productFiltersView.module.scss | 57 ++++++++- src/shared/Input/view/InputView.ts | 1 + src/shared/Store/observer.ts | 9 +- src/shared/constants/initialState.ts | 1 + src/shared/types/form.ts | 1 + src/shared/types/productFilters.ts | 12 +- src/styles.scss | 1 + src/widgets/Catalog/model/CatalogModel.ts | 41 ++++--- 12 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 src/app/styles/noUiSlider.scss diff --git a/package.json b/package.json index 204f3752..f27e973c 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "js-cookie": "^3.0.5", "materialize-css": "^1.0.0-rc.2", "modern-normalize": "^2.0.0", + "nouislider": "^15.7.1", "postcode-validator": "^3.8.20", "vite-plugin-checker": "^0.6.4", "vite-plugin-image-optimizer": "^1.1.7", diff --git a/src/app/styles/noUiSlider.scss b/src/app/styles/noUiSlider.scss new file mode 100644 index 00000000..d1302f9c --- /dev/null +++ b/src/app/styles/noUiSlider.scss @@ -0,0 +1,100 @@ +/* stylelint-disable selector-class-pattern */ +.noUi-touch-area { + width: 100%; + height: 100%; +} + +.noUi-handle.noUi-handle-lower, +.noUi-handle.noUi-handle-upper { + outline: 0; +} + +.noUi-horizontal .noUi-handle { + right: -12px; + top: -5px; + width: var(--extra-small-offset); + height: var(--extra-small-offset); +} + +.noUi-handle { + position: absolute; + border: 2px solid var(--noble-white-100); + border-radius: 50%; + box-shadow: none; + background-color: var(--steam-green-800); + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transition: background-color 0.3; + cursor: pointer; +} + +.noUi-target, +.noUi-target * { + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + -ms-touch-action: none; + touch-action: none; + -webkit-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +.noUi-horizontal .noUi-origin { + height: 0; +} + +.noUi-connect, +.noUi-origin { + position: absolute; + right: 0; + top: 0; + z-index: 1; + width: 100%; + height: 100%; + -ms-transform-origin: 0 0; + -webkit-transform-origin: 0 0; + transform-origin: 0 0; + -webkit-transform-style: preserve-3d; + transform-style: flat; + will-change: transform; +} + +.noUi-connect { + height: 8px; + background: var(--steam-green-800); + cursor: pointer; +} + +.noUi-base, +.noUi-connects { + position: relative; + z-index: 1; + width: 100%; + height: 100%; +} + +.noUi-connects { + z-index: 0; + overflow: hidden; + border-radius: 3px; +} + +.noUi-target { + position: relative; + border-radius: 4px; + background: var(--steam-green-400); + cursor: pointer; +} + +.noUi-horizontal { + height: 8px; +} + +.noUi-state-tap .noUi-connect, +.noUi-state-tap .noUi-origin { + -webkit-transition: transform 0.3s; + transition: transform 0.3s; +} diff --git a/src/features/ProductFilters/model/ProductFiltersModel.ts b/src/features/ProductFilters/model/ProductFiltersModel.ts index 7fd09786..07344dce 100644 --- a/src/features/ProductFilters/model/ProductFiltersModel.ts +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -11,6 +11,10 @@ import ProductFiltersView from '../view/ProductFiltersView.ts'; class ProductFiltersModel { private selectedFilters: SelectedFilters = { category: new Set(), + price: { + max: 0, + min: 0, + }, }; private view: ProductFiltersView; @@ -22,6 +26,7 @@ class ProductFiltersModel { private init(): void { this.initCategoryFilters(); + this.initPriceFilters(); observeStore(selectCurrentLanguage, () => { this.initCategoryFilters(); @@ -40,6 +45,37 @@ class ProductFiltersModel { }); } + private initPriceFilters(): void { + const fromInput = this.view.getPriceInputs().get('from'); + const toInput = this.view.getPriceInputs().get('to'); + const priceSlider = this.view.getPriceSlider(); + if (!fromInput || !toInput) { + return; + } + priceSlider.on('update', (values) => { + const [min, max] = values; + fromInput.getHTML().value = min.toString(); + toInput.getHTML().value = max.toString(); + }); + + priceSlider.on('set', (values) => { + const [min, max] = values; + this.selectedFilters.price = { + max: +max, + min: +min, + }; + + getStore().dispatch(setSelectedFilters(this.selectedFilters)); + }); + + fromInput + .getHTML() + .addEventListener('change', () => priceSlider.set([fromInput.getHTML().value, toInput.getHTML().value])); + toInput + .getHTML() + .addEventListener('change', () => priceSlider.set([fromInput.getHTML().value, toInput.getHTML().value])); + } + private setCategoryLinksHandlers(): void { this.view.getCategoryLinks().forEach((categoryLink) => { categoryLink.getHTML().addEventListener('click', () => { diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index 81697c4c..c8844d31 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -1,11 +1,14 @@ import type { Category } from '@/shared/types/product'; import type ProductFiltersParams from '@/shared/types/productFilters'; +import InputModel from '@/shared/Input/model/InputModel.ts'; import LinkModel from '@/shared/Link/model/LinkModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/buttons.ts'; +import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import * as noUiSlider from 'nouislider'; import styles from './productFiltersView.module.scss'; @@ -23,9 +26,14 @@ class ProductFiltersView { private params: ProductFiltersParams; + private priceInputs: Map = new Map(); + + private priceSlider: noUiSlider.API; + constructor(params: ProductFiltersParams) { this.params = params; this.categoryList = this.createCategoryList(); + this.priceSlider = this.createPriceSlider(); this.filters = this.createHTML(); } @@ -56,7 +64,7 @@ class ProductFiltersView { this.categoryCountSpan.push(span); categoryLink.getHTML().append(span); - this.params.products.forEach((product) => { + this.params.products?.forEach((product) => { product.category.forEach((categoryEl) => { if (category.key === categoryEl.key) { const text = span.innerText.match(/\d+/); @@ -82,7 +90,7 @@ class ProductFiltersView { }); this.categoryList.append(title); - this.params.categories.forEach((category) => { + this.params.categories?.forEach((category) => { const li = createBaseElement({ cssClasses: [styles.categoryItem], tag: 'li', @@ -90,15 +98,15 @@ class ProductFiltersView { li.append(this.createCategoryLink(category).getHTML()); this.categoryList.append(li); + }); - observeStore(selectCurrentLanguage, () => { - this.categoryCountSpan.length = 0; - this.categoryLinks.length = 0; - this.categoryList.innerHTML = ''; - this.categoryList.append(title); - this.params.categories.forEach((category) => { - this.categoryList.append(this.createCategoryLink(category).getHTML()); - }); + observeStore(selectCurrentLanguage, () => { + this.categoryCountSpan.length = 0; + this.categoryLinks.length = 0; + this.categoryList.innerHTML = ''; + this.categoryList.append(title); + this.params.categories?.forEach((category) => { + this.categoryList.append(this.createCategoryLink(category).getHTML()); }); }); @@ -111,11 +119,78 @@ class ProductFiltersView { tag: 'div', }); - this.filters.append(this.categoryList); + this.filters.append(this.categoryList, this.createPriceWrapper(), this.priceSlider.target); return this.filters; } + private createPriceLabel(direction: string): HTMLLabelElement { + const priceLabel = createBaseElement({ + attributes: { + for: direction, + }, + cssClasses: [styles.priceLabel], + tag: 'label', + }); + + const priceSpan = createBaseElement({ + cssClasses: [styles.priceSpan], + innerContent: direction, + tag: 'span', + }); + + const minPrice = this.params.priceRange?.min.toFixed(2) ?? ''; + const maxPrice = this.params.priceRange?.max.toFixed(2) ?? ''; + + const priceInput = new InputModel({ + autocomplete: 'off', + id: direction, + placeholder: direction === 'from' ? minPrice : maxPrice, + type: INPUT_TYPE.NUMBER, + value: direction === 'from' ? minPrice : maxPrice, + }); + priceInput.getHTML().classList.add(styles.priceInput, styles[direction]); + this.priceInputs.set(direction, priceInput); + priceLabel.append(priceSpan, priceInput.getHTML()); + return priceLabel; + } + + private createPriceSlider(): noUiSlider.API { + const slider = createBaseElement({ + attributes: { + id: 'slider', + }, + cssClasses: [styles.slider], + tag: 'div', + }); + + this.priceSlider = noUiSlider.create(slider, { + behaviour: 'tap', + connect: true, + keyboardSupport: true, + range: this.params.priceRange ?? { max: 100, min: 0 }, + start: [40, 320], + }); + + return this.priceSlider; + } + + private createPriceWrapper(): HTMLDivElement { + const priceWrapper = createBaseElement({ + cssClasses: [styles.priceWrapper], + tag: 'div', + }); + + const title = createBaseElement({ + cssClasses: [styles.priceTitle], + innerContent: 'Price range', + tag: 'h3', + }); + + priceWrapper.append(title, this.createPriceLabel('from'), this.createPriceLabel('to')); + return priceWrapper; + } + public getCategoryLinks(): LinkModel[] { return this.categoryLinks; } @@ -124,18 +199,26 @@ class ProductFiltersView { return this.filters; } + public getPriceInputs(): Map { + return this.priceInputs; + } + + public getPriceSlider(): noUiSlider.API { + return this.priceSlider; + } + public switchSelectCategory(categoryLink: LinkModel): void { categoryLink.getHTML().classList.toggle(styles.activeLink); } public updateParams(params: ProductFiltersParams): void { this.params = params; - this.params.categories.forEach((category) => { + this.params.categories?.forEach((category) => { const currentSpan = this.categoryCountSpan.find((span) => span.id === category.id) ?? null; if (currentSpan) { currentSpan.innerText = BASE_CATEGORY_LINK_COUNT; } - this.params.products.forEach((product) => { + this.params.products?.forEach((product) => { product.category.forEach((categoryEl) => { if (category.id === categoryEl.id && currentSpan) { const text = currentSpan?.innerText.match(/\d+/); diff --git a/src/features/ProductFilters/view/productFiltersView.module.scss b/src/features/ProductFilters/view/productFiltersView.module.scss index 597cd8b1..55e38da2 100644 --- a/src/features/ProductFilters/view/productFiltersView.module.scss +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -2,9 +2,11 @@ position: sticky; top: 100px; order: 1; + overflow: scroll; border-radius: var(--medium-br); width: 20%; height: max-content; + max-height: 500px; background-color: var(--noble-white-200); } @@ -18,7 +20,8 @@ background-color: var(--noble-white-200); } -.categoryTitle { +.categoryTitle, +.priceTitle { padding: calc(var(--extra-small-offset)); width: 100%; font: var(--medium-font); @@ -57,3 +60,55 @@ opacity: 1; } } + +.priceWrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.priceInput { + border: 2px solid var(--noble-gray-800); + border-radius: var(--small-br); + padding: calc(var(--extra-small-offset) / 2); + width: 85%; + height: var(--extra-small-offset); + max-width: 150px; + font: var(--regular-font); + letter-spacing: 1px; + color: var(--steam-green-800); + background-color: var(--noble-white-200); + transition: + border-color 0.2s, + background-color 0.2s; + + &:focus { + border-color: var(--steam-green-400); + } + + @media (hover: hover) { + &:hover { + border-color: var(--steam-green-400); + background-color: var(--noble-white-100); + } + } + + &[type='number']::-webkit-outer-spin-button, + &[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} + +.priceLabel { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--extra-small-offset); + padding: 0 var(--extra-small-offset); + width: 100%; + font: var(--regular-font); + letter-spacing: 1px; + color: var(--noble-gray-800); + gap: var(--extra-small-offset); +} diff --git a/src/shared/Input/view/InputView.ts b/src/shared/Input/view/InputView.ts index 4d6bdce8..6be448fb 100644 --- a/src/shared/Input/view/InputView.ts +++ b/src/shared/Input/view/InputView.ts @@ -17,6 +17,7 @@ class InputView { lang: attrs.lang || '', placeholder: attrs.placeholder || '', type: attrs.type, + value: attrs.value || '', }, tag: 'input', }); diff --git a/src/shared/Store/observer.ts b/src/shared/Store/observer.ts index 34e35b0c..e85acc6d 100644 --- a/src/shared/Store/observer.ts +++ b/src/shared/Store/observer.ts @@ -59,11 +59,18 @@ export const selectIsUserLoggedIn = (state: State): boolean => state.isUserLogge export const selectCurrentPage = (state: State): string => state.currentPage; -export const selectSelectedFilters = (state: State): Set | null => { +export const selectSelectedFiltersCategory = (state: State): Set | null => { if (state.selectedFilters) { return state.selectedFilters.category; } return null; }; +export const selectSelectedFiltersPrice = (state: State): { max: number; min: number } | null => { + if (state.selectedFilters) { + return state.selectedFilters.price; + } + return null; +}; + export default observeStore; diff --git a/src/shared/constants/initialState.ts b/src/shared/constants/initialState.ts index 5a95b53e..f50ddde4 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/constants/initialState.ts @@ -11,6 +11,7 @@ const initialState: State = { products: [], selectedFilters: { category: new Set(), + price: null, }, shippingCountry: '', }; diff --git a/src/shared/types/form.ts b/src/shared/types/form.ts index d703acf4..f04ef7da 100644 --- a/src/shared/types/form.ts +++ b/src/shared/types/form.ts @@ -4,6 +4,7 @@ export interface InputParams { lang?: string; placeholder: null | string; type: 'checkbox' | 'color' | 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text'; + value?: null | string; } export interface LabelParams { diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts index b6e487d3..843b2a0d 100644 --- a/src/shared/types/productFilters.ts +++ b/src/shared/types/productFilters.ts @@ -1,12 +1,20 @@ import type { Category, Product } from './product.ts'; interface ProductFiltersParams { - categories: Category[]; - products: Product[]; + categories: Category[] | null; + priceRange: { + max: number; + min: number; + } | null; + products: Product[] | null; } export interface SelectedFilters { category: Set; + price: { + max: number; + min: number; + } | null; } export default ProductFiltersParams; diff --git a/src/styles.scss b/src/styles.scss index f34f02b5..b3c6d2dd 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2,3 +2,4 @@ @import 'src/app/styles/common'; @import 'src/app/styles/variables'; @import 'src/app/styles/fonts'; +@import 'src/app/styles/noUiSlider'; diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index fb3d01f0..ee7df9bb 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -1,4 +1,4 @@ -import type { Category, Product } from '@/shared/types/product.ts'; +import type ProductFiltersParams from '@/shared/types/productFilters.ts'; import ProductCardModel from '@/entities/ProductCard/model/ProductCardModel.ts'; import ProductFiltersModel from '@/features/ProductFilters/model/ProductFiltersModel.ts'; @@ -7,7 +7,11 @@ import addFilter from '@/shared/API/product/utils/filter.ts'; import { FilterFields, type OptionsRequest } from '@/shared/API/types/type.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { observeSetInStore, selectSelectedFilters } from '@/shared/Store/observer.ts'; +import observeStore, { + observeSetInStore, + selectSelectedFiltersCategory, + selectSelectedFiltersPrice, +} from '@/shared/Store/observer.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import showBadRequestMessage from '@/shared/utils/showBadRequestMessage.ts'; @@ -22,9 +26,7 @@ class CatalogModel { this.init(); } - private async getProductItems( - options: OptionsRequest, - ): Promise<{ categories: Category[] | null; products: Product[] | null } | null> { + private async getProductItems(options: OptionsRequest): Promise { const productList = this.view.getItemsList(); const loader = new LoaderModel(LOADER_SIZE.EXTRA_LARGE); loader.setAbsolutePosition(); @@ -34,7 +36,8 @@ class CatalogModel { const categories = await getProductModel().getCategories(); try { const products = await getProductModel().getProducts(options); - return { categories, products }; + const priceRange = await getProductModel().getPriceRange(); + return { categories, priceRange, products }; } catch { showBadRequestMessage(); } @@ -48,10 +51,12 @@ class CatalogModel { } private getSelectedFilters(): OptionsRequest { + const { category, price } = getStore().getState().selectedFilters || {}; const filter: OptionsRequest['filter'] = []; - getStore() - .getState() - .selectedFilters?.category.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); + category?.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); + if (price) { + filter.push(addFilter(FilterFields.PRICE, price)); + } return { filter, limit: 100, sort: { direction: 'desc', field: 'name', locale: 'en' } }; } @@ -61,19 +66,21 @@ class CatalogModel { .then((data) => { if (!data?.products?.length) { productList.textContent = 'Ничего не найдено'; + } else { + data.products.forEach((productData) => productList.append(new ProductCardModel(productData, null).getHTML())); + this.productFilters = new ProductFiltersModel(data); + this.getHTML().append(this.productFilters.getHTML()); } - data?.products?.forEach((productData) => productList.append(new ProductCardModel(productData, null).getHTML())); - this.productFilters = new ProductFiltersModel({ - categories: data?.categories ?? [], - products: data?.products ?? [], - }); - this.getHTML().append(this.productFilters.getHTML()); }) .catch(() => { showBadRequestMessage(); }); - observeSetInStore(selectSelectedFilters, () => { + observeSetInStore(selectSelectedFiltersCategory, () => { + this.redrawProductList(this.getSelectedFilters()); + }); + + observeStore(selectSelectedFiltersPrice, () => { this.redrawProductList(this.getSelectedFilters()); }); } @@ -87,7 +94,7 @@ class CatalogModel { data?.products?.forEach((productData) => productList.append(new ProductCardModel(productData, null).getHTML()), ); - this.productFilters?.updateParams({ categories: data?.categories ?? [], products: data?.products ?? [] }); + this.productFilters?.updateParams(data); } else { productList.textContent = 'Ничего не найдено'; } From 555c4292430710bb681a554ff268bc3417d122bd Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Sun, 12 May 2024 23:09:42 +0300 Subject: [PATCH 07/10] feat: implement size filter --- .../ProductCard/model/ProductCardModel.ts | 3 +- .../ProductCard/view/ProductCardView.ts | 5 +- .../view/productCardView.module.scss | 5 + .../model/ProductFiltersModel.ts | 32 ++- .../ProductFilters/view/ProductFiltersView.ts | 204 +++++++++++++----- .../view/productFiltersView.module.scss | 30 +-- src/shared/Input/view/InputView.ts | 3 + src/shared/Store/observer.ts | 7 + src/shared/constants/common.ts | 5 + src/shared/constants/filters.ts | 23 ++ src/shared/constants/initialState.ts | 1 + src/shared/types/form.ts | 3 + src/shared/types/productFilters.ts | 9 + src/widgets/Catalog/model/CatalogModel.ts | 48 +++-- .../Catalog/view/catalogView.module.scss | 2 + src/widgets/Header/view/HeaderView.ts | 4 +- 16 files changed, 291 insertions(+), 93 deletions(-) create mode 100644 src/shared/constants/common.ts create mode 100644 src/shared/constants/filters.ts diff --git a/src/entities/ProductCard/model/ProductCardModel.ts b/src/entities/ProductCard/model/ProductCardModel.ts index 2f4f016d..e06a24b9 100644 --- a/src/entities/ProductCard/model/ProductCardModel.ts +++ b/src/entities/ProductCard/model/ProductCardModel.ts @@ -1,4 +1,3 @@ -import type { SizeType } from '@/shared/types/product.ts'; import type ProductCardParams from '@/shared/types/productCard'; import ProductCardView from '../view/ProductCardView.ts'; @@ -6,7 +5,7 @@ import ProductCardView from '../view/ProductCardView.ts'; class ProductCardModel { private view: ProductCardView; - constructor(params: ProductCardParams, size: SizeType | null) { + constructor(params: ProductCardParams, size: null | string) { this.view = new ProductCardView(params, size); } diff --git a/src/entities/ProductCard/view/ProductCardView.ts b/src/entities/ProductCard/view/ProductCardView.ts index 643e7c5f..8e850c0c 100644 --- a/src/entities/ProductCard/view/ProductCardView.ts +++ b/src/entities/ProductCard/view/ProductCardView.ts @@ -1,4 +1,3 @@ -import type { SizeType } from '@/shared/types/product'; import type ProductCardParams from '@/shared/types/productCard.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; @@ -36,9 +35,9 @@ class ProductCardView { private productShortDescription: HTMLParagraphElement; - private size: SizeType | null; + private size: null | string; - constructor(params: ProductCardParams, size: SizeType | null) { + constructor(params: ProductCardParams, size: null | string) { this.size = size; this.params = params; this.productImage = this.createProductImage(); diff --git a/src/entities/ProductCard/view/productCardView.module.scss b/src/entities/ProductCard/view/productCardView.module.scss index 5d6cde81..2894a01f 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -16,6 +16,10 @@ &:hover { outline: 2px solid var(--steam-green-800); transform: scale(0.98); + + .productImage { + transform: scaleY(0.9); + } } } } @@ -33,6 +37,7 @@ .productImage { width: 100%; height: auto; + transition: transform 0.2s; } .bottomWrapper { diff --git a/src/features/ProductFilters/model/ProductFiltersModel.ts b/src/features/ProductFilters/model/ProductFiltersModel.ts index 07344dce..d9f21f32 100644 --- a/src/features/ProductFilters/model/ProductFiltersModel.ts +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -15,6 +15,7 @@ class ProductFiltersModel { max: 0, min: 0, }, + size: null, }; private view: ProductFiltersView; @@ -25,22 +26,25 @@ class ProductFiltersModel { } private init(): void { + this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; this.initCategoryFilters(); this.initPriceFilters(); + this.initSizeFilters(); observeStore(selectCurrentLanguage, () => { + this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; + this.initSizeFilters(); this.initCategoryFilters(); }); } private initCategoryFilters(): void { this.setCategoryLinksHandlers(); - this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; const categoryLinks = this.view.getCategoryLinks(); this.selectedFilters.category.forEach((categoryID) => { const currentLink = categoryLinks.find((link) => link.getHTML().id === categoryID); if (currentLink) { - this.view.switchSelectCategory(currentLink); + this.view.switchSelectedFilter(currentLink, true); } }); } @@ -76,10 +80,19 @@ class ProductFiltersModel { .addEventListener('change', () => priceSlider.set([fromInput.getHTML().value, toInput.getHTML().value])); } + private initSizeFilters(): void { + this.setSizeLinksHandlers(); + const sizeLinks = this.view.getSizeLinks(); + const currentLink = sizeLinks.find((link) => link.getHTML().id === this.selectedFilters.size); + if (currentLink) { + this.view.switchSelectedFilter(currentLink, true); + } + } + private setCategoryLinksHandlers(): void { this.view.getCategoryLinks().forEach((categoryLink) => { categoryLink.getHTML().addEventListener('click', () => { - this.view.switchSelectCategory(categoryLink); + this.view.switchSelectedFilter(categoryLink); const categoryID = categoryLink.getHTML().id; if (this.selectedFilters.category.has(categoryID)) { this.selectedFilters.category.delete(categoryID); @@ -92,6 +105,19 @@ class ProductFiltersModel { }); } + private setSizeLinksHandlers(): void { + this.view.getSizeLinks().forEach((sizeLink) => { + sizeLink.getHTML().addEventListener('click', () => { + this.view.getSizeLinks().forEach((link) => this.view.switchSelectedFilter(link, false)); + this.view.switchSelectedFilter(sizeLink, true); + const sizeID = sizeLink.getHTML().id; + this.selectedFilters.size = sizeID; + + getStore().dispatch(setSelectedFilters(this.selectedFilters)); + }); + }); + } + public getCategoryLinks(): LinkModel[] { return this.view.getCategoryLinks(); } diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index c8844d31..54bbd801 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -1,3 +1,4 @@ +import type { SizeProductCount } from '@/shared/API/types/type'; import type { Category } from '@/shared/types/product'; import type ProductFiltersParams from '@/shared/types/productFilters'; @@ -6,14 +7,17 @@ import LinkModel from '@/shared/Link/model/LinkModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/buttons.ts'; +import { AUTOCOMPLETE_OPTION } from '@/shared/constants/common.ts'; +import { FILTER_INPUT_RANGE_LABEL, FILTER_TITLE } from '@/shared/constants/filters.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import * as noUiSlider from 'nouislider'; import styles from './productFiltersView.module.scss'; -const BASE_CATEGORY_LINK_COUNT = '(0)'; -const TEXT_RADIX = 10; +const BASE_FILTER_LINK_COUNT = '(0)'; +const SLIDER_PRICE_OFFSET = 10; +const INPUT_PRICE_STEP = 5; class ProductFiltersView { private categoryCountSpan: HTMLSpanElement[] = []; @@ -30,10 +34,17 @@ class ProductFiltersView { private priceSlider: noUiSlider.API; + private sizeCountSpan: HTMLSpanElement[] = []; + + private sizeLinks: LinkModel[] = []; + + private sizesList: HTMLUListElement; + constructor(params: ProductFiltersParams) { this.params = params; this.categoryList = this.createCategoryList(); this.priceSlider = this.createPriceSlider(); + this.sizesList = this.createSizesList(); this.filters = this.createHTML(); } @@ -57,38 +68,31 @@ class ProductFiltersView { id: category.id, }, cssClasses: [styles.categoryLinkCount], - innerContent: BASE_CATEGORY_LINK_COUNT, + innerContent: BASE_FILTER_LINK_COUNT, tag: 'span', }); this.categoryCountSpan.push(span); categoryLink.getHTML().append(span); - this.params.products?.forEach((product) => { - product.category.forEach((categoryEl) => { - if (category.key === categoryEl.key) { - const text = span.innerText.match(/\d+/); - const updatedText = text ? `(${Number(parseInt(text[0], TEXT_RADIX)) + 1})` : BASE_CATEGORY_LINK_COUNT; - span.innerText = updatedText; - } - }); - }); + const productsCount = + this.params.categoriesProductCount?.find((item) => item.category.key === category.key)?.count ?? 0; + span.textContent = `(${productsCount})`; + this.categoryLinks.push(categoryLink); return categoryLink; } private createCategoryList(): HTMLUListElement { - this.categoryList = createBaseElement({ - cssClasses: [styles.categoryList], - tag: 'ul', - }); + const { filtersList, filtersListTitle } = this.createFiltersList( + { + list: [styles.categoryList], + title: [styles.categoryTitle], + }, + FILTER_TITLE[getStore().getState().currentLanguage].CATEGORY, + ); - const title = createBaseElement({ - cssClasses: [styles.categoryTitle], - innerContent: 'Categories', - tag: 'h3', - }); - this.categoryList.append(title); + this.categoryList = filtersList; this.params.categories?.forEach((category) => { const li = createBaseElement({ @@ -104,7 +108,8 @@ class ProductFiltersView { this.categoryCountSpan.length = 0; this.categoryLinks.length = 0; this.categoryList.innerHTML = ''; - this.categoryList.append(title); + this.categoryList.append(filtersListTitle); + filtersListTitle.textContent = FILTER_TITLE[getStore().getState().currentLanguage].CATEGORY; this.params.categories?.forEach((category) => { this.categoryList.append(this.createCategoryLink(category).getHTML()); }); @@ -113,18 +118,42 @@ class ProductFiltersView { return this.categoryList; } + private createFiltersList( + classes: Record, + title: string, + ): { + filtersList: HTMLUListElement; + filtersListTitle: HTMLHeadingElement; + } { + const filtersList = createBaseElement({ + cssClasses: classes.list, + tag: 'ul', + }); + + const filtersListTitle = createBaseElement({ + cssClasses: classes.title, + innerContent: title, + tag: 'h3', + }); + filtersList.append(filtersListTitle); + return { filtersList, filtersListTitle }; + } + private createHTML(): HTMLDivElement { this.filters = createBaseElement({ cssClasses: [styles.filters], tag: 'div', }); - this.filters.append(this.categoryList, this.createPriceWrapper(), this.priceSlider.target); + this.filters.append(this.categoryList, this.createPriceWrapper(), this.priceSlider.target, this.sizesList); return this.filters; } - private createPriceLabel(direction: string): HTMLLabelElement { + private createPriceLabel(direction: string): { + priceLabel: HTMLLabelElement; + priceSpan: HTMLSpanElement; + } { const priceLabel = createBaseElement({ attributes: { for: direction, @@ -143,19 +172,29 @@ class ProductFiltersView { const maxPrice = this.params.priceRange?.max.toFixed(2) ?? ''; const priceInput = new InputModel({ - autocomplete: 'off', - id: direction, - placeholder: direction === 'from' ? minPrice : maxPrice, + autocomplete: AUTOCOMPLETE_OPTION.OFF, + id: + direction === FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM + ? FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM + : FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].TO, + max: this.params.priceRange?.max, + min: this.params.priceRange?.min, + placeholder: + direction === FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM ? minPrice : maxPrice, + step: INPUT_PRICE_STEP, type: INPUT_TYPE.NUMBER, - value: direction === 'from' ? minPrice : maxPrice, + value: direction === FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM ? minPrice : maxPrice, }); priceInput.getHTML().classList.add(styles.priceInput, styles[direction]); this.priceInputs.set(direction, priceInput); priceLabel.append(priceSpan, priceInput.getHTML()); - return priceLabel; + return { priceLabel, priceSpan }; } private createPriceSlider(): noUiSlider.API { + const { max, min } = this.params.priceRange ?? { max: 0, min: 0 }; + const SLIDER_START_MIN = min + max / SLIDER_PRICE_OFFSET; + const SLIDER_START_MAX = max - max / SLIDER_PRICE_OFFSET; const slider = createBaseElement({ attributes: { id: 'slider', @@ -168,8 +207,8 @@ class ProductFiltersView { behaviour: 'tap', connect: true, keyboardSupport: true, - range: this.params.priceRange ?? { max: 100, min: 0 }, - start: [40, 320], + range: this.params.priceRange ?? { max: 0, min: 0 }, + start: [SLIDER_START_MIN, SLIDER_START_MAX], }); return this.priceSlider; @@ -183,14 +222,88 @@ class ProductFiltersView { const title = createBaseElement({ cssClasses: [styles.priceTitle], - innerContent: 'Price range', + innerContent: FILTER_TITLE[getStore().getState().currentLanguage].PRICE, tag: 'h3', }); - priceWrapper.append(title, this.createPriceLabel('from'), this.createPriceLabel('to')); + observeStore(selectCurrentLanguage, () => { + title.textContent = FILTER_TITLE[getStore().getState().currentLanguage].PRICE; + }); + + const from = this.createPriceLabel(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM); + const to = this.createPriceLabel(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].TO); + priceWrapper.append(from.priceLabel, this.priceSlider.target, to.priceLabel); + + observeStore(selectCurrentLanguage, () => { + from.priceSpan.textContent = FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM; + to.priceSpan.textContent = FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].TO; + }); return priceWrapper; } + private createSizeLink(size: SizeProductCount): LinkModel { + const sizeLink = new LinkModel({ + attrs: { + href: size.size, + id: size.size, + }, + classes: [styles.sizeLink], + text: size.size, + }); + + sizeLink.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + }); + + const span = createBaseElement({ + attributes: { + id: size.size, + }, + cssClasses: [styles.sizeLinkCount], + innerContent: `(${size.count})`, + tag: 'span', + }); + + this.sizeCountSpan.push(span); + sizeLink.getHTML().append(span); + + this.sizeLinks.push(sizeLink); + return sizeLink; + } + + private createSizesList(): HTMLUListElement { + const { filtersList, filtersListTitle } = this.createFiltersList( + { + list: [styles.sizesList], + title: [styles.sizesTitle], + }, + FILTER_TITLE[getStore().getState().currentLanguage].SIZE, + ); + + this.sizesList = filtersList; + + this.params.sizes?.forEach((size) => { + const li = createBaseElement({ + cssClasses: [styles.sizeItem], + tag: 'li', + }); + + li.append(this.createSizeLink(size).getHTML()); + this.sizesList.append(li); + }); + + observeStore(selectCurrentLanguage, () => { + this.sizeCountSpan.length = 0; + this.sizeLinks.length = 0; + this.sizesList.innerHTML = ''; + this.sizesList.append(filtersListTitle); + filtersListTitle.textContent = FILTER_TITLE[getStore().getState().currentLanguage].SIZE; + this.params.sizes?.forEach((size) => this.sizesList.append(this.createSizeLink(size).getHTML())); + }); + + return this.sizesList; + } + public getCategoryLinks(): LinkModel[] { return this.categoryLinks; } @@ -207,26 +320,21 @@ class ProductFiltersView { return this.priceSlider; } - public switchSelectCategory(categoryLink: LinkModel): void { - categoryLink.getHTML().classList.toggle(styles.activeLink); + public getSizeLinks(): LinkModel[] { + return this.sizeLinks; + } + + public switchSelectedFilter(filterLink: LinkModel, toggle?: boolean): void { + filterLink.getHTML().classList.toggle(styles.activeLink, toggle); } public updateParams(params: ProductFiltersParams): void { this.params = params; - this.params.categories?.forEach((category) => { - const currentSpan = this.categoryCountSpan.find((span) => span.id === category.id) ?? null; + this.params.categoriesProductCount?.forEach((categoryCount) => { + const currentSpan = this.categoryCountSpan.find((span) => span.id === categoryCount.category.id) ?? null; if (currentSpan) { - currentSpan.innerText = BASE_CATEGORY_LINK_COUNT; + currentSpan.innerText = `(${categoryCount.count})`; } - this.params.products?.forEach((product) => { - product.category.forEach((categoryEl) => { - if (category.id === categoryEl.id && currentSpan) { - const text = currentSpan?.innerText.match(/\d+/); - const updatedText = text ? `(${Number(parseInt(text[0], TEXT_RADIX)) + 1})` : BASE_CATEGORY_LINK_COUNT; - currentSpan.innerText = updatedText; - } - }); - }); }); } } diff --git a/src/features/ProductFilters/view/productFiltersView.module.scss b/src/features/ProductFilters/view/productFiltersView.module.scss index 55e38da2..a0c807a8 100644 --- a/src/features/ProductFilters/view/productFiltersView.module.scss +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -1,27 +1,25 @@ .filters { - position: sticky; - top: 100px; order: 1; - overflow: scroll; + outline: 2px solid var(--noble-white-200); border-radius: var(--medium-br); width: 20%; height: max-content; - max-height: 500px; background-color: var(--noble-white-200); } -.categoryList { +.categoryList, +.sizesList { display: flex; flex-direction: column; align-items: center; border-radius: var(--medium-br); width: 100%; - height: 70%; background-color: var(--noble-white-200); } .categoryTitle, -.priceTitle { +.priceTitle, +.sizesTitle { padding: calc(var(--extra-small-offset)); width: 100%; font: var(--medium-font); @@ -30,11 +28,13 @@ color: var(--steam-green-500); } -.categoryItem { +.categoryItem, +.sizeItem { width: 100%; } -.categoryLink { +.categoryLink, +.sizeLink { display: flex; align-items: center; justify-content: space-between; @@ -61,6 +61,12 @@ } } +.sizeLink { + &.activeLink { + cursor: default; + } +} + .priceWrapper { display: flex; flex-direction: column; @@ -92,12 +98,6 @@ background-color: var(--noble-white-100); } } - - &[type='number']::-webkit-outer-spin-button, - &[type='number']::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } } .priceLabel { diff --git a/src/shared/Input/view/InputView.ts b/src/shared/Input/view/InputView.ts index 6be448fb..785e59a9 100644 --- a/src/shared/Input/view/InputView.ts +++ b/src/shared/Input/view/InputView.ts @@ -15,7 +15,10 @@ class InputView { autocomplete: attrs.autocomplete, id: attrs.id, lang: attrs.lang || '', + max: String(attrs.max || 0), + min: String(attrs.min || 0), placeholder: attrs.placeholder || '', + step: String(attrs.step || 1), type: attrs.type, value: attrs.value || '', }, diff --git a/src/shared/Store/observer.ts b/src/shared/Store/observer.ts index e85acc6d..15533c95 100644 --- a/src/shared/Store/observer.ts +++ b/src/shared/Store/observer.ts @@ -73,4 +73,11 @@ export const selectSelectedFiltersPrice = (state: State): { max: number; min: nu return null; }; +export const selectSelectedFiltersSize = (state: State): null | string => { + if (state.selectedFilters) { + return state.selectedFilters.size; + } + return null; +}; + export default observeStore; diff --git a/src/shared/constants/common.ts b/src/shared/constants/common.ts new file mode 100644 index 00000000..abfa72e6 --- /dev/null +++ b/src/shared/constants/common.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const AUTOCOMPLETE_OPTION = { + OFF: 'off', + ON: 'on', +} as const; diff --git a/src/shared/constants/filters.ts b/src/shared/constants/filters.ts new file mode 100644 index 00000000..eb547ef8 --- /dev/null +++ b/src/shared/constants/filters.ts @@ -0,0 +1,23 @@ +export const FILTER_TITLE = { + en: { + CATEGORY: 'Category', + PRICE: 'Price', + SIZE: 'Size', + }, + ru: { + CATEGORY: 'Категория', + PRICE: 'Цена', + SIZE: 'Размер', + }, +} as const; + +export const FILTER_INPUT_RANGE_LABEL = { + en: { + FROM: 'From', + TO: 'To', + }, + ru: { + FROM: 'От', + TO: 'До', + }, +} as const; diff --git a/src/shared/constants/initialState.ts b/src/shared/constants/initialState.ts index f50ddde4..ad452aad 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/constants/initialState.ts @@ -12,6 +12,7 @@ const initialState: State = { selectedFilters: { category: new Set(), price: null, + size: null, }, shippingCountry: '', }; diff --git a/src/shared/types/form.ts b/src/shared/types/form.ts index f04ef7da..1c60a3d2 100644 --- a/src/shared/types/form.ts +++ b/src/shared/types/form.ts @@ -2,7 +2,10 @@ export interface InputParams { autocomplete: 'off' | 'on'; id: string; lang?: string; + max?: null | number; + min?: null | number; placeholder: null | string; + step?: null | number; type: 'checkbox' | 'color' | 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text'; value?: null | string; } diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts index 843b2a0d..8f0a0111 100644 --- a/src/shared/types/productFilters.ts +++ b/src/shared/types/productFilters.ts @@ -1,12 +1,20 @@ +import type { SizeProductCount } from '../API/types/type.ts'; import type { Category, Product } from './product.ts'; interface ProductFiltersParams { categories: Category[] | null; + categoriesProductCount: + | { + category: Category; + count: number; + }[] + | null; priceRange: { max: number; min: number; } | null; products: Product[] | null; + sizes: SizeProductCount[] | null; } export interface SelectedFilters { @@ -15,6 +23,7 @@ export interface SelectedFilters { max: number; min: number; } | null; + size: null | string; } export default ProductFiltersParams; diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index ee7df9bb..0e30c933 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -11,6 +11,7 @@ import observeStore, { observeSetInStore, selectSelectedFiltersCategory, selectSelectedFiltersPrice, + selectSelectedFiltersSize, } from '@/shared/Store/observer.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import showBadRequestMessage from '@/shared/utils/showBadRequestMessage.ts'; @@ -37,7 +38,9 @@ class CatalogModel { try { const products = await getProductModel().getProducts(options); const priceRange = await getProductModel().getPriceRange(); - return { categories, priceRange, products }; + const sizes = await getProductModel().getSizeProductCount(); + const categoriesProductCount = await getProductModel().getCategoriesProductCount(); + return { categories, categoriesProductCount, priceRange, products, sizes }; } catch { showBadRequestMessage(); } @@ -51,57 +54,62 @@ class CatalogModel { } private getSelectedFilters(): OptionsRequest { - const { category, price } = getStore().getState().selectedFilters || {}; + const { category, price, size } = getStore().getState().selectedFilters || {}; const filter: OptionsRequest['filter'] = []; category?.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); if (price) { filter.push(addFilter(FilterFields.PRICE, price)); } - return { filter, limit: 100, sort: { direction: 'desc', field: 'name', locale: 'en' } }; + if (size) { + filter.push(addFilter(FilterFields.SIZE, size)); + } + return { filter, sort: { direction: 'desc', field: 'name', locale: 'en' } }; } private init(): void { const productList = this.view.getItemsList(); + const { selectedFilters } = getStore().getState(); this.getProductItems(this.getSelectedFilters()) .then((data) => { if (!data?.products?.length) { productList.textContent = 'Ничего не найдено'; } else { - data.products.forEach((productData) => productList.append(new ProductCardModel(productData, null).getHTML())); - this.productFilters = new ProductFiltersModel(data); - this.getHTML().append(this.productFilters.getHTML()); + data.products.forEach((productData) => + productList.append( + new ProductCardModel(productData, selectedFilters ? selectedFilters.size : null).getHTML(), + ), + ); } + this.productFilters = new ProductFiltersModel( + data ?? { categories: null, categoriesProductCount: null, priceRange: null, products: null, sizes: null }, + ); + this.getHTML().append(this.productFilters.getHTML()); }) - .catch(() => { - showBadRequestMessage(); - }); + .catch(() => showBadRequestMessage()); - observeSetInStore(selectSelectedFiltersCategory, () => { - this.redrawProductList(this.getSelectedFilters()); - }); - - observeStore(selectSelectedFiltersPrice, () => { - this.redrawProductList(this.getSelectedFilters()); - }); + observeSetInStore(selectSelectedFiltersCategory, () => this.redrawProductList(this.getSelectedFilters())); + observeStore(selectSelectedFiltersPrice, () => this.redrawProductList(this.getSelectedFilters())); + observeStore(selectSelectedFiltersSize, () => this.redrawProductList(this.getSelectedFilters())); } private redrawProductList(options?: OptionsRequest): void { const productList = this.view.getItemsList(); + const { selectedFilters } = getStore().getState(); productList.innerHTML = ''; this.getProductItems(options ?? {}) .then((data) => { if (data?.products?.length) { data?.products?.forEach((productData) => - productList.append(new ProductCardModel(productData, null).getHTML()), + productList.append( + new ProductCardModel(productData, selectedFilters ? selectedFilters.size : null).getHTML(), + ), ); this.productFilters?.updateParams(data); } else { productList.textContent = 'Ничего не найдено'; } }) - .catch(() => { - showBadRequestMessage(); - }); + .catch(() => showBadRequestMessage()); } public getHTML(): HTMLDivElement { diff --git a/src/widgets/Catalog/view/catalogView.module.scss b/src/widgets/Catalog/view/catalogView.module.scss index a24c2e82..4d195863 100644 --- a/src/widgets/Catalog/view/catalogView.module.scss +++ b/src/widgets/Catalog/view/catalogView.module.scss @@ -2,6 +2,7 @@ position: relative; display: flex; justify-content: space-between; + min-height: max-content; gap: var(--small-offset); } @@ -13,6 +14,7 @@ order: 2; grid-template-columns: repeat(3, 1fr); width: 80%; + height: max-content; min-height: 500px; gap: var(--small-offset); diff --git a/src/widgets/Header/view/HeaderView.ts b/src/widgets/Header/view/HeaderView.ts index 5cfb47d2..58f97936 100644 --- a/src/widgets/Header/view/HeaderView.ts +++ b/src/widgets/Header/view/HeaderView.ts @@ -5,8 +5,8 @@ import getStore from '@/shared/Store/Store.ts'; import { switchAppTheme } from '@/shared/Store/actions.ts'; import observeStore, { selectCurrentLanguage, selectCurrentPage, selectCurrentUser } from '@/shared/Store/observer.ts'; import { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; +import { AUTOCOMPLETE_OPTION } from '@/shared/constants/common.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; -import { EMAIL_FIELD } from '@/shared/constants/forms/login/fieldParams.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; import APP_THEME from '@/shared/constants/styles.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; @@ -168,7 +168,7 @@ class HeaderView { private createSwitchThemeCheckbox(): InputModel { this.switchThemeCheckbox = new InputModel({ - autocomplete: EMAIL_FIELD.inputParams.autocomplete, + autocomplete: AUTOCOMPLETE_OPTION.OFF, id: styles.switchThemeLabel, placeholder: '', type: INPUT_TYPE.CHECK_BOX, From 7fe30e67eee2c6882ccbc3c3699020689f0698dc Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Mon, 13 May 2024 00:51:05 +0300 Subject: [PATCH 08/10] feat: filters reset button --- .../view/productCardView.module.scss | 11 +++---- .../model/ProductFiltersModel.ts | 31 +++++++++++++++++-- .../ProductFilters/view/ProductFiltersView.ts | 27 ++++++++++++++-- .../view/productFiltersView.module.scss | 24 ++++++++++++++ src/shared/constants/filters.ts | 9 ++++++ src/widgets/Catalog/model/CatalogModel.ts | 5 +-- .../Catalog/view/catalogView.module.scss | 2 +- .../Header/view/headerView.module.scss | 8 ++--- 8 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/entities/ProductCard/view/productCardView.module.scss b/src/entities/ProductCard/view/productCardView.module.scss index 2894a01f..522973f6 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -8,6 +8,7 @@ border-radius: var(--medium-br); min-height: 350px; max-width: 270px; + background-color: var(--noble-white-200); transition: outline 0.2s, transform 0.2s; @@ -16,10 +17,6 @@ &:hover { outline: 2px solid var(--steam-green-800); transform: scale(0.98); - - .productImage { - transform: scaleY(0.9); - } } } } @@ -37,18 +34,17 @@ .productImage { width: 100%; height: auto; - transition: transform 0.2s; } .bottomWrapper { display: flex; + flex-grow: 1; flex-direction: column; align-items: center; - justify-content: space-evenly; padding: calc(var(--extra-small-offset) / 2) calc(var(--extra-small-offset) / 4); width: 100%; - height: 70%; background-color: var(--noble-white-200); + gap: calc(var(--extra-small-offset) / 4); } .productName { @@ -108,6 +104,7 @@ display: flex; align-items: center; justify-content: center; + margin-top: auto; width: 100%; text-align: center; gap: var(--extra-small-offset); diff --git a/src/features/ProductFilters/model/ProductFiltersModel.ts b/src/features/ProductFilters/model/ProductFiltersModel.ts index d9f21f32..0d322835 100644 --- a/src/features/ProductFilters/model/ProductFiltersModel.ts +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -5,10 +5,13 @@ import type { SelectedFilters } from '@/shared/types/productFilters.ts'; import getStore from '@/shared/Store/Store.ts'; import { setSelectedFilters } from '@/shared/Store/actions.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { FILTER_INPUT_RANGE_LABEL } from '@/shared/constants/filters.ts'; import ProductFiltersView from '../view/ProductFiltersView.ts'; class ProductFiltersModel { + private params: ProductFiltersParams; + private selectedFilters: SelectedFilters = { category: new Set(), price: { @@ -21,6 +24,7 @@ class ProductFiltersModel { private view: ProductFiltersView; constructor(params: ProductFiltersParams) { + this.params = params; this.view = new ProductFiltersView(params); this.init(); } @@ -30,6 +34,7 @@ class ProductFiltersModel { this.initCategoryFilters(); this.initPriceFilters(); this.initSizeFilters(); + this.setResetFiltersButtonHandler(); observeStore(selectCurrentLanguage, () => { this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; @@ -50,8 +55,10 @@ class ProductFiltersModel { } private initPriceFilters(): void { - const fromInput = this.view.getPriceInputs().get('from'); - const toInput = this.view.getPriceInputs().get('to'); + const fromInput = this.view + .getPriceInputs() + .get(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM); + const toInput = this.view.getPriceInputs().get(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].TO); const priceSlider = this.view.getPriceSlider(); if (!fromInput || !toInput) { return; @@ -105,6 +112,26 @@ class ProductFiltersModel { }); } + private setResetFiltersButtonHandler(): void { + const filtersResetButton = this.view.getFiltersResetButton(); + filtersResetButton.getHTML().addEventListener('click', () => { + this.view.getCategoryLinks().forEach((link) => this.view.switchSelectedFilter(link, false)); + this.view.getSizeLinks().forEach((link) => this.view.switchSelectedFilter(link, false)); + this.selectedFilters = { + category: new Set(), + price: { + max: this.params.priceRange?.max ?? 0, + min: this.params.priceRange?.min ?? 0, + }, + size: null, + }; + + this.view.getPriceSlider().set([this.params.priceRange?.min ?? 0, this.params.priceRange?.max ?? 0]); + + getStore().dispatch(setSelectedFilters(this.selectedFilters)); + }); + } + private setSizeLinksHandlers(): void { this.view.getSizeLinks().forEach((sizeLink) => { sizeLink.getHTML().addEventListener('click', () => { diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index 54bbd801..bab86dec 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -2,13 +2,14 @@ import type { SizeProductCount } from '@/shared/API/types/type'; import type { Category } from '@/shared/types/product'; import type ProductFiltersParams from '@/shared/types/productFilters'; +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; import LinkModel from '@/shared/Link/model/LinkModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/buttons.ts'; import { AUTOCOMPLETE_OPTION } from '@/shared/constants/common.ts'; -import { FILTER_INPUT_RANGE_LABEL, FILTER_TITLE } from '@/shared/constants/filters.ts'; +import { FILTER_INPUT_RANGE_LABEL, FILTER_RESET_BUTTON, FILTER_TITLE } from '@/shared/constants/filters.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import * as noUiSlider from 'nouislider'; @@ -34,6 +35,8 @@ class ProductFiltersView { private priceSlider: noUiSlider.API; + private resetFiltersButton: ButtonModel; + private sizeCountSpan: HTMLSpanElement[] = []; private sizeLinks: LinkModel[] = []; @@ -45,6 +48,7 @@ class ProductFiltersView { this.categoryList = this.createCategoryList(); this.priceSlider = this.createPriceSlider(); this.sizesList = this.createSizesList(); + this.resetFiltersButton = this.createResetFiltersButton(); this.filters = this.createHTML(); } @@ -145,7 +149,13 @@ class ProductFiltersView { tag: 'div', }); - this.filters.append(this.categoryList, this.createPriceWrapper(), this.priceSlider.target, this.sizesList); + this.filters.append( + this.categoryList, + this.createPriceWrapper(), + this.priceSlider.target, + this.sizesList, + this.resetFiltersButton.getHTML(), + ); return this.filters; } @@ -241,6 +251,15 @@ class ProductFiltersView { return priceWrapper; } + private createResetFiltersButton(): ButtonModel { + this.resetFiltersButton = new ButtonModel({ + classes: [styles.resetFiltersButton], + text: FILTER_RESET_BUTTON[getStore().getState().currentLanguage].RESET, + }); + + return this.resetFiltersButton; + } + private createSizeLink(size: SizeProductCount): LinkModel { const sizeLink = new LinkModel({ attrs: { @@ -308,6 +327,10 @@ class ProductFiltersView { return this.categoryLinks; } + public getFiltersResetButton(): ButtonModel { + return this.resetFiltersButton; + } + public getHTML(): HTMLDivElement { return this.filters; } diff --git a/src/features/ProductFilters/view/productFiltersView.module.scss b/src/features/ProductFilters/view/productFiltersView.module.scss index a0c807a8..9f730df1 100644 --- a/src/features/ProductFilters/view/productFiltersView.module.scss +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -112,3 +112,27 @@ color: var(--noble-gray-800); gap: var(--extra-small-offset); } + +.resetFiltersButton { + display: flex; + margin: calc(var(--extra-small-offset) / 2) auto; + border-radius: var(--medium-br); + padding: calc(var(--small-offset) / 3) var(--small-offset); + font: var(--regular-font); + letter-spacing: 1px; + color: var(--noble-gray-200); + background-color: var(--steam-green-800); + transition: + color 0.2s, + background-color 0.2s; + + &:focus { + background-color: var(--steam-green-700); + } + + @media (hover: hover) { + &:hover { + background-color: var(--steam-green-700); + } + } +} diff --git a/src/shared/constants/filters.ts b/src/shared/constants/filters.ts index eb547ef8..80807786 100644 --- a/src/shared/constants/filters.ts +++ b/src/shared/constants/filters.ts @@ -21,3 +21,12 @@ export const FILTER_INPUT_RANGE_LABEL = { TO: 'До', }, } as const; + +export const FILTER_RESET_BUTTON = { + en: { + RESET: 'Reset', + }, + ru: { + RESET: 'Сбросить', + }, +} as const; diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index 0e30c933..d67a2549 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -57,13 +57,14 @@ class CatalogModel { const { category, price, size } = getStore().getState().selectedFilters || {}; const filter: OptionsRequest['filter'] = []; category?.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); - if (price) { + if (price?.max || price?.min) { filter.push(addFilter(FilterFields.PRICE, price)); } if (size) { filter.push(addFilter(FilterFields.SIZE, size)); } - return { filter, sort: { direction: 'desc', field: 'name', locale: 'en' } }; + + return { filter, limit: 100, sort: { direction: 'desc', field: 'name', locale: 'en' } }; } private init(): void { diff --git a/src/widgets/Catalog/view/catalogView.module.scss b/src/widgets/Catalog/view/catalogView.module.scss index 4d195863..9bfd5837 100644 --- a/src/widgets/Catalog/view/catalogView.module.scss +++ b/src/widgets/Catalog/view/catalogView.module.scss @@ -12,7 +12,7 @@ align-items: stretch; justify-content: center; order: 2; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(3, auto); width: 80%; height: max-content; min-height: 500px; diff --git a/src/widgets/Header/view/headerView.module.scss b/src/widgets/Header/view/headerView.module.scss index 2b3c8e65..a1d0b526 100644 --- a/src/widgets/Header/view/headerView.module.scss +++ b/src/widgets/Header/view/headerView.module.scss @@ -6,7 +6,8 @@ z-index: 1; border-bottom: 1px solid var(--steam-green-300); width: 100%; - background-color: var(--white); + background-color: #1a1a1ab5; + backdrop-filter: blur(10px); } .wrapper { @@ -32,11 +33,10 @@ order: 3; padding: 70px var(--extra-small-offset) 0; width: 20%; - height: 100%; - background-color: var(--steam-green-gr-800); + height: 100dvh; + background-color: #1a1a1ab5; transform: none; transition: transform 0.3s; - backdrop-filter: blur(2px); gap: var(--medium-offset); &.open { From 08dea053b0029cc7598063523e9a312c8a5ef073 Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Mon, 13 May 2024 01:18:01 +0300 Subject: [PATCH 09/10] fix: burger styles --- src/app/App/model/AppModel.ts | 4 +++- src/app/styles/variables.scss | 2 ++ src/widgets/Header/model/HeaderModel.ts | 6 +++++- src/widgets/Header/view/HeaderView.ts | 6 +++++- src/widgets/Header/view/headerView.module.scss | 11 ++++++----- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/app/App/model/AppModel.ts b/src/app/App/model/AppModel.ts index 2dcf3a4c..d42920c5 100644 --- a/src/app/App/model/AppModel.ts +++ b/src/app/App/model/AppModel.ts @@ -73,7 +73,9 @@ class AppModel { private async initialize(): Promise { document.body.append(this.appView.getHTML()); - this.appView.getHTML().insertAdjacentElement('beforebegin', new HeaderModel(this.router).getHTML()); + this.appView + .getHTML() + .insertAdjacentElement('beforebegin', new HeaderModel(this.router, this.appView.getHTML()).getHTML()); this.appView.getHTML().insertAdjacentElement('afterend', new FooterModel(this.router).getHTML()); const routes = await this.createRoutes(); diff --git a/src/app/styles/variables.scss b/src/app/styles/variables.scss index 40072dda..94b60bec 100644 --- a/src/app/styles/variables.scss +++ b/src/app/styles/variables.scss @@ -34,6 +34,7 @@ --noble-gray-600: #a5a5a5; --noble-gray-700: #727272; --noble-gray-800: #3d3d3d; + --noble-gray-1000: #1a1a1ab5; --red-power-600: #d0302f; --steam-green-300: #46a35880; --steam-green-400: #c8f4b4; @@ -56,6 +57,7 @@ --noble-gray-600: #cdcdcd; --noble-gray-700: #b0b0b0; --noble-gray-800: #c4c4c4; + --noble-gray-1000: #1a1a1ab5; --red-power-600: #d0302f; --steam-green-300: #46a35880; --steam-green-400: #c8f4b4; diff --git a/src/widgets/Header/model/HeaderModel.ts b/src/widgets/Header/model/HeaderModel.ts index fb1606a7..97e778b7 100644 --- a/src/widgets/Header/model/HeaderModel.ts +++ b/src/widgets/Header/model/HeaderModel.ts @@ -15,11 +15,14 @@ import HeaderView from '../view/HeaderView.ts'; class HeaderModel { private navigation: NavigationModel; + private parent: HTMLDivElement; + private router: RouterModel; private view = new HeaderView(); - constructor(router: RouterModel) { + constructor(router: RouterModel, parent: HTMLDivElement) { + this.parent = parent; this.router = router; this.navigation = new NavigationModel(this.router); this.init(); @@ -56,6 +59,7 @@ class HeaderModel { private init(): boolean { this.view.getWrapper().append(this.navigation.getHTML()); + this.parent.insertAdjacentElement('beforebegin', this.view.getNavigationWrapper()); this.checkCurrentUser(); this.setLogoHandler(); this.observeCurrentUser(); diff --git a/src/widgets/Header/view/HeaderView.ts b/src/widgets/Header/view/HeaderView.ts index 58f97936..53344bb6 100644 --- a/src/widgets/Header/view/HeaderView.ts +++ b/src/widgets/Header/view/HeaderView.ts @@ -273,7 +273,7 @@ class HeaderView { tag: 'div', }); - this.wrapper.append(this.linkLogo.getHTML(), this.navigationWrapper, this.burgerButton.getHTML()); + this.wrapper.append(this.linkLogo.getHTML(), this.burgerButton.getHTML()); return this.wrapper; } @@ -293,6 +293,10 @@ class HeaderView { return this.logoutButton; } + public getNavigationWrapper(): HTMLDivElement { + return this.navigationWrapper; + } + public getSwitchLanguageButton(): ButtonModel { return this.switchLanguageButton; } diff --git a/src/widgets/Header/view/headerView.module.scss b/src/widgets/Header/view/headerView.module.scss index a1d0b526..57490c8c 100644 --- a/src/widgets/Header/view/headerView.module.scss +++ b/src/widgets/Header/view/headerView.module.scss @@ -3,10 +3,10 @@ left: 0; right: 0; top: 0; - z-index: 1; + z-index: 10; border-bottom: 1px solid var(--steam-green-300); width: 100%; - background-color: #1a1a1ab5; + background-color: var(--noble-gray-1000); backdrop-filter: blur(10px); } @@ -25,8 +25,8 @@ .navigationWrapper { position: fixed; right: -100%; - top: 0; - z-index: 2; + top: 69px; + z-index: 12; display: flex; flex-direction: column; align-items: center; @@ -34,9 +34,10 @@ padding: 70px var(--extra-small-offset) 0; width: 20%; height: 100dvh; - background-color: #1a1a1ab5; + background-color: var(--noble-gray-1000); transform: none; transition: transform 0.3s; + backdrop-filter: blur(10px); gap: var(--medium-offset); &.open { From ea983aae23108059c71847e476c2fdb41a774696 Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Mon, 13 May 2024 01:23:01 +0300 Subject: [PATCH 10/10] fix: lost title --- src/features/ProductFilters/view/ProductFiltersView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index bab86dec..4dc0a306 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -242,7 +242,7 @@ class ProductFiltersView { const from = this.createPriceLabel(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM); const to = this.createPriceLabel(FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].TO); - priceWrapper.append(from.priceLabel, this.priceSlider.target, to.priceLabel); + priceWrapper.append(title, from.priceLabel, this.priceSlider.target, to.priceLabel); observeStore(selectCurrentLanguage, () => { from.priceSpan.textContent = FILTER_INPUT_RANGE_LABEL[getStore().getState().currentLanguage].FROM;