From 81af8734d6e3d560c8fcc1ffab84034931ed1eff Mon Sep 17 00:00:00 2001 From: Max <133934232+Kleostro@users.noreply.github.com> Date: Mon, 13 May 2024 01:25:13 +0300 Subject: [PATCH] feat(RSS-ECOMM-3_03): implement filtering product list (#227) * feat: expand loder options * fix: styles product card * feat: implement saving Set into LS Co-authored-by: Meg G. * feat: get and draw product items * feat: create ProductFilters component * feat: implement price filter * feat: implement size filter * feat: filters reset button * fix: burger styles * fix: lost title --- package.json | 1 + src/app/App/model/AppModel.ts | 12 +- src/app/styles/noUiSlider.scss | 100 +++++ src/app/styles/variables.scss | 2 + .../ProductCard/model/ProductCardModel.ts | 3 +- .../ProductCard/view/ProductCardView.ts | 52 ++- .../view/productCardView.module.scss | 12 +- .../model/ProductFiltersModel.ts | 161 ++++++++ .../ProductFilters/view/ProductFiltersView.ts | 365 ++++++++++++++++++ .../view/productFiltersView.module.scss | 138 +++++++ src/shared/Input/view/InputView.ts | 4 + src/shared/Loader/model/LoaderModel.ts | 8 +- src/shared/Loader/view/LoaderView.ts | 27 +- src/shared/Loader/view/loaderView.module.scss | 14 + src/shared/Store/Store.ts | 4 +- src/shared/Store/actions.ts | 17 +- src/shared/Store/observer.ts | 50 +++ src/shared/Store/reducer.ts | 13 +- src/shared/Store/test.spec.ts | 3 + src/shared/constants/common.ts | 5 + src/shared/constants/filters.ts | 32 ++ src/shared/constants/initialState.ts | 5 + 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/form.ts | 4 + src/shared/types/productFilters.ts | 29 ++ src/shared/types/validation/state.ts | 31 ++ src/shared/utils/showBadRequestMessage.ts | 11 + src/styles.scss | 1 + src/widgets/Catalog/model/CatalogModel.ts | 121 ++++-- .../Catalog/view/catalogView.module.scss | 21 +- src/widgets/Header/model/HeaderModel.ts | 6 +- src/widgets/Header/view/HeaderView.ts | 10 +- .../Header/view/headerView.module.scss | 15 +- src/widgets/LoginForm/model/LoginFormModel.ts | 6 +- .../model/RegistrationFormModel.ts | 6 +- 38 files changed, 1247 insertions(+), 94 deletions(-) create mode 100644 src/app/styles/noUiSlider.scss 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 create mode 100644 src/shared/constants/common.ts create mode 100644 src/shared/constants/filters.ts 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/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/App/model/AppModel.ts b/src/app/App/model/AppModel.ts index dc44b237..707e0cd9 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>> { @@ -79,7 +77,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/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/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/entities/ProductCard/model/ProductCardModel.ts b/src/entities/ProductCard/model/ProductCardModel.ts index 634a4e74..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) { + 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 3bc69dfe..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'; @@ -6,8 +5,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 +35,9 @@ class ProductCardView { private productShortDescription: HTMLParagraphElement; - private size: SizeType; + private size: null | string; - constructor(params: ProductCardParams, size: SizeType) { + constructor(params: ProductCardParams, size: null | string) { this.size = size; this.params = params; this.productImage = this.createProductImage(); @@ -62,10 +62,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 +117,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 +143,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 +158,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 +179,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..522973f6 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -3,9 +3,12 @@ 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; + background-color: var(--noble-white-200); transition: outline 0.2s, transform 0.2s; @@ -35,13 +38,13 @@ .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 { @@ -58,7 +61,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 +82,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; @@ -101,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 new file mode 100644 index 00000000..0d322835 --- /dev/null +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -0,0 +1,161 @@ +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 { 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: { + max: 0, + min: 0, + }, + size: null, + }; + + private view: ProductFiltersView; + + constructor(params: ProductFiltersParams) { + this.params = params; + this.view = new ProductFiltersView(params); + this.init(); + } + + private init(): void { + this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; + this.initCategoryFilters(); + this.initPriceFilters(); + this.initSizeFilters(); + this.setResetFiltersButtonHandler(); + + observeStore(selectCurrentLanguage, () => { + this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; + this.initSizeFilters(); + this.initCategoryFilters(); + }); + } + + private initCategoryFilters(): void { + this.setCategoryLinksHandlers(); + const categoryLinks = this.view.getCategoryLinks(); + this.selectedFilters.category.forEach((categoryID) => { + const currentLink = categoryLinks.find((link) => link.getHTML().id === categoryID); + if (currentLink) { + this.view.switchSelectedFilter(currentLink, true); + } + }); + } + + private initPriceFilters(): void { + 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; + } + 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 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.switchSelectedFilter(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)); + }); + }); + } + + 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', () => { + 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(); + } + + 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..4dc0a306 --- /dev/null +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -0,0 +1,365 @@ +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_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'; + +import styles from './productFiltersView.module.scss'; + +const BASE_FILTER_LINK_COUNT = '(0)'; +const SLIDER_PRICE_OFFSET = 10; +const INPUT_PRICE_STEP = 5; + +class ProductFiltersView { + private categoryCountSpan: HTMLSpanElement[] = []; + + private categoryLinks: LinkModel[] = []; + + private categoryList: HTMLUListElement; + + private filters: HTMLDivElement; + + private params: ProductFiltersParams; + + private priceInputs: Map = new Map(); + + private priceSlider: noUiSlider.API; + + private resetFiltersButton: ButtonModel; + + 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.resetFiltersButton = this.createResetFiltersButton(); + 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_FILTER_LINK_COUNT, + tag: 'span', + }); + + this.categoryCountSpan.push(span); + categoryLink.getHTML().append(span); + + 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 { + const { filtersList, filtersListTitle } = this.createFiltersList( + { + list: [styles.categoryList], + title: [styles.categoryTitle], + }, + FILTER_TITLE[getStore().getState().currentLanguage].CATEGORY, + ); + + this.categoryList = filtersList; + + 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(filtersListTitle); + filtersListTitle.textContent = FILTER_TITLE[getStore().getState().currentLanguage].CATEGORY; + this.params.categories?.forEach((category) => { + this.categoryList.append(this.createCategoryLink(category).getHTML()); + }); + }); + + 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.sizesList, + this.resetFiltersButton.getHTML(), + ); + + return this.filters; + } + + private createPriceLabel(direction: string): { + priceLabel: HTMLLabelElement; + priceSpan: HTMLSpanElement; + } { + 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: 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 === 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, 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', + }, + cssClasses: [styles.slider], + tag: 'div', + }); + + this.priceSlider = noUiSlider.create(slider, { + behaviour: 'tap', + connect: true, + keyboardSupport: true, + range: this.params.priceRange ?? { max: 0, min: 0 }, + start: [SLIDER_START_MIN, SLIDER_START_MAX], + }); + + return this.priceSlider; + } + + private createPriceWrapper(): HTMLDivElement { + const priceWrapper = createBaseElement({ + cssClasses: [styles.priceWrapper], + tag: 'div', + }); + + const title = createBaseElement({ + cssClasses: [styles.priceTitle], + innerContent: FILTER_TITLE[getStore().getState().currentLanguage].PRICE, + tag: 'h3', + }); + + 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(title, 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 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: { + 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; + } + + public getFiltersResetButton(): ButtonModel { + return this.resetFiltersButton; + } + + public getHTML(): HTMLDivElement { + return this.filters; + } + + public getPriceInputs(): Map { + return this.priceInputs; + } + + public getPriceSlider(): noUiSlider.API { + return this.priceSlider; + } + + 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.categoriesProductCount?.forEach((categoryCount) => { + const currentSpan = this.categoryCountSpan.find((span) => span.id === categoryCount.category.id) ?? null; + if (currentSpan) { + currentSpan.innerText = `(${categoryCount.count})`; + } + }); + } +} + +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..9f730df1 --- /dev/null +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -0,0 +1,138 @@ +.filters { + order: 1; + outline: 2px solid var(--noble-white-200); + border-radius: var(--medium-br); + width: 20%; + height: max-content; + background-color: var(--noble-white-200); +} + +.categoryList, +.sizesList { + display: flex; + flex-direction: column; + align-items: center; + border-radius: var(--medium-br); + width: 100%; + background-color: var(--noble-white-200); +} + +.categoryTitle, +.priceTitle, +.sizesTitle { + padding: calc(var(--extra-small-offset)); + width: 100%; + font: var(--medium-font); + letter-spacing: 1px; + text-align: left; + color: var(--steam-green-500); +} + +.categoryItem, +.sizeItem { + width: 100%; +} + +.categoryLink, +.sizeLink { + 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; + } +} + +.sizeLink { + &.activeLink { + cursor: default; + } +} + +.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); + } + } +} + +.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); +} + +.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/Input/view/InputView.ts b/src/shared/Input/view/InputView.ts index 4d6bdce8..785e59a9 100644 --- a/src/shared/Input/view/InputView.ts +++ b/src/shared/Input/view/InputView.ts @@ -15,8 +15,12 @@ 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 || '', }, tag: 'input', }); 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%); +} 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..15533c95 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,25 @@ export const selectIsUserLoggedIn = (state: State): boolean => state.isUserLogge export const selectCurrentPage = (state: State): string => state.currentPage; +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 const selectSelectedFiltersSize = (state: State): null | string => { + if (state.selectedFilters) { + return state.selectedFilters.size; + } + 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/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..80807786 --- /dev/null +++ b/src/shared/constants/filters.ts @@ -0,0 +1,32 @@ +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; + +export const FILTER_RESET_BUTTON = { + en: { + RESET: 'Reset', + }, + ru: { + RESET: 'Сбросить', + }, +} as const; diff --git a/src/shared/constants/initialState.ts b/src/shared/constants/initialState.ts index eabf69a0..ad452aad 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/constants/initialState.ts @@ -9,6 +9,11 @@ const initialState: State = { isAppThemeLight: true, isUserLoggedIn: false, products: [], + selectedFilters: { + category: new Set(), + price: null, + size: null, + }, shippingCountry: '', }; diff --git a/src/shared/constants/pages.ts b/src/shared/constants/pages.ts index c9d744aa..a5cf998e 100644 --- a/src/shared/constants/pages.ts +++ b/src/shared/constants/pages.ts @@ -99,3 +99,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/form.ts b/src/shared/types/form.ts index d703acf4..1c60a3d2 100644 --- a/src/shared/types/form.ts +++ b/src/shared/types/form.ts @@ -2,8 +2,12 @@ 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; } export interface LabelParams { diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts new file mode 100644 index 00000000..8f0a0111 --- /dev/null +++ b/src/shared/types/productFilters.ts @@ -0,0 +1,29 @@ +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 { + category: Set; + price: { + max: number; + min: number; + } | null; + size: null | string; +} + +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; 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 23ce57d2..d67a2549 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -1,49 +1,116 @@ +import type ProductFiltersParams from '@/shared/types/productFilters.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 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'; import CatalogView from '../view/CatalogView.ts'; class CatalogModel { + private productFilters: ProductFiltersModel | null = null; + private view = new CatalogView(); constructor() { - this.getProductItems(); + this.init(); + } + + private async getProductItems(options: OptionsRequest): Promise { + 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); + const priceRange = await getProductModel().getPriceRange(); + const sizes = await getProductModel().getSizeProductCount(); + const categoriesProductCount = await getProductModel().getCategoriesProductCount(); + return { categories, categoriesProductCount, priceRange, products, sizes }; + } catch { + showBadRequestMessage(); + } + } catch { + showBadRequestMessage(); + } finally { + loader.getHTML().remove(); + } + + return null; + } + + private getSelectedFilters(): OptionsRequest { + const { category, price, size } = getStore().getState().selectedFilters || {}; + const filter: OptionsRequest['filter'] = []; + category?.forEach((categoryID) => filter.push(addFilter(FilterFields.CATEGORY, categoryID))); + if (price?.max || price?.min) { + filter.push(addFilter(FilterFields.PRICE, price)); + } + if (size) { + filter.push(addFilter(FilterFields.SIZE, size)); + } + + return { filter, limit: 100, sort: { direction: 'desc', field: 'name', locale: 'en' } }; } - private getProductItems(): void { + 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, 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()); + + observeSetInStore(selectSelectedFiltersCategory, () => this.redrawProductList(this.getSelectedFilters())); + observeStore(selectSelectedFiltersPrice, () => this.redrawProductList(this.getSelectedFilters())); + observeStore(selectSelectedFiltersSize, () => this.redrawProductList(this.getSelectedFilters())); + } - const options: OptionsRequest = { - filter: [addFilter(FilterFields.SIZE, 'S')], - limit: 5, - sort: { - direction: 'asc', - field: 'name', - locale: 'en', - }, - }; - - getProductModel() - .getProducts(options) + private redrawProductList(options?: OptionsRequest): void { + const productList = this.view.getItemsList(); + const { selectedFilters } = getStore().getState(); + 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, selectedFilters ? selectedFilters.size : null).getHTML(), + ), + ); + this.productFilters?.updateParams(data); + } 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..9bfd5837 100644 --- a/src/widgets/Catalog/view/catalogView.module.scss +++ b/src/widgets/Catalog/view/catalogView.module.scss @@ -1,12 +1,23 @@ +.catalog { + position: relative; + display: flex; + justify-content: space-between; + min-height: max-content; + 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, auto); + width: 80%; + height: max-content; + 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/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 5cfb47d2..53344bb6 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, @@ -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 2b3c8e65..57490c8c 100644 --- a/src/widgets/Header/view/headerView.module.scss +++ b/src/widgets/Header/view/headerView.module.scss @@ -3,10 +3,11 @@ left: 0; right: 0; top: 0; - z-index: 1; + z-index: 10; border-bottom: 1px solid var(--steam-green-300); width: 100%; - background-color: var(--white); + background-color: var(--noble-gray-1000); + backdrop-filter: blur(10px); } .wrapper { @@ -24,19 +25,19 @@ .navigationWrapper { position: fixed; right: -100%; - top: 0; - z-index: 2; + top: 69px; + z-index: 12; display: flex; flex-direction: column; align-items: center; order: 3; padding: 70px var(--extra-small-offset) 0; width: 20%; - height: 100%; - background-color: var(--steam-green-gr-800); + height: 100dvh; + background-color: var(--noble-gray-1000); transform: none; transition: transform 0.3s; - backdrop-filter: blur(2px); + backdrop-filter: blur(10px); gap: var(--medium-offset); &.open { 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)))