diff --git a/.github/labeler.yml b/.github/labeler.yml index 26585235..1f864ee7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -48,3 +48,6 @@ sprint3: sprint4: - head-branch: 'ECOMM-4' + +sprint5: + - head-branch: 'ECOMM-5' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8e1c0deb..ba4b9165 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,10 +3,10 @@ πŸ“ conforms with the following format: - [ ] prefix (following the [convention](https://www.conventionalcommits.org/en/v1.0.0-beta.2/): `feat`, `fix`, `hotfix`, `chore`, `refactor`, `revert`, `docs`, `style`, `test`) -- [ ] sprint and issue number (e.g. `RSS-ECOMM-3_01`, where `3` - is the sprint number and `01` - is the issue number) +- [ ] sprint and issue number (e.g. `RSS-ECOMM-5_01`, where `5` - is the sprint number and `01` - is the issue number) - [ ] short description -πŸ‘€ Example: `feat(RSS-ECOMM-3_01): description` +πŸ‘€ Example: `feat(RSS-ECOMM-5_01): description` ## PR Description πŸ§™β€β™‚οΈ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 560f20e6..83fa1194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - sprint-2 - sprint-3 - sprint-4 + - sprint-5 jobs: format: diff --git a/.validate-branch-namerc.cjs b/.validate-branch-namerc.cjs index 15ea94ee..2c0fefe4 100644 --- a/.validate-branch-namerc.cjs +++ b/.validate-branch-namerc.cjs @@ -1,5 +1,6 @@ module.exports = { - pattern: /^(feat|fix|hotfix|chore|refactor|revert|docs|style|test|)\(RSS-ECOMM-\d_\d{2}\)\/[a-z]+[a-zA-Z0-9]*$/, + pattern: + /^sprint-4|^(feat|fix|hotfix|chore|refactor|revert|docs|style|test|)\(RSS-ECOMM-\d_\d{2}\)\/[a-z]+[a-zA-Z0-9]*$/, errorMsg: 'Please use correct branch name', }; @@ -7,3 +8,6 @@ module.exports = { // "feat(RSS-ECOMM-1_01)/addNewProduct" // where 1 is the sprint number and 01 is the issue number // "fix(RSS-ECOMM-2_15)/addCorrectProduct" // where 2 is the sprint number and 15 is the issue number +// "chore(RSS-ECOMM-3_01)/addNewProduct" // where 3 is the sprint number and 01 is the issue number +// "refactor(RSS-ECOMM-4_01)/addNewProduct" // where 4 is the sprint number and 01 is the issue number +// "revert(RSS-ECOMM-5_01)/addNewProduct" // where 5 is the sprint number and 01 is the issue number diff --git a/package.json b/package.json index a67e8a92..363df682 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,11 @@ "@commercetools/sdk-client-v2": "^2.5.0", "@commercetools/sdk-middleware-auth": "^7.0.1", "@commercetools/sdk-middleware-http": "^7.0.4", + "@types/hammerjs": "^2.0.45", "@types/js-cookie": "^3.0.6", + "@types/uuid": "^9.0.8", "autoprefixer": "^10.4.19", + "hammerjs": "^2.0.8", "isomorphic-fetch": "^3.0.0", "js-cookie": "^3.0.5", "materialize-css": "^1.0.0-rc.2", @@ -75,6 +78,7 @@ "postcode-validator": "^3.8.20", "sharp": "^0.33.3", "swiper": "^11.1.3", + "uuid": "^9.0.1", "vite-plugin-checker": "^0.6.4", "vite-plugin-image-optimizer": "^1.1.7", "vite-plugin-svg-spriter": "^1.0.0", diff --git a/public/img/png/instagram.png b/public/img/png/instagram.png index 0cfa8b9c..94d29c5f 100644 Binary files a/public/img/png/instagram.png and b/public/img/png/instagram.png differ diff --git a/public/img/png/instagram_.png b/public/img/png/instagram_.png new file mode 100644 index 00000000..42ac440e Binary files /dev/null and b/public/img/png/instagram_.png differ diff --git a/public/img/png/linkedin.png b/public/img/png/linkedin.png index 417c3c4d..0c3cec3e 100644 Binary files a/public/img/png/linkedin.png and b/public/img/png/linkedin.png differ diff --git a/public/img/png/meta.png b/public/img/png/meta.png index 7e7cb68d..0798af01 100644 Binary files a/public/img/png/meta.png and b/public/img/png/meta.png differ diff --git a/public/img/png/pay-ae.png b/public/img/png/pay-ae.png new file mode 100644 index 00000000..1910764d Binary files /dev/null and b/public/img/png/pay-ae.png differ diff --git a/public/img/png/pay-mc.png b/public/img/png/pay-mc.png new file mode 100644 index 00000000..06679ce8 Binary files /dev/null and b/public/img/png/pay-mc.png differ diff --git a/public/img/png/pay-pp.png b/public/img/png/pay-pp.png new file mode 100644 index 00000000..d4e5fd2e Binary files /dev/null and b/public/img/png/pay-pp.png differ diff --git a/public/img/png/pay-visa.png b/public/img/png/pay-visa.png new file mode 100644 index 00000000..312542a9 Binary files /dev/null and b/public/img/png/pay-visa.png differ diff --git a/public/img/png/twitterx.png b/public/img/png/twitterx.png index 406ebec1..1e5a1158 100644 Binary files a/public/img/png/twitterx.png and b/public/img/png/twitterx.png differ diff --git a/public/img/png/union.png b/public/img/png/union.png index 9dd1fd59..c573203c 100644 Binary files a/public/img/png/union.png and b/public/img/png/union.png differ diff --git a/src/app/App/model/AppModel.ts b/src/app/App/model/AppModel.ts index 9ef6b96e..0adc04b7 100644 --- a/src/app/App/model/AppModel.ts +++ b/src/app/App/model/AppModel.ts @@ -3,6 +3,7 @@ import type { Page, PageParams, PagesType } from '@/shared/types/page.ts'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; +import ScrollToTopModel from '@/shared/ScrollToTop/model/ScrollToTopModel.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; import FooterModel from '@/widgets/Footer/model/FooterModel.ts'; import HeaderModel from '@/widgets/Header/model/HeaderModel.ts'; @@ -32,9 +33,9 @@ class AppModel { const { default: CartPageModel } = await import('@/pages/CartPage/model/CartPageModel.ts'); return new CartPageModel(this.appView.getHTML()); }, - [PAGE_ID.CATALOG_PAGE]: async (params: PageParams): Promise => { + [PAGE_ID.CATALOG_PAGE]: async (): Promise => { const { default: CatalogPageModel } = await import('@/pages/CatalogPage/model/CatalogPageModel.ts'); - return new CatalogPageModel(this.appView.getHTML(), params); + return new CatalogPageModel(this.appView.getHTML()); }, [PAGE_ID.DEFAULT_PAGE]: async (): Promise => { const { default: MainPageModel } = await import('@/pages/MainPage/model/MainPageModel.ts'); @@ -77,7 +78,8 @@ class AppModel { private async initialize(): Promise { const routes = await this.createRoutes(); const router = new RouterModel(routes); - document.body.append(this.appView.getHTML()); + const scrollToTop = new ScrollToTopModel(); + document.body.append(this.appView.getHTML(), scrollToTop.getHTML().getHTML()); this.appView.getHTML().insertAdjacentElement('beforebegin', new HeaderModel(this.appView.getHTML()).getHTML()); this.appView.getHTML().insertAdjacentElement('afterend', new FooterModel().getHTML()); this.appView.getHTML().insertAdjacentElement('afterend', modal.getHTML()); diff --git a/src/app/Router/model/RouterModel.ts b/src/app/Router/model/RouterModel.ts index dbacb44c..49b0d4f4 100644 --- a/src/app/Router/model/RouterModel.ts +++ b/src/app/Router/model/RouterModel.ts @@ -1,11 +1,12 @@ import type { PageParams, PagesType } from '@/shared/types/page'; +import getStore from '@/shared/Store/Store.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; import { isValidPath, isValidState } from '@/shared/types/validation/paths.ts'; -import formattedText from '@/shared/utils/formattedText.ts'; +import setPageTitle from '@/shared/utils/setPageTitle.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; -const PROJECT_TITLE = import.meta.env.VITE_APP_PROJECT_TITLE; const DEFAULT_SEGMENT = import.meta.env.VITE_APP_DEFAULT_SEGMENT; const NEXT_SEGMENT = import.meta.env.VITE_APP_NEXT_SEGMENT; const PATH_SEGMENTS_TO_KEEP = import.meta.env.VITE_APP_PATH_SEGMENTS_TO_KEEP; @@ -23,17 +24,19 @@ class RouterModel { this.routes = routes; document.addEventListener('DOMContentLoaded', () => { - const currentPath = window.location.pathname.slice(NEXT_SEGMENT).split(DEFAULT_SEGMENT) || PAGE_ID.DEFAULT_PAGE; + const currentPath = + (window.location.pathname + window.location.search).slice(NEXT_SEGMENT).split(DEFAULT_SEGMENT) || + PAGE_ID.DEFAULT_PAGE; this.navigateTo(currentPath.join(DEFAULT_SEGMENT)); }); window.addEventListener('popstate', (event) => { const currentState: unknown = event.state; - let currentPage = ''; + let [currentPage] = ''; let currentPath = ''; if (isValidState(currentState)) { - currentPage = currentState.path.split(DEFAULT_SEGMENT)[PATH_SEGMENTS_TO_KEEP] + DEFAULT_SEGMENT; + [currentPage] = currentState.path.split(DEFAULT_SEGMENT)[PATH_SEGMENTS_TO_KEEP].split(SEARCH_SEGMENT); currentPath = currentState.path; } @@ -46,20 +49,51 @@ class RouterModel { }); } + public static appendSearchParams(key: string, value: string): void { + const url = new URL(decodeURIComponent(window.location.href)); + url.searchParams.append(key, value); + const path = url.pathname + url.search.toString(); + window.history.pushState({ path: path.slice(NEXT_SEGMENT) }, '', path); + } + + public static clearSearchParams(): void { + const url = new URL(decodeURIComponent(window.location.href)); + const path = `${DEFAULT_SEGMENT}${url.pathname.split(DEFAULT_SEGMENT)[NEXT_SEGMENT]}${DEFAULT_SEGMENT}`; + window.history.pushState({ path: path.slice(NEXT_SEGMENT) }, '', path); + } + + public static deleteSearchParams(key: string): void { + const url = new URL(decodeURIComponent(window.location.href)); + url.searchParams.delete(key); + const path = url.pathname + url.search.toString(); + window.history.pushState({ path: path.slice(NEXT_SEGMENT) }, '', path); + } + public static getInstance(): RouterModel { return RouterModel.router; } + public static getSearchParams(): URLSearchParams { + return new URL(decodeURIComponent(window.location.href)).searchParams; + } + + public static setSearchParams(key: string, value: string): void { + const url = new URL(decodeURIComponent(window.location.href)); + url.searchParams.delete(key); + url.searchParams.set(key, value); + const path = url.pathname + url.search.toString(); + window.history.pushState({ path: path.slice(NEXT_SEGMENT) }, '', path); + } + private async checkPageAndParams( currentPage: string, path: string, ): Promise<{ hasRoute: boolean; params: PageParams } | null> { const hasRoute = this.routes.has(currentPage); - const decodePath = decodeURIComponent(path); - const id = decodePath.split(DEFAULT_SEGMENT).slice(PATH_SEGMENTS_TO_KEEP, -NEXT_SEGMENT)[NEXT_SEGMENT]; - const searchParams = decodeURIComponent(decodePath).split(SEARCH_SEGMENT)[NEXT_SEGMENT]; - const title = `${PROJECT_TITLE} | ${hasRoute ? formattedText(currentPage === PAGE_ID.DEFAULT_PAGE ? PAGE_ID.MAIN_PAGE.slice(PATH_SEGMENTS_TO_KEEP, -NEXT_SEGMENT) : currentPage.slice(PATH_SEGMENTS_TO_KEEP, -NEXT_SEGMENT)) : PAGE_ID.NOT_FOUND_PAGE.slice(PATH_SEGMENTS_TO_KEEP, -NEXT_SEGMENT)}`; - document.title = title; + const id = path.split(DEFAULT_SEGMENT)[NEXT_SEGMENT]?.split(SEARCH_SEGMENT)[PATH_SEGMENTS_TO_KEEP] || null; + + setPageTitle(currentPage, hasRoute); + observeStore(selectCurrentLanguage, () => this.checkPageAndParams(currentPage, path)); if (!hasRoute) { await this.routes.get(PAGE_ID.NOT_FOUND_PAGE)?.({}); @@ -68,12 +102,7 @@ class RouterModel { return { hasRoute, - params: { - [currentPage.slice(PATH_SEGMENTS_TO_KEEP, -NEXT_SEGMENT)]: { - id: id ?? null, - searchParams: searchParams ?? null, - }, - }, + params: { [currentPage.slice(PATH_SEGMENTS_TO_KEEP)]: { id } }, }; } @@ -88,15 +117,14 @@ class RouterModel { } public navigateTo(path: string): void { - const currentPage = path.split(DEFAULT_SEGMENT)[PATH_SEGMENTS_TO_KEEP] + DEFAULT_SEGMENT || PAGE_ID.DEFAULT_PAGE; - this.checkPageAndParams(currentPage, path) - .then((check) => { - if (check) { - this.routes.get(currentPage)?.(check.params).catch(showErrorMessage); - history.pushState({ path }, '', `/${path}`); - } - }) - .catch(showErrorMessage); + const currentPage = + path.split(DEFAULT_SEGMENT)[PATH_SEGMENTS_TO_KEEP].split(SEARCH_SEGMENT)[PATH_SEGMENTS_TO_KEEP] || + PAGE_ID.DEFAULT_PAGE; + + if (currentPage !== getStore().getState().currentPage || currentPage === PAGE_ID.DEFAULT_PAGE) { + this.handleRequest(currentPage, path); + } + history.pushState({ path }, '', `/${path}`); } } diff --git a/src/app/styles/mixins.scss b/src/app/styles/mixins.scss index 475fee90..be64c27c 100644 --- a/src/app/styles/mixins.scss +++ b/src/app/styles/mixins.scss @@ -1,3 +1,63 @@ +$transition-duration: 0.2s; +$transform-scale: 0.9; +$transform-scale-active: 0.9; +$filter-opacity: 0.5; +$filter-brightness: 1.3; +$basic-font: var(--regular-font); +$one: var(--one); +$two: var(--two); +$active-color: var(--steam-green-800); + +@mixin link($padding: 0 calc(var(--extra-small-offset) / 2), $color: var(--noble-gray-800)) { + position: relative; + display: flex; + align-items: center; + padding: $padding; + height: 100%; + font: $basic-font; + letter-spacing: $one; + text-transform: uppercase; + color: $color; + transition: color $transition-duration; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: calc($one * -1); + width: 100%; + height: $two; + background-color: currentcolor; + opacity: 0; + transform: scaleX(0); + transform-origin: center; + transition: + transform $transition-duration, + opacity $transition-duration; + } + + @media (hover: hover) { + &:hover { + color: $active-color; + + &::after { + opacity: 1; + transform: scaleX(1); + } + } + } +} + +@mixin active { + color: $active-color; + opacity: 1; + + &::after { + opacity: 1; + transform: scaleX(1); + } +} + @mixin green-btn { display: flex; align-items: center; @@ -6,28 +66,121 @@ border-radius: var(--medium-br); padding: calc(var(--small-offset) / 3) var(--small-offset); width: max-content; - font: var(--regular-font); - letter-spacing: var(--one); - color: var(--white); - background-color: var(--steam-green-800); + font: $basic-font; + letter-spacing: $one; + color: var(--button-white); + background-color: $active-color; transition: - color 0.2s, - background-color 0.2s, - transform 0.2s; + filter $transition-duration, + color $transition-duration, + background-color $transition-duration, + transform $transition-duration; + + svg { + fill: var(--noble-gray-900); + stroke: var(--noble-gray-900); + transition: + fill $transition-duration, + stroke $transition-duration; + } @media (hover: hover) { &:hover { - background-color: var(--steam-green-700); + filter: brightness($filter-brightness); + + svg { + fill: var(--noble-gray-1000); + stroke: var(--noble-gray-1000); + } } } /* stylelint-disable-next-line order/order */ &:active { - transform: scale(0.9); + transform: scale($transform-scale-active); } &:disabled { - background-color: var(--noble-gray-300); + filter: opacity($filter-opacity); pointer-events: none; } } + +@mixin round-btn( + $btn-padding: calc(var(--tiny-offset) * 1.5), + $btn-bg: var(--noble-white-100), + $btn-hover-bg: var(--white-tr), + $btn-fill: $active-color, + $btn-stroke: $active-color +) { + display: flex; + align-items: center; + justify-content: center; + justify-self: center; + border-radius: 50%; + padding: $btn-padding; + width: fit-content; + background-color: $btn-bg; + transition: + filter $transition-duration, + color $transition-duration, + background-color $transition-duration, + transform $transition-duration, + opacity $transition-duration; + + @media (hover: hover) { + &:hover { + background-color: $btn-hover-bg; + + svg { + fill: $btn-fill; + stroke: $btn-stroke; + } + } + /* stylelint-disable-next-line order/order */ + &:active { + transform: scale($transform-scale-active); + } + + &:disabled { + filter: opacity($filter-opacity); + pointer-events: none; + } + } +} + +@mixin svg-logo( + $logo-width: var(--small-offset), + $logo-height: var(--small-offset), + $logo-fill: var(--noble-gray-900), + $logo-stroke: var(--noble-gray-900) +) { + display: flex; + align-items: center; + justify-content: center; + justify-self: center; + + svg { + width: $logo-width; + height: $logo-height; + fill: $logo-fill; + stroke: $logo-stroke; + transition: + fill $transition-duration, + stroke $transition-duration; + } +} + +@mixin gradient-outline-linear($border-width, $color-center, $color-outer, $direction: to right) { + border: $border-width solid transparent; + border-image: linear-gradient($direction, $color-center 50%, $color-outer 100%) 1; + border-image-slice: 1; +} + +@mixin gradient-border-bottom-linear($border-width, $color-center, $color-outer, $direction: to right) { + border-bottom: $border-width solid transparent; + background-image: linear-gradient($direction, $color-center, $color-outer); + background-position: 0 calc(100% + #{$border-width}); + background-size: 100% $border-width; + background-repeat: no-repeat; +} diff --git a/src/app/styles/noUiSlider.scss b/src/app/styles/noUiSlider.scss index ad64af9d..c1ec9a2a 100644 --- a/src/app/styles/noUiSlider.scss +++ b/src/app/styles/noUiSlider.scss @@ -14,6 +14,13 @@ top: -0.3rem; width: var(--extra-small-offset); height: var(--extra-small-offset); + + @media (max-width: 768px) { + right: -0.5rem; + top: -0.2rem; + width: calc(var(--extra-small-offset) / 1.5); + height: calc(var(--extra-small-offset) / 1.5); + } } .noUi-handle { @@ -21,7 +28,7 @@ border: var(--two) solid var(--noble-white-100); border-radius: 50%; box-shadow: none; - background-color: var(--steam-green-800); + background-color: var(--steam-green-700); -webkit-backface-visibility: hidden; backface-visibility: hidden; transition: background-color 0.3; @@ -64,8 +71,12 @@ .noUi-connect { height: 0.5rem; - background: var(--steam-green-800); + background: var(--steam-green-700); cursor: pointer; + + @media (max-width: 768px) { + height: 0.4rem; + } } .noUi-base, @@ -85,12 +96,16 @@ .noUi-target { position: relative; border-radius: 0.25rem; - background: var(--steam-green-400); + background: var(--steam-green-1100); cursor: pointer; } .noUi-horizontal { height: 0.5rem; + + @media (max-width: 768px) { + height: 0.4rem; + } } .noUi-state-tap .noUi-connect, diff --git a/src/app/styles/variables.scss b/src/app/styles/variables.scss index 80f0fe34..fc6de1ac 100644 --- a/src/app/styles/variables.scss +++ b/src/app/styles/variables.scss @@ -1,6 +1,6 @@ :root { // body wrapper - --wrapper-width: calc(var(--large-offset) * 18); + --wrapper-width: calc(var(--large-offset) * 18); // 1440px // small vars --one: 0.0625rem; // 1px @@ -23,7 +23,7 @@ // shadows --mellow-shadow-050: 0 0 20px rgb(0 0 0 / 6%); --mellow-shadow-100: rgb(0 0 0 / 19%) 0 10px 20px, rgb(0 0 0 / 23%) 0 6px 6px; - --mellow-shadow-200: rgb(255 255 255 / 10%) 0 1px 1px 0 inset, rgb(50 50 93 / 25%) 0 50px 100px -20px, + --mellow-shadow-200: hsl(0deg 0% 100% / 10%) 0 1px 1px 0 inset, rgb(50 50 93 / 25%) 0 50px 100px -20px, rgb(0 0 0 / 30%) 0 30px 60px -30px; --mellow-shadow-300: rgb(0 0 0 / 25%) 0 14px 28px, rgb(0 0 0 / 22%) 0 10px 10px; --mellow-shadow-400: rgb(0 0 0 / 9%) 0 2px 1px, rgb(0 0 0 / 9%) 0 4px 2px, rgb(0 0 0 / 9%) 0 8px 4px, @@ -43,57 +43,90 @@ --black-font: 900 2.1875rem 'Cerapro', sans-serif; // 35px --extra-black-font: 900 2.5rem 'Cerapro', sans-serif; // 40px - // 1440px body.light { // colors - --white: #fff; - --white-tr: #ffffffa9; - --black: #000; - --noble-white-100: #f5f5f5; - --noble-white-200: #f0f0f0; - --noble-gray-200: #eaeaea; - --noble-gray-300: #cbcbcb; - --noble-gray-400: #c4c4c4; - --noble-gray-500: #acacac; - --noble-gray-600: #a5a5a5; - --noble-gray-700: #727272; - --noble-gray-800: #3d3d3d; - --noble-gray-tr-800: #acacacbd; - --noble-gray-900: #1d1d1d; - --noble-gray-1000: #1a1a1ab5; - --red-power-600: #d0302f; - --steam-green-300: #46a35880; - --steam-green-350: #46a35937; - --steam-green-400: #c8f4b4; - --steam-green-500: #b6f09c; - --steam-green-700: #70d27a; - --steam-green-800: #46a358; - --steam-green-900: #46a3581a; - --steam-green-gr-800: #8c998f2b; + // basic colors: + --white: #fff; // table background, buttons text, blog post footer, blog text background, user menu, form background + --button-white: #fff; // buttons text + + // backgrounds: + --noble-white-100: #f5f5f5; // page background, edit forms background + --noble-white-200: #f0f0f0; // catalog filters background and product cards + --white-tr: #ffffffa9; // footer background, modal background, pagination background, card buttons background + --noble-gray-200: #eaeaea; // loader / input basic + --noble-gray-1000: #1a1a1ab5; // header background + --steam-green-900: #46a3581a; // blog background, personal info background + --steam-green-1000: #46a35937; // footer links background, pics round thingies + + // tranparent colors: + --noble-gray-tr-800: #acacacbd; // not active labels, tumblers + --noble-gray-tr-900: #c4c4c4a8; // not active labels, tumblers + --steam-green-300: #46a35880; // header outline, social media buttons outline + + // outline: + --noble-gray-300: #cbcbcb; // item cards and pagination outline + + // text colors: + --black: #000; // blog titles, footer titles + --red-power-600: #d0302f; // error messages, form errors + --noble-gray-400: #c4c4c4; // item cards description + --noble-gray-500: #acacac; // form titles + --noble-gray-600: #a5a5a5; // placeholders + --noble-gray-700: #727272; // blog/footer text + --noble-gray-800: #3d3d3d; // links, labels, base text, disabled svg, filters, descriptions + --noble-gray-900: #1d1d1d; // user profile text, buttons + --noble-gray-1100: #c4c4c4; // navigation links + + // highlight colors: + --steam-green-400: #4b8f3a; // price inputs, filter highlight, pagination active, product info titles + --steam-green-500: #5a9f50; // new price, product name, filter titles + --steam-green-700: #70d27a; // price slider, more, added to wishlist + --steam-green-800: #46a358; // main green: buttons, logo, active links, burger, scroll, blog posts details, cart price, active svg + --steam-green-1100: #a7e599; // theme tumbler round thingy } body.dark { // colors - --black: #fff; - --white: #1a1a1a; - --white-tr: #1a1a1ab0; - --noble-white-100: #2b2b2b; - --noble-white-200: #4a4a4a; - --noble-gray-200: #f6f6f6; - --noble-gray-300: #dcdbdb; - --noble-gray-400: #ddd; - --noble-gray-500: #cfcfcf; - --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-350: #46a35937; - --steam-green-400: #c8f4b4; - --steam-green-500: #b6f09c; - --steam-green-700: #70d27a; - --steam-green-800: #46a358; - --steam-green-gr-800: #c5e1cb2b; + // colors that mustn't change + --button-white: #fff; // buttons text + --red-power-600: #d0302f; // error messages, form errors + --steam-green-300: #46a35880; // header outline, social media buttons outline + --steam-green-800: #46a358; // main green: buttons, logo, active links, burger, scroll, blog posts details, cart price, active svg + --steam-green-900: #46a3581a; // blog background, personal info background + --steam-green-1000: #46a35937; // footer links background, pics round thingies + --steam-green-1100: #a7e599; // theme tumbler round thingy + --noble-gray-1000: #1a1a1ab5; // header background + --noble-gray-tr-900: #c4c4c4a8; // not active labels, tumblers + --noble-gray-1100: #ccc; // navigation links + + // basic colors: + + --white: #000; // table background, buttons text, blog post footer, blog text background, user menu, form background + + // backgrounds: + --noble-white-100: #2b2b2b; // page background, edit forms background + --noble-white-200: #4a4a4a; // catalog filters background and product cards + --white-tr: #1a1a1ab5; // footer background, modal background, pagination background, card buttons background + --noble-gray-200: #f6f6f6; // loader / input basic + + // tranparent colors: + --noble-gray-tr-800: #acacacbd; // not active labels, tumblers + + // outline: + --noble-gray-300: #dcdbdb; // item cards and pagination outline + + // text colors: + --black: #fff; // blog titles, footer titles + --noble-gray-400: #ddd; // item cards description + --noble-gray-500: #cfcfcf; // form titles + --noble-gray-600: #cdcdcd; // placeholders + --noble-gray-700: #b0b0b0; // blog/footer text + --noble-gray-800: #c4c4c4; // links, labels, base text, disabled svg, filters, descriptions + --noble-gray-900: #f5f5f5; // user profile text, buttons + + // highlight colors: + --steam-green-400: #c8f4b4; // price inputs, filter highlight, pagination active, product info titles + --steam-green-500: #b6f09c; // new price, product name, filter titles + --steam-green-700: #70d27a; // price slider, more, added to wishlist } } diff --git a/src/entities/Address/model/AddressModel.ts b/src/entities/Address/model/AddressModel.ts index b105c11c..4e161abc 100644 --- a/src/entities/Address/model/AddressModel.ts +++ b/src/entities/Address/model/AddressModel.ts @@ -13,7 +13,7 @@ class AddressModel { private view: AddressView; - constructor(addressType: AddressType, options: AddressOptions) { + constructor(options: AddressOptions, addressType: AddressType = ADDRESS_TYPE.GENERAL) { this.addressType = addressType; this.view = new AddressView(addressType, options); this.init(); @@ -26,9 +26,22 @@ class AddressModel { public getAddressData(personalData: PersonalData): Address { const store = getStore().getState(); + let country: string; + + switch (this.addressType) { + case ADDRESS_TYPE.BILLING: + country = store.billingCountry; + break; + case ADDRESS_TYPE.SHIPPING: + country = store.shippingCountry; + break; + default: + country = store.defaultCountry; + break; + } const addressData: Address = { city: formattedText(this.view.getCityField().getView().getValue()), - country: this.addressType === ADDRESS_TYPE.BILLING ? store.billingCountry : store.shippingCountry, + country, email: personalData.email, firstName: personalData.firstName, id: '', @@ -38,6 +51,7 @@ class AddressModel { streetName: formattedText(this.view.getStreetField().getView().getValue()), streetNumber: '', }; + return addressData; } diff --git a/src/entities/Address/view/AddressView.ts b/src/entities/Address/view/AddressView.ts index 939fdb71..596c9af3 100644 --- a/src/entities/Address/view/AddressView.ts +++ b/src/entities/Address/view/AddressView.ts @@ -212,19 +212,29 @@ class AddressView { } private createTitle(): HTMLHeadingElement { + let titleText: string; + let key: string; + switch (this.addressType) { + case ADDRESS_TYPE.BILLING: + titleText = TITLE_TEXT[getStore().getState().currentLanguage].BILLING_ADDRESS; + key = TITLE_TEXT_KEYS.BILLING_ADDRESS; + break; + case ADDRESS_TYPE.SHIPPING: + titleText = TITLE_TEXT[getStore().getState().currentLanguage].SHIPPING_ADDRESS; + key = TITLE_TEXT_KEYS.SHIPPING_ADDRESS; + break; + default: + titleText = TITLE_TEXT[getStore().getState().currentLanguage].ADDRESS; + key = TITLE_TEXT_KEYS.ADDRESS; + break; + } + const title = createBaseElement({ cssClasses: [styles.title], - innerContent: - this.addressType === ADDRESS_TYPE.SHIPPING - ? TITLE_TEXT[getStore().getState().currentLanguage].SHIPPING_ADDRESS - : TITLE_TEXT[getStore().getState().currentLanguage].BILLING_ADDRESS, + innerContent: titleText, tag: 'h3', }); - observeCurrentLanguage( - title, - TITLE_TEXT, - this.addressType === ADDRESS_TYPE.SHIPPING ? TITLE_TEXT_KEYS.SHIPPING_ADDRESS : TITLE_TEXT_KEYS.BILLING_ADDRESS, - ); + observeCurrentLanguage(title, TITLE_TEXT, key); return title; } diff --git a/src/entities/Address/view/addressView.module.scss b/src/entities/Address/view/addressView.module.scss index 23290767..61196719 100644 --- a/src/entities/Address/view/addressView.module.scss +++ b/src/entities/Address/view/addressView.module.scss @@ -40,6 +40,7 @@ .shippingAddressWrapper { grid-row: 3; + animation: show 0.5s ease-in forwards; } .billingAddressWrapper { diff --git a/src/entities/Navigation/view/navigationView.module.scss b/src/entities/Navigation/view/navigationView.module.scss index 908a3d45..e16ac383 100644 --- a/src/entities/Navigation/view/navigationView.module.scss +++ b/src/entities/Navigation/view/navigationView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .navigation { display: flex; align-items: center; @@ -6,54 +8,21 @@ order: 2; margin: 0 auto; height: calc(var(--extra-small-offset) * 3.5); // 70px -} - -.link { - position: relative; - display: flex; - align-items: center; - padding: 0 calc(var(--extra-small-offset) / 2); - height: 100%; - font: var(--regular-font); - letter-spacing: 1px; - text-transform: uppercase; - color: var(--noble-gray-800); - transition: color 0.2s; - &::after { - content: ''; - position: absolute; - left: 0; - bottom: calc(var(--one) * -1); - width: 100%; - height: var(--two); - background-color: currentcolor; - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 0.2s, - opacity 0.2s; + @media (max-width: 768px) { + align-self: auto; + justify-content: space-between; + margin: 0; } +} - @media (hover: hover) { - &:hover { - color: var(--steam-green-800); +$color: var(--noble-gray-1100); +$padding: 0 calc(var(--extra-small-offset) / 2); - &::after { - opacity: 1; - transform: scaleX(1); - } - } - } +.link { + @include link($padding, $color); } .active { - color: var(--steam-green-800); - opacity: 1; - - &::after { - opacity: 1; - transform: scaleX(1); - } + @include active; } diff --git a/src/entities/ProductCard/view/ProductCardView.ts b/src/entities/ProductCard/view/ProductCardView.ts index 598b13f7..b600eca8 100644 --- a/src/entities/ProductCard/view/ProductCardView.ts +++ b/src/entities/ProductCard/view/ProductCardView.ts @@ -8,6 +8,7 @@ import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts' import { MORE_TEXT } from '@/shared/constants/buttons.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { PRODUCT_INFO_TEXT } from '@/shared/constants/product.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; import { buildPathName } from '@/shared/utils/buildPathname.ts'; @@ -25,6 +26,8 @@ class ProductCardView { private currentSize: null | string; + private discountLabel: HTMLSpanElement; + private goDetailsPageLink: LinkModel; private moreButton: ButtonModel; @@ -51,6 +54,7 @@ class ProductCardView { this.goDetailsPageLink = this.createGoDetailsPageLink(); this.buttonsWrapper = this.createButtonsWrapper(); this.productImage = this.createProductImage(); + this.discountLabel = this.createDiscountLabel(); this.productImageWrapper = this.createProductImageWrapper(); this.productName = this.createProductName(); this.productShortDescription = this.createProductShortDescription(); @@ -113,6 +117,30 @@ class ProductCardView { return this.buttonsWrapper; } + private createDiscountLabel(): HTMLSpanElement { + const currentVariant = this.params.variant.find(({ size }) => size === this.currentSize) ?? this.params.variant[0]; + const innerContent = `${Math.round((1 - currentVariant.discount / currentVariant.price) * 100)}%`; + this.discountLabel = createBaseElement({ + cssClasses: [styles.discountLabel], + innerContent, + tag: 'span', + }); + + const discountSpan = createBaseElement({ + cssClasses: [styles.discountSpan], + innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DISCOUNT_LABEL, + tag: 'span', + }); + + observeStore(selectCurrentLanguage, () => { + discountSpan.textContent = PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DISCOUNT_LABEL; + }); + + this.discountLabel.append(discountSpan); + + return this.discountLabel; + } + private createGoDetailsPageLink(): LinkModel { const href = `${buildPathName(PAGE_ID.PRODUCT_PAGE, this.params.key, { size: [this.currentSize ?? this.params.variant[0].size], @@ -181,6 +209,10 @@ class ProductCardView { this.productImage.classList.remove(styles.hidden); loader.remove(); }); + + if (this.params.variant.some(({ discount }) => discount)) { + this.productImageWrapper.append(this.discountLabel); + } return this.productImageWrapper; } diff --git a/src/entities/ProductCard/view/productCardView.module.scss b/src/entities/ProductCard/view/productCardView.module.scss index 27dd52ac..08118b0f 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -4,7 +4,7 @@ flex-direction: column; align-items: center; justify-self: center; - outline: var(--two) solid var(--noble-gray-300); + outline: calc(var(--one) * 1.5) solid var(--noble-gray-300); border-radius: var(--medium-br); min-height: calc(var(--extra-small-offset) * 17.5); // 350px max-width: calc(var(--extra-large-offset) * 2.5); // 250px @@ -16,7 +16,7 @@ @media (hover: hover) { &:hover { - outline: var(--two) solid var(--steam-green-800); + outline: calc(var(--one) * 1.5) solid var(--steam-green-800); transform: scale(0.98); } } @@ -71,15 +71,13 @@ .switchToWishListButton, .goDetailsPageLink { position: relative; - outline: var(--two) solid var(--noble-gray-700); + outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); border-radius: var(--medium-br); padding: var(--tiny-offset); width: var(--small-offset); height: var(--small-offset); - background-color: var(--noble-gray-1000); - transition: - transform 0.2s, - outline 0.2s; + background-color: var(--white-tr); + transition: outline 0.2s; backdrop-filter: blur(10px); svg { @@ -98,7 +96,7 @@ .goDetailsPageLink { @media (hover: hover) { &:hover { - outline: var(--two) solid var(--steam-green-400); + outline: calc(var(--one) * 1.5) solid var(--steam-green-400); svg { fill: var(--steam-green-400); @@ -115,7 +113,7 @@ } .inWishList { - outline: var(--two) solid var(--red-power-600); + outline: calc(var(--one) * 1.5) solid var(--red-power-600); svg { fill: var(--red-power-600); @@ -125,7 +123,7 @@ .switchToWishListButton { @media (hover: hover) { &:hover { - outline: var(--two) solid var(--red-power-600); + outline: calc(var(--one) * 1.5) solid var(--red-power-600); svg { fill: var(--red-power-600); @@ -136,7 +134,7 @@ &.inWishList { @media (hover: hover) { &:hover { - outline: var(--two) solid var(--noble-gray-700); + outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); svg { fill: var(--noble-gray-700); @@ -151,6 +149,7 @@ flex-grow: 1; flex-direction: column; align-items: center; + border-radius: var(--medium-br); padding: calc(var(--extra-small-offset) / 2) calc(var(--extra-small-offset) / 4); width: 100%; background-color: var(--noble-white-200); @@ -175,7 +174,7 @@ letter-spacing: var(--one); text-align: left; text-overflow: ellipsis; - color: var(--noble-gray-400); + color: var(--noble-gray-700); -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -192,6 +191,7 @@ order: 4; margin-top: calc(var(--extra-small-offset) * (-0.2)); margin-right: var(--five); + padding: var(--five); font: var(--regular-font); letter-spacing: var(--one); color: var(--steam-green-700); @@ -207,3 +207,20 @@ .hidden { display: none; } + +.discountLabel { + position: absolute; + left: 0; + top: calc(var(--two) * 5); + z-index: 1; + display: flex; + border-radius: 0 var(--five) var(--five) 0; + padding: calc(var(--tiny-offset) / 2); + font: var(--regular-font); + letter-spacing: var(--one); + text-align: center; + text-transform: uppercase; + color: var(--steam-green-1100); + background-color: var(--steam-green-800); + gap: var(--two); +} diff --git a/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts b/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts index 9737f4f1..1629a884 100644 --- a/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts +++ b/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts @@ -13,6 +13,8 @@ const SLIDER_DELAY = 5000; const SLIDER_PER_VIEW = 1; class ProductModalSliderModel { + private modalSlider: Swiper | null = null; + private view: ProductModalSliderView; constructor(params: ProductInfoParams) { @@ -21,7 +23,7 @@ class ProductModalSliderModel { } private init(): void { - const modalSlider = new Swiper(this.view.getModalSlider(), { + this.modalSlider = new Swiper(this.view.getModalSlider(), { autoplay: { delay: SLIDER_DELAY, }, @@ -34,12 +36,15 @@ class ProductModalSliderModel { }, slidesPerView: SLIDER_PER_VIEW, }); - modalSlider.autoplay.start(); } public getHTML(): HTMLDivElement { return this.view.getHTML(); } + + public getModalSlider(): Swiper | null { + return this.modalSlider; + } } export default ProductModalSliderModel; diff --git a/src/entities/ProductModalSlider/view/ProductModalSliderView.ts b/src/entities/ProductModalSlider/view/ProductModalSliderView.ts index a2f1da7b..a069842c 100644 --- a/src/entities/ProductModalSlider/view/ProductModalSliderView.ts +++ b/src/entities/ProductModalSlider/view/ProductModalSliderView.ts @@ -4,11 +4,12 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import SVG_DETAILS from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; import styles from './productModalSliderView.module.scss'; -const BIG_SLIDER_WIDTH = 40; const CLOSE_BUTTON_CONTENT = 'x'; class ProductModalSliderView { @@ -65,9 +66,6 @@ class ProductModalSliderView { tag: 'div', }); - const maxWidth = BIG_SLIDER_WIDTH; - slider.style.maxWidth = `${maxWidth}rem`; - slider.append(this.createModalSliderWrapper()); return slider; } @@ -123,6 +121,10 @@ class ProductModalSliderView { this.nextSlideButton = new ButtonModel({ classes: [styles.nextSlideButton], }); + + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.ARROW_UP)); + this.nextSlideButton.getHTML().append(svg); return this.nextSlideButton; } @@ -130,6 +132,9 @@ class ProductModalSliderView { this.prevSlideButton = new ButtonModel({ classes: [styles.prevSlideButton], }); + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.ARROW_UP)); + this.prevSlideButton.getHTML().append(svg); return this.prevSlideButton; } diff --git a/src/entities/ProductModalSlider/view/productModalSliderView.module.scss b/src/entities/ProductModalSlider/view/productModalSliderView.module.scss index 5dcfc78e..1888039c 100644 --- a/src/entities/ProductModalSlider/view/productModalSliderView.module.scss +++ b/src/entities/ProductModalSlider/view/productModalSliderView.module.scss @@ -2,23 +2,43 @@ margin: 0; width: 100%; min-height: 40rem; + max-width: 40rem; + + @media (max-width: 768px) { + min-height: 24rem; + max-width: 24rem; + } } .modalSliderWrapper { height: auto; min-height: 40rem; + + @media (max-width: 768px) { + min-height: 24rem; + } } .modalSliderImage { width: 100%; height: 100%; min-height: 40rem; + + @media (max-width: 768px) { + min-height: 24rem; + } } .modalSliderSlide { /* stylelint-disable-next-line declaration-no-important */ width: 40rem !important; min-height: 40rem; + + @media (max-width: 768px) { + /* stylelint-disable-next-line declaration-no-important */ + width: 24rem !important; + min-height: 24rem; + } } .modalCloseButton { @@ -58,8 +78,9 @@ .nextSlideButton, .prevSlideButton { - position: relative; - display: block; + display: flex; + align-items: center; + justify-content: center; border: var(--one) solid var(--noble-gray-200); border-radius: 50%; width: 3rem; @@ -72,10 +93,21 @@ border-color 0.2s, transform 0.2s; + svg { + width: 2.5rem; + height: 2.5rem; + stroke: var(--steam-green-800); + transition: stroke 0.2s; + } + @media (hover: hover) { &:hover { border-color: var(--steam-green-400); color: var(--steam-green-700); + + svg { + stroke: var(--steam-green-1100); + } } } @@ -86,22 +118,14 @@ } .nextSlideButton { - &::after { - content: '↑'; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%) rotate(90deg); + svg { + transform: rotate(90deg); } } .prevSlideButton { - &::after { - content: '↑'; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%) rotate(-90deg); + svg { + transform: rotate(-90deg); } } diff --git a/src/entities/ProductPrice/view/productPriceView.scss b/src/entities/ProductPrice/view/productPriceView.scss index 71666994..9e36a732 100644 --- a/src/entities/ProductPrice/view/productPriceView.scss +++ b/src/entities/ProductPrice/view/productPriceView.scss @@ -37,6 +37,10 @@ justify-content: start; order: 2; margin-top: 0; - margin-bottom: var(--extra-small-offset); + + @media (max-width: 768px) { + align-items: center; + justify-content: center; + } } } diff --git a/src/entities/UserAddress/model/UserAddressModel.ts b/src/entities/UserAddress/model/UserAddressModel.ts index 4af86fb4..77e2ad1a 100644 --- a/src/entities/UserAddress/model/UserAddressModel.ts +++ b/src/entities/UserAddress/model/UserAddressModel.ts @@ -1,11 +1,17 @@ import type { Address, User } from '@/shared/types/user.ts'; -// import AddressEditModel from '@/features/AddressEdit/model/AddressEditModel.ts'; +import AddressEditModel from '@/features/AddressEdit/model/AddressEditModel.ts'; import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/CustomerModel.ts'; +import ConfirmModel from '@/shared/Confirm/model/ConfirmModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; -import { USER_ADDRESS_TYPE, type UserAddressType } from '@/shared/constants/forms.ts'; +import getStore from '@/shared/Store/Store.ts'; +import { setBillingCountry } from '@/shared/Store/actions.ts'; +import { USER_MESSAGE } from '@/shared/constants/confirmUserMessage.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { ADDRESS_TYPE, type AddressTypeType } from '@/shared/constants/forms.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; @@ -13,49 +19,107 @@ import showErrorMessage from '@/shared/utils/userMessage.ts'; import UserAddressView from '../view/UserAddressView.ts'; class UserAddressModel { + private callback: (isDisabled: boolean) => void; + + private currentAddress: Address; + + private labels: Map; + private view: UserAddressView; - constructor(user: User, address: Address, type: UserAddressType, defaultAddressId: string) { - this.view = new UserAddressView(user, address, type, defaultAddressId); - this.setDeleteButtonHandler(address, type); - this.setEditButtonHandler(address, type); + constructor( + address: Address, + activeTypes: AddressTypeType[], + callback: (isDisabled: boolean) => void, + inactiveTypes?: AddressTypeType[], + ) { + this.callback = callback; + this.currentAddress = address; + this.view = new UserAddressView(address, activeTypes, inactiveTypes); + this.labels = this.view.getLabels(); + this.setEditButtonHandler(address); + this.setDeleteButtonHandler(address); + this.setLabelClickHandler(); } - private setDeleteButtonHandler(address: Address, type: UserAddressType): void { - this.view - .getDeleteButton() - .getHTML() - .addEventListener('click', async () => { - const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); - this.view.getDeleteButton().getHTML().append(loader); + private async deleteAddress(address: Address): Promise { + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { try { - const user = await getCustomerModel().getCurrentUser(); - if (user) { - try { - if (type === USER_ADDRESS_TYPE.BILLING) { - await getCustomerModel().editCustomer([CustomerModel.actionRemoveBillingAddress(address)], user); - await getCustomerModel().editCustomer([CustomerModel.actionRemoveAddress(address)], user); - // TBD Check requests to delete address - } - if (type === USER_ADDRESS_TYPE.SHIPPING) { - await getCustomerModel().editCustomer([CustomerModel.actionRemoveShippingAddress(address)], user); - } - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_DELETED, MESSAGE_STATUS.SUCCESS); - this.view.getHTML().remove(); - } catch (error) { - showErrorMessage(); - } - } + await getCustomerModel().editCustomer([CustomerModel.actionRemoveAddress(address)], user); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_DELETED, MESSAGE_STATUS.SUCCESS); } catch (error) { - showErrorMessage(); - } finally { - loader.remove(); + showErrorMessage(error); } + } + } catch (error) { + showErrorMessage(error); + } + } + + private async handleAddressType(user: User, activeType: AddressTypeType, inactive: boolean): Promise { + const customerModel = getCustomerModel(); + + const actions = { + [ADDRESS_TYPE.BILLING]: inactive + ? CustomerModel.actionAddBillingAddress(this.currentAddress.id) + : CustomerModel.actionRemoveBillingAddress(this.currentAddress), + [ADDRESS_TYPE.DEFAULT_BILLING]: inactive + ? CustomerModel.actionEditDefaultBillingAddress(this.currentAddress.id) + : CustomerModel.actionEditDefaultBillingAddress(undefined), + [ADDRESS_TYPE.DEFAULT_SHIPPING]: inactive + ? CustomerModel.actionEditDefaultShippingAddress(this.currentAddress.id) + : CustomerModel.actionEditDefaultShippingAddress(undefined), + [ADDRESS_TYPE.SHIPPING]: inactive + ? CustomerModel.actionAddShippingAddress(this.currentAddress.id) + : CustomerModel.actionRemoveShippingAddress(this.currentAddress), + }; + + const action = actions[activeType]; + + if (action) { + await customerModel.editCustomer([action], user); + } + } + + private async labelClickHandler(activeType: AddressTypeType, inactive?: boolean): Promise { + const loader = new LoaderModel(LOADER_SIZE.MEDIUM); + loader.setAbsolutePosition(); + this.callback(true); + this.view.toggleState(true); + this.getHTML().append(loader.getHTML()); + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + await this.handleAddressType(user, activeType, inactive ?? false); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_STATUS_CHANGED, MESSAGE_STATUS.SUCCESS); + } + } catch (error) { + showErrorMessage(error); + } finally { + this.view.toggleState(false); + loader.getHTML().remove(); + } + } + + private setDeleteButtonHandler(address: Address): void { + this.view + .getDeleteButton() + .getHTML() + .addEventListener('click', () => { + const confirmModel = new ConfirmModel( + () => this.deleteAddress(address), + USER_MESSAGE[getStore().getState().currentLanguage].DELETE_ADDRESS, + ); + modal.setContent(confirmModel.getHTML()); + modal.show(); }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private setEditButtonHandler(_address: Address, _type: UserAddressType): void { + private setEditButtonHandler(address: Address): void { this.view .getEditButton() .getHTML() @@ -65,16 +129,25 @@ class UserAddressModel { if (!user) { return; } + + getStore().dispatch(setBillingCountry(address.country)); + const newAddressEditForm = new AddressEditModel(address, user).getHTML(); modal.show(); - // modal.setContent(new AddressEditModel( address, _type).getHTML()); + modal.setContent(newAddressEditForm); } catch (error) { - showErrorMessage(); - } finally { - modal.hide(); + showErrorMessage(error); } }); } + private setLabelClickHandler(): void { + this.labels.forEach((value: { inactive?: boolean; type: AddressTypeType }, label: HTMLDivElement) => { + label.addEventListener('click', async () => { + await this.labelClickHandler(value.type, value.inactive); + }); + }); + } + public getHTML(): HTMLLIElement { return this.view.getHTML(); } diff --git a/src/entities/UserAddress/view/UserAddressView.ts b/src/entities/UserAddress/view/UserAddressView.ts index cff58393..ff17f573 100644 --- a/src/entities/UserAddress/view/UserAddressView.ts +++ b/src/entities/UserAddress/view/UserAddressView.ts @@ -1,58 +1,103 @@ -import type { Address, User } from '@/shared/types/user'; +import type { TooltipTextKeysType } from '@/shared/constants/tooltip.ts'; +import type { Address } from '@/shared/types/user'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import COUNTRIES_LIST from '@/shared/constants/countriesList.ts'; -import { USER_ADDRESS_TYPE, type UserAddressType } from '@/shared/constants/forms.ts'; +import { ADDRESS_TEXT, ADDRESS_TYPE, type AddressTypeType } from '@/shared/constants/forms.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; -import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; +import TOOLTIP_TEXT, { TOOLTIP_TEXT_KEYS } from '@/shared/constants/tooltip.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; import findKeyByValue from '@/shared/utils/findKeyByValue.ts'; -import { - addressMessage, - addressTemplate, - defaultBillingAddress, - defaultShippingAddress, -} from '@/shared/utils/messageTemplates.ts'; import styles from './userAddressView.module.scss'; class UserAddressView { - private deleteButton: ButtonModel; + private citySpan: HTMLSpanElement; + + private countrySpan: HTMLSpanElement; + + private currentAddress: Address; - private deleteLogo: HTMLDivElement; + private deleteButton: ButtonModel; private editButton: ButtonModel; - private editLogo: HTMLDivElement; + private labels: Map = new Map(); + + private labelsWrapper: HTMLDivElement; + + private postalCodeSpan: HTMLSpanElement; + + private streetNameSpan: HTMLSpanElement; private view: HTMLLIElement; - constructor(user: User, address: Address, type: UserAddressType, defaultAddressId: string) { - this.deleteLogo = this.createDeleteLogo(); - this.editLogo = this.createEditLogo(); + constructor(address: Address, types: AddressTypeType[], inactiveTypes?: AddressTypeType[]) { + this.currentAddress = address; this.deleteButton = this.createDeleteButton(); this.editButton = this.createEditButton(); - this.view = this.createHTML(user, address, type, defaultAddressId); + this.citySpan = this.createCitySpan(); + this.countrySpan = this.createCountrySpan(); + this.postalCodeSpan = this.createPostalCodeSpan(); + this.streetNameSpan = this.createStreetNameSpan(); + this.labelsWrapper = this.createLabelsWrapper(); + this.view = this.createHTML(types, inactiveTypes); + } + + private createCitySpan(): HTMLSpanElement { + this.citySpan = createBaseElement({ + cssClasses: [styles.citySpan], + innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].CITY, + tag: 'span', + }); + + observeStore(selectCurrentLanguage, () => { + const text = ADDRESS_TEXT[getStore().getState().currentLanguage].CITY; + const textNode = [...this.citySpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = text; + } + }); + + const accentSpan = createBaseElement({ + cssClasses: [styles.accentSpan], + innerContent: this.currentAddress.city, + tag: 'span', + }); + + this.citySpan.append(accentSpan); + return this.citySpan; } - private createAddress(user: User, address: Address, type: UserAddressType, defaultAddressId: string): string { - const { locale } = user; + private createCountrySpan(): HTMLSpanElement { + this.countrySpan = createBaseElement({ + cssClasses: [styles.countrySpan], + innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].COUNTRY, + tag: 'span', + }); + + const accentSpan = createBaseElement({ + cssClasses: [styles.accentSpan], + innerContent: + findKeyByValue(COUNTRIES_LIST[getStore().getState().currentLanguage], this.currentAddress.country) ?? '', + tag: 'span', + }); - const country = findKeyByValue(COUNTRIES_LIST[locale], address.country); - const standartAddressText = addressTemplate(address.streetName, address.city, country, address.postalCode); - let addressText = addressMessage(type, standartAddressText); + this.countrySpan.append(accentSpan); + observeStore(selectCurrentLanguage, () => { + accentSpan.innerText = + findKeyByValue(COUNTRIES_LIST[getStore().getState().currentLanguage], this.currentAddress.country) ?? ''; - if (defaultAddressId === address.id) { - if (type === USER_ADDRESS_TYPE.BILLING) { - addressText = defaultBillingAddress(standartAddressText); - } else if (type === USER_ADDRESS_TYPE.SHIPPING) { - addressText = defaultShippingAddress(standartAddressText); + const text = ADDRESS_TEXT[getStore().getState().currentLanguage].COUNTRY; + const textNode = [...this.countrySpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = text; } - } - return addressText; + }); + return this.countrySpan; } private createDeleteButton(): ButtonModel { @@ -61,7 +106,9 @@ class UserAddressView { title: TOOLTIP_TEXT[getStore().getState().currentLanguage].DELETE_ADDRESS, }); - this.deleteButton.getHTML().append(this.deleteLogo); + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.DELETE)); + this.deleteButton.getHTML().append(svg); observeStore(selectCurrentLanguage, () => { this.deleteButton.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].DELETE_ADDRESS; @@ -70,21 +117,15 @@ class UserAddressView { return this.deleteButton; } - private createDeleteLogo(): HTMLDivElement { - this.deleteLogo = createBaseElement({ cssClasses: [styles.deleteLogo], tag: 'div' }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.BIN)); - this.deleteLogo.append(svg); - return this.deleteLogo; - } - private createEditButton(): ButtonModel { this.editButton = new ButtonModel({ classes: [styles.editButton], title: TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_ADDRESS, }); - this.editButton.getHTML().append(this.editLogo); + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.EDIT)); + this.editButton.getHTML().append(svg); observeStore(selectCurrentLanguage, () => { this.editButton.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_ADDRESS; @@ -93,29 +134,162 @@ class UserAddressView { return this.editButton; } - private createEditLogo(): HTMLDivElement { - this.editLogo = createBaseElement({ cssClasses: [styles.editLogo], tag: 'div' }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.EDIT)); - this.editLogo.append(svg); - return this.editLogo; - } - - private createHTML(user: User, address: Address, type: UserAddressType, defaultAddressId: string): HTMLLIElement { + private createHTML(activeTypes: AddressTypeType[], inactiveTypes?: AddressTypeType[]): HTMLLIElement { this.view = createBaseElement({ - cssClasses: [styles.info], + cssClasses: [styles.addressItem], tag: 'li', }); - const addressText = createBaseElement({ - cssClasses: [styles.addressText], - innerContent: this.createAddress(user, address, type, defaultAddressId), + + activeTypes.forEach((type) => { + this.setActiveAddressLabel(type); + }); + + if (inactiveTypes) { + inactiveTypes.forEach((type) => { + this.setActiveAddressLabel(type, true); + }); + } + + const addressTextWrapper = createBaseElement({ + cssClasses: [styles.addressTextWrapper], + tag: 'div', + }); + addressTextWrapper.append(this.countrySpan, this.citySpan, this.streetNameSpan, this.postalCodeSpan); + + const buttonsWrapper = createBaseElement({ + cssClasses: [styles.buttonsWrapper], tag: 'div', }); + buttonsWrapper.append(this.editButton.getHTML(), this.deleteButton.getHTML()); + + this.view.append(this.labelsWrapper, addressTextWrapper, buttonsWrapper); - this.view.append(addressText, this.editButton.getHTML(), this.deleteButton.getHTML()); return this.view; } + private createLabel(text: string, additionalStyles: string[], titleKey: TooltipTextKeysType): HTMLDivElement { + const label = createBaseElement({ + cssClasses: [styles.addressType, ...additionalStyles], + innerContent: text, + tag: 'div', + title: TOOLTIP_TEXT[getStore().getState().currentLanguage][titleKey], + }); + + observeStore(selectCurrentLanguage, () => { + label.title = TOOLTIP_TEXT[getStore().getState().currentLanguage][titleKey]; + }); + + return label; + } + + private createLabelsWrapper(): HTMLDivElement { + this.labelsWrapper = createBaseElement({ + cssClasses: [styles.labelsWrapper], + tag: 'div', + }); + return this.labelsWrapper; + } + + private createPostalCodeSpan(): HTMLSpanElement { + this.postalCodeSpan = createBaseElement({ + cssClasses: [styles.postalCodeSpan], + innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].POSTAL_CODE, + tag: 'span', + }); + + observeStore(selectCurrentLanguage, () => { + const text = ADDRESS_TEXT[getStore().getState().currentLanguage].POSTAL_CODE; + const textNode = [...this.postalCodeSpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = text; + } + }); + + const accentSpan = createBaseElement({ + cssClasses: [styles.accentSpan], + innerContent: this.currentAddress.postalCode, + tag: 'span', + }); + + this.postalCodeSpan.append(accentSpan); + return this.postalCodeSpan; + } + + private createStreetNameSpan(): HTMLSpanElement { + this.streetNameSpan = createBaseElement({ + cssClasses: [styles.streetNameSpan], + innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].STREET, + tag: 'span', + }); + + observeStore(selectCurrentLanguage, () => { + const text = ADDRESS_TEXT[getStore().getState().currentLanguage].STREET; + const textNode = [...this.streetNameSpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = text; + } + }); + + const accentSpan = createBaseElement({ + cssClasses: [styles.accentSpan], + innerContent: this.currentAddress.streetName, + tag: 'span', + }); + + this.streetNameSpan.append(accentSpan); + return this.streetNameSpan; + } + + private setActiveAddressLabel(ActiveType: AddressTypeType, inactive?: boolean): void { + let addressType = null; + switch (ActiveType) { + case ADDRESS_TYPE.BILLING: + addressType = this.createLabel(ActiveType, [styles.billing], TOOLTIP_TEXT_KEYS.SWITCH_BILLING_ADDRESS); + this.labelsWrapper.append(addressType); + break; + + case ADDRESS_TYPE.SHIPPING: + addressType = this.createLabel(ActiveType, [styles.shipping], TOOLTIP_TEXT_KEYS.SWITCH_SHIPPING_ADDRESS); + this.labelsWrapper.append(addressType); + break; + + case ADDRESS_TYPE.DEFAULT_BILLING: + addressType = this.createLabel( + ActiveType, + [styles.defaultBilling], + TOOLTIP_TEXT_KEYS.SWITCH_DEFAULT_BILLING_ADDRESS, + ); + this.labelsWrapper.append(addressType); + break; + + case ADDRESS_TYPE.DEFAULT_SHIPPING: + addressType = this.createLabel( + ActiveType, + [styles.defaultShipping], + TOOLTIP_TEXT_KEYS.SWITCH_DEFAULT_SHIPPING_ADDRESS, + ); + this.labelsWrapper.append(addressType); + break; + default: + break; + } + + if (addressType) { + this.labels.set(addressType, { + inactive, + type: ActiveType, + }); + } + + if (inactive) { + addressType?.classList.add(styles.inactive); + } + } + + public getCurrentAddress(): Address { + return this.currentAddress; + } + public getDeleteButton(): ButtonModel { return this.deleteButton; } @@ -127,6 +301,22 @@ class UserAddressView { public getHTML(): HTMLLIElement { return this.view; } + + public getLabels(): Map { + return this.labels; + } + + public setDisabled(): void { + this.view.classList.add(styles.disabled); + } + + public setEnabled(): void { + this.view.classList.remove(styles.disabled); + } + + public toggleState(isDisabled: boolean): void { + this.view.classList.toggle(styles.disabled, isDisabled); + } } export default UserAddressView; diff --git a/src/entities/UserAddress/view/userAddressView.module.scss b/src/entities/UserAddress/view/userAddressView.module.scss index 18ce13ff..e2dc0918 100644 --- a/src/entities/UserAddress/view/userAddressView.module.scss +++ b/src/entities/UserAddress/view/userAddressView.module.scss @@ -1,55 +1,134 @@ -.info { - grid-column: 2 span; - max-width: 100%; - word-break: break-all; +@import 'src/app/styles/mixins'; + +.addressItem { + position: relative; + display: grid; + align-items: center; + border-radius: var(--medium-br); + padding: var(--extra-small-offset); + background: var(--white-tr); + gap: var(--tiny-offset); } -.deleteLogo, -.editLogo { +.labelsWrapper { display: flex; - margin: 0 auto; - - svg { - z-index: 1; - width: var(--extra-small-offset); - height: var(--extra-small-offset); - fill: var(--noble-gray-900); - transition: 0.2s; - } + flex-wrap: wrap; + grid-row: 1; + gap: var(--tiny-offset); } -.deleteButton, -.editButton { +.addressType { display: flex; - margin: var(--tiny-offset); + align-items: center; + justify-content: center; border-radius: var(--medium-br); padding: var(--tiny-offset); - height: max-content; - max-width: max-content; - font: var(--regular-font); - letter-spacing: 1px; - color: var(--white); - background-color: var(--steam-green-800); - transition: - color 0.2s, - background-color 0.2s; + min-width: 8rem; + font: var(--small-font); + text-align: center; + color: var(--black); + background-color: var(--steam-green-900); + transition: scale 0.2s; - &:focus { - background-color: var(--steam-green-700); + &:hover { + cursor: pointer; + scale: 1.05; } - @media (hover: hover) { - &:hover { - background-color: var(--steam-green-700); + &:active { + scale: 0.95; + } +} + +.billing { + order: 1; + background-color: var(--steam-green-1000); +} + +.shipping { + order: 2; + background-color: var(--steam-green-1000); +} + +.defaultBilling { + order: 3; + background-color: var(--steam-green-300); +} + +.defaultShipping { + order: 4; + background-color: var(--steam-green-300); +} + +.inactive { + filter: grayscale(1); + transition: + filter 0.2s, + scale 0.2s; +} - svg { - fill: var(--noble-gray-1000); - } +.editButton, +.deleteButton { + @include green-btn; + + padding: 0; + width: calc(var(--extra-small-offset) * 1.5); + height: calc(var(--extra-small-offset) * 1.5); + background-color: transparent; + + svg { + width: calc(var(--extra-small-offset) * 1.5); + height: calc(var(--extra-small-offset) * 1.5); + + &:hover { + fill: var(--steam-green-800); + stroke: var(--steam-green-800); } } - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; + &:hover { + filter: brightness(1); + } +} + +.buttonsWrapper { + display: flex; + grid-row: 1; + margin-left: auto; + gap: var(--tiny-offset); + + @media (max-width: 768px) { + flex-direction: column; + grid-row: 2; + } +} + +.addressTextWrapper { + display: flex; + flex-direction: column; + grid-row: 2; + gap: var(--tiny-offset); +} + +.citySpan, +.streetNameSpan, +.postalCodeSpan, +.countrySpan { + font: var(--regular-font); + letter-spacing: var(--one); + word-break: break-all; + color: var(--steam-green-400); +} + +.disabled { + pointer-events: none; + + &::after { + content: ''; + position: absolute; + z-index: 1; + border-radius: var(--medium-br); + backdrop-filter: blur(5px); + inset: 0; } } diff --git a/src/features/AddressAdd/model/AddressAddModel.ts b/src/features/AddressAdd/model/AddressAddModel.ts new file mode 100644 index 00000000..56a73890 --- /dev/null +++ b/src/features/AddressAdd/model/AddressAddModel.ts @@ -0,0 +1,191 @@ +import type InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; +import type { AddressType } from '@/shared/types/address.ts'; +import type { Address, User } from '@/shared/types/user.ts'; +import type { MyCustomerUpdateAction } from '@commercetools/platform-sdk'; + +import AddressModel from '@/entities/Address/model/AddressModel.ts'; +import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/CustomerModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import { ADDRESS_TYPE } from '@/shared/types/address.ts'; +import formattedText from '@/shared/utils/formattedText.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; + +import AddressAddView from '../view/AddressAddView.ts'; + +class AddressAddModel { + private addressType: AddressType; + + private inputFields: InputFieldModel[] = []; + + private newAddress: AddressModel; + + private view = new AddressAddView(); + + constructor(type: AddressType, options: Record) { + this.addressType = type; + this.newAddress = new AddressModel(options, this.addressType); + this.init(); + } + + private async addAddressType(): Promise { + try { + const updatedUser = await getCustomerModel().getCurrentUser(); + if (updatedUser) { + const newAddress = this.getNewAddress(updatedUser); + if (newAddress?.id) { + const actions = this.getAddressActions(newAddress.id); + if (this.shouldSetDefaultAddress()) { + actions.push(this.getDefaultAddressAction(newAddress.id)); + } + + await getCustomerModel().editCustomer(actions, updatedUser); + this.handleSuccess(); + } + } + } catch (error) { + showErrorMessage(error); + } + } + + private createAddress(user: User): Address { + const store = getStore().getState(); + const { email, firstName, lastName } = user; + const { city, postalCode, streetName } = this.getFormAddressData(); + return { + city, + country: this.addressType === ADDRESS_TYPE.BILLING ? store.billingCountry : store.shippingCountry, + email, + firstName, + id: '', + lastName, + postalCode, + state: '', + streetName, + streetNumber: '', + }; + } + + private async createNewAddress(): Promise { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getSaveChangesButton().getHTML().append(loader); + + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + const address = this.createAddress(user); + await this.saveAddress(user, address); + await this.addAddressType(); + } + } catch (error) { + showErrorMessage(error); + } finally { + loader.remove(); + } + } + + private getAddressActions(addressId: string): MyCustomerUpdateAction[] { + const addAddressAction = + this.addressType === ADDRESS_TYPE.BILLING + ? CustomerModel.actionAddBillingAddress(addressId) + : CustomerModel.actionAddShippingAddress(addressId); + + return [addAddressAction]; + } + + private getDefaultAddressAction(addressId: string): MyCustomerUpdateAction { + return this.addressType === ADDRESS_TYPE.BILLING + ? CustomerModel.actionEditDefaultBillingAddress(addressId) + : CustomerModel.actionEditDefaultShippingAddress(addressId); + } + + private getFormAddressData(): Record { + return { + city: formattedText(this.newAddress.getView().getCityField().getView().getInput().getValue()), + country: this.newAddress.getView().getCountryField().getView().getInput().getValue(), + postalCode: this.newAddress.getView().getPostalCodeField().getView().getInput().getValue(), + streetName: formattedText(this.newAddress.getView().getStreetField().getView().getInput().getValue()), + }; + } + + private getNewAddress(user: User): Address | null { + return user.addresses[user.addresses.length - 1] || null; + } + + private handleSuccess(): void { + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_ADDED, MESSAGE_STATUS.SUCCESS); + modal.hide(); + } + + private init(): void { + this.getHTML().append(this.newAddress.getHTML()); + this.inputFields = [ + this.newAddress.getView().getCityField(), + this.newAddress.getView().getCountryField(), + this.newAddress.getView().getPostalCodeField(), + this.newAddress.getView().getStreetField(), + ]; + + this.inputFields.forEach((inputField) => this.setInputFieldHandlers(inputField)); + this.setPreventDefaultToForm(); + this.setSubmitFormHandler(); + this.setCancelButtonHandler(); + this.view.getHTML().append(this.newAddress.getHTML()); + } + + private async saveAddress(user: User, address: Address): Promise { + await getCustomerModel().editCustomer([CustomerModel.actionAddAddress(address)], user); + } + + private setCancelButtonHandler(): boolean { + const cancelButton = this.view.getCancelButton().getHTML(); + cancelButton.addEventListener('click', () => { + modal.hide(); + modal.removeContent(); + }); + return true; + } + + private setInputFieldHandlers(inputField: InputFieldModel): boolean { + const inputHTML = inputField.getView().getInput().getHTML(); + inputHTML.addEventListener('input', () => this.switchSubmitFormButtonAccess()); + return true; + } + + private setPreventDefaultToForm(): boolean { + this.getHTML().addEventListener('submit', (event) => event.preventDefault()); + return true; + } + + private setSubmitFormHandler(): boolean { + const submitButton = this.view.getSaveChangesButton().getHTML(); + submitButton.addEventListener('click', () => this.createNewAddress()); + return true; + } + + private shouldSetDefaultAddress(): boolean { + return this.newAddress.getView().getAddressByDefaultCheckBox()?.getHTML().checked || false; + } + + private switchSubmitFormButtonAccess(): boolean { + if (this.inputFields.every((inputField) => inputField.getIsValid())) { + this.view.getSaveChangesButton().setEnabled(); + } else { + this.view.getSaveChangesButton().setDisabled(); + } + return true; + } + + public getHTML(): HTMLFormElement { + return this.view.getHTML(); + } +} + +export default AddressAddModel; diff --git a/src/features/AddressAdd/view/AddressAddView.ts b/src/features/AddressAdd/view/AddressAddView.ts new file mode 100644 index 00000000..7ffc48e5 --- /dev/null +++ b/src/features/AddressAdd/view/AddressAddView.ts @@ -0,0 +1,63 @@ +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import { BUTTON_TEXT, BUTTON_TYPE } from '@/shared/constants/buttons.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import styles from './addressAddView.module.scss'; + +class AddressAddView { + private cancelButton: ButtonModel; + + private saveChangesButton: ButtonModel; + + private view: HTMLFormElement; + + constructor() { + this.saveChangesButton = this.createSaveChangesButton(); + this.cancelButton = this.createCancelButton(); + this.view = this.createHTML(); + } + + private createCancelButton(): ButtonModel { + this.cancelButton = new ButtonModel({ + classes: [styles.cancelButton], + text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + }); + return this.cancelButton; + } + + private createHTML(): HTMLFormElement { + this.view = createBaseElement({ + cssClasses: [styles.wrapper], + tag: 'form', + }); + this.view.append(this.saveChangesButton.getHTML(), this.cancelButton.getHTML()); + return this.view; + } + + private createSaveChangesButton(): ButtonModel { + this.saveChangesButton = new ButtonModel({ + attrs: { + type: BUTTON_TYPE.SUBMIT, + }, + classes: [styles.saveChangesButton], + text: BUTTON_TEXT[getStore().getState().currentLanguage].ADD_ADDRESS, + }); + this.saveChangesButton.setDisabled(); + return this.saveChangesButton; + } + + public getCancelButton(): ButtonModel { + return this.cancelButton; + } + + public getHTML(): HTMLFormElement { + return this.view; + } + + public getSaveChangesButton(): ButtonModel { + return this.saveChangesButton; + } +} + +export default AddressAddView; diff --git a/src/features/AddressAdd/view/addressAddView.module.scss b/src/features/AddressAdd/view/addressAddView.module.scss new file mode 100644 index 00000000..71fcc608 --- /dev/null +++ b/src/features/AddressAdd/view/addressAddView.module.scss @@ -0,0 +1,35 @@ +@import 'src/app/styles/mixins'; + +.wrapper { + display: grid; + place-items: center center; + grid-template-rows: repeat(auto, auto); + border-bottom: var(--tiny-offset) solid var(--steam-green-800); + padding: var(--small-offset); + height: max-content; + background-color: var(--noble-white-100); +} + +.saveChangesButton, +.cancelButton { + @include green-btn; + + grid-row: 5; + margin-top: var(--tiny-offset); +} + +.cancelButton { + grid-column: 1; + + @media (max-width: 768px) { + grid-row: 6; + } +} + +.saveChangesButton { + grid-column: 2; + + @media (max-width: 768px) { + grid-column: 1; + } +} diff --git a/src/features/AddressEdit/model/AddressEditModel.ts b/src/features/AddressEdit/model/AddressEditModel.ts index f91accbd..185c1294 100644 --- a/src/features/AddressEdit/model/AddressEditModel.ts +++ b/src/features/AddressEdit/model/AddressEditModel.ts @@ -1,115 +1,153 @@ -// import type InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; -// import type { UserAddressType } from '@/shared/constants/forms.ts'; -// import type { Address } from '@/shared/types/user.ts'; - -// import AddressModel from '@/entities/Address/model/AddressModel.ts'; -// import getCustomerModel from '@/shared/API/customer/model/CustomerModel.ts'; -// import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; -// import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; -// import modal from '@/shared/Modal/model/ModalModel.ts'; -// import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; -// import MEDIATOR_EVENT from '@/shared/constants/events.ts'; -// import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; -// import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -// import showErrorMessage from '@/shared/utils/userMessage.ts'; - -// import AddressEditView from '../view/AddressEditView.ts'; +import type InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; +import type { Address, User } from '@/shared/types/user.ts'; + +import AddressModel from '@/entities/Address/model/AddressModel.ts'; +import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/CustomerModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import COUNTRIES_LIST from '@/shared/constants/countriesList.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import findKeyByValue from '@/shared/utils/findKeyByValue.ts'; +import formattedText from '@/shared/utils/formattedText.ts'; +import getCountryIndex from '@/shared/utils/getCountryIndex.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; + +import AddressEditView from '../view/AddressEditView.ts'; class AddressEditModel { - // private address: AddressModel; - // private currentAddress: Address; - // private inputFields: InputFieldModel[] = []; - // private view = new AddressEditView(); - // constructor(address: Address, type: UserAddressType) { - // this.address = new AddressModel(type, { setDefault: true }); - // this.currentAddress = address; - // this.init(); - // } - // private async editPersonalInfo(): Promise { - // const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); - // this.view.getSaveChangesButton().getHTML().append(loader); - // try { - // const user = await getCustomerModel().getCurrentUser(); - // if (user) { - // // const {} = this.getFormPersonalData(); - // await getCustomerModel().editCustomer( - // [ - // // CustomerModel - // ], - // user, - // ); - // modal.hide(); - // serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_CHANGED, MESSAGE_STATUS.SUCCESS); - // EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESS, ''); - // } - // } catch (error) { - // showErrorMessage(); - // } finally { - // loader.remove(); - // } - // } - // private getFormPersonalData(): Record { - // return { - // // dateOfBirth: this.personalInfo.getView().getDateOfBirthField().getView().getInput().getValue(), - // // email: this.view.getEmailField().getView().getInput().getValue(), - // // firstName: formattedText(this.personalInfo.getView().getFirstNameField().getView().getInput().getValue()), - // // lastName: formattedText(this.personalInfo.getView().getLastNameField().getView().getInput().getValue()), - // }; - // } - // private init(): void { - // this.initiateFieldsValues(); - // // this.getHTML().append(this.personalInfo.getHTML()); - // this.inputFields = [ - // this.personalInfo.getView().getFirstNameField(), - // this.personalInfo.getView().getLastNameField(), - // this.personalInfo.getView().getDateOfBirthField(), - // ]; - // this.inputFields.forEach((inputField) => this.setInputFieldHandlers(inputField)); - // this.setPreventDefaultToForm(); - // this.setSubmitFormHandler(); - // this.setCancelButtonHandler(); - // } - // private initiateFieldsValues(): void { - // this.personalInfo.getView().getFirstNameField().getView().getInput().setValue(this.currentUser.firstName); - // this.personalInfo.getView().getLastNameField().getView().getInput().setValue(this.currentUser.lastName); - // this.personalInfo.getView().getDateOfBirthField().getView().getInput().setValue(this.currentUser.birthDate); - // } - // private setCancelButtonHandler(): boolean { - // const cancelButton = this.view.getCancelButton().getHTML(); - // cancelButton.addEventListener('click', () => { - // modal.hide(); - // }); - // return true; - // } - // private setInputFieldHandlers(inputField: InputFieldModel): boolean { - // const inputHTML = inputField.getView().getInput().getHTML(); - // inputHTML.addEventListener('input', () => { - // this.switchSubmitFormButtonAccess(); - // }); - // return true; - // } - // private setPreventDefaultToForm(): boolean { - // this.getHTML().addEventListener('submit', (event) => { - // event.preventDefault(); - // }); - // return true; - // } - // private setSubmitFormHandler(): boolean { - // const submitButton = this.view.getSaveChangesButton().getHTML(); - // submitButton.addEventListener('click', () => this.editPersonalInfo()); - // return true; - // } - // private switchSubmitFormButtonAccess(): boolean { - // if (this.inputFields.every((inputField) => inputField.getIsValid())) { - // this.view.getSaveChangesButton().setEnabled(); - // } else { - // this.view.getSaveChangesButton().setDisabled(); - // } - // return true; - // } - // public getHTML(): HTMLFormElement { - // return this.view.getHTML(); - // } + private currentAddress: Address; + + private inputFields: InputFieldModel[] = []; + + private newAddress: AddressModel; + + private user: User; + + private view = new AddressEditView(); + + constructor(address: Address, user: User) { + this.user = user; + this.currentAddress = address; + this.newAddress = new AddressModel({}); + this.init(); + } + + private createAddress(user: User): Address { + const { email, firstName, lastName } = user; + const { city, country, postalCode, streetName } = this.getFormAddressData(); + return { + city, + country: getCountryIndex(formattedText(country)), + email, + firstName, + id: this.currentAddress.id, + lastName, + postalCode, + state: '', + streetName, + streetNumber: '', + }; + } + + private async editAddressInfo(): Promise { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getSaveChangesButton().getHTML().append(loader); + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + await getCustomerModel().editCustomer([CustomerModel.actionEditAddress(this.createAddress(user))], user); + modal.hide(); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_CHANGED, MESSAGE_STATUS.SUCCESS); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); + } + } catch (error) { + showErrorMessage(error); + } finally { + loader.remove(); + } + } + + private getFormAddressData(): Record { + return { + city: formattedText(this.newAddress.getView().getCityField().getView().getInput().getValue()), + country: this.newAddress.getView().getCountryField().getView().getInput().getValue(), + postalCode: this.newAddress.getView().getPostalCodeField().getView().getInput().getValue(), + streetName: formattedText(this.newAddress.getView().getStreetField().getView().getInput().getValue()), + }; + } + + private init(): void { + this.initiateFieldsValues(); + this.getHTML().append(this.newAddress.getHTML()); + this.inputFields = [ + this.newAddress.getView().getCityField(), + this.newAddress.getView().getCountryField(), + this.newAddress.getView().getPostalCodeField(), + this.newAddress.getView().getStreetField(), + ]; + this.inputFields.forEach((inputField) => this.setInputFieldHandlers(inputField)); + this.setPreventDefaultToForm(); + this.setSubmitFormHandler(); + this.setCancelButtonHandler(); + this.view.getHTML().append(this.newAddress.getHTML()); + } + + private initiateFieldsValues(): void { + const { locale } = this.user; + const currentCountry = findKeyByValue(COUNTRIES_LIST[locale], this.currentAddress.country); + this.newAddress.getView().getCityField().getView().getInput().setValue(this.currentAddress.city); + this.newAddress + .getView() + .getCountryField() + .getView() + .getInput() + .setValue(currentCountry ?? ''); + this.newAddress.getView().getStreetField().getView().getInput().setValue(this.currentAddress.streetName); + this.newAddress.getView().getPostalCodeField().getView().getInput().setValue(this.currentAddress.postalCode); + } + + private setCancelButtonHandler(): boolean { + const cancelButton = this.view.getCancelButton().getHTML(); + cancelButton.addEventListener('click', () => { + modal.hide(); + modal.removeContent(); + }); + return true; + } + + private setInputFieldHandlers(inputField: InputFieldModel): boolean { + const inputHTML = inputField.getView().getInput().getHTML(); + inputHTML.addEventListener('input', () => this.switchSubmitFormButtonAccess()); + return true; + } + + private setPreventDefaultToForm(): boolean { + this.getHTML().addEventListener('submit', (event) => event.preventDefault()); + return true; + } + + private setSubmitFormHandler(): boolean { + const submitButton = this.view.getSaveChangesButton().getHTML(); + submitButton.addEventListener('click', () => this.editAddressInfo()); + return true; + } + + private switchSubmitFormButtonAccess(): boolean { + if (this.inputFields.every((inputField) => inputField.getIsValid())) { + this.view.getSaveChangesButton().setEnabled(); + } else { + this.view.getSaveChangesButton().setDisabled(); + } + return true; + } + + public getHTML(): HTMLFormElement { + return this.view.getHTML(); + } } export default AddressEditModel; diff --git a/src/features/AddressEdit/view/AddressEditView.ts b/src/features/AddressEdit/view/AddressEditView.ts index b108f31b..96fac017 100644 --- a/src/features/AddressEdit/view/AddressEditView.ts +++ b/src/features/AddressEdit/view/AddressEditView.ts @@ -28,9 +28,11 @@ class AddressEditView { private createHTML(): HTMLFormElement { this.view = createBaseElement({ - cssClasses: [styles.style], + cssClasses: [styles.wrapper], tag: 'form', }); + + this.view.append(this.saveChangesButton.getHTML(), this.cancelButton.getHTML()); return this.view; } diff --git a/src/features/AddressEdit/view/addressEditView.module.scss b/src/features/AddressEdit/view/addressEditView.module.scss index 0abd8a96..71fcc608 100644 --- a/src/features/AddressEdit/view/addressEditView.module.scss +++ b/src/features/AddressEdit/view/addressEditView.module.scss @@ -1,68 +1,35 @@ +@import 'src/app/styles/mixins'; + .wrapper { display: grid; place-items: center center; - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(3, auto); + grid-template-rows: repeat(auto, auto); border-bottom: var(--tiny-offset) solid var(--steam-green-800); padding: var(--small-offset); height: max-content; - max-width: max-content; background-color: var(--noble-white-100); - gap: calc(var(--extra-small-offset) * 1.5) var(--extra-small-offset); - - @media (max-width: 768px) { - grid-template-columns: repeat(1, 1fr); - grid-template-rows: repeat(4, auto); - padding: var(--medium-offset); - } } .saveChangesButton, .cancelButton { - margin: 0 auto; - border-radius: var(--medium-br); - padding: calc(var(--small-offset) / 3) var(--small-offset); - width: 100%; - height: max-content; - font: var(--regular-font); - letter-spacing: 1px; - color: var(--white); - background-color: var(--steam-green-800); - transition: - color 0.2s, - background-color 0.2s; + @include green-btn; - &:focus { - background-color: var(--steam-green-700); - } + grid-row: 5; + margin-top: var(--tiny-offset); +} - @media (hover: hover) { - &:hover { - background-color: var(--steam-green-700); - } - } +.cancelButton { + grid-column: 1; - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; + @media (max-width: 768px) { + grid-row: 6; } } .saveChangesButton { - display: flex; grid-column: 2; - grid-row: 3; @media (max-width: 768px) { grid-column: 1; } } - -.cancelButton { - grid-column: 1; - grid-row: 3; - - @media (max-width: 768px) { - grid-row: 4; - } -} diff --git a/src/features/Breadcrumbs/view/breadcrumbsView.module.scss b/src/features/Breadcrumbs/view/breadcrumbsView.module.scss index 83709183..89200a9a 100644 --- a/src/features/Breadcrumbs/view/breadcrumbsView.module.scss +++ b/src/features/Breadcrumbs/view/breadcrumbsView.module.scss @@ -1,56 +1,26 @@ +@import 'src/app/styles/mixins'; + .breadcrumbs { display: flex; align-items: center; margin-bottom: var(--medium-offset); gap: var(--tiny-offset); + + @media (max-width: 768px) { + margin-bottom: var(--small-offset); + } } .link { - position: relative; - display: flex; - align-items: center; + @include link; + padding: var(--tiny-offset) calc(var(--tiny-offset) / 2); - height: 100%; - font: var(--regular-font); font-weight: 100; - letter-spacing: var(--one); - text-transform: uppercase; - color: var(--noble-gray-800); - transition: color 0.2s; - - &::after { - content: ''; - position: absolute; - left: 0; - bottom: calc(var(--one) * -1); - width: 100%; - height: var(--two); - background-color: currentcolor; - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 0.2s, - opacity 0.2s; - } - - @media (hover: hover) { - &:hover { - color: var(--steam-green-800); - - &::after { - opacity: 1; - transform: scaleX(1); - } - } - } } .active { color: var(--steam-green-800); opacity: 1; - cursor: auto; - pointer-events: none; &::after { opacity: 1; diff --git a/src/features/CountryChoice/model/CountryChoiceModel.ts b/src/features/CountryChoice/model/CountryChoiceModel.ts index abf02d41..9f207305 100644 --- a/src/features/CountryChoice/model/CountryChoiceModel.ts +++ b/src/features/CountryChoice/model/CountryChoiceModel.ts @@ -1,10 +1,6 @@ import getStore from '@/shared/Store/Store.ts'; -import { setBillingCountry, setShippingCountry } from '@/shared/Store/actions.ts'; -import observeStore, { - selectBillingCountry, - selectCurrentLanguage, - selectShippingCountry, -} from '@/shared/Store/observer.ts'; +import { setBillingCountry, setDefaultCountry, setShippingCountry } from '@/shared/Store/actions.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { DATA_KEYS } from '@/shared/constants/common.ts'; import COUNTRIES_LIST from '@/shared/constants/countriesList.ts'; import { USER_ADDRESS_TYPE } from '@/shared/constants/forms.ts'; @@ -20,16 +16,6 @@ class CountryChoiceModel { this.view = new CountryChoiceView(input); this.setCountryItemsHandlers(input); this.setInputHandler(input); - - const action = - input.getAttribute(DATA_KEYS.ADDRESS_TYPE) === USER_ADDRESS_TYPE.BILLING - ? selectBillingCountry - : selectShippingCountry; - - observeStore(action, () => { - const event = new Event('input'); - input.dispatchEvent(event); - }); } private observeCurrentLanguage(item: HTMLDivElement): boolean { @@ -54,6 +40,8 @@ class CountryChoiceModel { if (currentItem.textContent) { inputHTML.value = currentItem.textContent; this.setCountryToStore(currentItem, inputHTML.getAttribute(DATA_KEYS.ADDRESS_TYPE) ?? ''); + const event = new Event('input'); + input.dispatchEvent(event); this.view.hideCountryChoice(); } }); @@ -66,7 +54,20 @@ class CountryChoiceModel { element instanceof HTMLDivElement ? formattedText(element.textContent ?? '') : formattedText(element.value), ); - const action = key === USER_ADDRESS_TYPE.BILLING ? setBillingCountry : setShippingCountry; + let action; + + switch (key) { + case USER_ADDRESS_TYPE.BILLING: + action = setBillingCountry; + break; + case USER_ADDRESS_TYPE.SHIPPING: + action = setShippingCountry; + break; + default: + action = setDefaultCountry; + break; + } + getStore().dispatch(action(currentCountryIndex)); return true; } diff --git a/src/features/CountryChoice/view/countryChoiceView.module.scss b/src/features/CountryChoice/view/countryChoiceView.module.scss index fbfc65ba..92f9146e 100644 --- a/src/features/CountryChoice/view/countryChoiceView.module.scss +++ b/src/features/CountryChoice/view/countryChoiceView.module.scss @@ -84,8 +84,8 @@ border: 1px solid var(--noble-gray-300); border-radius: var(--small-br); width: 100%; - min-height: calc(var(--extra-large-offset) * 2); // 200px - max-height: calc(var(--extra-large-offset) * 2); // 200px + min-height: calc(var(--extra-large-offset) * 1.3); + max-height: calc(var(--extra-large-offset) * 1.3); background-color: var(--white); opacity: 1; visibility: visible; diff --git a/src/features/Pagination/model/PaginationModel.ts b/src/features/Pagination/model/PaginationModel.ts new file mode 100644 index 00000000..b4a13931 --- /dev/null +++ b/src/features/Pagination/model/PaginationModel.ts @@ -0,0 +1,22 @@ +import PaginationView from '../view/PaginationView.ts'; + +class PaginationModel { + private view: PaginationView; + + constructor( + productInfo: { productTotalCount: number; productsPerPageCount: number }, + callback: (page: string) => void, + ) { + this.view = new PaginationView(productInfo, callback); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } + + public getView(): PaginationView { + return this.view; + } +} + +export default PaginationModel; diff --git a/src/features/Pagination/view/PaginationView.ts b/src/features/Pagination/view/PaginationView.ts new file mode 100644 index 00000000..b49380f2 --- /dev/null +++ b/src/features/Pagination/view/PaginationView.ts @@ -0,0 +1,121 @@ +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import { DEFAULT_PAGE, SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import styles from './paginationView.module.scss'; + +const PREV_TEXT = '<'; +const NEXT_TEXT = '>'; + +class PaginationView { + private callback: (page: string) => void; + + private nextPageButton: ButtonModel; + + private pageButtons: ButtonModel[] = []; + + private prevPageButton: ButtonModel; + + private productInfo: { productTotalCount: number; productsPerPageCount: number }; + + private view: HTMLDivElement; + + constructor( + productInfo: { productTotalCount: number; productsPerPageCount: number }, + callback: (page: string) => void, + ) { + this.productInfo = productInfo; + this.callback = callback; + this.nextPageButton = this.createNextPageButton(); + this.prevPageButton = this.createPrevPageButton(); + this.view = this.createHTML(); + } + + private createHTML(): HTMLDivElement { + this.view = createBaseElement({ + cssClasses: [styles.pagination], + tag: 'div', + }); + + this.redrawPagination(); + + return this.view; + } + + private createNextPageButton(): ButtonModel { + this.nextPageButton = new ButtonModel({ + classes: [styles.pageButton], + text: NEXT_TEXT, + }); + + this.nextPageButton.getHTML().addEventListener('click', () => { + const currentPage = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.PAGE); + if (currentPage) { + this.callback(String(Number(currentPage) + DEFAULT_PAGE)); + } else { + this.callback(String(DEFAULT_PAGE + DEFAULT_PAGE)); + } + }); + + return this.nextPageButton; + } + + private createPageButton(page: number): HTMLButtonElement { + const btn = new ButtonModel({ + classes: [styles.pageButton], + text: page.toString(), + }); + + btn.getHTML().addEventListener('click', () => this.callback(page.toString())); + + this.pageButtons.push(btn); + + return btn.getHTML(); + } + + private createPrevPageButton(): ButtonModel { + this.prevPageButton = new ButtonModel({ + classes: [styles.pageButton], + text: PREV_TEXT, + }); + + this.prevPageButton.getHTML().addEventListener('click', () => { + const currentPage = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.PAGE); + if (currentPage) { + this.callback(String(Number(currentPage) - DEFAULT_PAGE)); + } + }); + return this.prevPageButton; + } + + private redrawPagination(): void { + if (this.productInfo.productTotalCount > this.productInfo.productsPerPageCount) { + const pagesCount = Math.ceil(this.productInfo.productTotalCount / this.productInfo.productsPerPageCount); + const pages = Array(pagesCount) + .fill(0) + .map((_, index) => index + DEFAULT_PAGE) + .map(this.createPageButton.bind(this)); + this.view.append(this.prevPageButton.getHTML(), ...pages, this.nextPageButton.getHTML()); + } + } + + private switchStateNavigationButtons(page: number): void { + this.prevPageButton.getHTML().disabled = page === DEFAULT_PAGE; + this.nextPageButton.getHTML().disabled = page === this.pageButtons.length; + } + + public getHTML(): HTMLDivElement { + return this.view; + } + + public setSelectedButton(page: number): void { + this.pageButtons.forEach((btn) => { + btn.getHTML().classList.remove(styles.active); + }); + this.pageButtons[page - DEFAULT_PAGE]?.getHTML().classList.add(styles.active); + this.switchStateNavigationButtons(page); + } +} + +export default PaginationView; diff --git a/src/features/Pagination/view/paginationView.module.scss b/src/features/Pagination/view/paginationView.module.scss new file mode 100644 index 00000000..2aac1f73 --- /dev/null +++ b/src/features/Pagination/view/paginationView.module.scss @@ -0,0 +1,54 @@ +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--tiny-offset); +} + +.pageButton { + display: flex; + align-items: center; + justify-content: center; + margin: var(--tiny-offset) 0; + outline: calc(var(--one) * 1.5) solid var(--noble-gray-300); + border-radius: var(--small-br); + width: var(--extra-small-offset); + height: var(--extra-small-offset); + font: var(--regular-font); + color: var(--noble-gray-700); + background-color: var(--white-tr); + transition: + color 0.2s, + outline 0.2s, + transform 0.2s, + opacity 0.2s; + user-select: none; + + @media (hover: hover) { + &:hover { + outline: calc(var(--one) * 1.5) solid var(--steam-green-400); + color: var(--steam-green-400); + } + } + + /* stylelint-disable-next-line order/order */ + &:active { + transform: scale(0.9); + } + + &:disabled { + opacity: 0.5; + pointer-events: none; + } + + &:nth-child(1) { + margin-left: var(--tiny-offset); + } +} + +.active { + outline: calc(var(--one) * 1.5) solid var(--steam-green-400); + color: var(--steam-green-400); + background-color: var(--noble-white-100); + pointer-events: none; +} diff --git a/src/features/PasswordEdit/model/PasswordEditModel.ts b/src/features/PasswordEdit/model/PasswordEditModel.ts index 9ff865de..0446de62 100644 --- a/src/features/PasswordEdit/model/PasswordEditModel.ts +++ b/src/features/PasswordEdit/model/PasswordEditModel.ts @@ -1,3 +1,5 @@ +import type InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; + import getCustomerModel from '@/shared/API/customer/model/CustomerModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; @@ -17,10 +19,41 @@ class PasswordEditModel { } private init(): void { + this.view.getInputFields().forEach((inputField) => this.setInputFieldHandlers(inputField)); + this.setPreventDefaultToForm(); this.setSwitchOldPasswordVisibilityHandler(); this.setSwitchNewPasswordVisibilityHandler(); + this.setSubmitFormHandler(); this.setCancelButtonHandler(); - this.setSaveChangesButtonHandler(); + } + + private async saveNewPassword(): Promise { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getSubmitButton().getHTML().append(loader); + try { + await getCustomerModel() + .getCurrentUser() + .then(async (user) => { + if (user) { + try { + await getCustomerModel().editPassword( + user, + this.view.getOldPasswordField().getView().getValue(), + this.view.getNewPasswordField().getView().getValue(), + ); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_CHANGED, MESSAGE_STATUS.SUCCESS); + modal.hide(); + } catch { + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_NOT_CHANGED, MESSAGE_STATUS.ERROR); + } + } + }); + } catch (error) { + showErrorMessage(error); + } finally { + loader.remove(); + } + return true; } private setCancelButtonHandler(): boolean { @@ -33,37 +66,20 @@ class PasswordEditModel { return true; } - private setSaveChangesButtonHandler(): boolean { - this.view - .getSaveChangesButton() - .getHTML() - .addEventListener('click', async () => { - const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); - this.view.getSaveChangesButton().getHTML().append(loader); - try { - await getCustomerModel() - .getCurrentUser() - .then(async (user) => { - if (user) { - try { - await getCustomerModel().editPassword( - user, - this.view.getOldPasswordField().getView().getValue(), - this.view.getNewPasswordField().getView().getValue(), - ); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_CHANGED, MESSAGE_STATUS.SUCCESS); - modal.hide(); - } catch { - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_NOT_CHANGED, MESSAGE_STATUS.ERROR); - } - } - }); - } catch { - showErrorMessage(); - } finally { - loader.remove(); - } - }); + private setInputFieldHandlers(inputField: InputFieldModel): boolean { + const inputHTML = inputField.getView().getInput().getHTML(); + inputHTML.addEventListener('input', () => this.switchSubmitFormButtonAccess()); + return true; + } + + private setPreventDefaultToForm(): boolean { + this.view.getHTML().addEventListener('submit', (event) => event.preventDefault()); + return true; + } + + private setSubmitFormHandler(): boolean { + const submitButton = this.view.getSubmitButton().getHTML(); + submitButton.addEventListener('click', this.saveNewPassword.bind(this)); return true; } @@ -87,7 +103,16 @@ class PasswordEditModel { return true; } - public getHTML(): HTMLDivElement { + private switchSubmitFormButtonAccess(): boolean { + if (this.view.getInputFields().every((inputField) => inputField.getIsValid())) { + this.view.getSubmitButton().setEnabled(); + } else { + this.view.getSubmitButton().setDisabled(); + } + return true; + } + + public getHTML(): HTMLFormElement { return this.view.getHTML(); } diff --git a/src/features/PasswordEdit/view/PasswordEditView.ts b/src/features/PasswordEdit/view/PasswordEditView.ts index 9f4fef42..84268183 100644 --- a/src/features/PasswordEdit/view/PasswordEditView.ts +++ b/src/features/PasswordEdit/view/PasswordEditView.ts @@ -21,16 +21,16 @@ class PasswordEditView { private oldPasswordField: InputFieldModel; - private saveChangesButton: ButtonModel; - private showNewPasswordElement: HTMLDivElement; private showOldPasswordElement: HTMLDivElement; - private view: HTMLDivElement; + private submitButton: ButtonModel; + + private view: HTMLFormElement; constructor() { - this.saveChangesButton = this.createSaveChangesButton(); + this.submitButton = this.createSubmitButton(); this.cancelButton = this.createCancelButton(); this.showOldPasswordElement = this.createShowOldPasswordElement(); this.showNewPasswordElement = this.createShowNewPasswordElement(); @@ -47,10 +47,10 @@ class PasswordEditView { return this.cancelButton; } - private createHTML(): HTMLDivElement { + private createHTML(): HTMLFormElement { this.view = createBaseElement({ cssClasses: [styles.wrapper], - tag: 'div', + tag: 'form', }); this.inputFields.forEach((inputField) => { @@ -65,7 +65,7 @@ class PasswordEditView { } }); - this.view.append(this.cancelButton.getHTML(), this.saveChangesButton.getHTML()); + this.view.append(this.submitButton.getHTML(), this.cancelButton.getHTML()); return this.view; } @@ -89,14 +89,6 @@ class PasswordEditView { return this.oldPasswordField; } - private createSaveChangesButton(): ButtonModel { - this.saveChangesButton = new ButtonModel({ - classes: [styles.saveChangesButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].SAVE_CHANGES, - }); - return this.saveChangesButton; - } - private createShowNewPasswordElement(): HTMLDivElement { this.showNewPasswordElement = createBaseElement({ cssClasses: [styles.showPasswordElement], @@ -115,11 +107,20 @@ class PasswordEditView { return this.showOldPasswordElement; } + private createSubmitButton(): ButtonModel { + this.submitButton = new ButtonModel({ + classes: [styles.submitButton], + text: BUTTON_TEXT[getStore().getState().currentLanguage].SAVE_CHANGES, + }); + this.submitButton.setDisabled(); + return this.submitButton; + } + public getCancelButton(): ButtonModel { return this.cancelButton; } - public getHTML(): HTMLDivElement { + public getHTML(): HTMLFormElement { return this.view; } @@ -135,10 +136,6 @@ class PasswordEditView { return this.oldPasswordField; } - public getSaveChangesButton(): ButtonModel { - return this.saveChangesButton; - } - public getShowNewPasswordElement(): HTMLDivElement { return this.showNewPasswordElement; } @@ -147,6 +144,10 @@ class PasswordEditView { return this.showOldPasswordElement; } + public getSubmitButton(): ButtonModel { + return this.submitButton; + } + public switchPasswordElementSVG(type: string, el: HTMLDivElement): SVGSVGElement { const element = el; const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); diff --git a/src/features/PasswordEdit/view/passwordEditView.module.scss b/src/features/PasswordEdit/view/passwordEditView.module.scss index eff20751..90b26ecd 100644 --- a/src/features/PasswordEdit/view/passwordEditView.module.scss +++ b/src/features/PasswordEdit/view/passwordEditView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .wrapper { display: grid; place-items: center center; @@ -10,39 +12,12 @@ gap: var(--extra-small-offset); } -.saveChangesButton, +.submitButton, .cancelButton { - margin: 0 auto; - border-radius: var(--medium-br); - padding: calc(var(--small-offset) / 3) var(--small-offset); - height: max-content; - max-width: max-content; - font: var(--regular-font); - letter-spacing: var(--one); - color: var(--white); - 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); - } - } - - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; - } + @include green-btn; } -.saveChangesButton { - display: flex; +.submitButton { grid-row: 3; } diff --git a/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts b/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts index 8f2e87b6..d055e70b 100644 --- a/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts +++ b/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts @@ -47,11 +47,11 @@ class PersonalInfoEditModel { user, ); modal.hide(); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PERSONAL_INFO_CHANGED, MESSAGE_STATUS.SUCCESS); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_INFO, ''); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PERSONAL_INFO_CHANGED, MESSAGE_STATUS.SUCCESS); } } catch (error) { - showErrorMessage(); + showErrorMessage(error); } finally { loader.remove(); } @@ -92,6 +92,7 @@ class PersonalInfoEditModel { const cancelButton = this.view.getCancelButton().getHTML(); cancelButton.addEventListener('click', () => { modal.hide(); + modal.removeContent(); }); return true; } diff --git a/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts b/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts index f6b25789..49ffbfb4 100644 --- a/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts +++ b/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts @@ -55,7 +55,7 @@ class PersonalInfoEditView { this.view.append(inputFieldElement.getHTML()); } - this.view.append(this.cancelButton.getHTML(), this.saveChangesButton.getHTML()); + this.view.append(this.saveChangesButton.getHTML(), this.cancelButton.getHTML()); return this.view; } diff --git a/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss b/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss index 9137de11..dd753ca5 100644 --- a/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss +++ b/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .wrapper { display: grid; place-items: center center; @@ -29,33 +31,12 @@ .saveChangesButton, .cancelButton { + @include green-btn; + margin: 0 auto; - border-radius: var(--medium-br); padding: calc(var(--small-offset) / 3) var(--small-offset); width: 100%; height: max-content; - font: var(--regular-font); - letter-spacing: var(--one); - color: var(--white); - 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); - } - } - - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; - } } .saveChangesButton { diff --git a/src/features/ProductFilters/model/ProductFiltersModel.ts b/src/features/ProductFilters/model/ProductFiltersModel.ts index 1a943905..7b44af5d 100644 --- a/src/features/ProductFilters/model/ProductFiltersModel.ts +++ b/src/features/ProductFilters/model/ProductFiltersModel.ts @@ -1,146 +1,16 @@ -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 RouterModel from '@/app/Router/model/RouterModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { setSelectedFilters } from '@/shared/Store/actions.ts'; -import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; -import { META_FILTERS } from '@/shared/constants/filters.ts'; -import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import ProductFiltersView from '../view/ProductFiltersView.ts'; -const DEFAULT_SEGMENT = import.meta.env.VITE_APP_DEFAULT_SEGMENT; -const NEXT_SEGMENT = import.meta.env.VITE_APP_NEXT_SEGMENT; - class ProductFiltersModel { - private selectedFilters: SelectedFilters = { - category: new Set(), - metaFilter: META_FILTERS.en.ALL_PRODUCTS, - price: { - max: 0, - min: 0, - }, - size: null, - }; - private view: ProductFiltersView; - constructor(params: ProductFiltersParams | null) { - this.view = new ProductFiltersView(params); - this.init(); - } - - private init(): void { - this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; - this.initCategoryFilters(); - this.initPriceFilters(); - this.initSizeFilters(); - this.setMetaLinksHandlers(); - this.setResetFiltersButtonHandler(); - - observeStore(selectCurrentLanguage, () => { - this.selectedFilters = getStore().getState().selectedFilters ?? this.selectedFilters; - }); - } - - 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 { - this.view.getPriceSlider().on('set', (values) => { - const [min, max] = values; - this.selectedFilters.price = { - max: +max, - min: +min, - }; - const url = new URL(decodeURIComponent(window.location.href)); - url.searchParams.delete(SEARCH_PARAMS_FIELD.MIN_PRICE); - url.searchParams.set(SEARCH_PARAMS_FIELD.MIN_PRICE, String(this.selectedFilters.price.min)); - url.searchParams.delete(SEARCH_PARAMS_FIELD.MAX_PRICE); - url.searchParams.set(SEARCH_PARAMS_FIELD.MAX_PRICE, String(this.selectedFilters.price.max)); - const path = url.pathname + encodeURIComponent(url.search); - RouterModel.getInstance().navigateTo(path.slice(1)); - }); - } - - 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); - }); - }); - } - - private setMetaLinksHandlers(): void { - const activeMetaLink = this.view - .getMetaLinks() - .find((link) => link.getHTML().id === this.selectedFilters.metaFilter); - if (activeMetaLink) { - this.switchLinkState(activeMetaLink); - } - this.view.getMetaLinks().forEach((metaLink) => { - metaLink.getHTML().addEventListener('click', () => { - this.switchLinkState(metaLink); - this.selectedFilters.metaFilter = metaLink.getHTML().id; - getStore().dispatch(setSelectedFilters(this.selectedFilters)); - }); - }); - } - - private setResetFiltersButtonHandler(): void { - const filtersResetButton = this.view.getFiltersResetButton(); - filtersResetButton.getHTML().addEventListener('click', () => { - const url = new URL(decodeURIComponent(window.location.href)); - const path = `${DEFAULT_SEGMENT}${url.pathname.split(DEFAULT_SEGMENT)[NEXT_SEGMENT]}${DEFAULT_SEGMENT}`; - RouterModel.getInstance().navigateTo(path.slice(1)); - }); - } - - 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); - }); - }); - } - - private switchLinkState(metaLink: LinkModel): void { - this.view.getMetaLinks().forEach((link) => { - this.view.switchSelectedFilter(link, false); - }); - this.view.switchSelectedFilter(metaLink, true); - } - - public getDefaultFilters(): HTMLDivElement { - return this.view.getDefaultFilters(); - } - - public getMetaFilters(): HTMLDivElement { - return this.view.getMetaFilters(); + constructor(params: ProductFiltersParams | null, callback: () => void) { + this.view = new ProductFiltersView(params, callback); } - public updateParams(params: ProductFiltersParams | null): void { - this.view.updateParams(params); + public getView(): ProductFiltersView { + return this.view; } } diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index 3fbaba91..ae915464 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -4,12 +4,14 @@ import type ProductFiltersParams from '@/shared/types/productFilters'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.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 { BUTTON_TEXT } from '@/shared/constants/buttons.ts'; import { AUTOCOMPLETE_OPTION, LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import { META_FILTERS, META_FILTERS_ID, PRICE_RANGE_LABEL, TITLE } from '@/shared/constants/filters.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; @@ -22,6 +24,8 @@ const BASE_PRODUCT_COUNT = '(0)'; const INPUT_PRICE_STEP = 5; class ProductFiltersView { + private callback: () => void; + private categoryCountSpan: HTMLSpanElement[] = []; private categoryLinks: LinkModel[] = []; @@ -48,8 +52,9 @@ class ProductFiltersView { private sizesList: HTMLUListElement; - constructor(params: ProductFiltersParams | null) { + constructor(params: ProductFiltersParams | null, callback: () => void) { this.params = params; + this.callback = callback; this.categoryList = this.createCategoryList(); this.priceSlider = this.createPriceSlider(); this.sizesList = this.createSizesList(); @@ -60,14 +65,19 @@ class ProductFiltersView { } private categoryClickHandler(parentCategory: { category: Category; count: number } | null): void { - const url = new URL(decodeURIComponent(window.location.href)); - if (url.searchParams.has(SEARCH_PARAMS_FIELD.CATEGORY)) { - url.searchParams.delete(SEARCH_PARAMS_FIELD.CATEGORY); + const searchParams = RouterModel.getSearchParams(); + if ( + searchParams.has(SEARCH_PARAMS_FIELD.CATEGORY) && + searchParams.get(SEARCH_PARAMS_FIELD.CATEGORY) === parentCategory?.category.parent?.id + ) { + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.CATEGORY); } else { - url.searchParams.set(SEARCH_PARAMS_FIELD.CATEGORY, parentCategory?.category.parent?.id ?? ''); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.CATEGORY, parentCategory?.category.parent?.id ?? ''); } - const path = url.pathname + url.search; - RouterModel.getInstance().navigateTo(path.slice(1)); + + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + + this.callback(); } private createCategoryItems(subcategories: Map): void { @@ -115,6 +125,7 @@ class ProductFiltersView { categoryLink.getHTML().addEventListener('click', (event) => { event.preventDefault(); + this.switchSelectedFilter(categoryLink); }); const innerContent = category.count ? `(${category.count})` : ''; @@ -252,11 +263,12 @@ class ProductFiltersView { link.getHTML().addEventListener('click', (event) => { event.preventDefault(); - const url = new URL(decodeURIComponent(window.location.href)); - url.searchParams.delete(SEARCH_PARAMS_FIELD.META); - url.searchParams.set(SEARCH_PARAMS_FIELD.META, id); - const path = url.pathname + encodeURIComponent(url.search); - RouterModel.getInstance().navigateTo(path.slice(1)); + + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.META, id); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + this.metaLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.switchSelectedFilter(link, true); + this.callback(); }); this.metaLinks.push(link); @@ -282,8 +294,8 @@ class ProductFiltersView { tag: 'span', }); - const minPrice = getStore().getState().selectedFilters?.price?.min.toFixed(2) ?? ''; - const maxPrice = getStore().getState().selectedFilters?.price?.max.toFixed(2) ?? ''; + const minPrice = this.params?.priceRange?.min.toFixed(2) ?? ''; + const maxPrice = this.params?.priceRange?.max.toFixed(2) ?? ''; const from = PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM; const to = PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO; @@ -304,9 +316,8 @@ class ProductFiltersView { } private createPriceSlider(): noUiSlider.API { - const { max, min } = getStore().getState().selectedFilters?.price ?? { max: 0, min: 0 }; - const SLIDER_START_MIN = min; - const SLIDER_START_MAX = max; + const min = this.params?.priceRange?.min ?? 0; + const max = this.params?.priceRange?.max ?? 0; const slider = createBaseElement({ cssClasses: [styles.slider], tag: 'div', @@ -317,7 +328,24 @@ class ProductFiltersView { connect: true, keyboardSupport: true, range: this.params?.priceRange ?? { max: 0, min: 0 }, - start: [SLIDER_START_MIN, SLIDER_START_MAX], + start: [min, max], + }); + + this.priceSlider.on('change', (values) => { + const [min, max] = values; + this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM)?.setValue(String(min)); + this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO)?.setValue(String(max)); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.MIN_PRICE); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.MAX_PRICE); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.MIN_PRICE, String(min)); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.MAX_PRICE, String(max)); + this.callback(); + }); + + this.priceSlider.on('slide', (values) => { + const [min, max] = values; + this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM)?.setValue(String(min)); + this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO)?.setValue(String(max)); }); return this.priceSlider; @@ -356,6 +384,25 @@ class ProductFiltersView { text: BUTTON_TEXT[getStore().getState().currentLanguage].RESET, }); + this.resetFiltersButton.getHTML().addEventListener('click', () => { + this.sizeLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.categoryLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.metaLinks.forEach((link) => { + this.switchSelectedFilter(link, false); + if (link.getHTML().id === META_FILTERS.en.ALL_PRODUCTS) { + this.switchSelectedFilter(link, true); + } + }); + + RouterModel.clearSearchParams(); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.CLEAR_CATALOG_SEARCH, ''); + this.callback(); + }); + + observeStore(selectCurrentLanguage, () => { + this.resetFiltersButton.getHTML().textContent = BUTTON_TEXT[getStore().getState().currentLanguage].RESET; + }); + return this.resetFiltersButton; } @@ -371,11 +418,11 @@ class ProductFiltersView { sizeLink.getHTML().addEventListener('click', (event) => { event.preventDefault(); - const url = new URL(decodeURIComponent(window.location.href)); - url.searchParams.delete(SEARCH_PARAMS_FIELD.SIZE); - url.searchParams.set(SEARCH_PARAMS_FIELD.SIZE, size.size); - const path = url.pathname + encodeURIComponent(url.search); - RouterModel.getInstance().navigateTo(path.slice(1)); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.SIZE, size.size); + this.sizeLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.switchSelectedFilter(sizeLink, true); + this.callback(); }); const span = createBaseElement({ @@ -478,38 +525,59 @@ class ProductFiltersView { const fromInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM); const toInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO); - this.priceSlider.on('update', (values) => { - const [min, max] = values; - fromInput?.setValue(String(min)); - toInput?.setValue(String(max)); - }); - - fromInput?.getHTML().addEventListener('change', () => { - this.priceSlider.set([fromInput.getValue(), toInput?.getValue() ?? 0]); - }); - toInput?.getHTML().addEventListener('change', () => { - this.priceSlider.set([fromInput?.getValue() ?? 0, toInput.getValue()]); - }); + fromInput?.getHTML().addEventListener('change', () => this.updateSelectedPrice(fromInput, toInput)); + toInput?.getHTML().addEventListener('change', () => this.updateSelectedPrice(fromInput, toInput)); } private subcategoryClickHandler(subcategory: { category: Category; count: number }): void { - const url = new URL(decodeURIComponent(window.location.href)); - const currentSubcategories = url.searchParams.getAll(SEARCH_PARAMS_FIELD.SUBCATEGORY); - const currentSubcategory = currentSubcategories.find((id) => id === subcategory.category.id); + const currentSubcategories = RouterModel.getSearchParams().getAll(SEARCH_PARAMS_FIELD.SUBCATEGORY); + const currentSubcategory = currentSubcategories?.find((id) => id === subcategory.category.id); + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === subcategory.category.id); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); if (currentSubcategory) { const filteredSubcategories = currentSubcategories.filter((id) => id !== currentSubcategory); if (!filteredSubcategories.length) { - url.searchParams.delete(SEARCH_PARAMS_FIELD.SUBCATEGORY); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY); + if (currentLink) { + this.switchSelectedFilter(currentLink, false); + } } else { - url.searchParams.delete(SEARCH_PARAMS_FIELD.SUBCATEGORY); - filteredSubcategories.forEach((id) => url.searchParams.append(SEARCH_PARAMS_FIELD.SUBCATEGORY, id)); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY); + filteredSubcategories.forEach((id) => RouterModel.appendSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY, id)); + filteredSubcategories.forEach((id) => { + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === id); + if (currentLink) { + this.switchSelectedFilter(currentLink, true); + } + }); } } else { - url.searchParams.append(SEARCH_PARAMS_FIELD.SUBCATEGORY, subcategory.category.id); + RouterModel.appendSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY, subcategory.category.id); + if (currentLink) { + this.switchSelectedFilter(currentLink, true); + } } - const path = url.pathname + encodeURIComponent(url.search); - RouterModel.getInstance().navigateTo(path.slice(1)); + this.callback(); + } + + private updatePriceRange(): void { + this.priceSlider.updateOptions( + { + start: [this.params?.priceRange?.min ?? 0, this.params?.priceRange?.max ?? 0], + }, + true, + ); + const fromInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM); + const toInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO); + fromInput?.setValue((this.params?.priceRange?.min ?? 0).toFixed(2)); + toInput?.setValue((this.params?.priceRange?.max ?? 0).toFixed(2)); + } + + private updateSelectedPrice(from: InputModel | null = null, to: InputModel | null = null): void { + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.MIN_PRICE, from?.getValue() ?? '0'); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.MAX_PRICE, to?.getValue() ?? '0'); + this.callback(); } public getCategoryLinks(): LinkModel[] { @@ -544,12 +612,42 @@ class ProductFiltersView { return this.sizeLinks; } + public setInitialActiveFilters(activeFilters: { + categoryLinks: string[]; + metaLinks: string[]; + sizeLinks: string[]; + }): void { + this.sizeLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.categoryLinks.forEach((link) => this.switchSelectedFilter(link, false)); + this.metaLinks.forEach((link) => this.switchSelectedFilter(link, false)); + + activeFilters.categoryLinks.forEach((id) => { + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === id); + if (currentLink) { + this.switchSelectedFilter(currentLink, true); + } + }); + activeFilters.sizeLinks.forEach((id) => { + const currentLink = this.sizeLinks.find((link) => link.getHTML().id === id); + if (currentLink) { + this.switchSelectedFilter(currentLink, true); + } + }); + activeFilters.metaLinks.forEach((id) => { + const currentLink = this.metaLinks.find((link) => link.getHTML().id === id); + if (currentLink) { + this.switchSelectedFilter(currentLink, true); + } + }); + } + public switchSelectedFilter(filterLink: LinkModel, toggle?: boolean): void { filterLink.getHTML().classList.toggle(styles.activeLink, toggle); } public updateParams(params: ProductFiltersParams | null): void { this.params = params; + this.categoryCountSpan.forEach((span) => { const currentSpan = span; currentSpan.innerText = BASE_PRODUCT_COUNT; @@ -558,6 +656,7 @@ class ProductFiltersView { const currentSpan = span; currentSpan.innerText = BASE_PRODUCT_COUNT; }); + this.updatePriceRange(); this.redrawProductsCount(); } } diff --git a/src/features/ProductFilters/view/productFiltersView.module.scss b/src/features/ProductFilters/view/productFiltersView.module.scss index 912efb87..4b8c5310 100644 --- a/src/features/ProductFilters/view/productFiltersView.module.scss +++ b/src/features/ProductFilters/view/productFiltersView.module.scss @@ -160,7 +160,7 @@ max-width: calc(var(--extra-large-offset) * 3.5); font: var(--regular-font); letter-spacing: var(--one); - color: var(--steam-green-800); + color: var(--steam-green-400); background-color: var(--noble-white-200); transition: border-color 0.2s, diff --git a/src/features/ProductSearch/model/ProductSearchModel.ts b/src/features/ProductSearch/model/ProductSearchModel.ts index 948ef3b8..faae9a5f 100644 --- a/src/features/ProductSearch/model/ProductSearchModel.ts +++ b/src/features/ProductSearch/model/ProductSearchModel.ts @@ -1,29 +1,33 @@ +import RouterModel from '@/app/Router/model/RouterModel.ts'; import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { setSearchValue } from '@/shared/Store/actions.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import debounce from '@/shared/utils/debounce.ts'; import ProductSearchView from '../view/ProductSearchView.ts'; -const SEARCH_DELAY = 800; +const SEARCH_DELAY = 300; class ProductSearchModel { - private eventMediator = EventMediatorModel.getInstance(); + private callback: () => void; private view = new ProductSearchView(); - constructor() { + constructor(callback: () => void) { + this.callback = callback; this.init(); } private handleSearchInput(): void { - getStore().dispatch(setSearchValue(this.view.getSearchField().getValue())); - this.eventMediator.notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, this.view.getSearchField().getValue()); + RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.SEARCH, this.view.getSearchField().getValue()); + this.callback(); } private init(): void { - getStore().dispatch(setSearchValue('')); + EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.CLEAR_CATALOG_SEARCH, () => + this.view.getSearchField().setValue(''), + ); this.setSearchFieldHandler(); } diff --git a/src/features/ProductSearch/view/ProductSearchView.ts b/src/features/ProductSearch/view/ProductSearchView.ts index 612d5a69..d6b2de53 100644 --- a/src/features/ProductSearch/view/ProductSearchView.ts +++ b/src/features/ProductSearch/view/ProductSearchView.ts @@ -1,8 +1,10 @@ +import RouterModel from '@/app/Router/model/RouterModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { AUTOCOMPLETE_OPTION } from '@/shared/constants/common.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; +import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import { TEXT } from '@/shared/constants/sorting.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; @@ -35,6 +37,11 @@ class ProductSearchView { type: INPUT_TYPE.SEARCH, }); + const initialValue = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SEARCH); + if (initialValue) { + this.searchField.setValue(initialValue); + } + observeStore(selectCurrentLanguage, () => { this.searchField.getHTML().placeholder = TEXT[getStore().getState().currentLanguage].SEARCH; }); diff --git a/src/features/ProductSorts/model/ProductSortsModel.ts b/src/features/ProductSorts/model/ProductSortsModel.ts index 76efb917..b0b6f5d7 100644 --- a/src/features/ProductSorts/model/ProductSortsModel.ts +++ b/src/features/ProductSorts/model/ProductSortsModel.ts @@ -1,44 +1,10 @@ -import type { SelectedSorting } from '@/shared/types/productSorting.ts'; - -import RouterModel from '@/app/Router/model/RouterModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { DATA_KEYS } from '@/shared/constants/common.ts'; -import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; - import ProductSortsView from '../view/ProductSortsView.ts'; class ProductSortsModel { - private selectedSorting: SelectedSorting = { - direction: '', - field: '', - }; - - private view = new ProductSortsView(); - - constructor() { - this.init(); - } - - private init(): void { - this.selectedSorting = getStore().getState().selectedSorting ?? this.selectedSorting; - this.setSortingLinksHandlers(); - } + private view: ProductSortsView; - private setSortingLinksHandlers(): void { - const sortingLinks = this.view.getSortingLinks(); - sortingLinks.forEach((link) => { - link.getHTML().addEventListener('click', () => { - this.selectedSorting.field = link.getHTML().id; - this.selectedSorting.direction = String(link.getHTML().getAttribute(DATA_KEYS.DIRECTION)); - const url = new URL(decodeURIComponent(window.location.href)); - url.searchParams.delete(SEARCH_PARAMS_FIELD.FIELD); - url.searchParams.set(SEARCH_PARAMS_FIELD.FIELD, this.selectedSorting.field); - url.searchParams.delete(SEARCH_PARAMS_FIELD.DIRECTION); - url.searchParams.set(SEARCH_PARAMS_FIELD.DIRECTION, this.selectedSorting.direction); - const path = url.pathname + encodeURIComponent(url.search); - RouterModel.getInstance().navigateTo(path.slice(1)); - }); - }); + constructor(callback: () => void) { + this.view = new ProductSortsView(callback); } public getHTML(): HTMLDivElement { diff --git a/src/features/ProductSorts/view/ProductSortsView.ts b/src/features/ProductSorts/view/ProductSortsView.ts index 9601c1f0..0f2fedce 100644 --- a/src/features/ProductSorts/view/ProductSortsView.ts +++ b/src/features/ProductSorts/view/ProductSortsView.ts @@ -1,15 +1,18 @@ +import RouterModel from '@/app/Router/model/RouterModel.ts'; import { SortDirection } from '@/shared/API/types/type.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 { DATA_KEYS } from '@/shared/constants/common.ts'; +import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import { SORTING_ID, TEXT } from '@/shared/constants/sorting.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; -import { isKeyOfSortField } from '@/shared/utils/isKeyOf.ts'; import styles from './productSortsView.module.scss'; class ProductSortsView { + private callback: () => void; + private currentSortingSpan: HTMLSpanElement; private sortingList: HTMLUListElement; @@ -20,7 +23,8 @@ class ProductSortsView { private sortingWrapper: HTMLDivElement; - constructor() { + constructor(callback: () => void) { + this.callback = callback; this.currentSortingSpan = this.createCurrentSortingSpan(); this.sortingListTitle = this.createSortingListTitle(); this.sortingList = this.createSortingList(); @@ -40,22 +44,22 @@ class ProductSortsView { } private createCurrentSortingSpan(): HTMLSpanElement { - const { currentLanguage, selectedSorting } = getStore().getState(); - const upperText = selectedSorting?.field.toUpperCase() ?? TEXT[currentLanguage].DEFAULT; - if (isKeyOfSortField(upperText)) { - this.currentSortingSpan = createBaseElement({ - cssClasses: [styles.currentSortingSpan], - innerContent: TEXT[currentLanguage][upperText], - tag: 'span', - }); - } + const selectedSorting = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); + + this.currentSortingSpan = createBaseElement({ + cssClasses: [styles.currentSortingSpan], + innerContent: selectedSorting + ? selectedSorting.toUpperCase() + : TEXT[getStore().getState().currentLanguage].DEFAULT.toUpperCase(), + tag: 'span', + }); observeStore(selectCurrentLanguage, () => { - const { currentLanguage, selectedSorting } = getStore().getState(); - const upperText = selectedSorting?.field.toUpperCase() ?? TEXT[currentLanguage].DEFAULT; - if (isKeyOfSortField(upperText)) { - this.currentSortingSpan.innerText = TEXT[currentLanguage][upperText]; - } + const selectedSorting = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); + + this.currentSortingSpan.innerText = selectedSorting + ? selectedSorting.toUpperCase() + : TEXT[getStore().getState().currentLanguage].DEFAULT.toUpperCase(); }); return this.currentSortingSpan; @@ -93,7 +97,15 @@ class ProductSortsView { } this.sortingListLinks.forEach((link) => link.getHTML().classList.remove(styles.activeLink)); link.getHTML().classList.add(styles.activeLink); + this.currentSortingSpan.innerText = text; + + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.FIELD, link.getHTML().id); + RouterModel.setSearchParams( + SEARCH_PARAMS_FIELD.DIRECTION, + String(link.getHTML().getAttribute(DATA_KEYS.DIRECTION)), + ); + this.callback(); }); this.sortingListLinks.push(link); @@ -116,19 +128,15 @@ class ProductSortsView { const priceLink = this.createSortingLink('', TEXT[getStore().getState().currentLanguage].PRICE, SORTING_ID.PRICE); const nameLink = this.createSortingLink('', TEXT[getStore().getState().currentLanguage].NAME, SORTING_ID.NAME); - const currentLink = this.sortingListLinks.find( - (link) => link.getHTML().id === getStore().getState().selectedSorting?.field, - ); + const initialField = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); + const initialDirection = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.DIRECTION); + const currentLink = this.sortingListLinks.find((link) => link.getHTML().id === initialField); currentLink?.getHTML().classList.add(styles.activeLink); - currentLink - ?.getHTML() - .classList.toggle(styles.pass, getStore().getState().selectedSorting?.direction === SortDirection.DESC); - currentLink - ?.getHTML() - .classList.toggle(styles.hight, getStore().getState().selectedSorting?.direction === SortDirection.DESC); - if (currentLink) { - currentLink.getHTML().dataset.direction = getStore().getState().selectedSorting?.direction; + if (currentLink && initialField) { + currentLink?.getHTML().classList.toggle(styles.pass, initialDirection === SortDirection.DESC); + currentLink?.getHTML().classList.toggle(styles.hight, initialDirection === SortDirection.DESC); + currentLink.getHTML().dataset.field = initialField; } observeStore(selectCurrentLanguage, () => { diff --git a/src/pages/AboutUsPage/view/AboutUsPageView.ts b/src/pages/AboutUsPage/view/AboutUsPageView.ts index b05ea098..d0a0f4ef 100644 --- a/src/pages/AboutUsPage/view/AboutUsPageView.ts +++ b/src/pages/AboutUsPage/view/AboutUsPageView.ts @@ -11,6 +11,7 @@ class AboutUsPageView { this.parent = parent; this.parent.innerHTML = ''; this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { diff --git a/src/pages/Blog/Post/view/PostView.ts b/src/pages/Blog/Post/view/PostView.ts index 5a804bdc..1647263e 100644 --- a/src/pages/Blog/Post/view/PostView.ts +++ b/src/pages/Blog/Post/view/PostView.ts @@ -23,6 +23,7 @@ export default class PostView { this.card.addEventListener('click', () => this.onPostClick()); observeStore(selectCurrentLanguage, () => this.updateLanguage()); + window.scrollTo(0, 0); } private createCardHTML(): HTMLLIElement { @@ -44,10 +45,10 @@ export default class PostView { const content = `
- ${this.post.tittle[ln]} + ${this.post.title[ln]}

${this.post.date[ln]} | ${read}

-

${this.post.tittle[ln]}

+

${this.post.title[ln]}

${this.post.shortDescription[ln]}

${readMore}

@@ -79,16 +80,16 @@ export default class PostView { this.post.content[ln] = this.post.content[ln].replace(/
/g, `

`); this.post.content[ln] = this.post.content[ln].replace(/
    /g, `

    `); this.post.content[ln] = this.post.content[ln].replace(/
  • /g, `

    `); - const tittle = ` + const title = `
    -

    ${this.post.tittle[ln]}

    +

    ${this.post.title[ln]}

    ${this.post.shortDescription[ln]}

    - ${this.post.tittle[ln]} + ${this.post.title[ln]}
    ${this.post.content[ln]}
    `; - return tittle; + return title; } public getCardHTML(short?: boolean): HTMLLIElement { diff --git a/src/pages/Blog/Post/view/post.module.scss b/src/pages/Blog/Post/view/post.module.scss index d5a5652c..26437cd1 100644 --- a/src/pages/Blog/Post/view/post.module.scss +++ b/src/pages/Blog/Post/view/post.module.scss @@ -1,5 +1,9 @@ .cardItem { background-color: var(--white); + transition: + box-shadow 0.2s, + color 0.2s, + transform 0.2s; &:hover { box-shadow: var(--mellow-shadow-500); diff --git a/src/pages/Blog/PostList/view/PostListView.ts b/src/pages/Blog/PostList/view/PostListView.ts index edcab59b..c67dd24f 100644 --- a/src/pages/Blog/PostList/view/PostListView.ts +++ b/src/pages/Blog/PostList/view/PostListView.ts @@ -28,6 +28,7 @@ export default class PostListView { this.description = this.createPageDescription(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { @@ -59,7 +60,7 @@ export default class PostListView { private createPageTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: [styles.pageTitle], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].LIST_TITTLE, + innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].LIST_TITLE, tag: 'h1', }); return this.title; @@ -81,11 +82,12 @@ export default class PostListView { public openPost(post: BlogPostView): void { this.parent.innerHTML = ''; this.parent.append(post.getPostHTML()); + window.scrollTo(0, 0); } public updateLanguage(): boolean { const ln = getStore().getState().currentLanguage; - this.title.innerText = BLOG_DESCRIPTION[ln].LIST_TITTLE; + this.title.innerText = BLOG_DESCRIPTION[ln].LIST_TITLE; this.description.innerText = BLOG_DESCRIPTION[ln].LIST_DESCRIPTION; return true; } diff --git a/src/pages/Blog/PostWidget/view/PostWidgetView.ts b/src/pages/Blog/PostWidget/view/PostWidgetView.ts index 10de4158..73a78337 100644 --- a/src/pages/Blog/PostWidget/view/PostWidgetView.ts +++ b/src/pages/Blog/PostWidget/view/PostWidgetView.ts @@ -27,6 +27,7 @@ export default class PostWidgetView { this.description = this.createPageDescription(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { @@ -58,7 +59,7 @@ export default class PostWidgetView { private createPageTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: [styles.pageTitle], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITTLE, + innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITLE, tag: 'h3', }); return this.title; @@ -80,10 +81,11 @@ export default class PostWidgetView { public openPost(post: BlogPostView): void { this.parent.innerHTML = ''; this.parent.append(post.getPostHTML()); + window.scrollTo(0, 0); } public updateLanguage(): boolean { - this.title.innerText = BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITTLE; + this.title.innerText = BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITLE; this.description.innerText = BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_DESCRIPTIONS; return true; } diff --git a/src/pages/Blog/data/posts.ts b/src/pages/Blog/data/posts.ts index 8b37cb1b..32a8e289 100644 --- a/src/pages/Blog/data/posts.ts +++ b/src/pages/Blog/data/posts.ts @@ -17,9 +17,9 @@ const postsData: Post[] = [ ru: 'Π’Ρ‹ Π·Π½Π°Π΅Ρ‚Π΅, Ρ‡Ρ‚ΠΎ вашим ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹ΠΌ растСниям Π½ΡƒΠΆΠ΅Π½ солнСчный свСт ΠΈ Π²ΠΎΠ΄Π°, Π½ΠΎ ΠΊΠ°ΠΊ насчСт ΡƒΠ΄ΠΎΠ±Ρ€Π΅Π½ΠΈΠΉ? Π£Π΄ΠΎΠ±Ρ€Π΅Π½ΠΈΠ΅ ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Ρ… растСний Π² Ρ‚Π΅Ρ‡Π΅Π½ΠΈΠ΅ Π²Π΅Π³Π΅Ρ‚Π°Ρ†ΠΈΠΎΠ½Π½ΠΎΠ³ΠΎ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π° обСспСчиваСт ΠΈΡ… Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΡ‹ΠΌΠΈ ΠΏΠΈΡ‚Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌΠΈ вСщСствами, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΠΎΠΌΠΎΠ³Π°ΡŽΡ‚ ΠΈΠΌ ΠΏΡ€ΠΎΡ†Π²Π΅Ρ‚Π°Ρ‚ΡŒ: Π°Π·ΠΎΡ‚ΠΎΠΌ (N), фосфором (P) ΠΈ ΠΊΠ°Π»ΠΈΠ΅ΠΌ (K).', }, time: 8, - tittle: { + title: { en: 'How to Fertilize Your Houseplants', - ru: 'Как Π£Π΄ΠΎΠ±Ρ€ΡΡ‚ΡŒ ΠšΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Π΅ РастСния', + ru: 'Как ΡƒΠ΄ΠΎΠ±Ρ€ΡΡ‚ΡŒ ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Π΅ растСния', }, }, { @@ -38,9 +38,9 @@ const postsData: Post[] = [ ru: 'Π’ΠΎΠ΄Π° - ΡƒΠ΄ΠΈΠ²ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΠ΅ явлСниС. Бостоящая ΠΈΠ· Π²ΠΎΠ΄ΠΎΡ€ΠΎΠ΄Π° ΠΈ кислорода, ΠΎΠ½Π° Π±ΡƒΠΊΠ²Π°Π»ΡŒΠ½ΠΎ ΠΎΡ‚Π²Π΅Ρ‡Π°Π΅Ρ‚ Π·Π° всС ΠΆΠΈΠ²ΠΎΠ΅ Π½Π° Π—Π΅ΠΌΠ»Π΅. ΠŸΠΎΠ»ΠΈΠ²Π°Ρ‚ΡŒ растСниС - Π΄Π΅Π»ΠΎ Π½Π΅Ρ…ΠΈΡ‚Ρ€ΠΎΠ΅, Π½ΠΎ Π²ΠΎΡ‚ сколько ΠΈ ΠΊΠ°ΠΊ часто - это ΡƒΠΆΠ΅ слоТнСС. К ΡΡ‡Π°ΡΡ‚ΡŒΡŽ, Ρƒ нас Π΅ΡΡ‚ΡŒ нСсколько ΠΈΠ΄Π΅ΠΉ ΠΏΠΎ ΠΏΠΎΠ²ΠΎΠ΄Ρƒ ΠΏΠΎΠ»ΠΈΠ²Π° для ΠΎΠΏΡ‚ΠΈΠΌΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ Π·Π΄ΠΎΡ€ΠΎΠ²ΡŒΡ растСний.', }, time: 6, - tittle: { + title: { en: 'How Often & How Much You Should Water Houseplants', - ru: 'Как Часто И Как Много НуТно ΠŸΠΎΠ»ΠΈΠ²Π°Ρ‚ΡŒ ΠšΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Π΅ РастСния', + ru: 'Как часто ΠΈ ΠΊΠ°ΠΊ ΠΌΠ½ΠΎΠ³ΠΎ Π½ΡƒΠΆΠ½ΠΎ ΠΏΠΎΠ»ΠΈΠ²Π°Ρ‚ΡŒ ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Π΅ растСния', }, }, { @@ -59,9 +59,9 @@ const postsData: Post[] = [ ru: 'Π‘ΠΎΠ±ΠΈΡ€Π°Π΅Ρ‚Π΅ΡΡŒ Π² отпуск? ΠœΡ‹ дСлимся с Π²Π°ΠΌΠΈ совСтами ΠΈ рСкомСндациями, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ ΠΏΠΎΠΌΠΎΠ³ΡƒΡ‚ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ ваши ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Π΅ растСния счастливыми ΠΈ Π·Π΄ΠΎΡ€ΠΎΠ²Ρ‹ΠΌΠΈ, ΠΏΠΎΠΊΠ° Π²Ρ‹ Π² ΠΎΡ‚ΡŠΠ΅Π·Π΄Π΅.', }, time: 4, - tittle: { + title: { en: 'How To Keep Your Plants Alive While On Vacation', - ru: 'Как Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ РастСния Π–ΠΈΠ²Ρ‹ΠΌΠΈ Π’ΠΎ ВрСмя ΠžΡ‚ΠΏΡƒΡΠΊΠ°', + ru: 'Как ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ растСния ΠΆΠΈΠ²Ρ‹ΠΌΠΈ Π²ΠΎ врСмя отпуска', }, }, { @@ -80,9 +80,9 @@ const postsData: Post[] = [ ru: 'Если Π²Ρ‹ ΠΊΠΎΠ³Π΄Π°-Π½ΠΈΠ±ΡƒΠ΄ΡŒ Π²ΠΈΠ΄Π΅Π»ΠΈ ΠΆΠ΅Π»Ρ‚Ρ‹Π΅ Π»ΠΈΡΡ‚ΡŒΡ Π½Π° своСм Π±Ρ‹Π²ΡˆΠ΅ΠΌ ΠΊΠΎΠ³Π΄Π°-Ρ‚ΠΎ Π·Π΅Π»Π΅Π½Ρ‹ΠΌ растСнии, Ρ‡ΠΈΡ‚Π°ΠΉΡ‚Π΅ дальшС, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ·Π½Π°Ρ‚ΡŒ ΠΎ ΠΏΡ€ΠΈΡ‡ΠΈΠ½Π°Ρ…, симптомах ΠΈ способах ΠΈΡ… устранСния. ВсС Π±ΡƒΠ΄Π΅Ρ‚ Ρ…ΠΎΡ€ΠΎΡˆΠΎ!', }, time: 3, - tittle: { - en: '5 Causes For Your Plant’s Yellow Leaves', - ru: '5 ΠŸΡ€ΠΈΡ‡ΠΈΠ½ ΠŸΠΎΠΆΠ΅Π»Ρ‚Π΅Π½ΠΈΡ Π›ΠΈΡΡ‚ΡŒΠ΅Π² Π’Π°ΡˆΠΈΡ… РастСний', + title: { + en: 'Five Causes For Your Plant’s Yellow Leaves', + ru: 'ΠŸΡΡ‚ΡŒ ΠΏΡ€ΠΈΡ‡ΠΈΠ½ поТСлтСния Π»ΠΈΡΡ‚ΡŒΠ΅Π² Π²Π°ΡˆΠΈΡ… растСний', }, }, { @@ -101,9 +101,9 @@ const postsData: Post[] = [ ru: 'Π˜Ρ‚Π°ΠΊ, вашС растСниС ΠΏΡ€ΠΈΠ±Ρ‹Π»ΠΎ Π² Π³ΠΎΡ€ΡˆΠΊΠ΅ ΠΈΠ· ΠΏΠΈΡ‚ΠΎΠΌΠ½ΠΈΠΊΠ° - Ρ‡Ρ‚ΠΎ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ?', }, time: 7, - tittle: { - en: 'To Pot or Not to Pot', - ru: 'ΠŸΠ΅Ρ€Π΅ΡΠ°ΠΆΠΈΠ²Π°Ρ‚ΡŒ Или НС ΠŸΠ΅Ρ€Π΅ΡΠ°ΠΆΠΈΠ²Π°Ρ‚ΡŒ', + title: { + en: 'To Pot or Not to Pot?', + ru: 'ΠŸΠ΅Ρ€Π΅ΡΠ°ΠΆΠΈΠ²Π°Ρ‚ΡŒ ΠΈΠ»ΠΈ Π½Π΅ ΠΏΠ΅Ρ€Π΅ΡΠ°ΠΆΠΈΠ²Π°Ρ‚ΡŒ?', }, }, { @@ -122,9 +122,9 @@ const postsData: Post[] = [ ru: 'ΠŸΠ΅Ρ€Π΅Π΅Π·ΠΆΠ°Π΅Ρ‚Π΅ Π² Π½ΠΎΠ²Ρ‹ΠΉ Π΄ΠΎΠΌ ΠΈΠ»ΠΈ ΠΊΠ²Π°Ρ€Ρ‚ΠΈΡ€Ρƒ? ВмСсто Ρ‚ΠΎΠ³ΠΎ Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΡ€ΠΈΡ‡ΠΈΠ½ΡΡ‚ΡŒ боль, этот процСсс Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ Ρ†Π΅Π½Π½Ρ‹ΠΌ, особСнно Ссли Π²Ρ‹ ΡƒΠΏΠ°ΠΊΠΎΠ²Ρ‹Π²Π°Π΅Ρ‚Π΅ нСсколько растСний. Π”Π΅Ρ€ΠΆΠΈΡ‚Π΅ ΠΏΠΎΠ΄ Ρ€ΡƒΠΊΠΎΠΉ эти совСты для счастливого ΠΏΠ΅Ρ€Π΅Π΅Π·Π΄Π° Π½Π° Π½ΠΎΠ²ΠΎΠ΅ мСсто ΠΆΠΈΡ‚Π΅Π»ΡŒΡΡ‚Π²Π°.', }, time: 2, - tittle: { + title: { en: 'New Digs: Moving Plants Small & Tall', - ru: 'На Новом ΠœΠ΅ΡΡ‚Π΅: ΠŸΠ΅Ρ€Π΅Π΅Π·Π΄ ΠœΠ°Π»Π΅Π½ΡŒΠΊΠΈΡ… И Π‘ΠΎΠ»ΡŒΡˆΠΈΡ… РастСний', + ru: 'На Π½ΠΎΠ²ΠΎΠΌ мСстС: ΠΏΠ΅Ρ€Π΅Π΅Π·Π΄ ΠΌΠ°Π»Π΅Π½ΡŒΠΊΠΈΡ… ΠΈ Π±ΠΎΠ»ΡŒΡˆΠΈΡ… растСний', }, }, { @@ -143,9 +143,9 @@ const postsData: Post[] = [ ru: 'Когда Π΄Π΅Π»ΠΎ Π΄ΠΎΡ…ΠΎΠ΄ΠΈΡ‚ Π΄ΠΎ вСсСннСй ΡƒΠ±ΠΎΡ€ΠΊΠΈ, наши домашниС растСния часто ΠΎΡΡ‚Π°ΡŽΡ‚ΡΡ Π±Π΅Π· внимания! ΠœΡ‹ дСлимся с Π²Π°ΠΌΠΈ совСтами, ΠΊΠ°ΠΊ провСсти вСсСннюю ΡƒΠ±ΠΎΡ€ΠΊΡƒ Π²Π°ΡˆΠΈΡ… Π»ΡŽΠ±ΠΈΠΌΡ‹Ρ… растСний ΠΈ ΠΈΡ… Π³ΠΎΡ€ΡˆΠΊΠΎΠ² Π΄ΠΎ Π½Π°Ρ‡Π°Π»Π° Π»Π΅Ρ‚Π½Π΅Π³ΠΎ сСзона роста.', }, time: 2, - tittle: { + title: { en: 'How To Spring Clean Your Houseplants', - ru: 'Как ΠŸΡ€ΠΎΠ²Π΅ΡΡ‚ΠΈ Π’Π΅ΡΠ΅Π½Π½ΡŽΡŽ Π£Π±ΠΎΡ€ΠΊΡƒ ΠšΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Ρ… РастСний', + ru: 'Как провСсти вСсСннюю ΡƒΠ±ΠΎΡ€ΠΊΡƒ ΠΊΠΎΠΌΠ½Π°Ρ‚Π½Ρ‹Ρ… растСний', }, }, { @@ -164,7 +164,7 @@ const postsData: Post[] = [ ru: 'ΠšΠ°Π»ΡŒΡ†ΠΈΠΉ. Π­Ρ‚ΠΎ вСщСство, ΠΈΠ· ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ состоят ΠΊΡ€Π΅ΠΏΠΊΠΈΠ΅ кости, - Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΡ‹ΠΉ элСмСнт для всСго ΠΆΠΈΠ²ΠΎΠ³ΠΎ. Но, нСсмотря Π½Π° Π΅Π³ΠΎ Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎΡΡ‚ΡŒ, ΠΈΠ½ΠΎΠ³Π΄Π° ΠΊΠ°Π»ΡŒΡ†ΠΈΠΉ ΠΌΠΎΠΆΠ΅Ρ‚ Π½Π°ΠΊΠ°ΠΏΠ»ΠΈΠ²Π°Ρ‚ΡŒΡΡ. Π’ΠΎΡ‚ Ρ‡Ρ‚ΠΎ, ΠΊΠ°ΠΊ ΠΈ Ρ‡Ρ‚ΠΎ с этим Π΄Π΅Π»Π°Ρ‚ΡŒ.', }, time: 2, - tittle: { + title: { en: 'Calcium Buildup', ru: 'НакоплСниС ΠΊΠ°Π»ΡŒΡ†ΠΈΡ', }, diff --git a/src/pages/CartPage/model/CartPageModel.ts b/src/pages/CartPage/model/CartPageModel.ts index 62a39a0f..0e1c66e8 100644 --- a/src/pages/CartPage/model/CartPageModel.ts +++ b/src/pages/CartPage/model/CartPageModel.ts @@ -2,9 +2,14 @@ import type { Cart } from '@/shared/types/cart.ts'; import type { Page } from '@/shared/types/page.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; import ProductOrderModel from '@/widgets/ProductOrder/model/ProductOrderModel.ts'; @@ -13,24 +18,96 @@ import CartPageView from '../view/CartPageView.ts'; class CartPageModel implements Page { private cart: Cart | null = null; + private changeProductHandler = (cart: Cart): void => { + this.cart = cart; + this.productsItem = this.productsItem.filter((productItem) => { + const searchEl = this.cart?.products.find((item) => item.lineItemId === productItem.getProduct().lineItemId); + if (!searchEl) { + productItem.getHTML().remove(); + return false; + } + return true; + }); + + if (!this.productsItem.length) { + this.view.renderEmpty(); + } + this.view.updateTotal(this.cart); + }; + + private clearCart = async (): Promise => { + await getCartModel() + .clearCart() + .then((cart) => { + this.cart = cart; + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.SUCCESSFUL_CLEAR_CART, MESSAGE_STATUS.SUCCESS); + this.productsItem = this.productsItem.filter((productItem) => { + const searchEl = this.cart?.products.find((item) => item.lineItemId === productItem.getProduct().lineItemId); + if (!searchEl) { + productItem.getHTML().remove(); + return false; + } + return true; + }); + this.renderCart(); + }) + .catch((error: Error) => { + showErrorMessage(error); + return this.cart; + }); + }; + private productsItem: ProductOrderModel[] = []; private view: CartPageView; constructor(parent: HTMLDivElement) { - this.view = new CartPageView(parent); + this.view = new CartPageView(parent, this.clearCart, this.addDiscountHandler.bind(this)); this.init().catch(showErrorMessage); } + private async addDiscountHandler(discountCode: string): Promise { + if (discountCode.trim()) { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getCouponButton().append(loader); + await getCartModel() + .addCoupon(discountCode) + .then((cart) => { + if (cart) { + serverMessageModel.showServerMessage( + SERVER_MESSAGE_KEYS.SUCCESSFUL_ADD_COUPON_TO_CART, + MESSAGE_STATUS.SUCCESS, + ); + this.cart = cart; + this.view.updateTotal(this.cart); + } + }) + .catch(showErrorMessage) + .finally(() => loader.remove()); + } + } + private async init(): Promise { getStore().dispatch(setCurrentPage(PAGE_ID.CART_PAGE)); this.cart = await getCartModel().addProductInfo(); - this.cart.products.forEach((product) => { - this.productsItem.push(new ProductOrderModel(product)); - }); + this.renderCart(); + observeStore(selectCurrentLanguage, () => this.view.updateLanguage()); + } + + private renderCart(): void { + if (this.cart) { + this.cart.products.forEach((product) => { + this.productsItem.push(new ProductOrderModel(product, this.changeProductHandler)); + }); - this.view.renderCart(this.productsItem); + if (this.productsItem.length) { + this.view.renderCart(this.productsItem); + } else { + this.view.renderEmpty(); + } + this.view.updateTotal(this.cart); + } } public getHTML(): HTMLDivElement { diff --git a/src/pages/CartPage/view/CartPageView.ts b/src/pages/CartPage/view/CartPageView.ts index a69b704d..61485a5e 100644 --- a/src/pages/CartPage/view/CartPageView.ts +++ b/src/pages/CartPage/view/CartPageView.ts @@ -1,12 +1,62 @@ +import type { LanguageChoiceType } from '@/shared/constants/common'; +import type { Cart } from '@/shared/types/cart'; +import type { languageVariants } from '@/shared/types/common'; import type ProductOrderModel from '@/widgets/ProductOrder/model/ProductOrderModel'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import ConfirmModel from '@/shared/Confirm/model/ConfirmModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import { USER_MESSAGE } from '@/shared/constants/confirmUserMessage.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import styles from './cartPageView.module.scss'; +type ClearCallback = () => void; +type DiscountCallback = (discount: string) => void; +type textElementsType = { + element: HTMLAnchorElement | HTMLButtonElement | HTMLParagraphElement | HTMLTableCellElement; + textItem: languageVariants; +}; + +const TITLE = { + BUTTON_CHECKOUT: { en: 'Proceed To Checkout', ru: 'ΠžΡ„ΠΎΡ€ΠΌΠΈΡ‚ΡŒ Π·Π°ΠΊΠ°Π·' }, + BUTTON_COUPON: { en: 'Apply', ru: 'ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ' }, + CART_TOTAL: { en: 'Cart Totals', ru: 'Π˜Ρ‚ΠΎΠ³ΠΎ ΠΏΠΎ ΠΊΠΎΡ€Π·ΠΈΠ½Π΅' }, + CLEAR: { en: 'Clear all', ru: 'ΠžΡ‡ΠΈΡΡ‚ΠΈΡ‚ΡŒ' }, + CONTINUE: { en: 'Continue Shopping', ru: 'ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ ΠΏΠΎΠΊΡƒΠΏΠΊΠΈ' }, + COUPON_APPLY: { en: 'Coupon Apply', ru: 'ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΊΡƒΠΏΠΎΠ½' }, + COUPON_DISCOUNT: { en: 'Coupon Discount', ru: 'Π‘ΠΊΠΈΠ΄ΠΊΠ° ΠΏΠΎ ΠΊΡƒΠΏΠΎΠ½Ρƒ' }, + EMPTY: { + en: `Oops! Looks like you haven't added the item to your cart yet.`, + ru: `Ой! ΠŸΠΎΡ…ΠΎΠΆΠ΅, Π²Ρ‹ Π΅Ρ‰Π΅ Π½Π΅ Π΄ΠΎΠ±Π°Π²ΠΈΠ»ΠΈ Ρ‚ΠΎΠ²Π°Ρ€ Π² ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ.`, + }, + INPUT_COUPON: { en: 'Enter coupon here...', ru: 'Π’Π²Π΅Π΄ΠΈΡ‚Π΅ ΠΊΡƒΠΏΠΎΠ½ здСсь...' }, + PRICE: { en: 'Price', ru: 'Π¦Π΅Π½Π°' }, + PRODUCT: { en: 'Product', ru: 'ΠŸΡ€ΠΎΠ΄ΡƒΠΊΡ‚' }, + QUANTITY: { en: 'Quantity', ru: 'ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ' }, + SUBTOTAL: { en: 'Subtotal', ru: 'Π‘ΡƒΠΌΠΌΠ°' }, + TOTAL: { en: 'Total', ru: 'Π˜Ρ‚ΠΎΠ³ΠΎ' }, +}; class CartPageView { + private addDiscountCallback: DiscountCallback; + + private clear: LinkModel; + + private clearCallback: ClearCallback; + + private couponButton: HTMLButtonElement; + + private discount: HTMLParagraphElement; + + private empty: HTMLDivElement; + + private language: LanguageChoiceType; + private page: HTMLDivElement; private parent: HTMLDivElement; @@ -15,20 +65,42 @@ class CartPageView { private productWrap: HTMLDivElement; + private subTotal: HTMLParagraphElement; + private table: HTMLTableElement | null = null; private tableBody: HTMLTableSectionElement | null = null; + private textElement: textElementsType[] = []; + + private total: HTMLParagraphElement; + private totalWrap: HTMLDivElement; - constructor(parent: HTMLDivElement) { + constructor(parent: HTMLDivElement, clearCallback: ClearCallback, addDiscountCallback: DiscountCallback) { + this.language = getStore().getState().currentLanguage; this.parent = parent; this.parent.innerHTML = ''; + this.clearCallback = clearCallback; + this.addDiscountCallback = addDiscountCallback; this.page = this.createPageHTML(); this.productWrap = this.createWrapHTML(); this.productWrap.classList.add(styles.products); + this.subTotal = createBaseElement({ cssClasses: [styles.totalTitle], tag: 'p' }); + this.total = createBaseElement({ cssClasses: [styles.totalPrice], tag: 'p' }); + this.discount = createBaseElement({ cssClasses: [styles.title], tag: 'p' }); + this.couponButton = createBaseElement({ + cssClasses: [styles.button, styles.applyBtn], + innerContent: TITLE.BUTTON_COUPON[this.language], + tag: 'button', + }); + this.clear = new LinkModel({ classes: [styles.continue, styles.clear], text: TITLE.CLEAR[this.language] }); this.totalWrap = this.createWrapHTML(); this.totalWrap.classList.add(styles.total); + this.page.append(this.productWrap); + this.page.append(this.totalWrap); + this.empty = this.createEmptyHTML(); + window.scrollTo(0, 0); } private addTableHeader(): void { @@ -37,26 +109,30 @@ class CartPageView { const tr = createBaseElement({ cssClasses: [styles.tr, styles.head], tag: 'tr' }); const thImage = createBaseElement({ cssClasses: [styles.th, styles.imgCell, styles.mainText], - innerContent: 'Product', + innerContent: TITLE.PRODUCT[this.language], tag: 'th', }); + this.textElement.push({ element: thImage, textItem: TITLE.PRODUCT }); const thProduct = createBaseElement({ cssClasses: [styles.th, styles.nameCell, styles.mainText], tag: 'th' }); const thPrice = createBaseElement({ cssClasses: [styles.th, styles.priceCell, styles.mainText], - innerContent: 'Price', + innerContent: TITLE.PRICE[this.language], tag: 'th', }); + this.textElement.push({ element: thPrice, textItem: TITLE.PRICE }); const thQuantity = createBaseElement({ cssClasses: [styles.th, styles.quantityCell, styles.mainText], - innerContent: 'Quantity', + innerContent: TITLE.QUANTITY[this.language], tag: 'th', }); + this.textElement.push({ element: thQuantity, textItem: TITLE.QUANTITY }); const thTotal = createBaseElement({ cssClasses: [styles.th, styles.totalCell, styles.mainText], - innerContent: 'Total', + innerContent: TITLE.TOTAL[this.language], tag: 'th', }); - const thDelete = createBaseElement({ cssClasses: [styles.th, styles.deleteCell, styles.mainText], tag: 'th' }); + this.textElement.push({ element: thTotal, textItem: TITLE.TOTAL }); + const thDelete = this.createDeleCell(); this.tableBody = createBaseElement({ cssClasses: [styles.tbody], tag: 'tbody' }); this.table.append(thead, this.tableBody); thead.append(tr); @@ -64,31 +140,31 @@ class CartPageView { this.productWrap.append(this.table); } - private addTotalInfo(totalPriceSum: number): void { + private addTotalInfo(): void { const title = createBaseElement({ cssClasses: [styles.totalTitle, styles.border, styles.mobileHide], - innerContent: 'Cart Totals', + innerContent: TITLE.CART_TOTAL[this.language], tag: 'p', }); + this.textElement.push({ element: title, textItem: TITLE.CART_TOTAL }); const couponTitle = createBaseElement({ cssClasses: [styles.title, styles.mobileHide], - innerContent: 'Coupon Apply', + innerContent: TITLE.COUPON_APPLY[this.language], tag: 'p', }); + this.textElement.push({ element: couponTitle, textItem: TITLE.COUPON_APPLY }); const couponWrap = this.createCouponHTML(); - const subtotalWrap = this.createSubtotalHTML(totalPriceSum); + const subtotalWrap = this.createSubtotalHTML(); const discountWrap = this.createDiscountHTML(); - const totalWrap = this.createTotalHTML(totalPriceSum); + const totalWrap = this.createTotalHTML(); const finalButton = createBaseElement({ - cssClasses: [styles.button], - innerContent: 'Proceed To Checkout', + cssClasses: [styles.button, styles.checkoutBtn], + innerContent: TITLE.BUTTON_CHECKOUT[this.language], tag: 'button', }); - const continueLink = createBaseElement({ - cssClasses: [styles.continue, styles.mobileHide], - innerContent: 'Continue Shopping', - tag: 'a', - }); + this.textElement.push({ element: finalButton, textItem: TITLE.BUTTON_CHECKOUT }); + const continueLink = this.createCatalogLinkHTML(); + continueLink.getHTML().classList.add(styles.mobileHide); this.totalWrap.append( title, couponTitle, @@ -97,32 +173,91 @@ class CartPageView { discountWrap, totalWrap, finalButton, - continueLink, + continueLink.getHTML(), ); } + private createCatalogLinkHTML(): LinkModel { + const link = new LinkModel({ + attrs: { + href: PAGE_ID.CATALOG_PAGE, + }, + classes: [styles.continue], + text: TITLE.CONTINUE[this.language], + }); + this.textElement.push({ element: link.getHTML(), textItem: TITLE.CONTINUE }); + + link.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + RouterModel.getInstance().navigateTo(PAGE_ID.CATALOG_PAGE); + }); + return link; + } + private createCouponHTML(): HTMLDivElement { const couponWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); const couponInput = new InputModel({ autocomplete: 'off', id: 'coupon', - placeholder: 'Enter coupon here...', + placeholder: TITLE.INPUT_COUPON[this.language], type: INPUT_TYPE.TEXT, }); - couponInput.getHTML().classList.add('couponInput'); - const couponButton = createBaseElement({ cssClasses: [styles.button], innerContent: 'Apply', tag: 'button' }); - couponWrap.append(couponInput.getHTML(), couponButton); + couponInput.getHTML().classList.add(styles.couponInput); + + this.textElement.push({ element: couponInput.getHTML(), textItem: TITLE.INPUT_COUPON }); + this.textElement.push({ element: this.couponButton, textItem: TITLE.BUTTON_COUPON }); + this.couponButton.addEventListener('click', (evn: Event) => { + evn.preventDefault(); + this.addDiscountCallback(couponInput.getHTML().value); + couponInput.getHTML().value = ''; + }); + couponWrap.append(couponInput.getHTML(), this.couponButton); return couponWrap; } + private createDeleCell(): HTMLTableCellElement { + const tdDelete = createBaseElement({ cssClasses: [styles.th, styles.deleteCell, styles.mainText], tag: 'th' }); + + this.textElement.push({ element: this.clear.getHTML(), textItem: TITLE.CLEAR }); + this.clear.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + const confirmModel = new ConfirmModel( + () => this.clearCallback(), + USER_MESSAGE[getStore().getState().currentLanguage].CLEAR_CART, + ); + modal.setContent(confirmModel.getHTML()); + modal.show(); + }); + tdDelete.append(this.clear.getHTML()); + return tdDelete; + } + private createDiscountHTML(): HTMLDivElement { const discountWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); - const discountTitle = createBaseElement({ cssClasses: [styles.title], innerContent: 'Coupon Discount', tag: 'p' }); - const discountValue = createBaseElement({ cssClasses: [styles.title], innerContent: '(-) 00.00', tag: 'p' }); - discountWrap.append(discountTitle, discountValue); + const discountTitle = createBaseElement({ + cssClasses: [styles.title], + innerContent: TITLE.COUPON_DISCOUNT[this.language], + tag: 'p', + }); + discountWrap.append(discountTitle, this.discount); + this.textElement.push({ element: discountTitle, textItem: TITLE.COUPON_DISCOUNT }); return discountWrap; } + private createEmptyHTML(): HTMLDivElement { + const empty = createBaseElement({ cssClasses: [styles.empty, styles.hide], tag: 'div' }); + const emptyTitle = createBaseElement({ + cssClasses: [styles.emptyTitle], + innerContent: TITLE.EMPTY[this.language], + tag: 'p', + }); + this.textElement.push({ element: emptyTitle, textItem: TITLE.EMPTY }); + const continueLink = this.createCatalogLinkHTML(); + empty.append(emptyTitle, continueLink.getHTML()); + this.page.append(empty); + return empty; + } + private createPageHTML(): HTMLDivElement { this.page = createBaseElement({ cssClasses: [styles.cartPage], @@ -134,27 +269,27 @@ class CartPageView { return this.page; } - private createSubtotalHTML(totalPriceSum: number): HTMLDivElement { + private createSubtotalHTML(): HTMLDivElement { const subtotalWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); - const subtotalTitle = createBaseElement({ cssClasses: [styles.title], innerContent: 'Subtotal', tag: 'p' }); - const subtotalValue = createBaseElement({ - cssClasses: [styles.totalTitle], - innerContent: `$ ${totalPriceSum.toFixed(2)}`, + const subtotalTitle = createBaseElement({ + cssClasses: [styles.title], + innerContent: TITLE.SUBTOTAL[this.language], tag: 'p', }); - subtotalWrap.append(subtotalTitle, subtotalValue); + subtotalWrap.append(subtotalTitle, this.subTotal); + this.textElement.push({ element: subtotalTitle, textItem: TITLE.SUBTOTAL }); return subtotalWrap; } - private createTotalHTML(totalPriceSum: number): HTMLDivElement { + private createTotalHTML(): HTMLDivElement { const totalWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); - const totalTitle = createBaseElement({ cssClasses: [styles.totalTitle], innerContent: 'Total', tag: 'p' }); - const totalValue = createBaseElement({ - cssClasses: [styles.totalPrice], - innerContent: `$ ${totalPriceSum.toFixed(2)}`, + const totalTitle = createBaseElement({ + cssClasses: [styles.totalTitle], + innerContent: TITLE.TOTAL[this.language], tag: 'p', }); - totalWrap.append(totalTitle, totalValue); + totalWrap.append(totalTitle, this.total); + this.textElement.push({ element: totalTitle, textItem: TITLE.TOTAL }); return totalWrap; } @@ -164,11 +299,13 @@ class CartPageView { tag: 'div', }); - this.page.append(wrap); - return wrap; } + public getCouponButton(): HTMLButtonElement { + return this.couponButton; + } + public getHTML(): HTMLDivElement { return this.page; } @@ -176,12 +313,40 @@ class CartPageView { public renderCart(productsItem: ProductOrderModel[]): void { this.productWrap.innerHTML = ''; this.totalWrap.innerHTML = ''; + this.productWrap.classList.remove(styles.hide); + this.totalWrap.classList.remove(styles.hide); + this.empty.classList.add(styles.hide); this.productRow.map((productEl) => productEl.remove()); this.productRow = []; this.addTableHeader(); productsItem.forEach((productEl) => this.tableBody?.append(productEl.getHTML())); - const totalPriceSum = productsItem.reduce((sum, product) => sum + product.getProduct().totalPrice, 0); - this.addTotalInfo(totalPriceSum); + this.addTotalInfo(); + } + + public renderEmpty(): void { + this.productWrap.innerHTML = ''; + this.totalWrap.innerHTML = ''; + this.productWrap.classList.add(styles.hide); + this.totalWrap.classList.add(styles.hide); + this.empty.classList.remove(styles.hide); + } + + public updateLanguage(): void { + this.language = getStore().getState().currentLanguage; + this.textElement.forEach((textEl) => { + const elHTML = textEl.element; + if (elHTML instanceof HTMLInputElement) { + elHTML.placeholder = textEl.textItem[this.language]; + } else { + elHTML.textContent = textEl.textItem[this.language]; + } + }); + } + + public updateTotal(cart: Cart): void { + this.subTotal.innerHTML = `$ ${(cart.total + cart.discounts).toFixed(2)}`; + this.discount.innerHTML = `-$ ${cart.discounts.toFixed(2)}`; + this.total.innerHTML = `$ ${cart.total.toFixed(2)}`; } } export default CartPageView; diff --git a/src/pages/CartPage/view/cartPageView.module.scss b/src/pages/CartPage/view/cartPageView.module.scss index 48a23966..3f51d986 100644 --- a/src/pages/CartPage/view/cartPageView.module.scss +++ b/src/pages/CartPage/view/cartPageView.module.scss @@ -1,9 +1,16 @@ +@import 'src/app/styles/mixins'; + .cartPage { display: flex; flex-grow: 1; + overflow: hidden; padding: 0 var(--small-offset); animation: show 0.2s ease-out forwards; gap: 2%; + + @media (max-width: 768px) { + flex-direction: column; + } } @keyframes show { @@ -29,16 +36,16 @@ flex-shrink: 1; @media (max-width: 768px) { - position: fixed; + position: sticky; left: 0; bottom: 0; z-index: 100; margin-bottom: var(--tiny-offset); - border-radius: var(--small-offset) var(--small-offset) 0 0; + border-radius: var(--large-br); padding: var(--small-offset); width: 100%; - box-shadow: var(--mellow-shadow-600); - background-color: var(--noble-gray-1000); + box-shadow: var(--mellow-shadow-700); + background-color: var(--white-tr); backdrop-filter: blur(10px); } } @@ -49,10 +56,6 @@ .thead { width: 100%; - - @media (max-width: 768px) { - display: none; - } } .mainText { @@ -64,13 +67,17 @@ .th { padding: var(--tiny-offset) 0; font: var(--bold-font); - color: var(--noble-gray-400); + color: var(--noble-gray-800); + + @media (max-width: 768px) { + display: none; + } } .tr { display: grid; grid-gap: 0; - grid-template-columns: calc(var(--tiny-offset) * 7) 2fr 1fr 1fr 1fr calc(var(--tiny-offset) * 7); + grid-template-columns: calc(var(--tiny-offset) * 10) 2fr 1fr 1fr 1fr calc(var(--tiny-offset) * 7); width: 100%; @media (max-width: 768px) { @@ -89,6 +96,7 @@ border-bottom: var(--one) solid var(--steam-green-800); } +.emptyTitle, .totalTitle { padding: var(--tiny-offset) 0; font: var(--bold-font); @@ -109,7 +117,7 @@ display: flex; align-items: stretch; justify-content: space-between; - max-width: calc(var(--extra-large-offset) * 3.8); // 380px + max-width: calc(var(--extra-large-offset) * 3.8); @media (max-width: 768px) { width: 100%; @@ -125,25 +133,47 @@ .couponInput { flex-grow: 1; - border: 1px solid var(--steam-green-800); - border-radius: 3px 0 0 3px; - padding: var(--tiny-offset) 0; + border: var(--one) solid var(--steam-green-800); + border-radius: var(--small-br) 0 0 var(--small-br); + padding: var(--tiny-offset); font: var(--regular-font); color: var(--noble-gray-800); } .button { + @include green-btn; + padding: var(--tiny-offset); - font: var(--bold-font); +} + +.applyBtn { + border-radius: 0 var(--small-br) var(--small-br) 0; + + &:active { + transform: scale(1); + } +} + +.checkoutBtn { + margin: 0 auto; + border-radius: var(--small-br); color: var(--noble-gray-200); - background-color: var(--steam-green-800); } +$padding: var(--tiny-offset) 0; +$color: var(--steam-green-800); + .continue { + @include link($padding, $color); + align-self: center; - padding: var(--tiny-offset) 0; - font: var(--regular-font); - color: var(--steam-green-800); + width: max-content; + height: max-content; + text-transform: none; + + &::after { + bottom: var(--five); + } } .mobileHide { @@ -151,3 +181,24 @@ display: none; } } + +.deleteCell { + @media (max-width: 768px) { + display: flex; + align-items: center; + justify-content: center; + grid-area: 1/3; + } +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; +} + +.hide { + display: none; +} diff --git a/src/pages/CatalogPage/model/CatalogPageModel.ts b/src/pages/CatalogPage/model/CatalogPageModel.ts index a30f750a..e1c0dad7 100644 --- a/src/pages/CatalogPage/model/CatalogPageModel.ts +++ b/src/pages/CatalogPage/model/CatalogPageModel.ts @@ -1,4 +1,4 @@ -import type { Page, PageParams } from '@/shared/types/page.ts'; +import type { Page } from '@/shared/types/page.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; @@ -8,13 +8,13 @@ import CatalogModel from '@/widgets/Catalog/model/CatalogModel.ts'; import CatalogPageView from '../view/CatalogPageView.ts'; class CatalogPageModel implements Page { - private catalog: CatalogModel; + private catalog: CatalogModel = new CatalogModel(); private view: CatalogPageView; - constructor(parent: HTMLDivElement, params: PageParams) { + constructor(parent: HTMLDivElement) { this.view = new CatalogPageView(parent); - this.catalog = new CatalogModel(params.catalog?.searchParams || ''); + this.init(); } diff --git a/src/pages/CatalogPage/view/CatalogPageView.ts b/src/pages/CatalogPage/view/CatalogPageView.ts index b189648f..15a4e713 100644 --- a/src/pages/CatalogPage/view/CatalogPageView.ts +++ b/src/pages/CatalogPage/view/CatalogPageView.ts @@ -11,6 +11,7 @@ class CatalogPageView { this.parent = parent; this.parent.innerHTML = ''; this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { diff --git a/src/pages/LoginPage/view/LoginPageView.ts b/src/pages/LoginPage/view/LoginPageView.ts index e6b37a04..9beb1616 100644 --- a/src/pages/LoginPage/view/LoginPageView.ts +++ b/src/pages/LoginPage/view/LoginPageView.ts @@ -44,6 +44,7 @@ class LoginPageView { this.linksWrapper = this.createLinksWrapper(); this.authWrapper = this.createAuthWrapper(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createAuthDescription(): HTMLHeadingElement { diff --git a/src/pages/LoginPage/view/loginPageView.module.scss b/src/pages/LoginPage/view/loginPageView.module.scss index 08a67df2..9584150c 100644 --- a/src/pages/LoginPage/view/loginPageView.module.scss +++ b/src/pages/LoginPage/view/loginPageView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .loginPage { position: relative; display: block; @@ -58,35 +60,14 @@ } .registerLink { - position: relative; - color: var(--noble-gray-800); - transition: color 0.2s; - - &::after { - content: ''; - position: absolute; - left: 0; - bottom: -4px; - width: 100%; - height: var(--two); - background-color: currentcolor; - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 0.2s, - opacity 0.2s; - } + @include link; - @media (hover: hover) { - &:hover { - color: var(--steam-green-800); + padding: 0; + font: var(--medium-font); + text-transform: none; - &::after { - opacity: 1; - transform: scaleX(1); - } - } + &::after { + bottom: calc(var(--one) * -4); } } @@ -97,6 +78,7 @@ font: var(--regular-font); letter-spacing: var(--one); text-align: center; + color: var(--noble-gray-900); } .toRegisterPageWrapper { diff --git a/src/pages/MainPage/view/MainPageView.ts b/src/pages/MainPage/view/MainPageView.ts index cd6bbd83..4f57f37b 100644 --- a/src/pages/MainPage/view/MainPageView.ts +++ b/src/pages/MainPage/view/MainPageView.ts @@ -11,6 +11,7 @@ class MainPageView { this.parent = parent; this.parent.innerHTML = ''; this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { diff --git a/src/pages/NotFoundPage/view/NotFoundPageView.ts b/src/pages/NotFoundPage/view/NotFoundPageView.ts index 4ec17f5b..b62f09ec 100644 --- a/src/pages/NotFoundPage/view/NotFoundPageView.ts +++ b/src/pages/NotFoundPage/view/NotFoundPageView.ts @@ -30,6 +30,7 @@ class NotFoundPageView { this.description = this.createPageDescription(); this.toMainButton = this.createToMainButton(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createHTML(): HTMLDivElement { diff --git a/src/pages/ProductPage/model/ProductPageModel.ts b/src/pages/ProductPage/model/ProductPageModel.ts index 96896cf7..6ba758e5 100644 --- a/src/pages/ProductPage/model/ProductPageModel.ts +++ b/src/pages/ProductPage/model/ProductPageModel.ts @@ -2,6 +2,7 @@ import type { BreadCrumbLink } from '@/shared/types/link.ts'; import type { Page, PageParams } from '@/shared/types/page.ts'; import type { Product, localization } from '@/shared/types/product.ts'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; import BreadcrumbsModel from '@/features/Breadcrumbs/model/BreadcrumbsModel.ts'; import getProductModel from '@/shared/API/product/model/ProductModel.ts'; import getStore from '@/shared/Store/Store.ts'; @@ -46,9 +47,9 @@ class ProductPageModel implements Page { }); } - if (subcategory) { + if (subcategory && category) { links.push({ - link: '', + link: buildPathName(PAGE_ID.CATALOG_PAGE, null, { category: [category.id], subcategory: [subcategory.id] }), name: subcategory.name[0].value, }); } @@ -57,8 +58,7 @@ class ProductPageModel implements Page { } private init(params: PageParams): void { - const searchParams = new URLSearchParams(params.product?.searchParams); - const currentSize = searchParams.get(SEARCH_PARAMS_FIELD.SIZE); + const currentSize = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SIZE); getProductModel() .getProductByKey(params.product?.id ?? '') diff --git a/src/pages/ProductPage/view/ProductPageView.ts b/src/pages/ProductPage/view/ProductPageView.ts index e2009f4c..b75c70c3 100644 --- a/src/pages/ProductPage/view/ProductPageView.ts +++ b/src/pages/ProductPage/view/ProductPageView.ts @@ -20,6 +20,7 @@ class ProductPageView { this.fullDescription = this.createFullDescription(); this.fullDescriptionWrapper = this.createFullDescriptionWrapper(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createFullDescription(): HTMLParagraphElement { diff --git a/src/pages/RegistrationPage/view/RegistrationPageView.ts b/src/pages/RegistrationPage/view/RegistrationPageView.ts index 6b9ed460..fec17b5f 100644 --- a/src/pages/RegistrationPage/view/RegistrationPageView.ts +++ b/src/pages/RegistrationPage/view/RegistrationPageView.ts @@ -44,6 +44,7 @@ class RegistrationPageView { this.linksWrapper = this.createLinksWrapper(); this.authWrapper = this.createAuthWrapper(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createAuthDescription(): HTMLHeadingElement { diff --git a/src/pages/RegistrationPage/view/registrationPageView.module.scss b/src/pages/RegistrationPage/view/registrationPageView.module.scss index 05ca13d5..2dceaca9 100644 --- a/src/pages/RegistrationPage/view/registrationPageView.module.scss +++ b/src/pages/RegistrationPage/view/registrationPageView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .registrationPage { position: relative; display: block; @@ -63,35 +65,14 @@ } .loginLink { - position: relative; - color: var(--noble-gray-800); - transition: color 0.2s; - - &::after { - content: ''; - position: absolute; - left: 0; - bottom: -4px; - width: 100%; - height: var(--two); - background-color: currentcolor; - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 0.2s, - opacity 0.2s; - } + @include link; - @media (hover: hover) { - &:hover { - color: var(--steam-green-800); + padding: 0; + font: var(--medium-font); + text-transform: none; - &::after { - opacity: 1; - transform: scaleX(1); - } - } + &::after { + bottom: calc(var(--one) * -4); } } @@ -102,6 +83,7 @@ font: var(--regular-font); letter-spacing: var(--one); text-align: center; + color: var(--noble-gray-900); } .toLoginPageWrapper { diff --git a/src/pages/UserProfilePage/model/UserProfilePageModel.ts b/src/pages/UserProfilePage/model/UserProfilePageModel.ts index 3aa90d64..3050d883 100644 --- a/src/pages/UserProfilePage/model/UserProfilePageModel.ts +++ b/src/pages/UserProfilePage/model/UserProfilePageModel.ts @@ -4,7 +4,9 @@ import RouterModel from '@/app/Router/model/RouterModel.ts'; import getCustomerModel from '@/shared/API/customer/model/CustomerModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setAuthToken, setCurrentPage, switchIsUserLoggedIn } from '@/shared/Store/actions.ts'; +import { SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; import UserAddressesModel from '@/widgets/UserAddresses/model/UserAddressesModel.ts'; import UserInfoModel from '@/widgets/UserInfo/model/UserInfoModel.ts'; @@ -16,14 +18,17 @@ class UserProfilePageModel implements Page { private userInfo: UserInfoModel | null = null; - private view: UserProfilePageView; + private view: UserProfilePageView | null = null; constructor(parent: HTMLDivElement) { - this.view = new UserProfilePageView(parent); - - this.setAddressesLinkHandler(); - this.setPersonalInfoLinkHandler(); - this.init().catch(showErrorMessage); + const { isUserLoggedIn } = getStore().getState(); + if (!isUserLoggedIn) { + RouterModel.getInstance().navigateTo(PAGE_ID.LOGIN_PAGE); + showErrorMessage(SERVER_MESSAGE_KEYS.NEED_LOGIN); + } else { + this.view = new UserProfilePageView(parent); + this.init().catch(showErrorMessage); + } } private addressesLinkHandler(): void { @@ -32,18 +37,20 @@ class UserProfilePageModel implements Page { } private async init(): Promise { + this.setAddressesLinkHandler(); + this.setPersonalInfoLinkHandler(); try { const user = await getCustomerModel().getCurrentUser(); if (user) { this.userInfo = new UserInfoModel(user); this.addresses = new UserAddressesModel(user); - this.view.getUserProfileWrapper().append(this.userInfo.getHTML(), this.addresses.getHTML()); + this.view?.getUserProfileWrapper().append(this.userInfo.getHTML(), this.addresses.getHTML()); this.setAccountLogoutButtonHandler(); getStore().dispatch(setCurrentPage(PAGE_ID.USER_PROFILE_PAGE)); } } catch (error) { - showErrorMessage(); + showErrorMessage(SERVER_MESSAGE_KEYS.NEED_LOGIN); } } @@ -61,22 +68,28 @@ class UserProfilePageModel implements Page { } private setAccountLogoutButtonHandler(): void { - const logoutButton = this.view.getAccountLogoutButton(); - logoutButton.getHTML().addEventListener('click', () => this.logoutHandler()); + const logoutButton = this.view?.getAccountLogoutButton(); + logoutButton?.getHTML().addEventListener('click', () => this.logoutHandler()); } private setAddressesLinkHandler(): void { - const addressesLink = this.view.getAddressesLink(); - addressesLink.getHTML().addEventListener('click', () => this.addressesLinkHandler()); + const addressesLink = this.view?.getAddressesLink(); + addressesLink?.getHTML().addEventListener('click', () => this.addressesLinkHandler()); } private setPersonalInfoLinkHandler(): void { - const personalInfoLink = this.view.getPersonalInfoLink(); - personalInfoLink.getHTML().addEventListener('click', () => this.personalInfoLinkHandler()); + const personalInfoLink = this.view?.getPersonalInfoLink(); + personalInfoLink?.getHTML().addEventListener('click', () => this.personalInfoLinkHandler()); } public getHTML(): HTMLDivElement { - return this.view.getHTML(); + if (this.view) { + return this.view.getHTML(); + } + + return createBaseElement({ + tag: 'div', + }); } } diff --git a/src/pages/UserProfilePage/view/UserProfilePageView.ts b/src/pages/UserProfilePage/view/UserProfilePageView.ts index ad1c3470..10007139 100644 --- a/src/pages/UserProfilePage/view/UserProfilePageView.ts +++ b/src/pages/UserProfilePage/view/UserProfilePageView.ts @@ -48,6 +48,7 @@ class UserProfilePageView { this.setLinksHandlers(); this.userProfileWrapper = this.createUserProfileWrapper(); this.page = this.createHTML(); + window.scrollTo(0, 0); } private createAccountMenu(): HTMLUListElement { diff --git a/src/pages/UserProfilePage/view/userProfilePageView.module.scss b/src/pages/UserProfilePage/view/userProfilePageView.module.scss index 4885fc0e..906ac94b 100644 --- a/src/pages/UserProfilePage/view/userProfilePageView.module.scss +++ b/src/pages/UserProfilePage/view/userProfilePageView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .userProfilePage { position: relative; display: block; @@ -7,8 +9,11 @@ .userProfileWrapper { display: flex; - flex-flow: row nowrap; justify-content: space-evenly; + + @media (max-width: 768px) { + flex-direction: column; + } } .addressesWrapper { @@ -42,8 +47,12 @@ padding: var(--small-offset); width: 25%; height: fit-content; - background-color: var(--white-tr); - gap: var(--extra-small-offset); + background-color: var(--white); + + @media (max-width: 768px) { + margin-bottom: var(--small-offset); + width: 100%; + } } .accountMenuItem { @@ -55,84 +64,21 @@ } .logoutButton { + @include green-btn; + margin: var(--small-offset) auto; - border-radius: var(--medium-br); padding: calc(var(--small-offset) / 3) var(--small-offset); - max-width: max-content; - font: var(--regular-font); - letter-spacing: var(--one); - color: var(--white); - 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); - } - } - - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; - } } .link { - position: relative; - display: flex; - align-items: center; - align-self: center; - width: 100%; - height: 2.5rem; - font: var(--regular-font); - letter-spacing: var(--one); - text-align: center; - text-transform: uppercase; - color: var(--noble-gray-800); - transition: color 0.2s; + @include link; - &::after { - content: ''; - position: absolute; - left: 0; - bottom: -5px; - width: 100%; - height: var(--two); - background-color: currentcolor; - opacity: 0; - transform: scaleX(0); - transform-origin: center; - transition: - transform 0.2s, - opacity 0.2s; - } - - @media (hover: hover) { - &:hover { - color: var(--steam-green-800); - - &::after { - opacity: 1; - transform: scaleX(1); - } - } - } + align-self: center; + height: calc(var(--large-offset) * 0.8); } .active { - color: var(--steam-green-800); - opacity: 1; - - &::after { - opacity: 1; - transform: scaleX(1); - } + @include active; } @keyframes show { diff --git a/src/shared/API/cart/CartApi.ts b/src/shared/API/cart/CartApi.ts index 7700a227..9c4ec6cb 100644 --- a/src/shared/API/cart/CartApi.ts +++ b/src/shared/API/cart/CartApi.ts @@ -2,14 +2,21 @@ import type { AddCartItem, Cart, CartProduct, EditCartItem } from '@/shared/type import type { CartPagedQueryResponse, Cart as CartResponse, + CartSetAnonymousIdAction, ClientResponse, MyCartDraft, + MyCartUpdateAction, } from '@commercetools/platform-sdk'; import { CURRENCY } from '@/shared/constants/product.ts'; import getApiClient, { type ApiClient } from '../sdk/client.ts'; +enum Actions { + addLineItem = 'addLineItem', + changeLineItemQuantity = 'changeLineItemQuantity', + removeLineItem = 'removeLineItem', +} export class CartApi { private client: ApiClient; @@ -27,7 +34,7 @@ export class CartApi { body: { actions: [ { - action: 'addLineItem', + action: Actions.addLineItem, productId: addCartItem.productId, quantity: addCartItem.quantity, variantId: addCartItem.variantId, @@ -43,6 +50,7 @@ export class CartApi { public async create(): Promise> { const myCart: MyCartDraft = { currency: CURRENCY, + deleteDaysAfterLastModification: 2, }; const newCart = await this.client .apiRoot() @@ -56,6 +64,21 @@ export class CartApi { return newCart; } + public async deleteCart(cart: Cart): Promise { + const data = await this.client + .apiRoot() + .me() + .carts() + .withId({ ID: cart.id }) + .delete({ + queryArgs: { + version: cart.version, + }, + }) + .execute(); + return data; + } + public async deleteProduct(cart: Cart, product: CartProduct): Promise { const data = await this.client .apiRoot() @@ -66,7 +89,7 @@ export class CartApi { body: { actions: [ { - action: 'removeLineItem', + action: Actions.removeLineItem, lineItemId: product.lineItemId, }, ], @@ -87,7 +110,7 @@ export class CartApi { body: { actions: [ { - action: 'changeLineItemQuantity', + action: Actions.changeLineItemQuantity, lineItemId: editCartItem.lineId, quantity: editCartItem.quantity, }, @@ -104,10 +127,46 @@ export class CartApi { return data; } + public async getAnonymCart(ID: string): Promise> { + const data = await this.client.apiRoot().carts().withId({ ID }).get().execute(); + return data; + } + public async getCarts(): Promise> { const data = await this.client.apiRoot().me().carts().get().execute(); return data; } + + public async setAnonymousId(cart: Cart, actions: CartSetAnonymousIdAction): Promise { + const data = await this.client + .apiRoot() + .carts() + .withId({ ID: cart.id }) + .post({ + body: { + actions: [actions], + version: cart.version, + }, + }) + .execute(); + return data; + } + + public async updateCart(cart: Cart, actions: MyCartUpdateAction[]): Promise> { + const data = await this.client + .apiRoot() + .me() + .carts() + .withId({ ID: cart.id }) + .post({ + body: { + actions, + version: cart.version, + }, + }) + .execute(); + return data; + } } const createCartApi = (): CartApi => new CartApi(); diff --git a/src/shared/API/cart/model/CartModel.ts b/src/shared/API/cart/model/CartModel.ts index f17df8e7..4d619b46 100644 --- a/src/shared/API/cart/model/CartModel.ts +++ b/src/shared/API/cart/model/CartModel.ts @@ -2,53 +2,71 @@ import type { AddCartItem, Cart, CartProduct, EditCartItem } from '@/shared/type import type { CartPagedQueryResponse, Cart as CartResponse, + CartSetAnonymousIdAction, ClientResponse, LineItem, + MyCartUpdateAction, } from '@commercetools/platform-sdk'; import getStore from '@/shared/Store/Store.ts'; import { setAnonymousCartId, setAnonymousId } from '@/shared/Store/actions.ts'; import { PRICE_FRACTIONS } from '@/shared/constants/product.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; import type { OptionsRequest } from '../../types/type.ts'; import getProductModel from '../../product/model/ProductModel.ts'; import FilterProduct from '../../product/utils/filter.ts'; -import { FilterFields } from '../../types/type.ts'; +import { Attribute, FilterFields } from '../../types/type.ts'; import { isCart, isCartPagedQueryResponse, isClientResponse } from '../../types/validation.ts'; import getCartApi, { type CartApi } from '../CartApi.ts'; +enum ACTIONS { + addDiscountCode = 'addDiscountCode', + removeLineItem = 'removeLineItem', + setAnonymousId = 'setAnonymousId', +} + type Callback = (cart: Cart) => boolean; export class CartModel { private callback: Callback[] = []; private cart: Cart | null = null; + private isSetAnonymousId = false; + private root: CartApi; constructor() { this.root = getCartApi(); + this.getCart().catch(showErrorMessage); } private adaptCart(data: CartResponse): Cart { - if (data.anonymousId) { + if (data.anonymousId && !getStore().getState().authToken) { getStore().dispatch(setAnonymousCartId(data.id)); getStore().dispatch(setAnonymousId(data.anonymousId)); } + const discount = data.discountOnTotalPrice?.discountedAmount?.centAmount; return { + discounts: discount ? discount / PRICE_FRACTIONS : 0, id: data.id, products: data.lineItems.map((lineItem) => this.adaptLineItem(lineItem)), + total: data.totalPrice.centAmount / PRICE_FRACTIONS || 0, version: data.version, }; } private adaptLineItem(product: LineItem): CartProduct { + const price = product.price.discounted?.value.centAmount + ? product.price.discounted?.value.centAmount + : product.price.value.centAmount; const result: CartProduct = { images: product.variant.images?.length ? product.variant.images[0].url : '', key: product.productKey || '', lineItemId: product.id, name: [], - price: product.price.value.centAmount / PRICE_FRACTIONS || 0, + price: price / PRICE_FRACTIONS || 0, productId: product.productId || '', quantity: product.quantity || 0, size: null, @@ -56,7 +74,7 @@ export class CartModel { }; result.name.push(...getProductModel().adaptLocalizationValue(product.name)); if (product.variant.attributes) { - const size = product.variant.attributes.find((attr) => attr.name === 'size'); + const size = product.variant.attributes.find((attr) => attr.name === Attribute.SIZE); if (size) { result.size = getProductModel().adaptSize(size); } @@ -64,25 +82,89 @@ export class CartModel { return result; } + private async deleteOtherCarts(data: ClientResponse): Promise { + if (this.cart) { + const carts: Cart[] = []; + data.body.results.forEach((cart) => { + carts.push(this.getCartFromData(cart)); + }); + const otherCarts = carts.filter((shopList) => this.cart?.id !== shopList.id); + await Promise.all(otherCarts.map((id) => this.root.deleteCart(id))); + } + } + private dispatchUpdate(): void { this.callback.forEach((callback) => (this.cart !== null ? callback(this.cart) : null)); } - private getCartFromData(data: ClientResponse): Cart { + private async getAnonymousCart(anonymousCartId: string, anonymousId: string): Promise { + const data = await this.root.getAnonymCart(anonymousCartId); + if (!data.body.customerId) { + this.cart = this.getCartFromData(data); + const cartAnonymId = data.body.anonymousId; + if (cartAnonymId !== anonymousId && !this.isSetAnonymousId) { + this.isSetAnonymousId = true; + const actions: CartSetAnonymousIdAction = { + action: ACTIONS.setAnonymousId, + anonymousId, + }; + const dataSetId = await this.root.setAnonymousId(this.cart, actions); + this.cart = this.getCartFromData(dataSetId); + } + } + + return this.cart; + } + + private getCartFromData(data: CartResponse | ClientResponse): Cart { let cart: Cart = { + discounts: 0, id: '', products: [], + total: 0, version: 0, }; if (isClientResponse(data) && isCart(data.body)) { cart = this.adaptCart(data.body); } else if (isClientResponse(data) && isCartPagedQueryResponse(data.body) && data.body.results.length) { cart = this.adaptCart(data.body.results[0]); + } else if (isCart(data)) { + cart = this.adaptCart(data); } return cart; } - public async addProductInfo(): Promise { + private async getUserCart(): Promise { + const data = await this.root.getCarts(); + if (data.body.count === 1) { + this.cart = this.getCartFromData(data); + } else if (data.body.count === 0) { + const newCart = await this.root.create(); + this.cart = this.getCartFromData(newCart); + } else { + const activeCart = await this.root.getActiveCart(); + this.cart = this.getCartFromData(activeCart); + await this.deleteOtherCarts(data); + } + return this.cart; + } + + public async addCoupon(discountCode: string): Promise { + if (!this.cart) { + this.cart = await this.getCart(); + } + const action: MyCartUpdateAction[] = [ + { + action: ACTIONS.addDiscountCode, + code: discountCode, + }, + ]; + const data = await this.root.updateCart(this.cart, action); + this.cart = this.getCartFromData(data); + return this.cart; + } + + public async addProductInfo(): Promise { if (!this.cart) { this.cart = await this.getCart(); } @@ -92,36 +174,33 @@ export class CartModel { filter.addFilter(FilterFields.ID, product.productId); }); const opt: OptionsRequest = { - filter: filter.getFilter(), + filter, + limit: this.cart.products.length, }; - const products = await getProductModel().getProducts(opt); - - if (products.products.length) { - this.cart.products = this.cart.products.map((product) => { - const productInfo = products.products.find(({ id }) => id === product.productId); - if (productInfo) { - if (!product.images) { - return { ...product, images: productInfo.images[0] }; - } + return getProductModel() + .getProducts(opt) + .then((products) => { + if (products.products.length && this.cart) { + this.cart.products = this.cart.products.map((product) => { + const productInfo = products.products.find(({ id }) => id === product.productId); + if (productInfo) { + if (!product.images) { + return { ...product, images: productInfo.images[0] }; + } + } + + return product; + }); } - - return product; + return this.cart; }); - } - - return this.cart; } - public async addProductToCart(addCartItem: AddCartItem): Promise { - if (!this.cart) { - this.cart = await this.getCart(); - } - const data = await this.root.addProduct(this.cart, addCartItem); + public async addProductToCart(addCartItem: AddCartItem): Promise { + const data = await this.root.addProduct(await this.getCart(), addCartItem); this.cart = this.getCartFromData(data); - this.dispatchUpdate(); - return this.cart; } @@ -130,24 +209,41 @@ export class CartModel { return true; } - public async create(): Promise { - const newCart = await this.root.create(); - this.cart = this.getCartFromData(newCart); - + public async clearCart(): Promise { + if (!this.cart) { + this.cart = await this.getCart(); + } + const actions: MyCartUpdateAction[] = this.cart?.products.map((lineItem) => ({ + action: ACTIONS.removeLineItem, + lineItemId: lineItem.lineItemId, + })); + const data = await this.root.updateCart(this.cart, actions); + this.cart = this.getCartFromData(data); this.dispatchUpdate(); - return this.cart; } - public async deleteProductFromCart(products: CartProduct): Promise { + public create(): Promise { + return this.root + .create() + .then((newCart) => { + this.cart = this.getCartFromData(newCart); + this.dispatchUpdate(); + return this.cart; + }) + .catch((error: Error) => { + showErrorMessage(error); + return this.cart; + }); + } + + public async deleteProductFromCart(products: CartProduct): Promise { if (!this.cart) { this.cart = await this.getCart(); } const data = await this.root.deleteProduct(this.cart, products); this.cart = this.getCartFromData(data); - this.dispatchUpdate(); - return this.cart; } @@ -163,15 +259,15 @@ export class CartModel { public async getCart(): Promise { if (!this.cart) { - const data = await this.root.getCarts(); - if (data.body.count === 1) { - this.cart = this.getCartFromData(data); - } else if (data.body.count === 0) { - const newCart = await this.root.create(); - this.cart = this.getCartFromData(newCart); - } else { - const activeCart = await this.root.getActiveCart(); - this.cart = this.getCartFromData(activeCart); + const { anonymousCartId, anonymousId } = getStore().getState(); + if (anonymousCartId && anonymousId) { + // const data = await this.root.getAnonymCart(anonymousCartId); + // this.cart = this.getCartFromData(data); + + this.cart = await this.getAnonymousCart(anonymousCartId, anonymousId); + } + if (!this.cart) { + this.cart = await this.getUserCart(); } } this.dispatchUpdate(); diff --git a/src/shared/API/customer/CustomerApi.ts b/src/shared/API/customer/CustomerApi.ts index 5c045c0b..2c7e9959 100644 --- a/src/shared/API/customer/CustomerApi.ts +++ b/src/shared/API/customer/CustomerApi.ts @@ -10,7 +10,7 @@ import type { } from '@commercetools/platform-sdk'; import getStore from '@/shared/Store/Store.ts'; -import { setAnonymousCartId, setAnonymousId } from '@/shared/Store/actions.ts'; +import { setAnonymousCartId, setAnonymousId, setAnonymousShopListId } from '@/shared/Store/actions.ts'; import findAddressIndex from '@/shared/utils/address.ts'; import getCartModel from '../cart/model/CartModel.ts'; @@ -22,6 +22,9 @@ interface ExtendedCustomerDraft extends MyCustomerDraft { billingAddresses?: number[]; shippingAddresses?: number[]; } +const CART_MERGE_MODE = 'MergeWithExistingCustomerCart'; +const CART_TYPE_ID = 'cart'; +const EMAIL = 'email'; export class CustomerApi { private client: ApiClient; @@ -40,7 +43,7 @@ export class CustomerApi { if (anonymousCartId && anonymousId) { authData.anonymousCartId = anonymousCartId; authData.anonymousId = anonymousId; - authData.anonymousCartSignInMode = 'MergeWithExistingCustomerCart'; + authData.anonymousCartSignInMode = CART_MERGE_MODE; authData.updateProductData = true; } return authData; @@ -54,8 +57,10 @@ export class CustomerApi { if (!isErrorResponse(testConnect)) { this.client.approveAuth(); getCartModel().clear(); + getShoppingListModel().clear(); getStore().dispatch(setAnonymousCartId(null)); getStore().dispatch(setAnonymousId(null)); + getStore().dispatch(setAnonymousShopListId(null)); isOk = true; } return isOk; @@ -78,7 +83,7 @@ export class CustomerApi { const anonymCart: CartResourceIdentifier | undefined = anonymousCartId ? { id: anonymousCartId, - typeId: 'cart', + typeId: CART_TYPE_ID, } : undefined; return { @@ -93,7 +98,7 @@ export class CustomerApi { password: userData.password, ...(anonymCart && { anonymousCart: anonymCart }), ...(anonymousId && { anonymousId }), - ...(anonymCart && { anonymousCartSignInMode: 'MergeWithExistingCustomerCart' }), + ...(anonymCart && { anonymousCartSignInMode: CART_MERGE_MODE }), ...(anonymCart && { updateProductData: true }), billingAddresses: billingAddress !== null ? [billingAddress] : undefined, shippingAddresses: shippingAddress !== null ? [shippingAddress] : undefined, @@ -106,7 +111,9 @@ export class CustomerApi { if (!isErrorResponse(data)) { await this.checkAuthConnection(authData); getCartModel().clear(); + getShoppingListModel().clear(); await getCartModel().getCart(); + await getShoppingListModel().getShoppingList(); } return data; } @@ -152,7 +159,7 @@ export class CustomerApi { const data = await this.client .apiRoot() .customers() - .get({ queryArgs: { where: `email="${email}"` } }) + .get({ queryArgs: { where: `${EMAIL}="${email}"` } }) .execute(); return data; } @@ -162,6 +169,8 @@ export class CustomerApi { const testConnect = this.client.apiRoot().get().execute(); getCartModel().clear(); await getCartModel().getCart(); + getShoppingListModel().clear(); + await getShoppingListModel().getShoppingList(); return client && !isErrorResponse(testConnect); } diff --git a/src/shared/API/customer/model/CustomerModel.ts b/src/shared/API/customer/model/CustomerModel.ts index fdd70986..bea7fe43 100644 --- a/src/shared/API/customer/model/CustomerModel.ts +++ b/src/shared/API/customer/model/CustomerModel.ts @@ -9,6 +9,8 @@ import type { MyCustomerUpdateAction, } from '@commercetools/platform-sdk'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; + import { isClientResponse, isCustomerPagedQueryResponse, @@ -17,6 +19,24 @@ import { } from '../../types/validation.ts'; import getCustomerApi, { type CustomerApi } from '../CustomerApi.ts'; +const CUSTOMER_FIELD = 'customer'; + +enum Actions { + addAddress = 'addAddress', + addBillingAddressId = 'addBillingAddressId', + addShippingAddressId = 'addShippingAddressId', + changeAddress = 'changeAddress', + changeEmail = 'changeEmail', + removeAddress = 'removeAddress', + removeBillingAddressId = 'removeBillingAddressId', + removeShippingAddressId = 'removeShippingAddressId', + setDateOfBirth = 'setDateOfBirth', + setDefaultBillingAddress = 'setDefaultBillingAddress', + setDefaultShippingAddress = 'setDefaultShippingAddress', + setFirstName = 'setFirstName', + setLastName = 'setLastName', + setLocale = 'setLocale', +} export class CustomerModel { private anonymousId = ''; @@ -27,51 +47,63 @@ export class CustomerModel { } public static actionAddAddress(address: Address): MyCustomerUpdateAction { - return { action: 'addAddress', address: CustomerModel.adaptAddressToServer(address) }; + return { action: Actions.addAddress, address: CustomerModel.adaptAddressToServer(address) }; + } + + public static actionAddBillingAddress(addressId: string): MyCustomerUpdateAction { + return { action: Actions.addBillingAddressId, addressId }; + } + + public static actionAddShippingAddress(addressId: string): MyCustomerUpdateAction { + return { action: Actions.addShippingAddressId, addressId }; } public static actionEditAddress(address: Address): MyCustomerUpdateAction { - return { action: 'changeAddress', address: CustomerModel.adaptAddressToServer(address), addressId: address.id }; + return { + action: Actions.changeAddress, + address: CustomerModel.adaptAddressToServer(address), + addressId: address.id, + }; } public static actionEditDateOfBirth(dateOfBirth: string): MyCustomerUpdateAction { - return { action: 'setDateOfBirth', dateOfBirth }; + return { action: Actions.setDateOfBirth, dateOfBirth }; } - public static actionEditDefaultBillingAddress(addressId: string): MyCustomerUpdateAction { - return { action: 'setDefaultBillingAddress', addressId }; + public static actionEditDefaultBillingAddress(addressId?: string): MyCustomerUpdateAction { + return { action: Actions.setDefaultBillingAddress, addressId }; } - public static actionEditDefaultShippingAddress(addressId: string): MyCustomerUpdateAction { - return { action: 'setDefaultShippingAddress', addressId }; + public static actionEditDefaultShippingAddress(addressId?: string): MyCustomerUpdateAction { + return { action: Actions.setDefaultShippingAddress, addressId }; } public static actionEditEmail(email: string): MyCustomerUpdateAction { - return { action: 'changeEmail', email }; + return { action: Actions.changeEmail, email }; } public static actionEditFirstName(firstName: string): MyCustomerUpdateAction { - return { action: 'setFirstName', firstName }; + return { action: Actions.setFirstName, firstName }; } public static actionEditLastName(lastName: string): MyCustomerUpdateAction { - return { action: 'setLastName', lastName }; + return { action: Actions.setLastName, lastName }; } public static actionRemoveAddress(address: Address): MyCustomerUpdateAction { - return { action: 'removeAddress', addressId: address.id }; + return { action: Actions.removeAddress, addressId: address.id }; } public static actionRemoveBillingAddress(address: Address): MyCustomerUpdateAction { - return { action: 'removeBillingAddressId', addressId: address.id }; + return { action: Actions.removeBillingAddressId, addressId: address.id }; } public static actionRemoveShippingAddress(address: Address): MyCustomerUpdateAction { - return { action: 'removeShippingAddressId', addressId: address.id }; + return { action: Actions.removeShippingAddressId, addressId: address.id }; } public static actionSetLocale(locale: string): MyCustomerUpdateAction { - return { action: 'setLocale', locale }; + return { action: Actions.setLocale, locale }; } private adaptAddress(address: AddressResponse[]): Address[] { @@ -104,7 +136,7 @@ export class CustomerModel { } private adaptCustomerData(customerData: { customer: Customer } | Customer): User { - const data = 'customer' in customerData ? customerData.customer : customerData; + const data = CUSTOMER_FIELD in customerData ? customerData.customer : customerData; return { addresses: this.adaptAddress(data.addresses), @@ -112,14 +144,14 @@ export class CustomerModel { birthDate: data.dateOfBirth ?? '', defaultBillingAddressId: null, defaultShippingAddressId: null, - email: data.email || '', + email: data.email ?? '', firstName: data.firstName ?? '', - id: data.id || '', + id: data.id ?? '', lastName: data.lastName ?? '', - locale: data.locale ?? 'en', + locale: data.locale ?? LANGUAGE_CHOICE.EN, password: data.password ?? '', shippingAddress: [], - version: data.version || 0, + version: data.version ?? 0, }; } @@ -148,7 +180,7 @@ export class CustomerModel { private adaptDefaultAddress(addressId: string | undefined, address: Address[]): Address | null { if (addressId) { const addressFound = address.find((address) => address.id === addressId); - return addressFound || null; + return addressFound ?? null; } return null; } diff --git a/src/shared/API/product/ProductApi.ts b/src/shared/API/product/ProductApi.ts index b147b9b2..31ff209b 100644 --- a/src/shared/API/product/ProductApi.ts +++ b/src/shared/API/product/ProductApi.ts @@ -5,12 +5,22 @@ import type { ProductProjectionPagedSearchResponse, } from '@commercetools/platform-sdk'; -import { DEFAULT_PAGE, MAX_PRICE, MIN_PRICE, PRODUCT_LIMIT } from '@/shared/constants/product.ts'; +import { DEFAULT_PAGE, MAX_PRICE, MIN_PRICE, PRICE_FRACTIONS, PRODUCT_LIMIT } from '@/shared/constants/product.ts'; import getApiClient, { type ApiClient } from '../sdk/client.ts'; import { type OptionsRequest } from '../types/type.ts'; import makeSortRequest from './utils/sort.ts'; +const Search = 'text'; +const FACET_ADD = 1; +enum Facets { + category = 'categories.id counting products', + price = 'variants.price.centAmount', + size = 'variants.attributes.size.key', +} +enum QueryParams { + range = 'range', +} export class ProductApi { private client: ApiClient; @@ -18,6 +28,16 @@ export class ProductApi { this.client = getApiClient(); } + private getFuzzyLevel(value: string): number { + if (value.length < 3) { + return 0; + } + if (value.length < 5) { + return 1; + } + return 2; + } + public async getCategories(): Promise> { const data = await this.client.apiRoot().categories().get().execute(); return data; @@ -30,7 +50,7 @@ export class ProductApi { .search() .get({ queryArgs: { - facet: [`variants.price.centAmount:range(${MIN_PRICE} to ${MAX_PRICE})`], + facet: [`${Facets.price}:${QueryParams.range}(${MIN_PRICE} to ${MAX_PRICE})`], limit: 0, }, }) @@ -45,6 +65,11 @@ export class ProductApi { public async getProducts(options?: OptionsRequest): Promise> { const { filter, limit = PRODUCT_LIMIT, page = DEFAULT_PAGE, search, sort } = options || {}; + const filterQuery = filter?.getFilter(); + const priceRange = filter?.getPriceRange(); + const min = Math.round((priceRange?.min ?? MIN_PRICE) * PRICE_FRACTIONS - FACET_ADD); + const max = Math.round((priceRange?.max ?? MAX_PRICE) * PRICE_FRACTIONS + FACET_ADD); + const fuzzyLevel = this.getFuzzyLevel(search?.value ? search?.value : ''); const data = await this.client .apiRoot() @@ -52,19 +77,16 @@ export class ProductApi { .search() .get({ queryArgs: { - facet: [ - `categories.id counting products`, - `variants.attributes.size.key`, - `variants.price.centAmount:range(${MIN_PRICE} to ${MAX_PRICE})`, - ], + facet: [Facets.category, Facets.size, `${Facets.price}:${QueryParams.range}(${min} to ${max})`], limit, markMatchingVariants: true, offset: (page - 1) * PRODUCT_LIMIT, - ...(search && { [`text.${search.locale}`]: search.value }), + ...(search && { [`${Search}.${search.locale}`]: search.value }), ...(search && { fuzzy: true }), + ...(search?.value && { fuzzyLevel }), ...(sort && { sort: makeSortRequest(sort) }), - ...(filter && { 'filter.query': filter }), - ...(filter && { 'filter.facets': filter }), + ...(filterQuery && { 'filter.query': filterQuery }), + ...(filterQuery && { 'filter.facets': filterQuery }), withTotal: true, }, }) diff --git a/src/shared/API/product/model/ProductModel.ts b/src/shared/API/product/model/ProductModel.ts index 3a0e8588..0e7972e1 100644 --- a/src/shared/API/product/model/ProductModel.ts +++ b/src/shared/API/product/model/ProductModel.ts @@ -1,4 +1,4 @@ -import type { Category, Product, SizeType, localization } from '@/shared/types/product.ts'; +import type { Category, LevelType, Product, SizeType, localization } from '@/shared/types/product.ts'; import type { Attribute as AttributeResponse, CategoryPagedQueryResponse, @@ -12,7 +12,9 @@ import type { } from '@commercetools/platform-sdk'; import { PRICE_FRACTIONS } from '@/shared/constants/product.ts'; -import getSize from '@/shared/utils/size.ts'; +import { getLevel, getSize } from '@/shared/utils/size.ts'; + +import type { ProductApi } from '../ProductApi.ts'; import { Attribute, @@ -21,6 +23,9 @@ import { type PriceRange, type ProductWithCount, type SizeProductCount, + SortDirection, + SortFields, + type SortOptions, } from '../../types/type.ts'; import { isAttributePlainEnumValue, @@ -35,7 +40,14 @@ import { isRangeFacetResult, isTermFacetResult, } from '../../types/validation.ts'; -import getProductApi, { type ProductApi } from '../ProductApi.ts'; +import getProductApi from '../ProductApi.ts'; + +enum ProductConstant { + categoriesId = 'categories.id', + isMatchingVariant = 'isMatchingVariant', + variantsAttributesSizeKey = 'variants.attributes.size.key', + variantsPriceCentAmount = 'variants.price.centAmount', +} export class ProductModel { private categories: Category[] = []; @@ -60,8 +72,8 @@ export class ProductModel { const parentCategory = data.results.find((item) => item.id === category.parent?.id); if (parentCategory) { result.parent = { - id: parentCategory.id || '', - key: parentCategory.key || '', + id: parentCategory.id ?? '', + key: parentCategory.key ?? '', name: [], parent: null, slug: [], @@ -109,6 +121,13 @@ export class ProductModel { return []; } + private adaptLevel(attribute: AttributeResponse): LevelType | null { + if (Array.isArray(attribute.value) && attribute.value.length && isAttributePlainEnumValue(attribute.value[0])) { + return getLevel(attribute.value[0].key); + } + return null; + } + private adaptPrice(variant: ProductVariant): number { let price = 0; @@ -129,9 +148,10 @@ export class ProductModel { category: [], description: [], fullDescription: [], - id: product.id || '', + id: product.id ?? '', images: [], key: product.key ?? '', + level: null, name: [], slug: [], variant: [], @@ -151,28 +171,38 @@ export class ProductModel { } private adaptVariants(product: Product, response: ProductProjection): Product { - const variants = [...response.variants, response.masterVariant]; + const variants = [response.masterVariant, ...response.variants]; variants.forEach((variant) => { - let size: SizeType | null = null; + if ( + (ProductConstant.isMatchingVariant in variant && variant.isMatchingVariant) || + !(ProductConstant.isMatchingVariant in variant) + ) { + let size: SizeType | null = null; + let level: LevelType | null = null; + + if (variant.attributes?.length) { + variant.attributes.forEach((attribute) => { + if (attribute.name === Attribute.FULL_DESCRIPTION && !product.fullDescription.length) { + product.fullDescription.push(...this.adaptFullDescription(attribute)); + } + if (attribute.name === Attribute.SIZE) { + size = this.adaptSize(attribute); + } + if (attribute.name === Attribute.LEVEL) { + level = this.adaptLevel(attribute); + const productEl = product; + productEl.level = level; + } + }); + } - if (variant.attributes?.length) { - variant.attributes.forEach((attribute) => { - if (attribute.name === Attribute.FULL_DESCRIPTION && !product.fullDescription.length) { - product.fullDescription.push(...this.adaptFullDescription(attribute)); - } - if (attribute.name === Attribute.SIZE) { - size = this.adaptSize(attribute); - } + product.variant.push({ + discount: this.adaptDiscount(variant) || 0, + id: variant.id, + price: this.adaptPrice(variant) || 0, + size, }); } - - product.variant.push({ - discount: this.adaptDiscount(variant) || 0, - id: variant.id, - price: this.adaptPrice(variant) || 0, - size, - }); - if (variant.images?.length) { variant.images.forEach((image) => { product.images.push(image.url); @@ -198,9 +228,9 @@ export class ProductModel { if ( isClientResponse(data) && isProductProjectionPagedSearchResponse(data.body) && - 'categories.id' in data.body.facets + ProductConstant.categoriesId in data.body.facets ) { - const categoriesFacet = data.body.facets['categories.id']; + const categoriesFacet = data.body.facets[ProductConstant.categoriesId]; if (isTermFacetResult(categoriesFacet)) { categoriesFacet.terms.forEach((term) => { if (isFacetTerm(term)) { @@ -226,9 +256,9 @@ export class ProductModel { if ( isClientResponse(date) && isProductProjectionPagedSearchResponse(date.body) && - 'variants.price.centAmount' in date.body.facets + ProductConstant.variantsPriceCentAmount in date.body.facets ) { - const variantsPrice = date.body.facets['variants.price.centAmount']; + const variantsPrice = date.body.facets[ProductConstant.variantsPriceCentAmount]; if (isRangeFacetResult(variantsPrice)) { variantsPrice.ranges.forEach((range) => { if (isFacetRange(range)) { @@ -262,16 +292,16 @@ export class ProductModel { if ( isClientResponse(data) && isProductProjectionPagedSearchResponse(data.body) && - 'variants.attributes.size.key' in data.body.facets + ProductConstant.variantsAttributesSizeKey in data.body.facets ) { - const categoriesFacet = data.body.facets['variants.attributes.size.key']; + const categoriesFacet = data.body.facets[ProductConstant.variantsAttributesSizeKey]; if (isTermFacetResult(categoriesFacet)) { categoriesFacet.terms.forEach((term) => { if (isFacetTerm(term) && typeof term.term === 'string') { const currentSize = getSize(term.term); if (currentSize) { category.push({ - count: term.count || 0, + count: term.count ?? 0, size: currentSize, }); } @@ -282,9 +312,52 @@ export class ProductModel { return category; } + private getTotalFromData(data: ClientResponse): number { + let total = 0; + if (isClientResponse(data) && isProductProjectionPagedQueryResponse(data.body)) { + total = data.body.total ?? 0; + } + return total; + } + + private sortVariants(products: Product[], sort: SortOptions): void { + products.forEach((product) => { + product.variant.sort((a, b) => { + let result = 0; + if (sort.field === SortFields.PRICE) { + if (sort.direction === SortDirection.ASC) { + const priceA = a.discount ? a.discount : a.price; + const priceB = b.discount ? b.discount : b.price; + result = priceA - priceB; + } else if (sort.direction === SortDirection.DESC) { + const priceA = a.discount ? a.discount : a.price; + const priceB = b.discount ? b.discount : b.price; + result = priceB - priceA; + } + } + return result; + }); + }); + products.sort((a, b) => { + let result = 0; + if (sort.field === SortFields.PRICE) { + if (sort.direction === SortDirection.ASC) { + const priceA = a.variant[0].discount ? a.variant[0].discount : a.variant[0].price; + const priceB = b.variant[0].discount ? b.variant[0].discount : b.variant[0].price; + result = priceA - priceB; + } else if (sort.direction === SortDirection.DESC) { + const priceA = a.variant[0].discount ? a.variant[0].discount : a.variant[0].price; + const priceB = b.variant[0].discount ? b.variant[0].discount : b.variant[0].price; + result = priceB - priceA; + } + } + return result; + }); + } + public adaptLocalizationValue(data: LocalizedString | undefined): localization[] { const result: localization[] = []; - Object.entries(data || {}).forEach(([language, value]) => { + Object.entries(data ?? {}).forEach(([language, value]) => { result.push({ language, value, @@ -300,7 +373,7 @@ export class ProductModel { return null; } - public async getCategories(): Promise { + public async getCategories(): Promise { if (!this.categories.length) { const data = await this.root.getCategories(); return this.getCategoriesFromData(data); @@ -324,14 +397,19 @@ export class ProductModel { await getProductModel().getCategories(); const data = await this.root.getProducts(options); const products = this.getProductsFromData(data); + if (options?.sort) { + this.sortVariants(products, options?.sort); + } const sizeCount = this.getSizeProductCountFromData(data); const categoryCount = this.getCategoriesProductCountFromData(data); const priceRange = this.getPriceRangeFromData(data); + const total = this.getTotalFromData(data); const result: ProductWithCount = { categoryCount, priceRange, products, sizeCount, + total, }; return result; } diff --git a/src/shared/API/product/utils/filter.ts b/src/shared/API/product/utils/filter.ts index 3110d636..cd4594d5 100644 --- a/src/shared/API/product/utils/filter.ts +++ b/src/shared/API/product/utils/filter.ts @@ -1,4 +1,4 @@ -import { PRICE_FRACTIONS } from '@/shared/constants/product.ts'; +import { MAX_PRICE, MIN_PRICE, PRICE_FRACTIONS } from '@/shared/constants/product.ts'; import type { FilterFieldsType, PriceRange } from '../../types/type.ts'; @@ -11,7 +11,10 @@ export default class FilterProduct { private newArrival = ''; - private price = ''; + private price: PriceRange = { + max: MAX_PRICE, + min: MIN_PRICE, + }; private sale = ''; @@ -42,7 +45,8 @@ export default class FilterProduct { break; case FilterFields.PRICE: if (value && typeof value !== 'string') { - this.price = `${field}: range(${value.min * PRICE_FRACTIONS} to ${value.max * PRICE_FRACTIONS})`; + this.price.max = value.max; + this.price.min = value.min; } break; default: @@ -66,11 +70,17 @@ export default class FilterProduct { result.push(this.newArrival); } if (this.price) { - result.push(this.price); + result.push( + `${FilterFields.PRICE}: range(${Math.round(this.price.min * PRICE_FRACTIONS)} to ${Math.round(this.price.max * PRICE_FRACTIONS)})`, + ); } if (this.sale) { result.push(this.sale); } return result; } + + public getPriceRange(): PriceRange { + return this.price; + } } diff --git a/src/shared/API/sdk/client.ts b/src/shared/API/sdk/client.ts index 655bd37d..0fe1aa04 100644 --- a/src/shared/API/sdk/client.ts +++ b/src/shared/API/sdk/client.ts @@ -1,6 +1,8 @@ import type { UserCredentials } from '@/shared/types/user'; import getStore from '@/shared/Store/Store.ts'; +import { setAnonymousId } from '@/shared/Store/actions.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; import { type ByProjectKeyRequestBuilder, createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'; import { type AnonymousAuthMiddlewareOptions, @@ -11,6 +13,7 @@ import { type PasswordAuthMiddlewareOptions, type RefreshAuthMiddlewareOptions, } from '@commercetools/sdk-client-v2'; +import { v4 as uuid } from 'uuid'; import type { TokenTypeType } from '../types/type.ts'; @@ -59,8 +62,9 @@ export class ApiClient { } else { this.anonymConnection = this.createAnonymConnection(); } - this.adminConnection = this.createAdminConnection(); + + this.init().catch(showErrorMessage); } private addAuthMiddleware( @@ -109,30 +113,30 @@ export class ApiClient { private createAnonymConnection(): ByProjectKeyRequestBuilder { const defaultOptions = this.getDefaultOptions(TokenType.ANONYM); const client = this.getDefaultClient(); + const anonymousId = uuid(); const anonymOptions: AnonymousAuthMiddlewareOptions = { ...defaultOptions, credentials: { ...defaultOptions.credentials, + anonymousId, }, }; client.withAnonymousSessionFlow(anonymOptions); - this.addRefreshMiddleware(TokenType.ANONYM, client, defaultOptions); - + getStore().dispatch(setAnonymousId(anonymousId)); this.anonymConnection = this.getConnection(client.build()); return this.anonymConnection; } private createAuthConnectionWithRefreshToken(): ByProjectKeyRequestBuilder { - if (!this.authConnection || (this.authConnection && !this.isAuth)) { - const defaultOptions = this.getDefaultOptions(TokenType.AUTH); - const client = this.getDefaultClient(); + const defaultOptions = this.getDefaultOptions(TokenType.AUTH); + const client = this.getDefaultClient(); - this.addRefreshMiddleware(TokenType.AUTH, client, defaultOptions); + this.addRefreshMiddleware(TokenType.AUTH, client, defaultOptions); + + this.authConnection = this.getConnection(client.build()); - this.authConnection = this.getConnection(client.build()); - } return this.authConnection; } @@ -161,10 +165,19 @@ export class ApiClient { host: URL_AUTH, projectKey: this.projectKey, scopes: this.scopes, - tokenCache: USE_SAVE_TOKEN && tokenType ? getTokenCache(tokenType) : undefined, + tokenCache: USE_SAVE_TOKEN && tokenType === TokenType.AUTH ? getTokenCache(tokenType) : undefined, }; } + private async init(): Promise { + await this.apiRoot() + .get() + .execute() + .catch((error: Error) => { + showErrorMessage(error); + }); + } + public adminRoot(): ByProjectKeyRequestBuilder { return this.adminConnection; } diff --git a/src/shared/API/sdk/token-cache/token-cache.ts b/src/shared/API/sdk/token-cache/token-cache.ts index 2601a08e..80644e58 100644 --- a/src/shared/API/sdk/token-cache/token-cache.ts +++ b/src/shared/API/sdk/token-cache/token-cache.ts @@ -54,7 +54,7 @@ export class MyTokenCache implements TokenCache { } public isExist(): boolean { - return this.myCache.token !== '' || this.myCache.refreshToken !== undefined; + return this.myCache.token !== '' ?? this.myCache.refreshToken !== undefined; } public set(newCache: TokenStore): void { diff --git a/src/shared/API/shopping-list/ShoppingListApi.ts b/src/shared/API/shopping-list/ShoppingListApi.ts index e9c4af9f..d57e0369 100644 --- a/src/shared/API/shopping-list/ShoppingListApi.ts +++ b/src/shared/API/shopping-list/ShoppingListApi.ts @@ -1,9 +1,11 @@ import type { ShoppingList, ShoppingListProduct } from '@/shared/types/shopping-list.ts'; import type { ClientResponse, + MyShoppingListAddLineItemAction, MyShoppingListDraft, ShoppingListPagedQueryResponse, ShoppingList as ShoppingListResponse, + ShoppingListSetAnonymousIdAction, } from '@commercetools/platform-sdk'; import getApiClient, { type ApiClient } from '../sdk/client.ts'; @@ -15,7 +17,10 @@ export class ShoppingListApi { this.client = getApiClient(); } - public async addProduct(shoppingList: ShoppingList, productId: string): Promise { + public async addProduct( + shoppingList: ShoppingList, + actions: MyShoppingListAddLineItemAction[], + ): Promise { const data = await this.client .apiRoot() .me() @@ -23,12 +28,7 @@ export class ShoppingListApi { .withId({ ID: shoppingList.id }) .post({ body: { - actions: [ - { - action: 'addLineItem', - productId, - }, - ], + actions, version: shoppingList.version, }, }) @@ -38,6 +38,7 @@ export class ShoppingListApi { public async create(): Promise> { const myShopList: MyShoppingListDraft = { + deleteDaysAfterLastModification: 2, name: { en: 'Favorite', ru: 'Favorite', @@ -76,10 +77,48 @@ export class ShoppingListApi { return data; } + public async deleteShopList(shoppingList: ShoppingList): Promise> { + const data = await this.client + .apiRoot() + .me() + .shoppingLists() + .withId({ ID: shoppingList.id }) + .delete({ + queryArgs: { + version: shoppingList.version, + }, + }) + .execute(); + return data; + } + public async get(): Promise> { const data = await this.client.apiRoot().me().shoppingLists().get().execute(); return data; } + + public async getAnonymList(ID: string): Promise> { + const data = await this.client.apiRoot().shoppingLists().withId({ ID }).get().execute(); + return data; + } + + public async setAnonymousId( + shoppingList: ShoppingList, + actions: ShoppingListSetAnonymousIdAction, + ): Promise> { + const data = await this.client + .apiRoot() + .shoppingLists() + .withId({ ID: shoppingList.id }) + .post({ + body: { + actions: [actions], + version: shoppingList.version, + }, + }) + .execute(); + return data; + } } const createShoppingListApi = (): ShoppingListApi => new ShoppingListApi(); diff --git a/src/shared/API/shopping-list/model/ShoppingListModel.ts b/src/shared/API/shopping-list/model/ShoppingListModel.ts index 8d571df0..ff15a043 100644 --- a/src/shared/API/shopping-list/model/ShoppingListModel.ts +++ b/src/shared/API/shopping-list/model/ShoppingListModel.ts @@ -1,18 +1,30 @@ import type { ShoppingList, ShoppingListProduct } from '@/shared/types/shopping-list.ts'; import type { ClientResponse, + MyShoppingListAddLineItemAction, ShoppingListLineItem, ShoppingListPagedQueryResponse, ShoppingList as ShoppingListResponse, + ShoppingListSetAnonymousIdAction, } from '@commercetools/platform-sdk'; import getStore from '@/shared/Store/Store.ts'; -import { setAnonymousId } from '@/shared/Store/actions.ts'; +import { setAnonymousId, setAnonymousShopListId } from '@/shared/Store/actions.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; -import { isClientResponse, isShoppingList, isShoppingListPagedQueryResponse } from '../../types/validation.ts'; +import { + isClientResponse, + isErrorResponse, + isShoppingList, + isShoppingListPagedQueryResponse, +} from '../../types/validation.ts'; import getShoppingListApi, { type ShoppingListApi } from '../ShoppingListApi.ts'; +enum ACTIONS { + addLineItem = 'addLineItem', + setAnonymousId = 'setAnonymousId', +} + export class ShoppingListModel { private root: ShoppingListApi; @@ -32,8 +44,9 @@ export class ShoppingListModel { } private adaptShopList(data: ShoppingListResponse): ShoppingList { - if (data.anonymousId) { + if (data.anonymousId && !getStore().getState().authToken) { getStore().dispatch(setAnonymousId(data.anonymousId)); + getStore().dispatch(setAnonymousShopListId(data.id)); } return { id: data.id, @@ -42,8 +55,25 @@ export class ShoppingListModel { }; } + private async getAnonymousShoppingList( + anonymousShopListId: string, + anonymousId: string, + ): Promise { + const dataAnonymList = await this.root.getAnonymList(anonymousShopListId); + const anonymShoppingList = this.getShopListFromData(dataAnonymList); + if (anonymShoppingList.id && !dataAnonymList.body.customer?.id) { + const actions: ShoppingListSetAnonymousIdAction = { + action: ACTIONS.setAnonymousId, + anonymousId, + }; + const dataUserList = await this.root.setAnonymousId(anonymShoppingList, actions); + this.shoppingList = this.getShopListFromData(dataUserList); + } + return this.shoppingList; + } + private getShopListFromData( - data: ClientResponse, + data: ClientResponse | ShoppingListResponse, ): ShoppingList { let cart: ShoppingList = { id: '', @@ -54,19 +84,73 @@ export class ShoppingListModel { cart = this.adaptShopList(data.body); } else if (isClientResponse(data) && isShoppingListPagedQueryResponse(data.body) && data.body.results.length) { cart = this.adaptShopList(data.body.results[0]); + } else if (isShoppingList(data)) { + cart = this.adaptShopList(data); } return cart; } + private async getUserShoppingLists(): Promise { + const data = await this.root.get(); + if (data.body.count === 0) { + const newShopList = await this.root.create(); + this.shoppingList = this.getShopListFromData(newShopList); + } else if (data.body.count > 1) { + this.shoppingList = await this.mergeShopLists(data); + } else { + this.shoppingList = this.getShopListFromData(data); + } + return this.shoppingList; + } + + private async mergeShopLists(data: ClientResponse): Promise { + const products: string[] = []; + const shopListId: ShoppingList[] = []; + data.body.results.forEach((el) => { + if (el.lineItems.length) { + products.push(...el.lineItems.map((item) => item.productId)); + } + shopListId.push(this.getShopListFromData(el)); + }); + const otherProducts = products.filter( + (product) => !data.body.results[0].lineItems.some((lineItem) => lineItem.productId === product), + ); + const otherShopLists = shopListId.filter((el) => data.body.results[0].id !== el.id); + const shopList = this.getShopListFromData(data); + const uniqProducts = Array.from(new Set(otherProducts)); + const actions: MyShoppingListAddLineItemAction[] = uniqProducts.map((lineItem) => ({ + action: ACTIONS.addLineItem, + productId: lineItem, + })); + const lastShopList = await this.root.addProduct(shopList, actions); + if (!isErrorResponse(lastShopList)) { + await Promise.all(otherShopLists.map((id) => this.root.deleteShopList(id))); + } + this.shoppingList = this.getShopListFromData(lastShopList); + return this.shoppingList; + } + public async addProduct(productId: string): Promise { if (!this.shoppingList) { this.shoppingList = await this.getShoppingList(); } - const data = await this.root.addProduct(this.shoppingList, productId); + const actions: MyShoppingListAddLineItemAction[] = [ + { + action: ACTIONS.addLineItem, + productId, + }, + ]; + + const data = await this.root.addProduct(this.shoppingList, actions); this.shoppingList = this.getShopListFromData(data); return this.shoppingList; } + public clear(): boolean { + this.shoppingList = null; + return true; + } + public async create(): Promise { const newShoppingList = await this.root.create(); this.shoppingList = this.getShopListFromData(newShoppingList); @@ -85,12 +169,12 @@ export class ShoppingListModel { public async getShoppingList(): Promise { if (!this.shoppingList) { - const data = await this.root.get(); - if (data.body.count === 0) { - const newShopList = await this.root.create(); - this.shoppingList = this.getShopListFromData(newShopList); - } else { - this.shoppingList = this.getShopListFromData(data); + const { anonymousId, anonymousShopListId } = getStore().getState(); + if (anonymousShopListId && anonymousId) { + this.shoppingList = await this.getAnonymousShoppingList(anonymousShopListId, anonymousId); + } + if (!this.shoppingList) { + this.shoppingList = await this.getUserShoppingLists(); } } return this.shoppingList; diff --git a/src/shared/API/types/type.ts b/src/shared/API/types/type.ts index 85c98fef..a9591dc3 100644 --- a/src/shared/API/types/type.ts +++ b/src/shared/API/types/type.ts @@ -1,7 +1,10 @@ import type { Category, Product, SizeType } from '@/shared/types/product.ts'; +import type FilterProduct from '../product/utils/filter'; + export const Attribute = { FULL_DESCRIPTION: 'full_description', + LEVEL: 'level', SIZE: 'size', } as const; @@ -49,7 +52,7 @@ export type SearchOptions = { }; export type OptionsRequest = { - filter?: string[]; + filter?: FilterProduct; limit?: number; page?: number; search?: SearchOptions; @@ -76,4 +79,5 @@ export type ProductWithCount = { priceRange: PriceRange; products: Product[]; sizeCount: SizeProductCount[]; + total: number; }; diff --git a/src/shared/Confirm/model/ConfirmModel.ts b/src/shared/Confirm/model/ConfirmModel.ts new file mode 100644 index 00000000..749981b4 --- /dev/null +++ b/src/shared/Confirm/model/ConfirmModel.ts @@ -0,0 +1,52 @@ +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; + +import ConfirmView from '../view/ConfirmView.ts'; + +class ConfirmModel { + private callback: () => Promise | void; + + private view: ConfirmView; + + constructor(callback: () => Promise | void, message: string) { + this.callback = callback; + this.view = new ConfirmView(message); + this.setCancelButtonHandler(); + this.setConfirmButtonHandler(); + } + + private setCancelButtonHandler(): void { + this.view + .getCancelButton() + .getHTML() + .addEventListener('click', () => { + modal.hide(); + }); + } + + private setConfirmButtonHandler(): void { + this.view + .getConfirmButton() + .getHTML() + .addEventListener('click', async () => { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getConfirmButton().getHTML().append(loader); + try { + await this.callback(); + } catch (error) { + showErrorMessage(error); + } finally { + loader.remove(); + } + modal.hide(); + }); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } +} + +export default ConfirmModel; diff --git a/src/shared/Confirm/view/ConfirmView.ts b/src/shared/Confirm/view/ConfirmView.ts new file mode 100644 index 00000000..bcec0507 --- /dev/null +++ b/src/shared/Confirm/view/ConfirmView.ts @@ -0,0 +1,79 @@ +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; + +import styles from './confirmView.module.scss'; + +class ConfirmView { + private cancelButton: ButtonModel; + + private confirmButton: ButtonModel; + + private userMessage: HTMLSpanElement; + + private view: HTMLDivElement; + + constructor(userMessage: string) { + this.userMessage = this.createUserMessage(userMessage); + this.cancelButton = this.createCancelButton(); + this.confirmButton = this.createConfirmButton(); + this.view = this.createHTML(); + } + + private createCancelButton(): ButtonModel { + this.cancelButton = new ButtonModel({ + classes: [styles.cancelButton], + text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + }); + + observeCurrentLanguage(this.cancelButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.CANCEL); + + return this.cancelButton; + } + + private createConfirmButton(): ButtonModel { + this.confirmButton = new ButtonModel({ + classes: [styles.confirmButton], + text: BUTTON_TEXT[getStore().getState().currentLanguage].CONFIRM, + }); + + observeCurrentLanguage(this.confirmButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.CONFIRM); + + return this.confirmButton; + } + + private createHTML(): HTMLDivElement { + this.view = createBaseElement({ + cssClasses: [styles.wrapper], + tag: 'div', + }); + this.view.append(this.userMessage, this.cancelButton.getHTML(), this.confirmButton.getHTML()); + return this.view; + } + + private createUserMessage(message: string): HTMLSpanElement { + this.userMessage = createBaseElement({ + cssClasses: [styles.userMessage], + innerContent: message, + tag: 'span', + }); + + return this.userMessage; + } + + public getCancelButton(): ButtonModel { + return this.cancelButton; + } + + public getConfirmButton(): ButtonModel { + return this.confirmButton; + } + + public getHTML(): HTMLDivElement { + return this.view; + } +} + +export default ConfirmView; diff --git a/src/shared/Confirm/view/confirmView.module.scss b/src/shared/Confirm/view/confirmView.module.scss new file mode 100644 index 00000000..0b03f335 --- /dev/null +++ b/src/shared/Confirm/view/confirmView.module.scss @@ -0,0 +1,41 @@ +@import 'src/app/styles/mixins'; + +.wrapper { + display: grid; + grid-column: 2 span; + grid-template-rows: repeat((2, max-content)); + margin: var(--small-offset) auto; + border-bottom: var(--tiny-offset) solid var(--steam-green-800); + padding: var(--small-offset); + width: 100%; + min-height: calc(var(--extra-large-offset) * 2.1); + max-width: calc(var(--extra-large-offset) * 4); + background-color: var(--noble-white-100); + gap: var(--extra-small-offset); +} + +.userMessage { + grid-column: 2 span; + grid-row: 1; + padding: var(--extra-small-offset); + font: var(--extra-regular-font); + letter-spacing: var(--one); + text-align: center; + color: var(--steam-green-800); + cursor: default; +} + +.cancelButton, +.confirmButton { + @include green-btn; + + grid-row: 2; +} + +.cancelButton { + grid-column: 1; +} + +.confirmButton { + grid-column: 2; +} diff --git a/src/shared/Loader/view/loaderView.module.scss b/src/shared/Loader/view/loaderView.module.scss index 2288b26d..529d7976 100644 --- a/src/shared/Loader/view/loaderView.module.scss +++ b/src/shared/Loader/view/loaderView.module.scss @@ -1,5 +1,5 @@ .loader { - margin: 0 calc(var(--extra-small-offset) / 4); + // margin: 0 calc(var(--extra-small-offset) / 4); border: var(--two) solid var(--noble-gray-200); border-top: var(--two) solid var(--steam-green-800); border-radius: 50%; @@ -55,5 +55,6 @@ position: absolute; left: 50%; top: 50%; + z-index: 10; transform: translate(-50%, -50%); } diff --git a/src/shared/Modal/model/ModalModel.ts b/src/shared/Modal/model/ModalModel.ts index efd461eb..0226ae95 100644 --- a/src/shared/Modal/model/ModalModel.ts +++ b/src/shared/Modal/model/ModalModel.ts @@ -11,6 +11,10 @@ class ModalModel { this.view.hide(); } + public removeContent(): void { + this.view.removeContent(); + } + public setContent(content: HTMLElement): void { this.view.setContent(content); } diff --git a/src/shared/Modal/view/ModalView.ts b/src/shared/Modal/view/ModalView.ts index 53040e4f..41527372 100644 --- a/src/shared/Modal/view/ModalView.ts +++ b/src/shared/Modal/view/ModalView.ts @@ -1,3 +1,4 @@ +import clearOutElement from '@/shared/utils/clearOutElement.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import styles from './modalView.module.scss'; @@ -76,8 +77,12 @@ class ModalView { document.body.classList.remove('stop-scroll'); } + public removeContent(): void { + clearOutElement(this.modalContent); + } + public setContent(content: HTMLElement): void { - this.modalContent.innerHTML = ''; + clearOutElement(this.modalContent); this.modalContent.append(content); } diff --git a/src/shared/Modal/view/modalView.module.scss b/src/shared/Modal/view/modalView.module.scss index e60982dd..999fd7d9 100644 --- a/src/shared/Modal/view/modalView.module.scss +++ b/src/shared/Modal/view/modalView.module.scss @@ -2,7 +2,7 @@ position: fixed; left: 0; top: 0; - z-index: 1; + z-index: 2; width: 100%; height: 100%; opacity: 1; @@ -43,6 +43,7 @@ top: 50%; border: var(--two) solid var(--noble-black-500); border-radius: var(--border-radius); + max-width: 95%; font: var(--small-font); letter-spacing: var(--one); color: var(--steam-green-500); diff --git a/src/shared/ScrollToTop/model/ScrollToTopModel.ts b/src/shared/ScrollToTop/model/ScrollToTopModel.ts new file mode 100644 index 00000000..db1c584d --- /dev/null +++ b/src/shared/ScrollToTop/model/ScrollToTopModel.ts @@ -0,0 +1,27 @@ +import ScrollToTopView from '../view/ScrollToTopView.ts'; + +class ScrollToTopModel { + private view: ScrollToTopView; + + constructor() { + this.view = new ScrollToTopView(); + this.init(); + } + + private init(): void { + const buttonElement = this.view.getHTML(); + buttonElement.addEventListener('click', this.scrollToTopHandler.bind(this)); + window.addEventListener('scroll', () => this.view.toggleVisibility()); + this.view.toggleVisibility(); + } + + private scrollToTopHandler(): void { + window.scrollTo(0, 0); + } + + public getHTML(): ScrollToTopView { + return this.view; + } +} + +export default ScrollToTopModel; diff --git a/src/shared/ScrollToTop/view/ScrollToTopView.ts b/src/shared/ScrollToTop/view/ScrollToTopView.ts new file mode 100644 index 00000000..868b629f --- /dev/null +++ b/src/shared/ScrollToTop/view/ScrollToTopView.ts @@ -0,0 +1,44 @@ +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { SCROLL_TO_TOP_THRESHOLD } from '@/shared/constants/common.ts'; +import SVG_DETAILS from '@/shared/constants/svg.ts'; +import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; + +import styles from './scrollToTopView.module.scss'; + +class ScrollToTopView { + private button: ButtonModel; + + constructor() { + this.button = this.createButton(); + } + + private createButton(): ButtonModel { + this.button = new ButtonModel({ + classes: [styles.scrollToTopButton], + title: TOOLTIP_TEXT[getStore().getState().currentLanguage].SCROLL_TO_TOP, + }); + + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.ARROW_UP)); + this.button.getHTML().append(svg); + + observeStore(selectCurrentLanguage, () => { + this.button.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].SCROLL_TO_TOP; + }); + + return this.button; + } + + public getHTML(): HTMLButtonElement { + return this.button.getHTML(); + } + + public toggleVisibility(): void { + this.button.getHTML().classList.toggle(styles.hidden, window.scrollY <= SCROLL_TO_TOP_THRESHOLD); + } +} + +export default ScrollToTopView; diff --git a/src/shared/ScrollToTop/view/scrollToTopView.module.scss b/src/shared/ScrollToTop/view/scrollToTopView.module.scss new file mode 100644 index 00000000..edf67c22 --- /dev/null +++ b/src/shared/ScrollToTop/view/scrollToTopView.module.scss @@ -0,0 +1,45 @@ +/* stylelint-disable scss/no-global-function-names */ +@import 'src/app/styles/mixins'; + +$btn-vars: ( + padding: var(--tiny-offset), + bg: var(--steam-green-900), + hover-bg: var(--steam-green-1000), + fill: none, + stroke: var(--steam-green-800), + offset: var(--small-offset), +); + +.scrollToTopButton { + @include round-btn( + map-get($btn-vars, padding), + map-get($btn-vars, bg), + map-get($btn-vars, hover-bg), + map-get($btn-vars, fill), + map-get($btn-vars, stroke) + ); + + position: fixed; + right: map-get($btn-vars, offset); + bottom: map-get($btn-vars, offset); + z-index: 1; + width: calc(map-get($btn-vars, offset) * 1.5); + height: calc(map-get($btn-vars, offset) * 1.5); + fill: none; + stroke: map-get($btn-vars, stroke); + transition: + transform 0.2s, + opacity 0.2s, + visibility 0.2s; + + svg { + width: map-get($btn-vars, offset); + height: map-get($btn-vars, offset); + } +} + +.hidden { + opacity: 0; + visibility: hidden; + transform: scale(0); +} diff --git a/src/shared/ServerMessage/view/serverMessageView.module.scss b/src/shared/ServerMessage/view/serverMessageView.module.scss index 753318c4..2e6d2669 100644 --- a/src/shared/ServerMessage/view/serverMessageView.module.scss +++ b/src/shared/ServerMessage/view/serverMessageView.module.scss @@ -1,7 +1,7 @@ .serverMessageWrapper { position: fixed; right: 0; - top: 50%; + top: 20%; z-index: 15; border: var(--two) solid var(--steam-green-800); border-top: var(--tiny-offset) solid var(--steam-green-800); @@ -13,7 +13,7 @@ word-break: break-word; text-align: center; background-color: var(--white); - transform: translate(110%, -50%); + transform: translate(110%, -80%); } .progressBar { diff --git a/src/shared/Store/Store.ts b/src/shared/Store/Store.ts index 3f4fe52e..9b2d996c 100644 --- a/src/shared/Store/Store.ts +++ b/src/shared/Store/Store.ts @@ -4,6 +4,7 @@ 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 { setCurrentPage } from './actions.ts'; import { rootReducer } from './reducer.ts'; export class Store implements ReduxStore { @@ -28,7 +29,10 @@ export class Store implements ReduxStore { this.rootReducer = rootReducer; // If you have unexpected bugs related to State of the app, or you need to clear out Local Storage to start afresh, comment out the next line, go to the browser tab, clear out the storage manually, and update the page one more time. Then come back here and uncomment it - window.addEventListener('beforeunload', () => saveCurrentStateToLocalStorage(this.state)); + window.addEventListener('beforeunload', () => { + getStore().dispatch(setCurrentPage(null)); + saveCurrentStateToLocalStorage(this.state); + }); } public dispatch(action: A): A { diff --git a/src/shared/Store/actions.ts b/src/shared/Store/actions.ts index ba48fbcc..7407d431 100644 --- a/src/shared/Store/actions.ts +++ b/src/shared/Store/actions.ts @@ -2,20 +2,17 @@ import type { TokenStore } from '@commercetools/sdk-client-v2'; import type { LanguageChoiceType } from '../constants/common.ts'; import type { PageIdType } from '../constants/pages.ts'; -import type { SelectedFilters } from '../types/productFilters'; -import type { SelectedSorting } from '../types/productSorting.ts'; const ACTION = { SET_ANONYM_TOKEN: 'setAnonymToken', SET_ANONYMOUS_CART_ID: 'setAnonymousCartId', SET_ANONYMOUS_ID: 'setAnonymousId', + SET_ANONYMOUS_SHOP_LIST_ID: 'setAnonymousShopListId', SET_AUTH_TOKEN: 'setAuthToken', SET_BILLING_COUNTRY: 'setBillingCountry', SET_CURRENT_LANGUAGE: 'setCurrentLanguage', SET_CURRENT_PAGE: 'setCurrentPage', - SET_SEARCH_VALUE: 'setSearchValue', - SET_SELECTED_FILTERS: 'setSelectedFilters', - SET_SELECTED_SORTING: 'setSelectedSorting', + SET_DEFAULT_COUNTRY: 'setDefaultCountry', SET_SHIPPING_COUNTRY: 'setShippingCountry', SWITCH_APP_THEME: 'switchAppTheme', SWITCH_IS_USER_LOGGED_IN: 'switchIsUserLoggedIn', @@ -32,6 +29,13 @@ interface ActionWithoutPayload { type: U; } +export const setAnonymousShopListId = ( + value: null | string, +): ActionWithPayload => ({ + payload: value, + type: ACTION.SET_ANONYMOUS_SHOP_LIST_ID, +}); + export const setAnonymToken = ( value: TokenStore | null, ): ActionWithPayload => ({ @@ -70,6 +74,11 @@ export const setShippingCountry = (value: string): ActionWithPayload => ({ + payload: value, + type: ACTION.SET_DEFAULT_COUNTRY, +}); + export const setCurrentLanguage = ( value: LanguageChoiceType, ): ActionWithPayload => ({ @@ -84,7 +93,9 @@ export const switchIsUserLoggedIn = ( type: ACTION.SWITCH_IS_USER_LOGGED_IN, }); -export const setCurrentPage = (value: PageIdType): ActionWithPayload => ({ +export const setCurrentPage = ( + value: PageIdType | null, +): ActionWithPayload => ({ payload: value, type: ACTION.SET_CURRENT_PAGE, }); @@ -92,22 +103,3 @@ export const setCurrentPage = (value: PageIdType): ActionWithPayload => ({ type: ACTION.SWITCH_APP_THEME, }); - -export const setSelectedFilters = ( - value: SelectedFilters | null, -): ActionWithPayload => ({ - payload: value, - type: ACTION.SET_SELECTED_FILTERS, -}); - -export const setSelectedSorting = ( - value: SelectedSorting | null, -): ActionWithPayload => ({ - payload: value, - type: ACTION.SET_SELECTED_SORTING, -}); - -export const setSearchValue = (value: string): ActionWithPayload => ({ - payload: value, - type: ACTION.SET_SEARCH_VALUE, -}); diff --git a/src/shared/Store/observer.ts b/src/shared/Store/observer.ts index 8267f062..0d874897 100644 --- a/src/shared/Store/observer.ts +++ b/src/shared/Store/observer.ts @@ -50,10 +50,12 @@ export const selectBillingCountry = (state: State): string => state.billingCount export const selectShippingCountry = (state: State): string => state.shippingCountry; +export const selectDefaultCountry = (state: State): string => state.defaultCountry; + export const selectCurrentLanguage = (state: State): string => state.currentLanguage; export const selectIsUserLoggedIn = (state: State): boolean => state.isUserLoggedIn; -export const selectCurrentPage = (state: State): string => state.currentPage; +export const selectCurrentPage = (state: State): null | string => state.currentPage; export default observeStore; diff --git a/src/shared/Store/reducer.ts b/src/shared/Store/reducer.ts index 6ecbf2da..6fa55541 100644 --- a/src/shared/Store/reducer.ts +++ b/src/shared/Store/reducer.ts @@ -3,8 +3,6 @@ import type { TokenStore } from '@commercetools/sdk-client-v2'; import type { LanguageChoiceType } from '../constants/common.ts'; import type { PageIdType } from '../constants/pages.ts'; -import type { SelectedFilters } from '../types/productFilters.ts'; -import type { SelectedSorting } from '../types/productSorting.ts'; import type * as actions from './actions.ts'; import type { Reducer } from './types.ts'; @@ -12,15 +10,14 @@ export interface State { anonymToken: TokenStore | null; anonymousCartId: null | string; anonymousId: null | string; + anonymousShopListId: null | string; authToken: TokenStore | null; billingCountry: string; currentLanguage: LanguageChoiceType; - currentPage: PageIdType; + currentPage: PageIdType | null; + defaultCountry: string; isAppThemeLight: boolean; isUserLoggedIn: boolean; - searchValue: string; - selectedFilters: SelectedFilters | null; - selectedSorting: SelectedSorting | null; shippingCountry: string; } @@ -44,6 +41,11 @@ export const rootReducer: Reducer = (state: State, action: Action ...state, anonymousCartId: action.payload, }; + case 'setAnonymousShopListId': + return { + ...state, + anonymousShopListId: action.payload, + }; case 'setAnonymousId': return { ...state, @@ -59,6 +61,11 @@ export const rootReducer: Reducer = (state: State, action: Action ...state, billingCountry: action.payload, }; + case 'setDefaultCountry': + return { + ...state, + defaultCountry: action.payload, + }; case 'setCurrentLanguage': return { ...state, @@ -79,21 +86,6 @@ export const rootReducer: Reducer = (state: State, action: Action ...state, isAppThemeLight: !state.isAppThemeLight, }; - case 'setSelectedFilters': - return { - ...state, - selectedFilters: action.payload, - }; - case 'setSelectedSorting': - return { - ...state, - selectedSorting: action.payload, - }; - case 'setSearchValue': - return { - ...state, - searchValue: action.payload, - }; default: return state; } diff --git a/src/shared/Store/test.spec.ts b/src/shared/Store/test.spec.ts index 7f2e9d98..b174d754 100644 --- a/src/shared/Store/test.spec.ts +++ b/src/shared/Store/test.spec.ts @@ -1,7 +1,5 @@ -import type { SelectedFilters } from '../types/productFilters.ts'; import type { State } from './reducer.ts'; -import { META_FILTERS } from '../constants/filters.ts'; import { PAGE_ID } from '../constants/pages.ts'; import getStore, { Store } from './Store.ts'; import * as actions from './actions.ts'; @@ -59,15 +57,14 @@ vi.mock('./Store.ts', async (importOriginal) => { anonymToken: null, anonymousCartId: null, anonymousId: null, + anonymousShopListId: null, authToken: null, billingCountry: '', currentLanguage: 'en', - currentPage: '/', + currentPage: '', + defaultCountry: '', isAppThemeLight: true, isUserLoggedIn: false, - searchValue: '', - selectedFilters: null, - selectedSorting: null, shippingCountry: '', }), }; @@ -177,15 +174,14 @@ describe('rootReducer', () => { anonymToken: null, anonymousCartId: null, anonymousId: null, + anonymousShopListId: null, authToken: null, billingCountry: '', currentLanguage: 'en', - currentPage: '/', + currentPage: '', + defaultCountry: '', isAppThemeLight: true, isUserLoggedIn: false, - searchValue: '', - selectedFilters: null, - selectedSorting: null, shippingCountry: '', }; }); @@ -219,7 +215,7 @@ describe('rootReducer', () => { }); it('should handle setCurrentPage action', () => { - const page = 'main/'; + const page = 'main'; const action = actions.setCurrentPage(PAGE_ID.MAIN_PAGE); const newState = rootReducer(initialState, action); expect(newState.currentPage).toEqual(page); @@ -230,16 +226,4 @@ describe('rootReducer', () => { const newState = rootReducer(initialState, action); expect(newState.isAppThemeLight).toEqual(!initialState.isAppThemeLight); }); - - it('should handle setSelectedFilters action', () => { - const filters: SelectedFilters = { - category: new Set(), - metaFilter: META_FILTERS.en.NEW_ARRIVALS, - price: null, - size: null, - }; - const action = actions.setSelectedFilters(filters); - const newState = rootReducer(initialState, action); - expect(newState.selectedFilters).toEqual(filters); - }); }); diff --git a/src/shared/constants/blog.ts b/src/shared/constants/blog.ts index f1ebbc7a..d7a91415 100644 --- a/src/shared/constants/blog.ts +++ b/src/shared/constants/blog.ts @@ -14,7 +14,7 @@ export interface Post { ru: string; }; time: number; - tittle: { + title: { en: string; ru: string; }; diff --git a/src/shared/constants/buttons.ts b/src/shared/constants/buttons.ts index 7523bdc3..f17587d8 100644 --- a/src/shared/constants/buttons.ts +++ b/src/shared/constants/buttons.ts @@ -6,27 +6,29 @@ export const BUTTON_TYPE = { export const BUTTON_TEXT = { en: { + ADD_ADDRESS: 'Add address', ADD_PRODUCT: 'Add to cart', BACK_TO_MAIN: 'Back to main', CANCEL: 'Cancel', + CONFIRM: 'Confirm', DELETE_PRODUCT: 'Remove from cart', EDIT_INFO: 'Edit', LOG_OUT: 'Log out', LOGIN: 'Login', - NEW_ADDRESS: 'New address', REGISTRATION: 'Register', RESET: 'Reset', SAVE_CHANGES: 'Save changes', }, ru: { + ADD_ADDRESS: 'Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ адрСс', ADD_PRODUCT: 'Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π² ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ', BACK_TO_MAIN: 'Π’Π΅Ρ€Π½ΡƒΡ‚ΡŒΡΡ Π½Π° Π³Π»Π°Π²Π½ΡƒΡŽ', CANCEL: 'ΠžΡ‚ΠΌΠ΅Π½Π°', + CONFIRM: 'ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ', DELETE_PRODUCT: 'Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΈΠ· ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹', EDIT_INFO: 'Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ', LOG_OUT: 'Π’Ρ‹ΠΉΡ‚ΠΈ', LOGIN: 'Π’ΠΎΠΉΡ‚ΠΈ', - NEW_ADDRESS: 'Новый адрСс', REGISTRATION: 'РСгистрация', RESET: 'Π‘Π±Ρ€ΠΎΡΠΈΡ‚ΡŒ', SAVE_CHANGES: 'Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ', @@ -34,14 +36,15 @@ export const BUTTON_TEXT = { } as const; export const BUTTON_TEXT_KEYS = { + ADD_ADDRESS: 'ADD_ADDRESS', ADD_PRODUCT: 'ADD_PRODUCT', BACK_TO_MAIN: 'BACK_TO_MAIN', CANCEL: 'CANCEL', + CONFIRM: 'CONFIRM', DELETE_PRODUCT: 'DELETE_PRODUCT', EDIT_INFO: 'EDIT_INFO', LOG_OUT: 'LOG_OUT', LOGIN: 'LOGIN', - NEW_ADDRESS: 'NEW_ADDRESS', REGISTRATION: 'REGISTRATION', RESET: 'RESET', SAVE_CHANGES: 'SAVE_CHANGES', diff --git a/src/shared/constants/common.ts b/src/shared/constants/common.ts index f69d1107..f3e7f2a6 100644 --- a/src/shared/constants/common.ts +++ b/src/shared/constants/common.ts @@ -13,4 +13,8 @@ export const DATA_KEYS = { DIRECTION: 'data-direction', } as const; +export const TABLET_WIDTH = 768; + +export const SCROLL_TO_TOP_THRESHOLD = 200; + export type LanguageChoiceType = (typeof LANGUAGE_CHOICE)[keyof typeof LANGUAGE_CHOICE]; diff --git a/src/shared/constants/confirmUserMessage.ts b/src/shared/constants/confirmUserMessage.ts new file mode 100644 index 00000000..75b40b4f --- /dev/null +++ b/src/shared/constants/confirmUserMessage.ts @@ -0,0 +1,19 @@ +export const USER_MESSAGE = { + en: { + CLEAR_CART: 'Are you sure you want to clear the cart?', + CONFIRM: 'Are you sure you want to proceed?', + DELETE_ADDRESS: 'Are you sure you want to delete this address?', + }, + ru: { + CLEAR_CART: 'Π’Ρ‹ ΡƒΠ²Π΅Ρ€Π΅Π½Ρ‹, Ρ‡Ρ‚ΠΎ Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΠΎΡ‡ΠΈΡΡ‚ΠΈΡ‚ΡŒ ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ?', + CONFIRM: 'Π’Ρ‹ ΡƒΠ²Π΅Ρ€Π΅Π½Ρ‹, Ρ‡Ρ‚ΠΎ Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ?', + DELETE_ADDRESS: 'Π’Ρ‹ ΡƒΠ²Π΅Ρ€Π΅Π½Ρ‹, Ρ‡Ρ‚ΠΎ Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ этот адрСс?', + }, +} as const; + +export const USER_MESSAGE_KEYS = { + CONFIRM: 'CONFIRM', + DELETE_ADDRESS: 'DELETE_ADDRESS', +}; + +export type UserMessageKeysType = (typeof USER_MESSAGE_KEYS)[keyof typeof USER_MESSAGE_KEYS]; diff --git a/src/shared/constants/events.ts b/src/shared/constants/events.ts index 55c71f75..64ee591b 100644 --- a/src/shared/constants/events.ts +++ b/src/shared/constants/events.ts @@ -1,6 +1,7 @@ const MEDIATOR_EVENT = { + CLEAR_CATALOG_SEARCH: 'CLEAR_CATALOG_SEARCH', REDRAW_PRODUCTS: 'REDRAW_PRODUCTS', - REDRAW_USER_ADDRESS: 'REDRAW_USER_ADDRESS', + REDRAW_USER_ADDRESSES: 'REDRAW_USER_ADDRESSES', REDRAW_USER_INFO: 'REDRAW_USER_INFO', } as const; diff --git a/src/shared/constants/filters.ts b/src/shared/constants/filters.ts index 516d9839..202b697d 100644 --- a/src/shared/constants/filters.ts +++ b/src/shared/constants/filters.ts @@ -24,9 +24,9 @@ export const PRICE_RANGE_LABEL = { export const META_FILTERS = { en: { - ALL_PRODUCTS: 'All', - NEW_ARRIVALS: 'New', - SALE: 'Sale', + ALL_PRODUCTS: 'all', + NEW_ARRIVALS: 'new', + SALE: 'sale', }, ru: { ALL_PRODUCTS: 'ВсС', diff --git a/src/shared/constants/forms.ts b/src/shared/constants/forms.ts index bcc37bd5..744b16f5 100644 --- a/src/shared/constants/forms.ts +++ b/src/shared/constants/forms.ts @@ -29,6 +29,10 @@ export const FORM_TEXT_KEYS = { SINGLE_ADDRESS: 'SINGLE_ADDRESS', } as const; +export const DEFAULT_ADDRESS = { + setDefault: true, +}; + export type FormTextKeysType = (typeof FORM_TEXT_KEYS)[keyof typeof FORM_TEXT_KEYS]; export const USER_COUNTRY_ADDRESS = { @@ -43,6 +47,32 @@ export const USER_ADDRESS_TYPE = { export type UserAddressType = (typeof USER_ADDRESS_TYPE)[keyof typeof USER_ADDRESS_TYPE]; +export const ADDRESS_TYPE = { + BILLING: 'billing', + DEFAULT_BILLING: 'default billing', + DEFAULT_SHIPPING: 'default shipping', + SHIPPING: 'shipping', +} as const; + +export type AddressTypeType = (typeof ADDRESS_TYPE)[keyof typeof ADDRESS_TYPE]; + +export const LABEL_TYPE = { + en: { + BILLING: 'billing', + DEFAULT_BILLING: 'default billing', + DEFAULT_SHIPPING: 'default shipping', + SHIPPING: 'shipping', + }, + ru: { + BILLING: 'ВыставлСниС счСтов', + DEFAULT_BILLING: 'По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ для выставлСния счСтов', + DEFAULT_SHIPPING: 'По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ для доставки', + SHIPPING: 'Доставка', + }, +} as const; + +export type LabelTypeType = (typeof LABEL_TYPE)[keyof typeof LABEL_TYPE]; + export const USER_POSTAL_CODE = { BILLING_POSTAL_CODE: 'billing_PostalCode', POSTAL_CODE: 'postalCode', @@ -52,3 +82,18 @@ export const PASSWORD_TEXT = { HIDDEN: '********', SHOWN: 'Password123', }; + +export const ADDRESS_TEXT = { + en: { + CITY: 'City: ', + COUNTRY: 'Country: ', + POSTAL_CODE: 'Postal code: ', + STREET: 'Address: ', + }, + ru: { + CITY: 'Π“ΠΎΡ€ΠΎΠ΄: ', + COUNTRY: 'Π‘Ρ‚Ρ€Π°Π½Π°: ', + POSTAL_CODE: 'ΠŸΠΎΡ‡Ρ‚ΠΎΠ²Ρ‹ΠΈΜ† индСкс: ', + STREET: 'АдрСс: ', + }, +} as const; diff --git a/src/shared/constants/forms/fieldParams.ts b/src/shared/constants/forms/fieldParams.ts index c727cc80..b06396e2 100644 --- a/src/shared/constants/forms/fieldParams.ts +++ b/src/shared/constants/forms/fieldParams.ts @@ -14,6 +14,20 @@ export const EMAIL = { }, } as const; +export const EMAIL_NOT_LABEL_TEXT = { + inputParams: { + autocomplete: 'off', + placeholder: 'user@example.com', + type: 'text', + }, + labelParams: { + text: { + en: '', + ru: '', + }, + }, +} as const; + export const PASSWORD = { inputParams: { autocomplete: 'off', @@ -225,6 +239,7 @@ export const BILLING_ADDRESS_POSTAL_CODE = { export const INPUT = [ EMAIL, + EMAIL_NOT_LABEL_TEXT, PASSWORD, FIRST_NAME, LAST_NAME, diff --git a/src/shared/constants/forms/text.ts b/src/shared/constants/forms/text.ts index 7b26a950..55e4ef7a 100644 --- a/src/shared/constants/forms/text.ts +++ b/src/shared/constants/forms/text.ts @@ -1,11 +1,13 @@ export const TITLE_TEXT = { en: { + ADDRESS: 'Address', BILLING_ADDRESS: 'Billing Address', CREDENTIALS: 'Credentials', PERSONAL: 'Personal', SHIPPING_ADDRESS: 'Shipping Address', }, ru: { + ADDRESS: 'АдрСс', BILLING_ADDRESS: 'АдрСс выставлСния счСтов', CREDENTIALS: 'Π›ΠΎΠ³ΠΈΠ½ ΠΈ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ', PERSONAL: 'Π›ΠΈΡ‡Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅', @@ -14,6 +16,7 @@ export const TITLE_TEXT = { } as const; export const TITLE_TEXT_KEYS = { + ADDRESS: 'ADDRESS', BILLING_ADDRESS: 'BILLING_ADDRESS', CREDENTIALS: 'CREDENTIALS', PERSONAL: 'PERSONAL', diff --git a/src/shared/constants/forms/validationParams.ts b/src/shared/constants/forms/validationParams.ts index 6055295d..bc1fc04e 100644 --- a/src/shared/constants/forms/validationParams.ts +++ b/src/shared/constants/forms/validationParams.ts @@ -14,6 +14,10 @@ export const EMAIL_VALIDATE = { }, } as const; +export const FOOTER_EMAIL_VALIDATE = { + required: false, +} as const; + export const PASSWORD_VALIDATE = { minLength: 8, notWhitespace: { diff --git a/src/shared/constants/initialState.ts b/src/shared/constants/initialState.ts index 57cdf32b..889c3655 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/constants/initialState.ts @@ -6,23 +6,14 @@ const initialState: State = { anonymToken: null, anonymousCartId: null, anonymousId: null, + anonymousShopListId: null, authToken: null, billingCountry: '', currentLanguage: 'en', currentPage: PAGE_ID.DEFAULT_PAGE, + defaultCountry: '', isAppThemeLight: true, isUserLoggedIn: false, - searchValue: '', - selectedFilters: { - category: new Set(), - metaFilter: 'All products', - price: null, - size: null, - }, - selectedSorting: { - direction: 'asc', - field: 'price', - }, shippingCountry: '', }; diff --git a/src/shared/constants/messages.ts b/src/shared/constants/messages.ts index 8f30f5e5..0cedca64 100644 --- a/src/shared/constants/messages.ts +++ b/src/shared/constants/messages.ts @@ -1,3 +1,5 @@ +import type { LanguageChoiceType } from './common'; + export const MESSAGE_STATUS = { ERROR: 'error', SUCCESS: 'success', @@ -12,70 +14,91 @@ export const MESSAGE_STATUS_KEYS = { export type MessageStatusKeysType = (typeof MESSAGE_STATUS_KEYS)[keyof typeof MESSAGE_STATUS_KEYS]; -export const SERVER_MESSAGE = { +export const SERVER_MESSAGE: Record> = { en: { + ADDRESS_ADDED: 'Address has been added successfully', ADDRESS_CHANGED: 'Address has been changed successfully', ADDRESS_DELETED: 'Address has been deleted successfully', + ADDRESS_STATUS_CHANGED: 'Address status has been changed successfully', BAD_REQUEST: 'Sorry, something went wrong. Try again later.', COPY_TO_CLIPBOARD: 'SKU copied to clipboard', GREETING: 'Hi! Welcome to our store. Enjoy shopping!', INCORRECT_PASSWORD: 'Please, enter a correct password', + INVALID_COUPON: 'Invalid coupon', INVALID_EMAIL: "User with this email doesn't exist. Please, register first", LANGUAGE_CHANGED: 'Language preferences have been updated successfully', + NEED_LOGIN: 'You need to log in to see this page', PASSWORD_CHANGED: 'Your password has been changed successfully', PASSWORD_NOT_CHANGED: 'Your password has not been changed. Please, try again', PERSONAL_INFO_CHANGED: 'Personal information has been changed successfully', + SUCCESSFUL_ADD_COUPON_TO_CART: 'Coupon has been applied to your cart successfully', SUCCESSFUL_ADD_PRODUCT_TO_CART: 'Product has been added successfully to your cart', SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: 'Product has been added successfully to your wishlist', + SUCCESSFUL_CLEAR_CART: 'Cart has been cleared successfully', SUCCESSFUL_COPY_TO_CLIPBOARD: 'SKU has been copied to clipboard', SUCCESSFUL_DELETE_PRODUCT_FROM_CART: 'Product has been deleted successfully from your cart', SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: 'Product has been deleted successfully from your wishlist', SUCCESSFUL_LOGIN: 'Welcome to our store. Enjoy shopping!', SUCCESSFUL_REGISTRATION: 'Your registration was successful', + SUCCESSFUL_SUBSCRIBE: 'You have successfully subscribed to our newsletter', USER_EXISTS: 'User with this email already exists, please check your email', }, ru: { - ADDRESS_CHANGED: 'АдрСс Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½', - ADDRESS_DELETED: 'АдрСс Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½', + ADDRESS_ADDED: 'АдрСс ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½', + ADDRESS_CHANGED: 'АдрСс ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½', + ADDRESS_DELETED: 'АдрСс ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½', + ADDRESS_STATUS_CHANGED: 'Бтатус адрСса ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½', BAD_REQUEST: 'Π˜Π·Π²ΠΈΠ½ΠΈΡ‚Π΅, Ρ‡Ρ‚ΠΎ-Ρ‚ΠΎ пошло Π½Π΅ Ρ‚Π°ΠΊ. ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅.', COPY_TO_CLIPBOARD: 'SKU скопирован Π² Π±ΡƒΡ„Π΅Ρ€ ΠΎΠ±ΠΌΠ΅Π½Π°', GREETING: 'ЗдравствуйтС! Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ Π² наш ΠΌΠ°Π³Π°Π·ΠΈΠ½. ΠŸΡ€ΠΈΡΡ‚Π½Ρ‹Ρ… ΠΏΠΎΠΊΡƒΠΏΠΎΠΊ!', INCORRECT_PASSWORD: 'ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, Π²Π²Π΅Π΄ΠΈΡ‚Π΅ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ', + INVALID_COUPON: 'НСвСрный ΠΊΡƒΠΏΠΎΠ½', INVALID_EMAIL: 'ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ с Ρ‚Π°ΠΊΠΈΠΌ адрСсом Π½Π΅ сущСствуСт. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, сначала Π·Π°Ρ€Π΅Π³ΠΈΡΡ‚Ρ€ΠΈΡ€ΡƒΠΉΡ‚Π΅ΡΡŒ', LANGUAGE_CHANGED: 'Настройки языка ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½Ρ‹', - PASSWORD_CHANGED: 'Π’Π°Ρˆ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½', + NEED_LOGIN: 'Π’Π°ΠΌ Π½ΡƒΠΆΠ½ΠΎ Π²ΠΎΠΈΜ†Ρ‚ΠΈ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠ΅Ρ€Π΅ΠΉΡ‚ΠΈ Π½Π° эту страницу', + PASSWORD_CHANGED: 'Π’Π°Ρˆ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½', PASSWORD_NOT_CHANGED: 'Π’Π°Ρˆ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ Π½Π΅ Π±Ρ‹Π» ΠΈΠ·ΠΌΠ΅Π½Π΅Π½. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π΅Ρ‰Ρ‘ Ρ€Π°Π·', - PERSONAL_INFO_CHANGED: 'ΠŸΠ΅Ρ€ΡΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅ Π±Ρ‹Π»ΠΈ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½Ρ‹', - SUCCESSFUL_ADD_PRODUCT_TO_CART: 'Π’ΠΎΠ²Π°Ρ€ Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ Π² ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ', - SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: 'Π’ΠΎΠ²Π°Ρ€ Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ Π² ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅', + PERSONAL_INFO_CHANGED: 'ΠŸΠ΅Ρ€ΡΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½Ρ‹', + SUCCESSFUL_ADD_COUPON_TO_CART: 'ΠšΡƒΠΏΠΎΠ½ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½ ΠΊ ΠΊΠΎΡ€Π·ΠΈΠ½Π΅', + SUCCESSFUL_ADD_PRODUCT_TO_CART: 'Π’ΠΎΠ²Π°Ρ€ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ Π² ΠΊΠΎΡ€Π·ΠΈΠ½Ρƒ', + SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: 'Π’ΠΎΠ²Π°Ρ€ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ Π² ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ΅', + SUCCESSFUL_CLEAR_CART: 'ΠšΠΎΡ€Π·ΠΈΠ½Π° Π±Ρ‹Π»Π° ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΠΎΡ‡ΠΈΡ‰Π΅Π½Π°', SUCCESSFUL_COPY_TO_CLIPBOARD: 'SKU ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ скопирован Π² Π±ΡƒΡ„Π΅Ρ€ ΠΎΠ±ΠΌΠ΅Π½Π°', - SUCCESSFUL_DELETE_PRODUCT_FROM_CART: 'Π’ΠΎΠ²Π°Ρ€ Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½ ΠΈΠ· ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹', - SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: 'Π’ΠΎΠ²Π°Ρ€ Π±Ρ‹Π» ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½ ΠΈΠ· ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ', + SUCCESSFUL_DELETE_PRODUCT_FROM_CART: 'Π’ΠΎΠ²Π°Ρ€ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½ ΠΈΠ· ΠΊΠΎΡ€Π·ΠΈΠ½Ρ‹', + SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: 'Π’ΠΎΠ²Π°Ρ€ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½ ΠΈΠ· ΠΈΠ·Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ', SUCCESSFUL_LOGIN: 'Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ Π² наш ΠΌΠ°Π³Π°Π·ΠΈΠ½. ΠŸΡ€ΠΈΡΡ‚Π½Ρ‹Ρ… ΠΏΠΎΠΊΡƒΠΏΠΎΠΊ!', SUCCESSFUL_REGISTRATION: 'РСгистрация ΠΏΡ€ΠΎΡˆΠ»Π° ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ', + SUCCESSFUL_SUBSCRIBE: 'Π’Ρ‹ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ подписались Π½Π° рассылку новостСй', USER_EXISTS: 'ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ с Ρ‚Π°ΠΊΠΈΠΌ адрСсом ΡƒΠΆΠ΅ сущСствуСт, поТалуйста, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ свою ΠΏΠΎΡ‡Ρ‚Ρƒ', }, } as const; -export const SERVER_MESSAGE_KEYS = { +export const SERVER_MESSAGE_KEYS: Record = { + ADDRESS_ADDED: 'ADDRESS_ADDED', ADDRESS_CHANGED: 'ADDRESS_CHANGED', ADDRESS_DELETED: 'ADDRESS_DELETED', + ADDRESS_STATUS_CHANGED: 'ADDRESS_STATUS_CHANGED', BAD_REQUEST: 'BAD_REQUEST', COPY_TO_CLIPBOARD: 'COPY_TO_CLIPBOARD', GREETING: 'GREETING', INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', + INVALID_COUPON: 'INVALID_COUPON', INVALID_EMAIL: 'INVALID_EMAIL', LANGUAGE_CHANGED: 'LANGUAGE_CHANGED', + NEED_LOGIN: 'NEED_LOGIN', PASSWORD_CHANGED: 'PASSWORD_CHANGED', PASSWORD_NOT_CHANGED: 'PASSWORD_NOT_CHANGED', PERSONAL_INFO_CHANGED: 'PERSONAL_INFO_CHANGED', + SUCCESSFUL_ADD_COUPON_TO_CART: 'SUCCESSFUL_ADD_COUPON_TO_CART', SUCCESSFUL_ADD_PRODUCT_TO_CART: 'SUCCESSFUL_ADD_PRODUCT_TO_CART', SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: 'SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST', + SUCCESSFUL_CLEAR_CART: 'SUCCESSFUL_CLEAR_CART', SUCCESSFUL_COPY_TO_CLIPBOARD: 'SUCCESSFUL_COPY_TO_CLIPBOARD', SUCCESSFUL_DELETE_PRODUCT_FROM_CART: 'SUCCESSFUL_DELETE_PRODUCT_FROM_CART', SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: 'SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST', SUCCESSFUL_LOGIN: 'SUCCESSFUL_LOGIN', SUCCESSFUL_REGISTRATION: 'SUCCESSFUL_REGISTRATION', + SUCCESSFUL_SUBSCRIBE: 'SUCCESSFUL_SUBSCRIBE', USER_EXISTS: 'USER_EXISTS', } as const; diff --git a/src/shared/constants/pages.ts b/src/shared/constants/pages.ts index 05c2be50..5e5478ab 100644 --- a/src/shared/constants/pages.ts +++ b/src/shared/constants/pages.ts @@ -1,3 +1,36 @@ +import type { LanguageChoiceType } from './common'; + +export const PAGE_TITLE: Record> = { + en: { + 404: '404', + about: 'About us', + address: 'Address', + blog: 'Blog', + cart: 'Cart', + catalog: 'Catalog', + login: 'Login', + main: 'Main', + product: 'Product', + profile: 'Profile', + register: 'Register', + wishlist: 'Wishlist', + }, + ru: { + 404: '404', + about: 'О нас', + address: 'АдрСс', + blog: 'Π‘Π»ΠΎΠ³', + cart: 'ΠšΠΎΡ€Π·ΠΈΠ½Π°', + catalog: 'ΠšΠ°Ρ‚Π°Π»ΠΎΠ³', + login: 'Π’Ρ…ΠΎΠ΄', + main: 'Главная', + product: 'Π’ΠΎΠ²Π°Ρ€', + profile: 'ΠŸΡ€ΠΎΡ„ΠΈΠ»ΡŒ', + register: 'РСгистрация', + wishlist: 'Π˜Π·Π±Ρ€Π°Π½Π½ΠΎΠ΅', + }, +} as const; + export const PAGE_LINK_TEXT = { en: { ABOUT: 'About us', @@ -47,16 +80,16 @@ export const BLOG_DESCRIPTION = { en: { LIST_DESCRIPTION: 'Empowering all people to be plant peopleβ€”a collection of articles from ours team of plant experts across a variety of plant care topics to inspire confidence in the next generation of plant parents. Welcome to GREENSHOP', - LIST_TITTLE: 'Your Journey to Plant Parenthood', + LIST_TITLE: 'Your Journey to Plant Parenthood', WIDGET_DESCRIPTIONS: 'This is where we share our experiences with all green friend lovers', - WIDGET_TITTLE: 'Our Blog Posts', + WIDGET_TITLE: 'Our Blog Posts', }, ru: { LIST_DESCRIPTION: 'Π”Π°Ρ‚ΡŒ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ всСм людям ΡΡ‚Π°Ρ‚ΡŒ родитСлями растСний - сборник статСй ΠΎΡ‚ нашСй ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ экспСртов ΠΏΠΎ ΡƒΡ…ΠΎΠ΄Ρƒ Π·Π° растСниями Π½Π° самыС Ρ€Π°Π·Π½Ρ‹Π΅ Ρ‚Π΅ΠΌΡ‹, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π²ΡΠ΅Π»ΠΈΡ‚ΡŒ ΡƒΠ²Π΅Ρ€Π΅Π½Π½ΠΎΡΡ‚ΡŒ Π² ΡΠ»Π΅Π΄ΡƒΡŽΡ‰Π΅Π΅ ΠΏΠΎΠΊΠΎΠ»Π΅Π½ΠΈΠ΅ Ρ€ΠΎΠ΄ΠΈΡ‚Π΅Π»Π΅ΠΉ растСний. Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ Π½Π° сайт GREENSHOP', - LIST_TITTLE: 'ΠŸΡ€Π΅Π²Ρ€Π°Ρ‰Π΅Π½ΠΈΠ΅ Π² Π·Π°Π±ΠΎΡ‚Π»ΠΈΠ²ΠΎΠ³ΠΎ родитСля растСний', + LIST_TITLE: 'ΠŸΡ€Π΅Π²Ρ€Π°Ρ‰Π΅Π½ΠΈΠ΅ Π² Π·Π°Π±ΠΎΡ‚Π»ΠΈΠ²ΠΎΠ³ΠΎ родитСля растСний', WIDGET_DESCRIPTIONS: 'Π—Π΄Π΅ΡΡŒ ΠΌΡ‹ дСлимся своим ΠΎΠΏΡ‹Ρ‚ΠΎΠΌ со всСми Π»ΡŽΠ±ΠΈΡ‚Π΅Π»ΡΠΌΠΈ Π·Π΅Π»Π΅Π½Ρ‹Ρ… Π΄Ρ€ΡƒΠ·Π΅ΠΉ', - WIDGET_TITTLE: 'Наши ΡΡ‚Π°Ρ‚ΡŒΠΈ Π² Π‘Π»ΠΎΠ³Π΅', + WIDGET_TITLE: 'Наши ΡΡ‚Π°Ρ‚ΡŒΠΈ Π² Π‘Π»ΠΎΠ³Π΅', }, } as const; @@ -87,17 +120,19 @@ export const PAGE_ANSWER_KEYS = { } as const; export const PAGE_ID = { - ABOUT_US_PAGE: 'about/', - BLOG: 'blog/', - CART_PAGE: 'cart/', - CATALOG_PAGE: 'catalog/', - DEFAULT_PAGE: '/', - LOGIN_PAGE: 'login/', - MAIN_PAGE: 'main/', - NOT_FOUND_PAGE: '404/', - PRODUCT_PAGE: 'product/', - REGISTRATION_PAGE: 'register/', - USER_PROFILE_PAGE: 'profile/', + ABOUT_US_PAGE: 'about', + ADDRESS: 'address', + BLOG: 'blog', + CART_PAGE: 'cart', + CATALOG_PAGE: 'catalog', + DEFAULT_PAGE: '', + LOGIN_PAGE: 'login', + MAIN_PAGE: 'main', + NOT_FOUND_PAGE: '404', + PRODUCT_PAGE: 'product', + REGISTRATION_PAGE: 'register', + USER_PROFILE_PAGE: 'profile', + WISHLIST_PAGE: 'wishlist', } as const; export const USER_INFO_TEXT = { diff --git a/src/shared/constants/product.ts b/src/shared/constants/product.ts index 2993c391..ea8f9f37 100644 --- a/src/shared/constants/product.ts +++ b/src/shared/constants/product.ts @@ -21,19 +21,36 @@ export const SEARCH_PARAMS_FIELD = { MAX_PRICE: 'price-max', META: 'meta', MIN_PRICE: 'price-min', + PAGE: 'page', + SEARCH: 'search', SIZE: 'size', SUBCATEGORY: 'subcategory', } as const; export const PRODUCT_INFO_TEXT = { en: { + CATEGORY: 'Categories: ', + DIFFICULTY: 'Difficulty: ', + DISCOUNT_LABEL: 'OFF', FULL_DESCRIPTION: 'Full description:', SHORT_DESCRIPTION: 'Short description:', SIZE: 'Size:', }, ru: { + CATEGORY: 'ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ: ', + DIFFICULTY: 'Π‘Π»ΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ: ', + DISCOUNT_LABEL: 'Π‘ΠΊΠΈΠ΄ΠΊΠ°', FULL_DESCRIPTION: 'ПолноС описаниС:', SHORT_DESCRIPTION: 'ΠšΡ€Π°Ρ‚ΠΊΠΎΠ΅ описаниС:', SIZE: 'Π Π°Π·ΠΌΠ΅Ρ€:', }, } as const; + +export const PRODUCT_INFO_TEXT_KEYS = { + CATEGORY: 'CATEGORY', + DIFFICULTY: 'DIFFICULTY', + DISCOUNT_LABEL: 'DISCOUNT_LABEL', + FULL_DESCRIPTION: 'FULL_DESCRIPTION', + SHORT_DESCRIPTION: 'SHORT_DESCRIPTION', + SIZE: 'SIZE', +} as const; diff --git a/src/shared/constants/svg.ts b/src/shared/constants/svg.ts index 19992a72..b7c75ea9 100644 --- a/src/shared/constants/svg.ts +++ b/src/shared/constants/svg.ts @@ -1,16 +1,21 @@ const SVG_DETAILS = { + ARROW_UP: 'arrowUp', + BILL: 'bill', BIN: 'bin', CART: 'cart', CLOSE_EYE: 'closeEye', COPY: 'copy', DARK: 'dark', DELETE: 'delete', + DELIVERY: 'delivery', EDIT: 'edit', FILL_HEART: 'heartFill', GO_DETAILS: 'arrow', KEY: 'key', + LEAVES: 'leaves', LIGHT: 'light', LOGO: 'logo', + NOT_FOUND: 'notFound', OPEN_EYE: 'openEye', PROFILE: 'userCircle', SVG_URL: 'http://www.w3.org/2000/svg', @@ -18,6 +23,8 @@ const SVG_DETAILS = { en: 'en', ru: 'ru', }, + TRUCK: 'truck', + WALLET: 'wallet', } as const; export default SVG_DETAILS; diff --git a/src/shared/constants/tooltip.ts b/src/shared/constants/tooltip.ts index afdf16ab..4e6ef6f6 100644 --- a/src/shared/constants/tooltip.ts +++ b/src/shared/constants/tooltip.ts @@ -1,20 +1,45 @@ -const TOOLTIP_TEXT = { +const TOOLTIP_TEXT: Record> = { en: { - DELETE_ADDRESS: 'Delete address', + ADD_BILLING_ADDRESS: 'Add new billing address', + ADD_SHIPPING_ADDRESS: 'Add new shipping address', + DELETE_ADDRESS: 'Delete address completely', EDIT_ADDRESS: 'Edit address', EDIT_PASSWORD: 'Edit password', + EDIT_SHIPPING_ADDRESS: 'Switch shipping address status', + SCROLL_TO_TOP: 'Scroll to top', + SWITCH_ADDRESS_STATUS: 'Change address status', + SWITCH_BILLING_ADDRESS: 'Switch billing address status', + SWITCH_DEFAULT_BILLING_ADDRESS: 'Switch default billing address status', + SWITCH_DEFAULT_SHIPPING_ADDRESS: 'Switch default shipping address status', + SWITCH_SHIPPING_ADDRESS: 'Switch shipping address status', }, ru: { - DELETE_ADDRESS: 'Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс', + ADD_BILLING_ADDRESS: 'Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ адрСс выставлСния счСтов', + ADD_SHIPPING_ADDRESS: 'Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ адрСс доставки', + DELETE_ADDRESS: 'Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ', EDIT_ADDRESS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ адрСс', EDIT_PASSWORD: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ', + SCROLL_TO_TOP: 'НавСрх', + SWITCH_ADDRESS_STATUS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ статус адрСса', + SWITCH_BILLING_ADDRESS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ статус адрСса выставлСния счСтов', + SWITCH_DEFAULT_BILLING_ADDRESS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ статус адрСса выставлСния счСтов ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ', + SWITCH_DEFAULT_SHIPPING_ADDRESS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ статус адрСса доставки ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ', + SWITCH_SHIPPING_ADDRESS: 'Π˜Π·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ статус адрСса доставки', }, } as const; export const TOOLTIP_TEXT_KEYS = { + ADD_BILLING_ADDRESS: 'ADD_BILLING_ADDRESS', + ADD_SHIPPING_ADDRESS: 'ADD_SHIPPING_ADDRESS', DELETE_ADDRESS: 'DELETE_ADDRESS', EDIT_ADDRESS: 'EDIT_ADDRESS', EDIT_PASSWORD: 'EDIT_PASSWORD', + SCROLL_TO_TOP: 'SCROLL_TO_TOP', + SWITCH_ADDRESS_STATUS: 'SWITCH_ADDRESS_STATUS', + SWITCH_BILLING_ADDRESS: 'SWITCH_BILLING_ADDRESS', + SWITCH_DEFAULT_BILLING_ADDRESS: 'SWITCH_DEFAULT_BILLING_ADDRESS', + SWITCH_DEFAULT_SHIPPING_ADDRESS: 'SWITCH_DEFAULT_SHIPPING_ADDRESS', + SWITCH_SHIPPING_ADDRESS: 'SWITCH_SHIPPING_ADDRESS', }; export type TooltipTextKeysType = (typeof TOOLTIP_TEXT_KEYS)[keyof typeof TOOLTIP_TEXT_KEYS]; diff --git a/src/shared/img/png/notFound.png b/src/shared/img/png/notFound.png index 4b1a07bf..ed861df6 100644 Binary files a/src/shared/img/png/notFound.png and b/src/shared/img/png/notFound.png differ diff --git a/src/shared/img/svg/arrowUp.svg b/src/shared/img/svg/arrowUp.svg index 0039f0fe..122cd44b 100644 --- a/src/shared/img/svg/arrowUp.svg +++ b/src/shared/img/svg/arrowUp.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/shared/img/svg/bill.svg b/src/shared/img/svg/bill.svg new file mode 100644 index 00000000..1b08f6b7 --- /dev/null +++ b/src/shared/img/svg/bill.svg @@ -0,0 +1 @@ + diff --git a/src/shared/img/svg/delete.svg b/src/shared/img/svg/delete.svg index a0ab1093..3af4de09 100644 --- a/src/shared/img/svg/delete.svg +++ b/src/shared/img/svg/delete.svg @@ -3,12 +3,12 @@ - + + stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /> - \ No newline at end of file + diff --git a/src/shared/img/svg/delivery.svg b/src/shared/img/svg/delivery.svg new file mode 100644 index 00000000..060676e7 --- /dev/null +++ b/src/shared/img/svg/delivery.svg @@ -0,0 +1 @@ + diff --git a/src/shared/img/svg/edit.svg b/src/shared/img/svg/edit.svg index d2886684..897e0654 100644 --- a/src/shared/img/svg/edit.svg +++ b/src/shared/img/svg/edit.svg @@ -1,5 +1 @@ - - - - - + diff --git a/src/shared/img/svg/leaves.svg b/src/shared/img/svg/leaves.svg index 44a0d821..50b5f595 100644 --- a/src/shared/img/svg/leaves.svg +++ b/src/shared/img/svg/leaves.svg @@ -1,14 +1,2 @@ - - - - - - - - - - - - - - + +leaf diff --git a/src/shared/img/svg/notFound.svg b/src/shared/img/svg/notFound.svg new file mode 100644 index 00000000..e7f63493 --- /dev/null +++ b/src/shared/img/svg/notFound.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/shared/img/svg/truck.svg b/src/shared/img/svg/truck.svg new file mode 100644 index 00000000..4872b693 --- /dev/null +++ b/src/shared/img/svg/truck.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/shared/img/svg/wallet.svg b/src/shared/img/svg/wallet.svg new file mode 100644 index 00000000..23a30d27 --- /dev/null +++ b/src/shared/img/svg/wallet.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/shared/types/address.ts b/src/shared/types/address.ts index 9e4c587f..dbb0d38d 100644 --- a/src/shared/types/address.ts +++ b/src/shared/types/address.ts @@ -1,12 +1,13 @@ export const ADDRESS_TYPE = { BILLING: 'billing', + GENERAL: 'general', SHIPPING: 'shipping', } as const; -export const SINGLE_ADDRESS = 'asBilling'; - export type AddressType = (typeof ADDRESS_TYPE)[keyof typeof ADDRESS_TYPE]; +export const SINGLE_ADDRESS = 'asBilling'; + export interface AddressOptions { setAsBilling?: boolean; setDefault?: boolean; diff --git a/src/shared/types/cart.ts b/src/shared/types/cart.ts index e46e8c2f..ca9b7f28 100644 --- a/src/shared/types/cart.ts +++ b/src/shared/types/cart.ts @@ -1,8 +1,10 @@ import type { SizeType, localization } from './product.ts'; export interface Cart { + discounts: number; id: string; products: CartProduct[]; + total: number; version: number; } @@ -28,3 +30,9 @@ export interface EditCartItem { lineId: string; quantity: number; } + +export enum CartActive { + DELETE = 'delete', + MINUS = 'minus', + PLUS = 'plus', +} diff --git a/src/shared/types/common.ts b/src/shared/types/common.ts new file mode 100644 index 00000000..60ad1b62 --- /dev/null +++ b/src/shared/types/common.ts @@ -0,0 +1,4 @@ +export type languageVariants = { + en: string; + ru: string; +}; diff --git a/src/shared/types/product.ts b/src/shared/types/product.ts index f218ef01..1443ab42 100644 --- a/src/shared/types/product.ts +++ b/src/shared/types/product.ts @@ -18,7 +18,14 @@ export const SIZE = { XL: 'XL', } as const; +export const LEVEL = { + 1: '1', + 2: '2', + 3: '3', +} as const; + export type SizeType = (typeof SIZE)[keyof typeof SIZE]; +export type LevelType = (typeof LEVEL)[keyof typeof LEVEL]; export interface Variant { discount: number; @@ -34,6 +41,7 @@ export interface Product { id: string; images: string[]; key: string; + level: LevelType | null; name: localization[]; slug: localization[]; variant: Variant[]; diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts index bf74f327..233e3e25 100644 --- a/src/shared/types/productFilters.ts +++ b/src/shared/types/productFilters.ts @@ -12,6 +12,7 @@ interface ProductFiltersParams { priceRange: PriceRange | null; products: Product[] | null; sizes: SizeProductCount[] | null; + totalProductCount: number; } export interface SelectedFilters { diff --git a/src/shared/utils/buildPathname.ts b/src/shared/utils/buildPathname.ts index 6a7cbea4..4f41af0a 100644 --- a/src/shared/utils/buildPathname.ts +++ b/src/shared/utils/buildPathname.ts @@ -13,5 +13,5 @@ export const buildPathName = ( .map(([key, values]) => `${key}=${values.filter(Boolean).join('_')}`) .join('&'); - return `${endpoint ? `${endpoint}` : ''}${id ? `${id}/` : ''}${queryString ? `${encodeURIComponent(`?${queryString}`)}` : ''}`; + return `${endpoint ? `${endpoint}` : ''}${id ? `/${id}` : ''}${queryString ? `${`?${queryString}`}` : ''}`; }; diff --git a/src/shared/utils/determineNewAddress.ts b/src/shared/utils/determineNewAddress.ts new file mode 100644 index 00000000..8f686b30 --- /dev/null +++ b/src/shared/utils/determineNewAddress.ts @@ -0,0 +1,127 @@ +/* eslint-disable max-lines-per-function */ +import type UserAddressModel from '@/entities/UserAddress/model/UserAddressModel'; + +import type { Address, User } from '../types/user'; + +import { ADDRESS_TYPE, type AddressTypeType } from '../constants/forms.ts'; + +const determineNewAddress = ( + addressesContainsID: (array: Address[]) => boolean, + defaultContainsID: (defaultAddress: Address | null) => boolean, + user: User, + createAddress: (activeTypes: AddressTypeType[], inactiveTypes?: AddressTypeType[]) => UserAddressModel, +): UserAddressModel => { + const { billingAddress, defaultBillingAddressId, defaultShippingAddressId, shippingAddress } = user; + switch (true) { + case defaultContainsID(defaultBillingAddressId) && + defaultContainsID(defaultShippingAddressId) && + addressesContainsID(billingAddress) && + addressesContainsID(shippingAddress): // billing, shipping, defaultBilling, defaultShipping + return createAddress([ + ADDRESS_TYPE.SHIPPING, + ADDRESS_TYPE.BILLING, + ADDRESS_TYPE.DEFAULT_BILLING, + ADDRESS_TYPE.DEFAULT_SHIPPING, + ]); + + case addressesContainsID(billingAddress) && + defaultContainsID(defaultBillingAddressId) && + defaultContainsID(defaultShippingAddressId): // billing, defaultBilling, defaultShipping + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.SHIPPING], + ); + + case addressesContainsID(shippingAddress) && + defaultContainsID(defaultBillingAddressId) && + defaultContainsID(defaultShippingAddressId): // shipping, defaultBilling, defaultShipping + return createAddress( + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.BILLING], + ); + + case addressesContainsID(shippingAddress) && + addressesContainsID(billingAddress) && + defaultContainsID(defaultBillingAddressId): // billing, shipping, defaultBilling + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_BILLING], + [ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + + case addressesContainsID(billingAddress) && + defaultContainsID(defaultShippingAddressId) && + addressesContainsID(shippingAddress): // billing, shipping, defaultShipping + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.DEFAULT_BILLING], + ); + + case defaultContainsID(defaultBillingAddressId) && defaultContainsID(defaultShippingAddressId): // defaultBilling, defaultShipping + return createAddress( + [ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.SHIPPING], + ); + + case addressesContainsID(billingAddress) && addressesContainsID(shippingAddress): // billing, shipping + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.SHIPPING], + [ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + + case addressesContainsID(billingAddress) && defaultContainsID(defaultBillingAddressId): // billing, defaultBilling + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + + case addressesContainsID(billingAddress) && defaultContainsID(defaultShippingAddressId): // billing, defaultShipping + return createAddress( + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + + case addressesContainsID(shippingAddress) && defaultContainsID(defaultShippingAddressId): // shipping, defaultShipping + return createAddress( + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + ); + + case addressesContainsID(shippingAddress) && defaultContainsID(defaultBillingAddressId): // shipping, defaultBilling + return createAddress( + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + ); + + case addressesContainsID(billingAddress): // billing + return createAddress( + [ADDRESS_TYPE.BILLING], + [ADDRESS_TYPE.DEFAULT_SHIPPING, ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_BILLING], + ); + + case addressesContainsID(shippingAddress): // shipping + return createAddress( + [ADDRESS_TYPE.SHIPPING], + [ADDRESS_TYPE.DEFAULT_SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + ); + + case defaultContainsID(defaultBillingAddressId): // defaultBilling + return createAddress( + [ADDRESS_TYPE.DEFAULT_BILLING], + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + + case defaultContainsID(defaultShippingAddressId): // defaultShipping + return createAddress( + [ADDRESS_TYPE.DEFAULT_SHIPPING], + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], + ); + + default: // None + return createAddress( + [], + [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], + ); + } +}; + +export default determineNewAddress; diff --git a/src/shared/utils/hasValue.ts b/src/shared/utils/hasValue.ts new file mode 100644 index 00000000..176d0203 --- /dev/null +++ b/src/shared/utils/hasValue.ts @@ -0,0 +1,5 @@ +export const arrayContainsObjectWithValue = (arr: T[], key: K, value: T[K]): boolean => + arr.some((obj) => obj[key] === value); + +export const objectHasPropertyValue = (obj: T | null, key: K, value: T[K]): boolean => + obj !== null && obj !== undefined && obj[key] === value; diff --git a/src/shared/utils/isKeyOf.ts b/src/shared/utils/isKeyOf.ts index c09a3278..44fbc5e8 100644 --- a/src/shared/utils/isKeyOf.ts +++ b/src/shared/utils/isKeyOf.ts @@ -1,11 +1,12 @@ import type MetaFilters from '../types/productFilters.ts'; import type { UserCredentials } from '../types/user'; -import { TEXT_KEYS, type TextKeysType } from '../constants/sorting.ts'; +import getStore from '../Store/Store.ts'; +import { PAGE_TITLE } from '../constants/pages.ts'; export const isKeyOfUserData = (context: UserCredentials, key: string): key is keyof UserCredentials => Object.hasOwnProperty.call(context, key); export const isKeyOfMetaFilters = (context: MetaFilters, key: string): key is keyof MetaFilters => key in context; -export const isKeyOfSortField = (key: string): key is TextKeysType => key in TEXT_KEYS; +export const keyExistsInPageTitle = (key: string): boolean => key in PAGE_TITLE[getStore().getState().currentLanguage]; diff --git a/src/shared/utils/messageTemplates.ts b/src/shared/utils/messageTemplates.ts index 426c608f..dd3095d5 100644 --- a/src/shared/utils/messageTemplates.ts +++ b/src/shared/utils/messageTemplates.ts @@ -79,13 +79,13 @@ export const minLengthMessage = (minLength: number): string => ? minLengthMessageEn(minLength) : minLengthMessageRu(minLength); -export function addressMessage(type: string, text: string): string { +export function addressMessage(text: string, type?: string): string { switch (type) { case ADDRESS_TYPE.BILLING: return billingAddressMessage(text); case ADDRESS_TYPE.SHIPPING: return shippingAddressMessage(text); default: - return ''; + return text; } } diff --git a/src/shared/utils/setPageTitle.ts b/src/shared/utils/setPageTitle.ts new file mode 100644 index 00000000..705f7705 --- /dev/null +++ b/src/shared/utils/setPageTitle.ts @@ -0,0 +1,34 @@ +import getStore from '../Store/Store.ts'; +import { PAGE_ID, PAGE_TITLE } from '../constants/pages.ts'; +import { keyExistsInPageTitle } from './isKeyOf.ts'; + +const appTitle = (projectTitle: string, currentPageTitle: string): string => { + const { currentLanguage } = getStore().getState(); + if (keyExistsInPageTitle(currentPageTitle)) { + return `${projectTitle} | ${PAGE_TITLE[currentLanguage][currentPageTitle]}`; + } + return `${projectTitle} | ${currentPageTitle}`; +}; + +const getPageTitle = (currentPage: string, hasRoute: boolean): string => { + const { VITE_APP_PATH_SEGMENTS_TO_KEEP: PATH_SEGMENTS_TO_KEEP, VITE_APP_PROJECT_TITLE: PROJECT_TITLE } = import.meta + .env; + + let currentPageTitle: string; + + if (hasRoute) { + currentPageTitle = currentPage === PAGE_ID.DEFAULT_PAGE ? PAGE_ID.MAIN_PAGE : currentPage; + } else { + currentPageTitle = PAGE_ID.NOT_FOUND_PAGE; + } + + const trimmedTitle = currentPageTitle.slice(PATH_SEGMENTS_TO_KEEP); + + return appTitle(PROJECT_TITLE, trimmedTitle); +}; + +const setPageTitle = (currentPage: string, hasRoute: boolean): void => { + document.title = getPageTitle(currentPage, hasRoute); +}; + +export default setPageTitle; diff --git a/src/shared/utils/size.ts b/src/shared/utils/size.ts index aaa810d0..2d36465b 100644 --- a/src/shared/utils/size.ts +++ b/src/shared/utils/size.ts @@ -1,8 +1,8 @@ -import type { SizeType } from '../types/product.ts'; +import type { LevelType, SizeType } from '../types/product.ts'; -import { SIZE } from '../types/product.ts'; +import { LEVEL, SIZE } from '../types/product.ts'; -export default function getSize(sizeString: string): SizeType | null { +export function getSize(sizeString: string): SizeType | null { const sizeValues = Object.values(SIZE); const foundValue = sizeValues.find((value) => value.toLowerCase() === sizeString.toLowerCase()); @@ -13,3 +13,15 @@ export default function getSize(sizeString: string): SizeType | null { return null; } + +export function getLevel(levelString: string): LevelType | null { + const levelValues = Object.values(LEVEL); + + const foundValue = levelValues.find((value) => value.toLowerCase() === levelString.toLowerCase()); + + if (foundValue) { + return foundValue; + } + + return null; +} diff --git a/src/shared/utils/userMessage.ts b/src/shared/utils/userMessage.ts index d3fbd7ef..6e698487 100644 --- a/src/shared/utils/userMessage.ts +++ b/src/shared/utils/userMessage.ts @@ -1,7 +1,16 @@ import serverMessageModel from '../ServerMessage/model/ServerMessageModel.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '../constants/messages.ts'; -const showErrorMessage = (): boolean => - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR); +const showErrorMessage = (param: unknown): boolean => { + if (param instanceof Error) { + return serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR, param.message); + } + + if (typeof param === 'string' && param in SERVER_MESSAGE_KEYS) { + return serverMessageModel.showServerMessage(param, MESSAGE_STATUS.ERROR); + } + + return serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR); +}; export default showErrorMessage; diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index e4496a0b..c5008bc8 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -1,7 +1,11 @@ import type { OptionsRequest, SortOptions } from '@/shared/API/types/type.ts'; import type ProductFiltersParams from '@/shared/types/productFilters.ts'; +import type { SelectedFilters } from '@/shared/types/productFilters.ts'; +import type { SelectedSorting } from '@/shared/types/productSorting.ts'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; import ProductCardModel from '@/entities/ProductCard/model/ProductCardModel.ts'; +import PaginationModel from '@/features/Pagination/model/PaginationModel.ts'; import ProductFiltersModel from '@/features/ProductFilters/model/ProductFiltersModel.ts'; import ProductSearchModel from '@/features/ProductSearch/model/ProductSearchModel.ts'; import ProductSortsModel from '@/features/ProductSorts/model/ProductSortsModel.ts'; @@ -13,10 +17,9 @@ import { FilterFields, SortDirection, SortFields } from '@/shared/API/types/type import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { setSelectedFilters, setSelectedSorting } from '@/shared/Store/actions.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import { META_FILTERS } from '@/shared/constants/filters.ts'; -import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; +import { DEFAULT_PAGE, PRODUCT_LIMIT, SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import { SORTING_ID } from '@/shared/constants/sorting.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; @@ -24,6 +27,10 @@ import showErrorMessage from '@/shared/utils/userMessage.ts'; import CatalogView from '../view/CatalogView.ts'; class CatalogModel { + private currentSize: null | string = null; + + private pagination: PaginationModel | null = null; + private productFilters: ProductFiltersModel | null = null; private productSearch: ProductSearchModel | null = null; @@ -32,8 +39,8 @@ class CatalogModel { private view = new CatalogView(); - constructor(searchParams: string) { - this.init(searchParams).catch(showErrorMessage); + constructor() { + this.init(); } private addCurrentMetaFilter(filter: FilterProduct, metaFilter: string): FilterProduct { @@ -49,72 +56,137 @@ class CatalogModel { } } - private decodeSearchParams(searchParams: string, productData: ProductFiltersParams): void { - const params = new URLSearchParams(searchParams); - const searchCategory = params.getAll(SEARCH_PARAMS_FIELD.CATEGORY); - searchCategory.push(...params.getAll(SEARCH_PARAMS_FIELD.SUBCATEGORY)); - const categories = new Set(searchCategory); - const searchMetaFilter = params.get(SEARCH_PARAMS_FIELD.META) ?? META_FILTERS.en.ALL_PRODUCTS; - const searchSize = params.get(SEARCH_PARAMS_FIELD.SIZE) ?? null; - const maxPrice = parseFloat(params.get(SEARCH_PARAMS_FIELD.MAX_PRICE) ?? '0'); - const minPrice = parseFloat(params.get(SEARCH_PARAMS_FIELD.MIN_PRICE) ?? '0'); - const searchPrice = { - max: maxPrice !== 0 ? maxPrice : productData.priceRange?.max || 0, - min: minPrice !== 0 ? minPrice : productData.priceRange?.min || 0, + private decodeSearchParams(): { + page: string; + searchValue: null | string; + selectedFilters: SelectedFilters; + selectedSorting?: SelectedSorting; + } { + const searchParams = RouterModel.getSearchParams(); + const searchCategory = searchParams.getAll(SEARCH_PARAMS_FIELD.CATEGORY); + searchCategory.push(...searchParams.getAll(SEARCH_PARAMS_FIELD.SUBCATEGORY)); + const category = new Set(searchCategory); + const metaFilter = searchParams.get(SEARCH_PARAMS_FIELD.META) ?? META_FILTERS.en.ALL_PRODUCTS; + const size = searchParams.get(SEARCH_PARAMS_FIELD.SIZE) ?? null; + const price = { + max: parseFloat(searchParams.get(SEARCH_PARAMS_FIELD.MAX_PRICE) ?? '0'), + min: parseFloat(searchParams.get(SEARCH_PARAMS_FIELD.MIN_PRICE) ?? '0'), }; - const searchField = params.get(SEARCH_PARAMS_FIELD.FIELD); - const searchDirection = params.get(SEARCH_PARAMS_FIELD.DIRECTION); + const field = searchParams.get(SEARCH_PARAMS_FIELD.FIELD); + const direction = searchParams.get(SEARCH_PARAMS_FIELD.DIRECTION); + const searchValue = searchParams.get(SEARCH_PARAMS_FIELD.SEARCH) ?? null; + const page = searchParams.get(SEARCH_PARAMS_FIELD.PAGE) ?? DEFAULT_PAGE.toString(); + const selectedFilters = { + category, + metaFilter, + price, + size, + }; - getStore().dispatch( - setSelectedFilters({ category: categories, metaFilter: searchMetaFilter, price: searchPrice, size: searchSize }), - ); + return { + page, + searchValue, + selectedFilters, + ...(field && direction && { selectedSorting: { direction, field } }), + }; + } - if (searchField && searchDirection) { - getStore().dispatch(setSelectedSorting({ direction: searchDirection, field: searchField })); + private async drawProducts(): Promise { + const productList = this.view.getItemsList(); + productList.innerHTML = ''; + const options = this.getOptions(); + const productsInfo = await this.getProductsInfo(options); + this.pagination?.getHTML().remove(); + if (productsInfo?.products?.length) { + const shoppingList = await getShoppingListModel().getShoppingList(); + const cart = await getCartModel().getCart(); + productsInfo.products.forEach((productData) => { + const product = new ProductCardModel(productData, this.currentSize, shoppingList, cart); + productList.append(product.getHTML()); + }); + this.view.switchEmptyList(!productsInfo?.products?.length); + this.pagination = new PaginationModel( + { productTotalCount: productsInfo?.totalProductCount, productsPerPageCount: PRODUCT_LIMIT }, + this.setCurrentPage.bind(this), + ); + this.pagination.getView().setSelectedButton(options.page ?? DEFAULT_PAGE); + this.view.getRightTopWrapper().append(this.pagination.getHTML()); } + this.productFilters?.getView().updateParams(productsInfo); + this.view.switchEmptyList(!productsInfo?.products?.length); } private getOptions(): OptionsRequest { - const { category, metaFilter, price, size } = getStore().getState().selectedFilters || {}; - const { currentLanguage, searchValue } = getStore().getState(); + let result = {}; + + const { page, searchValue, selectedFilters, selectedSorting } = this.decodeSearchParams(); + this.productFilters?.getView().setInitialActiveFilters({ + categoryLinks: Array.from(selectedFilters.category), + metaLinks: selectedFilters.metaFilter ? [selectedFilters.metaFilter] : [], + sizeLinks: selectedFilters.size ? [selectedFilters.size] : [], + }); + const { currentLanguage } = getStore().getState(); const filter = new FilterProduct(); - category?.forEach((categoryID) => filter.addFilter(FilterFields.CATEGORY, categoryID)); - if (price && price.max > price.min) { - filter.addFilter(FilterFields.PRICE, price); + selectedFilters.category.forEach((categoryID) => filter.addFilter(FilterFields.CATEGORY, categoryID)); + + if (selectedFilters.price && selectedFilters.price.max > selectedFilters.price.min) { + filter.addFilter(FilterFields.PRICE, selectedFilters.price); } - if (size) { - filter.addFilter(FilterFields.SIZE, size); + if (selectedFilters.size) { + this.currentSize = selectedFilters.size; + filter.addFilter(FilterFields.SIZE, selectedFilters.size); } - this.addCurrentMetaFilter(filter, metaFilter ?? META_FILTERS.en.ALL_PRODUCTS); - const currentSort = this.getSelectedSorting(); + this.addCurrentMetaFilter(filter, selectedFilters.metaFilter ?? META_FILTERS.en.ALL_PRODUCTS); + + const currentSort = this.getSelectedSorting(selectedSorting ?? null); if (currentSort) { - return { filter: filter.getFilter(), search: { locale: currentLanguage, value: searchValue }, sort: currentSort }; + result = { + filter, + page: Number(page), + search: { locale: currentLanguage, value: searchValue }, + sort: currentSort ?? null, + }; + } else { + result = { + filter, + page: Number(page), + search: { locale: currentLanguage, value: searchValue }, + }; } - return { filter: filter.getFilter() }; + return result; } - private async getProductItems(options: OptionsRequest): Promise { + private async getProductsInfo(options: OptionsRequest): Promise { const productList = this.view.getItemsList(); const loader = new LoaderModel(LOADER_SIZE.EXTRA_LARGE); loader.setAbsolutePosition(); productList.append(loader.getHTML()); try { - const { categoryCount, priceRange, products, sizeCount } = await getProductModel().getProducts(options); - return { categoriesProductCount: categoryCount, priceRange, products, sizes: sizeCount }; - } catch { - showErrorMessage(); + const { categoryCount, priceRange, products, sizeCount, total } = await getProductModel().getProducts(options); + return { + categoriesProductCount: categoryCount, + priceRange, + products, + sizes: sizeCount, + totalProductCount: total, + }; + } catch (error) { + showErrorMessage(error); } finally { loader.getHTML().remove(); } return null; } - private getSelectedSorting(): SortOptions | null { - const { direction, field } = getStore().getState().selectedSorting || {}; + private getSelectedSorting(selectedSorting: SelectedSorting | null): SortOptions | null { + if (!selectedSorting) { + return null; + } + const { direction, field } = selectedSorting; const currentDirection = direction === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC; const currentField = field === SortFields.NAME ? SortFields.NAME : SortFields.PRICE; @@ -127,47 +199,34 @@ class CatalogModel { return { direction: currentDirection, field: currentField, locale: getStore().getState().currentLanguage }; } - private async init(searchParams: string): Promise { - const categories = await getProductModel().getCategories(); - if (categories) { - const productData = await this.getProductItems({}); - if (productData && productData?.products?.length) { - EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.REDRAW_PRODUCTS, this.redrawProductList.bind(this)); - this.decodeSearchParams(searchParams, productData); - this.initSettingComponents(productData); - this.redrawProductList().catch(showErrorMessage); - } - this.view.switchEmptyList(!productData?.products?.length); - this.productFilters?.updateParams(productData); - } + private init(): void { + EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.REDRAW_PRODUCTS, this.drawProducts.bind(this)); + this.getProductsInfo({}) + .then((productsInfo) => { + this.initSettingComponents(productsInfo); + this.drawProducts().catch(showErrorMessage); + }) + .catch(showErrorMessage); } private initSettingComponents(data: ProductFiltersParams | null): void { - this.productFilters = new ProductFiltersModel(data); - this.productSorting = new ProductSortsModel(); - this.productSearch = new ProductSearchModel(); - this.view.getLeftWrapper().append(this.productFilters.getDefaultFilters()); + this.productFilters = new ProductFiltersModel(data, this.drawProducts.bind(this)); + this.productSorting = new ProductSortsModel(this.drawProducts.bind(this)); + this.productSearch = new ProductSearchModel(this.drawProducts.bind(this)); + + this.view.getLeftWrapper().append(this.productFilters.getView().getDefaultFilters()); this.view .getRightTopWrapper() - .append(this.productFilters.getMetaFilters(), this.productSorting.getHTML(), this.productSearch.getHTML()); + .append( + this.productFilters.getView().getMetaFilters(), + this.productSorting.getHTML(), + this.productSearch.getHTML(), + ); } - private async redrawProductList(): Promise { - const currentSize = getStore().getState().selectedFilters?.size ?? null; - const productList = this.view.getItemsList(); - productList.innerHTML = ''; - const productItems = await this.getProductItems(this.getOptions()); - if (productItems?.products?.length) { - const shoppingList = await getShoppingListModel().getShoppingList(); - const cart = await getCartModel().getCart(); - productList.innerHTML = ''; - productItems.products.forEach((productData) => { - const product = new ProductCardModel(productData, currentSize, shoppingList, cart); - productList.append(product.getHTML()); - }); - } - this.productFilters?.updateParams(productItems); - this.view.switchEmptyList(!productItems?.products?.length); + private setCurrentPage(page: string): void { + RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.PAGE, page); + this.drawProducts().catch(showErrorMessage); } public getHTML(): HTMLDivElement { diff --git a/src/widgets/Footer/model/FooterModel.ts b/src/widgets/Footer/model/FooterModel.ts index bcccbc71..73680a20 100644 --- a/src/widgets/Footer/model/FooterModel.ts +++ b/src/widgets/Footer/model/FooterModel.ts @@ -1,8 +1,121 @@ +import type { languageVariants } from '@/shared/types/common.ts'; +import type { Category } from '@/shared/types/product.ts'; + +import getProductModel from '@/shared/API/product/model/ProductModel.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { buildPathName } from '@/shared/utils/buildPathname.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; + import FooterView from '../view/FooterView.ts'; +export type Link = { + href?: string; + name: languageVariants; +}; + +const GENERAL_LINKS: Link[] = [ + { + href: PAGE_ID.USER_PROFILE_PAGE, + name: { + en: 'My account', + ru: 'Мой ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ', + }, + }, + { + href: PAGE_ID.ABOUT_US_PAGE, + name: { + en: 'About us', + ru: 'О нас', + }, + }, + { + href: PAGE_ID.BLOG, + name: { + en: 'Blog', + ru: 'Π‘Π»ΠΎΠ³', + }, + }, + { + name: { + en: 'Career', + ru: 'Вакансии', + }, + }, + { + name: { + en: 'Specials', + ru: 'БотрудничСство', + }, + }, +]; + +const HELP_LINKS: Link[] = [ + { + name: { + en: 'Help Center', + ru: 'ΠŸΠΎΠΌΠΎΡ‰ΡŒ', + }, + }, + { + name: { + en: 'How to Buy', + ru: 'ΠŸΠΎΠΊΡƒΠΏΠΊΠΈ', + }, + }, + { + name: { + en: 'Delivery', + ru: 'Доставка', + }, + }, + { + name: { + en: 'Product Policy', + ru: 'ΠŸΠΎΠ»ΠΈΡ‚ΠΈΠΊΠ°', + }, + }, + { + name: { + en: 'How to Return', + ru: 'Π’ΠΎΠ·Π²Ρ€Π°Ρ‚', + }, + }, +]; + +function generateRandomCategoryLink(categoriesArr: Category[]): Link[] { + const subCategory = categoriesArr.filter((category) => category.parent !== null); + const result: Link[] = []; + for (let i = 0; i < 5; i += 1) { + const randomIndex = Math.floor(Math.random() * subCategory.length); + const category = subCategory[randomIndex]; + subCategory.splice(randomIndex, 1); + result.push({ + href: buildPathName(PAGE_ID.CATALOG_PAGE, null, { subcategory: [category.id] }), + name: { + en: category.name[0].value, + ru: category.name[1].value, + }, + }); + } + return result; +} + class FooterModel { private view = new FooterView(); + constructor() { + this.init().catch(showErrorMessage); + } + + private async init(): Promise { + const categories: Category[] = await getProductModel().getCategories(); + const categoryLink: Link[] = generateRandomCategoryLink([...categories]); + this.view.addNavLists(GENERAL_LINKS, HELP_LINKS, categoryLink); + observeStore(selectCurrentLanguage, () => this.view.updateLanguage()); + return true; + } + public getHTML(): HTMLElement { return this.view.getHTML(); } diff --git a/src/widgets/Footer/view/FooterView.ts b/src/widgets/Footer/view/FooterView.ts index 432cb5f6..a9fd45b5 100644 --- a/src/widgets/Footer/view/FooterView.ts +++ b/src/widgets/Footer/view/FooterView.ts @@ -1,54 +1,98 @@ +import type { LanguageChoiceType } from '@/shared/constants/common'; +import type { languageVariants } from '@/shared/types/common'; + +import RouterModel from '@/app/Router/model/RouterModel.ts'; import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import InputModel from '@/shared/Input/model/InputModel.ts'; +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import * as FORM_FIELDS from '@/shared/constants/forms/fieldParams.ts'; import * as FORM_VALIDATION from '@/shared/constants/forms/validationParams.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import type { Link } from '../model/FooterModel'; + import styles from './footerView.module.scss'; type Goal = { alt: string; - description: string; + description: languageVariants; + id: string; imgH: number; imgW: number; src: string; - title: string; + title: languageVariants; }; type Contact = { alt: string; description: string; + href?: string; src: string; + tag: keyof HTMLElementTagNameMap; }; -type Social = { +type Img = { alt: string; src: string; }; +type GoalElement = { + goalDescription: HTMLParagraphElement; + goalItem: Goal; + goalTitle: HTMLParagraphElement; +}; + const GOALS: Goal[] = [ { alt: 'Garden Care', - description: 'Provide expert tips and tools for maintaining a healthy and beautiful garden', + description: { + en: 'Provide expert tips and tools for maintaining a healthy and beautiful garden', + ru: 'Для Вас совСты экспСртов ΠΈ инструмСнты для поддСрТания Π·Π΄ΠΎΡ€ΠΎΠ²ΠΎΠ³ΠΎ ΠΈ красивого сада', + }, + id: 'goal_1', imgH: 93, imgW: 61, src: '/img/png/plant-01.png', - title: 'Garden Care', + title: { + en: 'Garden Care', + ru: 'Π£Ρ…ΠΎΠ΄ Π·Π° садом', + }, }, { alt: 'Plant Renovation', - description: 'Offer solutions and products to revive and rejuvenate struggling plants', + description: { + en: 'Offer solutions and products to revive and rejuvenate struggling plants', + ru: 'ΠŸΡ€Π΅Π΄Π»Π°Π³Π°Π΅ΠΌ Ρ€Π΅ΡˆΠ΅Π½ΠΈΡ ΠΈ ΠΏΡ€ΠΎΠ΄ΡƒΠΊΡ‚Ρ‹ для восстановлСния ΠΈ омолоТСния растСний, ΠΏΠ΅Ρ€Π΅ΠΆΠΈΠ²Π°ΡŽΡ‰ΠΈΡ… трудности', + }, + id: 'goal_2', imgH: 87, imgW: 68, src: '/img/png/plant-02.png', - title: 'Plant Renovation', + title: { + en: 'Plant Renovation', + ru: 'ОбновлСниС растСний', + }, }, { alt: 'Watering Garden', - description: 'Ensure optimal hydration with efficient and innovative watering systems and advice', + description: { + en: 'Ensure optimal hydration with efficient and innovative watering systems and advice', + ru: 'ПомогаСм ΠΎΠ±Π΅ΡΠΏΠ΅Ρ‡ΠΈΡ‚ΡŒ ΠΎΠΏΡ‚ΠΈΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΡƒΡ…ΠΎΠ΄ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ эффСктивных ΠΈ ΠΈΠ½Π½ΠΎΠ²Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Ρ… систСм ΠΏΠΎΠ»ΠΈΠ²Π° ΠΈ Π½Π°ΡˆΠΈΡ… Ρ€Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°Ρ†ΠΈΠΉ', + }, + id: 'goal_3', imgH: 85, imgW: 83, src: '/img/png/plant-03.png', - title: 'Watering Garden', + title: { + en: 'Watering Garden', + ru: 'Полив сада', + }, }, ]; const CONTACTS: Contact[] = [ @@ -56,20 +100,25 @@ const CONTACTS: Contact[] = [ alt: 'location greenshop', description: '70 West Buckingham Ave. Farmingdale, NY 11735', src: '/img/png/location.png', + tag: 'address', }, { alt: 'email greenshop', description: 'contact@greenshop.com', + href: 'mailto:contact@greenshop.com', src: '/img/png/message.png', + tag: 'a', }, { alt: 'phone greenshop', description: '+88 01911 717 490', + href: 'tel:+8801911717490', src: '/img/png/calling.png', + tag: 'a', }, ]; -const SOCIAL: Social[] = [ +const SOCIAL: Img[] = [ { alt: 'link meta', src: '/img/png/meta.png', @@ -92,12 +141,85 @@ const SOCIAL: Social[] = [ }, ]; +const PAY: Img[] = [ + { + alt: 'pay PayPal', + src: '/img/png/pay-pp.png', + }, + { + alt: 'pay MasterCard', + src: '/img/png/pay-mc.png', + }, + { + alt: 'pay Visa', + src: '/img/png/pay-visa.png', + }, + { + alt: 'pay American Express', + src: '/img/png/pay-ae.png', + }, +]; + +type textElementsType = { + element: HTMLAnchorElement | HTMLButtonElement | HTMLInputElement | HTMLParagraphElement | HTMLUListElement; + textItem: languageVariants; +}; +const FOOTER_PAGE = { + NAV_CATEGORY: { + en: 'Categories', + ru: 'ΠšΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ', + }, + NAV_GENERAL: { + en: 'General', + ru: 'Главная', + }, + NAV_HELP: { + en: 'Help & Guide', + ru: 'FAQ', + }, + PAY: { + en: 'We accept', + ru: 'ΠœΡ‹ ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅ΠΌ', + }, + SOCIAL: { + en: 'Social Media', + ru: 'БоцсСти', + }, + SUB_BTN: { + en: 'Yes', + ru: 'Π”Π°', + }, + SUB_DESCRIPTION: { + en: 'Subscribe to our newsletter for updates on new arrivals, exclusive discounts, and notifications about our latest blog articles. Stay informed and enhance your gardening experience with our expert tips and offers!', + ru: 'ΠŸΠΎΠ΄ΠΏΠΈΡˆΠΈΡ‚Π΅ΡΡŒ Π½Π° Π½Π°ΡˆΡƒ рассылку, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ обновлСния ΠΎ Π½ΠΎΠ²Ρ‹Ρ… поступлСниях, ΡΠΊΡΠΊΠ»ΡŽΠ·ΠΈΠ²Π½Ρ‹Π΅ скидки ΠΈ увСдомлСния ΠΎ Π½Π°ΡˆΠΈΡ… послСдних ΡΡ‚Π°Ρ‚ΡŒΡΡ… Π² Π±Π»ΠΎΠ³Π΅. Π‘ΡƒΠ΄ΡŒΡ‚Π΅ Π² курсС ΠΈ ΡƒΠ»ΡƒΡ‡ΡˆΠ°ΠΉΡ‚Π΅ свой ΠΎΠΏΡ‹Ρ‚ садоводства с нашими экспСртными совСтами ΠΈ прСдлоТСниями!', + }, + SUB_PLACEHOLDER: { + en: 'enter your email address...', + ru: 'Π²Π²Π΅Π΄ΠΈΡ‚Π΅ ваш email...', + }, + SUB_TITLE: { + en: 'Want to subscribe to our newsletters?', + ru: 'Π₯ΠΎΡ‚ΠΈΡ‚Π΅ ΠΏΠΎΠ΄ΠΏΠΈΡΠ°Ρ‚ΡŒΡΡ Π½Π° Π½Π°ΡˆΡƒ рассылку?', + }, +}; + class FooterView { private footer: HTMLElement; + private goals: GoalElement[] = []; + + private language: LanguageChoiceType; + + private navList: HTMLUListElement[] = []; + + private navListWrap: HTMLDivElement; + + private textElements: textElementsType[] = []; + private wrapper: HTMLDivElement; constructor() { + this.language = getStore().getState().currentLanguage; this.wrapper = this.createWrapper(); this.footer = this.createHTML(); const blockGoals = this.createGoalsHTML(); @@ -105,8 +227,11 @@ class FooterView { const goalsSubWrap = this.createWrapHTML(); goalsSubWrap.append(blockGoals, blockSubscribe); const contactWrap = this.createContactHTML(); + const navigateWrap = this.createNavigateHTML(); const socialWrap = this.createSocialHTML(); - this.wrapper.append(goalsSubWrap, contactWrap, socialWrap); + this.navListWrap = this.createNavListWrapHTML(); + navigateWrap.append(this.navListWrap, socialWrap); + this.wrapper.append(goalsSubWrap, contactWrap, navigateWrap); } private createContactHTML(): HTMLDivElement { @@ -122,8 +247,6 @@ class FooterView { cssClasses: [styles.logoImage], tag: 'img', }); - logoImg.style.width = `150px`; - logoImg.style.height = `34px`; const wrapContactItems = createBaseElement({ cssClasses: [styles.contactItemsWrap], @@ -134,11 +257,19 @@ class FooterView { return wrap; } - private createContactItemHTML(contact: Contact): HTMLDivElement { + private createContactItemHTML(contact: Contact): HTMLElement { + const attributes: { [key: string]: string } = {}; + + if (contact.href) { + attributes.href = contact.href; + } + const wrap = createBaseElement({ + attributes, cssClasses: [styles.contactItem], - tag: 'div', + tag: contact.tag, }); + const icon = createBaseElement({ attributes: { alt: contact.alt, @@ -147,14 +278,14 @@ class FooterView { cssClasses: [styles.contactIcon], tag: 'img', }); - icon.style.width = `20px`; - icon.style.height = `20px`; - const tittle = createBaseElement({ + + const title = createBaseElement({ cssClasses: [styles.contactText], innerContent: contact.description, - tag: 'p', + tag: 'span', }); - wrap.append(icon, tittle); + + wrap.append(icon, title); return wrap; } @@ -167,33 +298,27 @@ class FooterView { cssClasses: [styles.goalImage], tag: 'img', }); - goalImg.style.width = `${goalItem.imgW}px`; - goalImg.style.height = `${goalItem.imgH}px`; - const goalImgWrap = createBaseElement({ - cssClasses: [styles.goalImgWrap], - tag: 'div', - }); + const goalImgWrap = createBaseElement({ cssClasses: [styles.goalImgWrap], tag: 'div' }); goalImgWrap.append(goalImg); - const goalTextWrap = createBaseElement({ - cssClasses: [styles.goalTextWrap], - tag: 'div', - }); + const goalTextWrap = createBaseElement({ cssClasses: [styles.goalTextWrap], tag: 'div' }); const title = createBaseElement({ cssClasses: [styles.goalTitle], - innerContent: goalItem.title, + innerContent: goalItem.title[this.language], tag: 'p', }); const description = createBaseElement({ cssClasses: [styles.goalsDescription], - innerContent: goalItem.description, + innerContent: goalItem.description[this.language], tag: 'p', }); goalTextWrap.append(title, description); - const goal = createBaseElement({ - cssClasses: [styles.goal], - tag: 'div', - }); + const goal = createBaseElement({ cssClasses: [styles.goal], tag: 'div' }); goal.append(goalImgWrap, goalTextWrap); + this.goals.push({ + goalDescription: description, + goalItem, + goalTitle: title, + }); return goal; } @@ -204,7 +329,9 @@ class FooterView { }); GOALS.forEach((goal) => { - wrap.append(this.createGoalHTML(goal)); + const goalHTML = this.createGoalHTML(goal); + + wrap.append(goalHTML); }); return wrap; @@ -220,6 +347,31 @@ class FooterView { return this.footer; } + private createLink(linkParams: Link): LinkModel { + const link = new LinkModel({ + attrs: { + ...(linkParams.href && { href: linkParams.href }), + }, + classes: [styles.link], + text: linkParams.name[this.language], + }); + + if (linkParams.href) { + link.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + if (linkParams.href) { + RouterModel.getInstance().navigateTo(linkParams.href); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, ''); + window.scrollTo(0, 0); + } + }); + } else { + link.getHTML().classList.add(styles.inactive); + } + + return link; + } + private createLinkHTML(): HTMLDivElement { const wrap = createBaseElement({ cssClasses: [styles.socialWrap], @@ -227,7 +379,7 @@ class FooterView { }); const title = createBaseElement({ cssClasses: [styles.subTitle], - innerContent: 'Social Media', + innerContent: FOOTER_PAGE.SOCIAL[this.language], tag: 'p', }); const buttonWrap = createBaseElement({ @@ -251,17 +403,91 @@ class FooterView { buttonWrap.append(link); }); wrap.append(title, buttonWrap); + this.textElements.push({ element: title, textItem: FOOTER_PAGE.SOCIAL }); + return wrap; + } + + private createNavListHTML(title: languageVariants, navItems: Link[]): HTMLUListElement { + const navList = createBaseElement({ + cssClasses: [styles.navTitle], + tag: 'ul', + }); + + const titleEl = createBaseElement({ + cssClasses: [styles.navTitle], + innerContent: title[this.language], + tag: 'p', + }); + navList.append(titleEl); + navItems.forEach((navItem) => { + const navItemHTML = createBaseElement({ + cssClasses: [styles.navItem], + tag: 'li', + }); + const link = this.createLink(navItem); + navItemHTML.append(link.getHTML()); + navList.append(navItemHTML); + this.textElements.push({ element: link.getHTML(), textItem: navItem.name }); + }); + this.textElements.push({ element: titleEl, textItem: title }); + return navList; + } + + private createNavListWrapHTML(): HTMLDivElement { + return createBaseElement({ + cssClasses: [styles.navListWrap], + tag: 'div', + }); + } + + private createNavigateHTML(): HTMLDivElement { + const wrap = createBaseElement({ + cssClasses: [styles.navigateWrap], + tag: 'div', + }); + return wrap; + } + + private createPayHTML(): HTMLDivElement { + const wrap = createBaseElement({ + cssClasses: [styles.payWrap], + tag: 'div', + }); + const title = createBaseElement({ + cssClasses: [styles.payTitle], + innerContent: FOOTER_PAGE.PAY[this.language], + tag: 'p', + }); + const buttonWrap = createBaseElement({ + cssClasses: [styles.payImgWrap], + tag: 'div', + }); + PAY.forEach((pay) => + buttonWrap.append( + createBaseElement({ + attributes: { + alt: pay.alt, + src: pay.src, + }, + cssClasses: [styles.payImg], + tag: 'img', + }), + ), + ); + wrap.append(title, buttonWrap); + this.textElements.push({ element: title, textItem: FOOTER_PAGE.PAY }); return wrap; } private createSocialHTML(): HTMLDivElement { const wrap = createBaseElement({ - cssClasses: [styles.socialSubWrap], + cssClasses: [styles.socialPayWrap], tag: 'div', }); const blockSocial = this.createLinkHTML(); - wrap.append(blockSocial); + const blockPay = this.createPayHTML(); + wrap.append(blockSocial, blockPay); return wrap; } @@ -270,12 +496,42 @@ class FooterView { cssClasses: [styles.subForm], tag: 'form', }); - const email = new InputFieldModel(FORM_FIELDS.EMAIL, FORM_VALIDATION.EMAIL_VALIDATE); + const email = new InputFieldModel(FORM_FIELDS.EMAIL_NOT_LABEL_TEXT, FORM_VALIDATION.FOOTER_EMAIL_VALIDATE); + const inputFieldElement = email.getView().getHTML(); const inputHTML = email.getView().getInput().getHTML(); - inputHTML.placeholder = 'enter your email address...'; + if (inputFieldElement instanceof HTMLLabelElement) { + inputFieldElement.classList.add(styles.label); + inputHTML.classList.add(styles.input); + form.append(inputFieldElement); + } else if (inputFieldElement instanceof InputModel) { + form.append(inputFieldElement.getHTML()); + } + inputHTML.placeholder = FOOTER_PAGE.SUB_PLACEHOLDER[this.language]; inputHTML.classList.add(styles.subInput); - const submit = createBaseElement({ cssClasses: [styles.subButton], innerContent: 'Join', tag: 'button' }); - form.append(inputHTML, submit); + const submit = new ButtonModel({ + classes: [styles.subButton], + text: FOOTER_PAGE.SUB_BTN[this.language], + }); + + submit.setDisabled(); + + form.addEventListener('submit', (event) => { + event.preventDefault(); + }); + + submit.getHTML().addEventListener('click', () => { + email.getView().getInput().clear(); + serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.SUCCESSFUL_SUBSCRIBE, MESSAGE_STATUS.SUCCESS); + submit.setDisabled(); + }); + + inputHTML.addEventListener('input', () => { + this.switchSubmitFormButtonAccess(email, submit); + }); + + form.append(submit.getHTML()); + this.textElements.push({ element: submit.getHTML(), textItem: FOOTER_PAGE.SUB_BTN }); + this.textElements.push({ element: inputHTML, textItem: FOOTER_PAGE.SUB_PLACEHOLDER }); return form; } @@ -286,17 +542,17 @@ class FooterView { }); const title = createBaseElement({ cssClasses: [styles.subTitle], - innerContent: 'Would you like to join newsletters?', + innerContent: FOOTER_PAGE.SUB_TITLE[this.language], tag: 'p', }); const description = createBaseElement({ cssClasses: [styles.subDescription], - innerContent: - 'Subscribe to our newsletter for updates on new arrivals, exclusive discounts, and notifications about our latest blog articles. Stay informed and enhance your gardening experience with our expert tips and offers!', + innerContent: FOOTER_PAGE.SUB_DESCRIPTION[this.language], tag: 'p', }); - + this.textElements.push({ element: title, textItem: FOOTER_PAGE.SUB_TITLE }); + this.textElements.push({ element: description, textItem: FOOTER_PAGE.SUB_DESCRIPTION }); const form = this.createSubFormHtml(); wrap.append(title, form, description); return wrap; @@ -320,12 +576,44 @@ class FooterView { return this.wrapper; } + private switchSubmitFormButtonAccess(email: InputFieldModel, submitButton: ButtonModel): boolean { + if (email.getIsValid()) { + submitButton.setEnabled(); + } else { + submitButton.setDisabled(); + } + + return true; + } + + public addNavLists(generalLinks: Link[], helpLinks: Link[], categoryLinks: Link[]): void { + const generalNav = this.createNavListHTML(FOOTER_PAGE.NAV_GENERAL, generalLinks); + const helpNav = this.createNavListHTML(FOOTER_PAGE.NAV_HELP, helpLinks); + const categoryNav = this.createNavListHTML(FOOTER_PAGE.NAV_CATEGORY, categoryLinks); + this.navList.push(generalNav, helpNav, categoryNav); + this.navListWrap.append(generalNav, helpNav, categoryNav); + } + public getHTML(): HTMLElement { return this.footer; } - public getWrapper(): HTMLDivElement { - return this.wrapper; + public updateLanguage(): void { + this.language = getStore().getState().currentLanguage; + this.goals.forEach((goalEl) => { + const title = goalEl.goalTitle; + const description = goalEl.goalDescription; + title.textContent = goalEl.goalItem.title[this.language]; + description.textContent = goalEl.goalItem.description[this.language]; + }); + this.textElements.forEach((el) => { + const elHTML = el.element; + if (elHTML instanceof HTMLInputElement) { + elHTML.placeholder = el.textItem[this.language]; + } else { + elHTML.textContent = el.textItem[this.language]; + } + }); } } diff --git a/src/widgets/Footer/view/footerView.module.scss b/src/widgets/Footer/view/footerView.module.scss index b104fda1..8469b27f 100644 --- a/src/widgets/Footer/view/footerView.module.scss +++ b/src/widgets/Footer/view/footerView.module.scss @@ -1,6 +1,6 @@ +@import 'src/app/styles/mixins'; + .footer { - margin-bottom: var(--small-offset); - padding: 0 var(--small-offset); width: 100%; } @@ -10,7 +10,6 @@ align-items: center; justify-content: center; margin: 0 auto; - padding: 0 var(--small-offset); } .socialWrap { @@ -18,7 +17,7 @@ flex-direction: column; } -.socialSubWrap, +.navigateWrap, .goalsSubWrap { display: flex; flex-wrap: wrap; @@ -28,8 +27,36 @@ background-color: var(--white-tr); } -.socialSubWrap { +.navigateWrap { + padding-left: var(--small-offset); +} + +.goalsSubWrap { + padding-left: calc(var(--small-offset) - var(--tiny-offset)); +} + +.socialPayWrap { + display: flex; + flex-direction: column; justify-content: space-around; + padding: var(--extra-small-offset); + gap: var(--extra-small-offset); +} + +.navListWrap, +.navigateWrap { + justify-content: space-between; + gap: var(--extra-small-offset); + + @media (max-width: 768px) { + justify-content: space-evenly; + } +} + +.navListWrap { + display: flex; + flex-grow: 1; + flex-wrap: wrap; } .goalsWrap { @@ -66,7 +93,7 @@ gap: var(--tiny-offset); &:not(:last-child) { - border-right: 1px solid var(--steam-green-350); + border-right: var(--one) solid var(--steam-green-1000); } @media (max-width: 768px) { @@ -87,12 +114,11 @@ position: absolute; left: 0; top: var(--tiny-offset); - z-index: 1; display: block; border-radius: 100%; width: var(--large-offset); height: var(--large-offset); - background-color: var(--steam-green-350); + background-color: var(--steam-green-1000); } } @@ -113,6 +139,7 @@ color: var(--black); } +.navItem, .goalsDescription, .subDescription { padding: var(--tiny-offset) 0; @@ -133,6 +160,8 @@ max-width: calc(var(--extra-large-offset) * 3); } +.navTitle, +.payTitle, .subTitle { padding: var(--tiny-offset) 0 var(--extra-small-offset); font: var(--medium-bold-font); @@ -146,25 +175,38 @@ width: 100%; } +.label { + display: flex; + flex-direction: column; + width: 78%; + + span { + font-size: 0.71rem; + word-break: break-all; + } +} + .subInput { - border-radius: var(--small-br); + margin-bottom: var(--tiny-offset); + border-radius: var(--small-br) 0 0 var(--small-br); padding: calc(var(--extra-small-offset) * 0.8) var(--tiny-offset); width: 100%; box-shadow: var(--mellow-shadow-050); font: var(--regular-font); - color: var(--noble-gray-400); + color: var(--noble-gray-800); } .subButton { - position: absolute; - right: 0; - top: 0; + @include green-btn; + border-radius: 0 var(--small-br) var(--small-br) 0; - padding: var(--tiny-offset) var(--extra-small-offset); - height: 100%; + padding: calc(var(--extra-small-offset) / 1.6) var(--extra-small-offset); + height: max-content; font: var(--medium-bold-font); - color: var(--noble-gray-200); - background-color: var(--steam-green-800); + + &:active { + transform: scale(1); + } } .contactWrap { @@ -173,16 +215,17 @@ align-items: center; justify-content: space-between; padding: var(--extra-small-offset); + padding-left: var(--small-offset); width: 100%; - background-color: var(--steam-green-350); - gap: var(--extra-small-offset); + background-color: var(--steam-green-1000); + gap: 10%; } .contactItemsWrap { display: flex; flex: 1 1 auto; flex-wrap: wrap; - justify-content: space-between; + justify-content: space-evenly; gap: var(--extra-small-offset); @media (max-width: 768px) { @@ -192,13 +235,17 @@ .logoImage { margin: 0 auto; - width: 150px; - height: 34px; + width: calc(var(--extra-large-offset) * 1.5); + height: calc(var(--extra-small-offset) * 1.7); + + @media (max-width: 484px) { + margin-bottom: var(--extra-small-offset); + } } .contactItem { display: flex; - flex-basis: 30%; + flex-basis: 25%; align-items: center; gap: var(--tiny-offset); } @@ -223,9 +270,57 @@ display: flex; align-items: center; justify-content: center; - border: 1px solid var(--steam-green-300); + border: var(--one) solid var(--steam-green-300); border-radius: var(--small-br); padding: var(--tiny-offset); width: var(--small-offset); height: var(--small-offset); + transition: background-color 0.2s; + cursor: pointer; + + &:hover { + background-color: var(--steam-green-1000); + } +} + +.payImgWrap { + display: flex; + align-items: center; + gap: var(--tiny-offset); +} + +.payImg { + width: var(--small-offset); + height: var(--small-offset); +} + +.link { + @include link; + + padding: 0; + width: max-content; + height: var(--extra-small-offset); + text-transform: none; + + &::after { + bottom: calc(var(--two) * -3); + } +} + +.inactive { + opacity: 0.5; + cursor: default; + pointer-events: none; + + &:hover { + color: var(--noble-gray-700); + } +} + +.navTitle { + flex-basis: 30%; + + @media (max-width: 768px) { + flex-basis: max-content; + } } diff --git a/src/widgets/Header/model/HeaderModel.ts b/src/widgets/Header/model/HeaderModel.ts index 322f8582..86e3684d 100644 --- a/src/widgets/Header/model/HeaderModel.ts +++ b/src/widgets/Header/model/HeaderModel.ts @@ -33,15 +33,6 @@ class HeaderModel { this.init(); } - private checkAuthUser(): boolean { - const { isUserLoggedIn } = getStore().getState(); - if (!isUserLoggedIn) { - RouterModel.getInstance().navigateTo(PAGE_ID.LOGIN_PAGE); - return false; - } - return true; - } - private checkCurrentUser(): void { const { isUserLoggedIn } = getStore().getState(); const logoutButton = this.view.getLogoutButton(); @@ -64,7 +55,6 @@ class HeaderModel { this.setCartLinkHandler(); this.observeCartChange(); this.setCartCount().catch(showErrorMessage); - this.setProfileLinkHandler(); this.setChangeLanguageCheckboxHandler(); } @@ -118,7 +108,7 @@ class HeaderModel { serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.LANGUAGE_CHANGED, MESSAGE_STATUS.SUCCESS); } } catch (error) { - showErrorMessage(); + showErrorMessage(error); } } else { getStore().dispatch(setCurrentLanguage(newLanguage)); @@ -143,17 +133,6 @@ class HeaderModel { }); } - private setProfileLinkHandler(): void { - const logo = this.view.getToProfileLink().getHTML(); - logo.addEventListener('click', (event) => { - event.preventDefault(); - // TBD remove unnecessary check (we don't show this logo when user is not logged in) ?? - if (this.checkAuthUser()) { - RouterModel.getInstance().navigateTo(PAGE_ID.USER_PROFILE_PAGE); - } - }); - } - public getHTML(): HTMLElement { return this.view.getHTML(); } diff --git a/src/widgets/Header/view/headerView.module.scss b/src/widgets/Header/view/headerView.module.scss index 05e9aa44..94ceae3c 100644 --- a/src/widgets/Header/view/headerView.module.scss +++ b/src/widgets/Header/view/headerView.module.scss @@ -5,8 +5,8 @@ left: 0; right: 0; top: 0; - z-index: 10; - border-bottom: 1px solid var(--steam-green-300); + z-index: 1; + border-bottom: var(--one) solid var(--steam-green-300); width: 100%; background-color: var(--noble-gray-1000); backdrop-filter: blur(10px); @@ -17,6 +17,11 @@ align-items: center; margin: 0 auto; padding: 0 var(--small-offset); + + @media (max-width: 768px) { + display: grid; + padding: 0; + } } .navigationWrapper { @@ -37,8 +42,18 @@ backdrop-filter: blur(10px); gap: var(--medium-offset); + @media (max-width: 768px) { + top: calc(var(--extra-small-offset) * 6.1); + grid-row: 2; + width: 45%; + } + &.open { transform: translateX(-500%); + + @media (max-width: 768px) { + transform: translateX(-220%); + } } } @@ -51,16 +66,22 @@ width: var(--small-offset); height: var(--small-offset); fill: var(--steam-green-800); - transition: fill 0.2s; + transition: filter 0.2s; } @media (hover: hover) { &:hover { svg { - fill: var(--steam-green-700); + filter: brightness(1.3); } } } + + @media (max-width: 768px) { + grid-row: 1; + margin-top: calc(var(--tiny-offset) * 1.5); + margin-left: calc(var(--extra-small-offset) * 1.5); + } } .logoutButton { @@ -79,7 +100,7 @@ svg { width: var(--small-offset); height: var(--small-offset); - fill: var(--noble-gray-800); + fill: var(--noble-gray-1100); transition: fill 0.2s; } @@ -108,7 +129,7 @@ order: 2; svg { - stroke: var(--noble-gray-800); + stroke: var(--noble-gray-1100); transition: stroke 0.2s; } @@ -135,11 +156,16 @@ .burgerButton { position: absolute; - right: calc(var(--tiny-offset) * 1.5); + right: calc(var(--extra-small-offset) * 1.5); z-index: 10; order: 4; width: calc(var(--tiny-offset) * 2.5); height: var(--extra-small-offset); + + @media (max-width: 768px) { + top: calc(var(--extra-small-offset) * 1.5); + grid-row: 1; + } } .burgerLine { @@ -153,7 +179,7 @@ .burgerButton:hover .burgerLine:nth-child(1), .burgerButton:hover .burgerLine:nth-child(2), .burgerButton:hover .burgerLine:nth-child(3) { - background-color: var(--steam-green-700); + filter: brightness(1.3); } .burgerButton:not(.open):hover .burgerLine:nth-child(1), @@ -212,8 +238,8 @@ } .burgerButton.open .burgerLine:nth-child(1) { - left: var(--five); // 5px - top: calc(var(--tiny-offset) * 1.5); // 15px + left: calc(var(--five) + 0.04rem); + top: calc(var(--tiny-offset) * 1.1); // 11px width: var(--extra-small-offset); // 20px transform: rotate(90deg); transition-delay: 150ms; @@ -230,7 +256,7 @@ .burgerButton.open .burgerLine:nth-child(3) { left: calc(var(--tiny-offset) * 1.3); // 13px top: calc(var(--tiny-offset) * 1.7); // 17px - width: calc(var(--tiny-offset) * 1.4); // 14px + width: calc(var(--tiny-offset) * 1.5); // 15px transform: rotate(-45deg); transition-delay: 100ms; } @@ -242,13 +268,9 @@ .switchThemeLabel { position: relative; display: inline-block; - width: calc(var(--extra-large-offset) + var(--extra-small-offset)); + width: var(--large-offset); height: calc(calc(var(--small-offset) / 1.5) + var(--extra-small-offset)); cursor: pointer; - - @media (max-width: 768px) { - width: calc(var(--extra-large-offset) + var(--extra-small-offset)); - } } .switchThemeCheckbox { @@ -258,18 +280,12 @@ &:checked { + .switchThemeLabelSpan { - background-color: #c4c4c4a8; + background-color: var(--noble-gray-tr-900); } + .switchThemeLabelSpan::before { - background-color: #a7e599; - transform: translate(calc(var(--small-offset) + var(--small-offset) + calc(var(--two) * 2)), -50%); - } - - @media (max-width: 768px) { - + .switchThemeLabelSpan::before { - transform: translate(calc(var(--small-offset) + var(--small-offset) - calc(var(--two) * 2)), -50%); - } + background-color: var(--steam-green-1100); + transform: translate(calc(var(--small-offset) + var(--two)), -50%); } } } @@ -277,7 +293,7 @@ .switchThemeLabelSpan { position: absolute; border-radius: calc(var(--large-br) * 2); - background-color: #d8d8d85c; + background-color: var(--noble-gray-tr-800); transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); cursor: pointer; pointer-events: none; @@ -294,7 +310,7 @@ width: calc(var(--small-offset) / 1.5); height: calc(var(--small-offset) / 1.5); box-shadow: var(--mellow-shadow-600); - background-color: #46a032; + background-color: var(--steam-green-800); transform: translateY(-50%); transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); } @@ -329,10 +345,6 @@ height: calc(calc(var(--small-offset) / 1.5) + var(--extra-small-offset)); cursor: pointer; - @media (max-width: 768px) { - width: var(--large-offset); - } - &:disabled { background-color: var(--noble-gray-300); pointer-events: none; @@ -346,18 +358,12 @@ &:checked { + .switchLanguageLabelSpan { - background-color: #c4c4c4a8; + background-color: var(--noble-gray-tr-900); } + .switchLanguageLabelSpan::before { - background-color: #c4c4c4a8; - transform: translate(calc(var(--small-offset) + calc(var(--one) * 3.5)), -50%); - } - - @media (max-width: 768px) { - + .switchLanguageLabelSpan::before { - transform: translate(calc(var(--small-offset) - calc(var(--one) * 3.5)), -50%); - } + background-color: var(--noble-gray-tr-900); + transform: translate(calc(var(--small-offset) + calc(var(--one) * 4)), -50%); } } } @@ -365,7 +371,7 @@ .switchLanguageLabelSpan { position: absolute; border-radius: calc(var(--large-br) * 2); - background-color: #d8d8d85c; + background-color: var(--noble-gray-tr-800); transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); cursor: pointer; pointer-events: none; @@ -382,7 +388,7 @@ width: calc(var(--small-offset) / 1.5); height: calc(var(--small-offset) / 1.5); box-shadow: 0 1px 5px #353535; - background-color: #c4c4c4a8; + background-color: var(--noble-gray-tr-900); transform: translateY(-50%); transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); } @@ -402,12 +408,10 @@ .enSVG { left: calc(var(--one) * 5); // 5px - fill: var(--noble-gray-800); // 5px } .ruSVG { right: calc(var(--one) * 5); - fill: var(--white); } @keyframes show { @@ -423,15 +427,15 @@ .badgeWrap { position: absolute; - right: -5px; - top: 2px; + right: -10%; + top: 1%; display: flex; align-items: center; justify-content: center; - border: 2px solid var(--noble-gray-1000); + border: var(--one) solid var(--noble-gray-1000); border-radius: 100%; - width: 16px; - height: 16px; + width: calc(var(--tiny-offset) * 2); + height: calc(var(--tiny-offset) * 2); font: var(--regular-font); background-color: var(--steam-green-800); } diff --git a/src/widgets/LoginForm/model/LoginFormModel.ts b/src/widgets/LoginForm/model/LoginFormModel.ts index 0cb93755..d871f922 100644 --- a/src/widgets/LoginForm/model/LoginFormModel.ts +++ b/src/widgets/LoginForm/model/LoginFormModel.ts @@ -46,7 +46,7 @@ class LoginFormModel { serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.INVALID_EMAIL, MESSAGE_STATUS.ERROR); } }) - .catch(() => showErrorMessage()) + .catch((error) => showErrorMessage(error)) .finally(() => loader.remove()); } diff --git a/src/widgets/ProductInfo/model/ProductInfoModel.ts b/src/widgets/ProductInfo/model/ProductInfoModel.ts index 8d4f0362..9ccce257 100644 --- a/src/widgets/ProductInfo/model/ProductInfoModel.ts +++ b/src/widgets/ProductInfo/model/ProductInfoModel.ts @@ -2,6 +2,7 @@ import type { Cart } from '@/shared/types/cart.ts'; import type { ProductInfoParams, Variant } from '@/shared/types/product.ts'; import type { ShoppingListProduct } from '@/shared/types/shopping-list.ts'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; import ProductModalSliderModel from '@/entities/ProductModalSlider/model/ProductModalSliderModel.ts'; import ProductPriceModel from '@/entities/ProductPrice/model/ProductPriceModel.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; @@ -12,7 +13,9 @@ import modal from '@/shared/Modal/model/ModalModel.ts'; import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import { buildPathName } from '@/shared/utils/buildPathname.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; import Swiper from 'swiper'; import 'swiper/css'; @@ -131,18 +134,39 @@ class ProductInfoModel { }); this.bigSlider.autoplay.start(); - const modalSlider = new ProductModalSliderModel(this.params).getHTML(); - - this.view.getBigSliderSlides().forEach((slide) => { + this.view.getBigSliderSlides().forEach((slide, index) => { slide.addEventListener('click', () => { + const modalSlider = new ProductModalSliderModel(this.params); modal.show(); - modal.setContent(modalSlider); + modalSlider.getModalSlider()?.slideTo(index); + modal.setContent(modalSlider.getHTML()); }); }); this.view.getRightWrapper().append(this.price.getHTML()); this.switchToCartButtonHandler(); this.switchToWishListButtonHandler(); + this.setSizeButtonHandler(); + } + + private setSizeButtonHandler(): void { + this.view.getSizeButtons().forEach((sizeButton) => { + sizeButton.getHTML().addEventListener('click', () => { + const currentVariant = this.params.variant.find(({ size }) => size === sizeButton.getHTML().textContent); + + const path = `${buildPathName(PAGE_ID.PRODUCT_PAGE, this.params.key, { + size: [currentVariant?.size ?? this.params.variant[0].size], + })}`; + RouterModel.getInstance().navigateTo(path); + modal.hide(); + this.currentVariant = currentVariant ?? this.params.variant[0]; + this.params.currentSize = currentVariant?.size ?? this.params.variant[0].size; + this.view.updateParams(this.params); + this.price.getHTML().remove(); + this.price = new ProductPriceModel(this.currentVariant); + this.view.getRightWrapper().append(this.price.getHTML()); + }); + }); } private switchToCartButtonHandler(): void { @@ -152,10 +176,12 @@ class ProductInfoModel { getCartModel() .getCart() .then((cart) => { - if (cart.products.every((product) => product.key !== this.params.key)) { - this.addProductToCart(); - } else { + if ( + cart.products.find((product) => product.key === this.params.key && product.size === this.params.currentSize) + ) { this.deleteProductFromCart(cart); + } else { + this.addProductToCart(); } }) .catch(showErrorMessage); diff --git a/src/widgets/ProductInfo/view/ProductInfoView.ts b/src/widgets/ProductInfo/view/ProductInfoView.ts index 7c3a6cc4..4968af4c 100644 --- a/src/widgets/ProductInfo/view/ProductInfoView.ts +++ b/src/widgets/ProductInfo/view/ProductInfoView.ts @@ -12,11 +12,12 @@ import { BUTTON_TEXT } from '@/shared/constants/buttons.ts'; import { AUTOCOMPLETE_OPTION, LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; -import { PRODUCT_INFO_TEXT } from '@/shared/constants/product.ts'; +import { PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS } from '@/shared/constants/product.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import showErrorMessage from '@/shared/utils/userMessage.ts'; import './productInfoView.scss'; @@ -42,6 +43,8 @@ class ProductInfoView { private shortDescription: HTMLParagraphElement; + private sizeButtons: ButtonModel[] = []; + private smallSlider: HTMLDivElement; private switchToCartButton: ButtonModel; @@ -134,10 +137,12 @@ class ProductInfoView { private createCategoriesSpan(): HTMLSpanElement { this.categoriesSpan = createBaseElement({ cssClasses: ['categoriesSpan'], - innerContent: 'Categories: ', + innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].CATEGORY, tag: 'span', }); + observeCurrentLanguage(this.categoriesSpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS.CATEGORY); + const category = this.params.category[0].parent?.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; const subcategory = @@ -164,6 +169,23 @@ class ProductInfoView { return this.categoriesSpan; } + private createDifficultyPoints(): HTMLSpanElement[] { + const difficultyPoints: HTMLSpanElement[] = []; + for (let index = 0; index < Number(this.params.level); index += 1) { + const difficultyPoint = createBaseElement({ + cssClasses: ['difficultyPoint'], + tag: 'span', + }); + + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.LEAVES)); + difficultyPoint.append(svg); + + difficultyPoints.push(difficultyPoint); + } + return difficultyPoints; + } + private createHTML(): HTMLDivElement { this.view = createBaseElement({ cssClasses: ['wrapper'], @@ -183,7 +205,7 @@ class ProductInfoView { private createProductTitle(): HTMLHeadingElement { this.title = createBaseElement({ - cssClasses: ['title'], + cssClasses: ['productTitle'], innerContent: this.params.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, tag: 'h3', }); @@ -216,6 +238,19 @@ class ProductInfoView { } }); + if (this.params.level) { + const difficultySpan = createBaseElement({ + cssClasses: ['difficultySpan'], + innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DIFFICULTY, + tag: 'span', + }); + + observeCurrentLanguage(difficultySpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS.DIFFICULTY); + + difficultySpan.append(...this.createDifficultyPoints()); + this.rightWrapper.append(difficultySpan); + } + shortDescriptionWrapper.append(this.shortDescription); this.rightWrapper.append(this.title, shortDescriptionWrapper); @@ -287,6 +322,17 @@ class ProductInfoView { button.setDisabled(); button.getHTML().classList.add('selected'); } + + button.getHTML().addEventListener('click', () => { + this.sizeButtons.forEach((btn) => { + btn.setEnabled(); + btn.getHTML().classList.remove('selected'); + }); + button.getHTML().classList.add('selected'); + button.setDisabled(); + }); + + this.sizeButtons.push(button); return button; } @@ -393,12 +439,14 @@ class ProductInfoView { getCartModel() .getCart() .then((cart) => { - if (cart.products.every((product) => product.key !== this.params.key)) { + if ( + cart.products.find((product) => product.key === this.params.key && product.size === this.params.currentSize) + ) { this.switchToCartButton.getHTML().textContent = - BUTTON_TEXT[getStore().getState().currentLanguage].ADD_PRODUCT; + BUTTON_TEXT[getStore().getState().currentLanguage].DELETE_PRODUCT; } else { this.switchToCartButton.getHTML().textContent = - BUTTON_TEXT[getStore().getState().currentLanguage].DELETE_PRODUCT; + BUTTON_TEXT[getStore().getState().currentLanguage].ADD_PRODUCT; } }) .catch(showErrorMessage); @@ -430,6 +478,10 @@ class ProductInfoView { return this.rightWrapper; } + public getSizeButtons(): ButtonModel[] { + return this.sizeButtons; + } + public getSmallSlider(): HTMLDivElement { return this.smallSlider; } @@ -453,6 +505,11 @@ class ProductInfoView { this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[getStore().getState().currentLanguage].ADD_PRODUCT; } } + + public updateParams(params: ProductInfoParams): void { + this.params = params; + this.hasProductInToCart(); + } } export default ProductInfoView; diff --git a/src/widgets/ProductInfo/view/productInfoView.scss b/src/widgets/ProductInfo/view/productInfoView.scss index 6564408d..d8af7842 100644 --- a/src/widgets/ProductInfo/view/productInfoView.scss +++ b/src/widgets/ProductInfo/view/productInfoView.scss @@ -6,25 +6,42 @@ justify-content: center; margin-bottom: var(--small-offset); gap: var(--small-offset); + + @media (max-width: 768px) { + flex-direction: column; + } } .leftWrapper { display: flex; max-width: 50%; + + @media (max-width: 768px) { + max-width: 100%; + } } .rightWrapper { display: flex; flex-direction: column; width: 40%; + gap: var(--extra-small-offset); + + @media (max-width: 768px) { + width: 100%; + gap: var(--tiny-offset); + } } -.title { +.productTitle { order: 1; - margin-bottom: var(--extra-small-offset); font: var(--medium-font); letter-spacing: var(--one); color: var(--steam-green-500); + + @media (max-width: 768px) { + text-align: center; + } } .smallSlider { @@ -77,25 +94,38 @@ font: var(--regular-font); letter-spacing: var(--one); color: var(--steam-green-400); + + @media (max-width: 768px) { + text-align: center; + } } .shortDescription, .fullDescription { - margin-bottom: var(--extra-small-offset); font: var(--regular-font); color: var(--noble-gray-800); + + @media (max-width: 768px) { + text-align: center; + } } .sizesWrapper { display: grid; order: 4; grid-template-columns: max-content; - margin-bottom: var(--extra-small-offset); width: max-content; font: var(--regular-font); letter-spacing: var(--one); color: var(--steam-green-400); gap: var(--tiny-offset); + + @media (max-width: 768px) { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + } } .sizeButton { @@ -123,6 +153,10 @@ color: var(--steam-green-400); } } + + @media (max-width: 768px) { + grid-row: none; + } } .selected { @@ -134,10 +168,13 @@ /* stylelint-disable-next-line selector-class-pattern */ .SKUSpan, .categoriesSpan { - margin-bottom: var(--extra-small-offset); font: var(--regular-font); letter-spacing: var(--one); color: var(--noble-gray-800); + + @media (max-width: 768px) { + align-self: center; + } } /* stylelint-disable-next-line selector-class-pattern */ @@ -183,23 +220,28 @@ @include green-btn; padding: calc(var(--small-offset) / 3) calc(var(--small-offset) / 2); + width: 100%; + max-width: 12rem; } .buttonsWrapper { display: flex; align-items: center; order: 5; - margin-bottom: var(--extra-small-offset); gap: var(--tiny-offset); + + @media (max-width: 768px) { + align-self: center; + } } .switchToWishListButton { - outline: var(--two) solid var(--noble-gray-700); + outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); border-radius: var(--medium-br); padding: var(--tiny-offset); width: var(--small-offset); height: var(--small-offset); - background-color: var(--noble-gray-1000); + background-color: var(--white-tr); transition: transform 0.2s, outline 0.2s; @@ -218,7 +260,7 @@ @media (hover: hover) { &:hover { - outline: var(--two) solid var(--red-power-600); + outline: calc(var(--one) * 1.5) solid var(--red-power-600); svg { fill: var(--red-power-600); @@ -229,7 +271,7 @@ &.inWishList { @media (hover: hover) { &:hover { - outline: var(--two) solid var(--noble-gray-700); + outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); svg { fill: var(--noble-gray-700); @@ -240,7 +282,7 @@ } .inWishList { - outline: var(--two) solid var(--red-power-600); + outline: calc(var(--one) * 1.5) solid var(--red-power-600); svg { fill: var(--red-power-600); @@ -252,10 +294,35 @@ margin-bottom: 0; border-radius: var(--medium-br); padding: var(--extra-small-offset); - background-color: var(--noble-gray-1000); + background-color: var(--white-tr); } } .hidden { display: none; } + +.difficultySpan { + display: flex; + align-items: center; + order: 3; + font: var(--regular-font); + letter-spacing: var(--one); + color: var(--steam-green-400); + gap: var(--one); + + @media (max-width: 768px) { + align-self: center; + } +} + +.difficultyPoint { + width: var(--extra-small-offset); + height: var(--extra-small-offset); + + svg { + width: var(--extra-small-offset); + height: var(--extra-small-offset); + fill: var(--steam-green-400); + } +} diff --git a/src/widgets/ProductOrder/model/ProductOrderModel.ts b/src/widgets/ProductOrder/model/ProductOrderModel.ts index 8b1982c2..89817414 100644 --- a/src/widgets/ProductOrder/model/ProductOrderModel.ts +++ b/src/widgets/ProductOrder/model/ProductOrderModel.ts @@ -1,79 +1,113 @@ -import type { CartProduct, EditCartItem } from '@/shared/types/cart.ts'; +import type { Cart, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import { CartActive } from '@/shared/types/cart.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; import ProductOrderView from '../view/ProductOrderView.ts'; -type CallbackQuantity = () => Promise; - -export type CallbackList = { - delete: CallbackQuantity; - minus: CallbackQuantity; - plus: CallbackQuantity; -}; +type Callback = (cart: Cart) => void; class ProductOrderModel { + private callback: Callback; + private productItem: CartProduct; private view: ProductOrderView; - constructor(productItem: CartProduct) { + constructor(productItem: CartProduct, callback: Callback) { + this.callback = callback; this.productItem = productItem; - const callbackList: CallbackList = { - delete: this.deleteClickHandler.bind(this), - minus: this.minusClickHandler.bind(this), - plus: this.plusClickHandler.bind(this), - }; - this.view = new ProductOrderView(this.productItem, callbackList); + this.view = new ProductOrderView(this.productItem, this.updateProductHandler.bind(this)); this.init(); } - private init(): void {} - - public async deleteClickHandler(): Promise { - const cart = await getCartModel().deleteProductFromCart(this.productItem); - const updateItem = cart.products.find((item) => item.lineItemId === this.productItem.lineItemId); - if (updateItem) { - this.productItem = updateItem; - this.view.updateQuantity(this.productItem.quantity); - } else { - this.getHTML().remove(); - } - } - - public getHTML(): HTMLDivElement { - return this.view.getHTML(); + private async activeDelete(): Promise { + const loader = new LoaderModel(LOADER_SIZE.EXTRA_SMALL).getHTML(); + this.view.getDeleteButton().append(loader); + await getCartModel() + .deleteProductFromCart(this.productItem) + .then((cart) => { + if (cart) { + serverMessageModel.showServerMessage( + SERVER_MESSAGE_KEYS.SUCCESSFUL_DELETE_PRODUCT_FROM_CART, + MESSAGE_STATUS.SUCCESS, + ); + const updateItem = cart.products.find((item) => item.lineItemId === this.productItem.lineItemId); + this.updateView(updateItem); + this.callback(cart); + } + }) + .catch((error) => { + showErrorMessage(error); + }) + .finally(() => loader.remove()); } - public getProduct(): CartProduct { - return this.productItem; - } - - public async minusClickHandler(): Promise { + private async activeMinus(): Promise { const active: EditCartItem = { lineId: this.productItem.lineItemId, quantity: this.productItem.quantity - 1, }; const cart = await getCartModel().editProductCount(active); const updateItem = cart.products.find((item) => item.lineItemId === this.productItem.lineItemId); - if (updateItem) { - this.productItem = updateItem; - this.view.updateQuantity(this.productItem.quantity); - } else { - this.getHTML().remove(); - } + this.updateView(updateItem); + this.callback(cart); } - public async plusClickHandler(): Promise { + private async activePlus(): Promise { const active: EditCartItem = { lineId: this.productItem.lineItemId, quantity: this.productItem.quantity + 1, }; const cart = await getCartModel().editProductCount(active); const updateItem = cart.products.find((item) => item.lineItemId === this.productItem.lineItemId); - if (updateItem) { - this.productItem = updateItem; - this.view.updateQuantity(this.productItem.quantity); + this.updateView(updateItem); + this.callback(cart); + } + + private init(): void { + observeStore(selectCurrentLanguage, () => this.view.updateLanguage()); + } + + private updateView(item: CartProduct | undefined): void { + if (item) { + this.productItem = item; + this.view.updateInfo(this.productItem); + } else { + this.getHTML().remove(); + } + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } + + public getProduct(): CartProduct { + return this.productItem; + } + + public async updateProductHandler(active: CartActive): Promise { + switch (active) { + case CartActive.DELETE: { + await this.activeDelete(); + break; + } + case CartActive.MINUS: { + await this.activeMinus(); + break; + } + case CartActive.PLUS: { + await this.activePlus(); + break; + } + default: + break; } } } diff --git a/src/widgets/ProductOrder/view/ProductOrderView.ts b/src/widgets/ProductOrder/view/ProductOrderView.ts index 31c5e3c9..898867a2 100644 --- a/src/widgets/ProductOrder/view/ProductOrderView.ts +++ b/src/widgets/ProductOrder/view/ProductOrderView.ts @@ -1,78 +1,143 @@ +import type { LanguageChoiceType } from '@/shared/constants/common.ts'; import type { CartProduct } from '@/shared/types/cart'; +import type { languageVariants } from '@/shared/types/common'; +import LinkModel from '@/shared/Link/model/LinkModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import { LANGUAGE_CHOICE, TABLET_WIDTH } from '@/shared/constants/common.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; +import { CartActive } from '@/shared/types/cart.ts'; +import { buildPathName } from '@/shared/utils/buildPathname.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; - -import type { CallbackList } from '../model/ProductOrderModel'; +import Hammer from 'hammerjs'; import styles from './productOrderView.module.scss'; +type CallbackActive = (active: CartActive) => Promise; + +type textElementsType = { + element: HTMLTableCellElement; + textItem: languageVariants; +}; + +const TITLE = { + MINUS: '-', + NAME: { + en: '', + ru: '', + }, + PLUS: '+', + SIZE: { + en: 'Size', + ru: 'Π Π°Π·ΠΌΠ΅Ρ€', + }, +}; class ProductOrderView { - private callbackList: CallbackList; + private callback: CallbackActive; + + private deleteButton: HTMLButtonElement; + + private language: LanguageChoiceType; + + private price: HTMLTableCellElement; + + private productItem: CartProduct; private quantity: HTMLParagraphElement; + private textElement: textElementsType[] = []; + + private total: HTMLTableCellElement; + private view: HTMLTableRowElement; - constructor(productItem: CartProduct, callbackList: CallbackList) { - this.callbackList = callbackList; + constructor(productItem: CartProduct, callback: CallbackActive) { + this.productItem = productItem; + this.language = getStore().getState().currentLanguage; + this.callback = callback; this.quantity = createBaseElement({ cssClasses: [styles.quantityCell, styles.quantityText], - innerContent: productItem.quantity.toString(), + innerContent: this.productItem.quantity.toString(), tag: 'p', }); - this.view = this.createHTML(productItem); + this.price = createBaseElement({ + cssClasses: [styles.td, styles.priceCell, styles.priceText], + innerContent: `$${this.productItem.price.toFixed(2)}`, + tag: 'td', + }); + this.total = createBaseElement({ + cssClasses: [styles.td, styles.totalCell, styles.totalText], + innerContent: `$${this.productItem.totalPrice.toFixed(2)}`, + tag: 'td', + }); + this.deleteButton = createBaseElement({ cssClasses: [styles.deleteButton], tag: 'button' }); + this.view = this.createHTML(); } private createDeleCell(): HTMLTableCellElement { - const tdDelete = createBaseElement({ cssClasses: [styles.td, styles.deleteCell], tag: 'td' }); - const deleteButton = createBaseElement({ cssClasses: [styles.deleteButton], tag: 'button' }); - deleteButton.addEventListener('click', () => this.callbackList.delete()); - tdDelete.append(deleteButton); + const tdDelete = createBaseElement({ cssClasses: [styles.td, styles.deleteCell, styles.hide], tag: 'td' }); + this.deleteButton.addEventListener('click', () => this.callback(CartActive.DELETE)); + tdDelete.append(this.deleteButton); const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); svg.append(createSVGUse(SVG_DETAILS.DELETE)); - deleteButton.append(svg); + this.deleteButton.append(svg); return tdDelete; } - private createHTML(productItem: CartProduct): HTMLTableRowElement { + private createHTML(): HTMLTableRowElement { this.view = createBaseElement({ cssClasses: [styles.tr, styles.trProduct], tag: 'tr' }); - const imgCell = this.createImgCell(productItem); + const imgCell = this.createImgCell(); const tdProduct = createBaseElement({ cssClasses: [styles.td, styles.nameCell, styles.mainText], - innerContent: productItem.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, + innerContent: this.productItem.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, tag: 'td', }); const tdSize = createBaseElement({ cssClasses: [styles.td, styles.sizeCell, styles.sizeText], - innerContent: productItem.size ? `Size: ${productItem.size}` : '', - tag: 'td', - }); - const tdPrice = createBaseElement({ - cssClasses: [styles.td, styles.priceCell, styles.priceText], - innerContent: `$${productItem.price.toFixed(2)}`, + innerContent: this.productItem.size ? `${TITLE.SIZE[this.language]}: ${this.productItem.size}` : '', tag: 'td', }); + this.textElement.push({ element: tdSize, textItem: TITLE.SIZE }); + this.textElement.push({ element: tdProduct, textItem: TITLE.NAME }); const quantityCell = this.createQuantityCell(); - const tdTotal = createBaseElement({ - cssClasses: [styles.td, styles.totalCell, styles.totalText], - innerContent: `$${productItem.totalPrice.toFixed(2)}`, - tag: 'td', - }); const deleteCell = this.createDeleCell(); - this.view.append(imgCell, tdProduct, tdSize, tdPrice, quantityCell, tdTotal, deleteCell); + this.view.append(imgCell, tdProduct, tdSize, this.price, quantityCell, this.total, deleteCell); + const animation = new Hammer(this.view); + animation.on('swipeleft', () => { + if (window.innerWidth <= TABLET_WIDTH) { + this.view.classList.add(styles.swipeRow); + deleteCell.classList.add(styles.swipeDelete); + deleteCell.classList.remove(styles.hide); + } + }); + animation.on('swiperight', () => { + if (window.innerWidth <= TABLET_WIDTH) { + this.view.classList.remove(styles.swipeRow); + deleteCell.classList.remove(styles.swipeDelete); + deleteCell.classList.add(styles.hide); + } + }); return this.view; } - private createImgCell(productItem: CartProduct): HTMLTableCellElement { + private createImgCell(): HTMLTableCellElement { const tdImage = createBaseElement({ cssClasses: [styles.td, styles.imgCell], tag: 'td' }); + const href = `${buildPathName(PAGE_ID.PRODUCT_PAGE, this.productItem.key, { + size: [this.productItem.size], + })}`; + const link = new LinkModel({ + attrs: { + href, + }, + classes: [styles.goDetailsPageLink], + }); const img = createBaseElement({ cssClasses: [styles.img], tag: 'img' }); - img.src = productItem.images; - img.alt = productItem.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; - tdImage.append(img); + img.src = this.productItem.images; + img.alt = this.productItem.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + link.getHTML().append(img); + tdImage.append(link.getHTML()); return tdImage; } @@ -83,26 +148,45 @@ class ProductOrderView { }); const plusButton = createBaseElement({ cssClasses: [styles.quantityCell, styles.quantityButton], - innerContent: '+', + innerContent: TITLE.PLUS, tag: 'button', }); const minusButton = createBaseElement({ cssClasses: [styles.quantityCell, styles.quantityButton], - innerContent: '-', + innerContent: TITLE.MINUS, tag: 'button', }); tdQuantity.append(minusButton, this.quantity, plusButton); - plusButton.addEventListener('click', () => this.callbackList.plus()); - minusButton.addEventListener('click', () => this.callbackList.minus()); + plusButton.addEventListener('click', () => this.callback(CartActive.PLUS)); + minusButton.addEventListener('click', () => this.callback(CartActive.MINUS)); return tdQuantity; } + public getDeleteButton(): HTMLButtonElement { + return this.deleteButton; + } + public getHTML(): HTMLDivElement { return this.view; } - public updateQuantity(quantity: number): void { - this.quantity.textContent = quantity.toString(); + public updateInfo(productItem: CartProduct): void { + this.productItem = productItem; + this.quantity.textContent = this.productItem.quantity.toString(); + this.price.textContent = `$${this.productItem.price.toFixed(2)}`; + this.total.textContent = `$${this.productItem.totalPrice.toFixed(2)}`; + } + + public updateLanguage(): void { + this.language = getStore().getState().currentLanguage; + this.textElement.forEach((textEl) => { + const elHTML = textEl.element; + if (textEl.textItem === TITLE.SIZE) { + elHTML.textContent = this.productItem.size ? `${TITLE.SIZE[this.language]}: ${this.productItem.size}` : ''; + } else if (textEl.textItem === TITLE.NAME) { + elHTML.textContent = this.productItem.name[Number(this.language === LANGUAGE_CHOICE.RU)].value; + } + }); } } diff --git a/src/widgets/ProductOrder/view/productOrderView.module.scss b/src/widgets/ProductOrder/view/productOrderView.module.scss index b78e8a62..02523d78 100644 --- a/src/widgets/ProductOrder/view/productOrderView.module.scss +++ b/src/widgets/ProductOrder/view/productOrderView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .style { display: block; } @@ -6,11 +8,37 @@ display: flex; align-items: center; justify-content: center; + padding: 0; +} + +.mainText { + padding: var(--tiny-offset); + font: var(--extra-font); + color: var(--noble-gray-800); +} + +.swipeRow { + transform: translateX(calc(var(--extra-large-offset) * -1)); +} + +.swipeDelete { + transform: translateX(calc(var(--extra-large-offset) * 1.5)); } .deleteCell { grid-area: 2 / 6 / 4 / 7; + @media (max-width: 768px) { + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + grid-area: 1 / 3 / 4 / 4; + transition: all 0.3s; + } +} + +.hide { @media (max-width: 768px) { display: none; } @@ -21,7 +49,15 @@ width: var(--extra-small-offset); height: var(--extra-small-offset); stroke: var(--noble-gray-800); - transition: fill 0.2s; + transition: + fill 0.2s, + stroke 0.2s; + + @media (max-width: 768px) { + width: var(--small-offset); + height: var(--small-offset); + stroke: var(--steam-green-800); + } } &:hover { @@ -34,33 +70,35 @@ .tr { display: grid; grid-gap: 0; - grid-template-columns: 70px 2fr 1fr 1fr 1fr 70px; + grid-template-columns: calc(var(--tiny-offset) * 10) 2fr 1fr 1fr 1fr calc(var(--tiny-offset) * 7); margin-bottom: var(--tiny-offset); width: 100%; @media (max-width: 768px) { - grid-template-columns: 100px 2fr 1fr; + grid-template-columns: calc(var(--tiny-offset) * 10) 2fr 1fr; } } .trProduct { - background-color: var(--noble-white-200); + background-color: var(--white); + transition: transform 0.3s ease-out; @media (max-width: 768px) { margin-bottom: var(--extra-small-offset); - border-radius: 14px; - box-shadow: var(--mellow-shadow-100); + border-radius: var(--medium-br); + box-shadow: var(--mellow-shadow-050); } } .img { - max-width: 70px; - max-height: 70px; + display: block; + width: 100%; + height: 100%; @media (max-width: 768px) { - border-radius: 14px 0 0 14px; - max-width: 100px; - max-height: 100px; + border-radius: var(--medium-br) 0 0 var(--medium-br); + width: 100%; + height: 100%; } } @@ -71,6 +109,8 @@ @media (max-width: 768px) { grid-area: 1 / 2 / 2 / 3; + padding: var(--tiny-offset); + padding-bottom: 0; } } @@ -118,12 +158,6 @@ } } -.mainText { - padding: var(--tiny-offset); - font: var(--extra-font); - color: var(--noble-gray-800); -} - .quantityText { padding: var(--tiny-offset); font: var(--regular-font); @@ -132,7 +166,7 @@ .totalText { padding: var(--tiny-offset); - font: var(--regular-font); + font: var(--bold-font); color: var(--steam-green-800); } @@ -147,14 +181,20 @@ font: var(--regular-font); text-align: left; color: var(--noble-gray-700); + + @media (max-width: 768px) { + padding-top: 0; + } } .quantityButton { - border-radius: 29px; - width: 22px; - height: 25px; - color: var(--noble-gray-200); - background-color: var(--steam-green-800); + @include green-btn; + + border-radius: 50%; + padding: 0; + width: calc(var(--tiny-offset) * 2.5); + height: calc(var(--tiny-offset) * 2.5); + font: var(--regular-font); } .mobileHide { diff --git a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts index 17f79169..322d2e3c 100644 --- a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts +++ b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts @@ -17,14 +17,20 @@ import { ADDRESS_TYPE } from '@/shared/types/address.ts'; import RegistrationFormView from '../view/RegistrationFormView.ts'; class RegisterFormModel { - private addressWrappers: Record = { - [ADDRESS_TYPE.BILLING]: new AddressModel(ADDRESS_TYPE.BILLING, { - setDefault: true, - }), - [ADDRESS_TYPE.SHIPPING]: new AddressModel(ADDRESS_TYPE.SHIPPING, { - setAsBilling: true, - setDefault: true, - }), + private addressWrappers: Record, AddressModel> = { + [ADDRESS_TYPE.BILLING]: new AddressModel( + { + setDefault: true, + }, + ADDRESS_TYPE.BILLING, + ), + [ADDRESS_TYPE.SHIPPING]: new AddressModel( + { + setAsBilling: true, + setDefault: true, + }, + ADDRESS_TYPE.SHIPPING, + ), }; private credentialsWrapper = new CredentialsModel(); diff --git a/src/widgets/UserAddresses/model/UserAddressesModel.ts b/src/widgets/UserAddresses/model/UserAddressesModel.ts index dbbf6d9f..db8641ea 100644 --- a/src/widgets/UserAddresses/model/UserAddressesModel.ts +++ b/src/widgets/UserAddresses/model/UserAddressesModel.ts @@ -1,35 +1,82 @@ -import type { UserAddressType } from '@/shared/constants/forms.ts'; +import type { AddressTypeType } from '@/shared/constants/forms.ts'; import type { Address, User } from '@/shared/types/user.ts'; import UserAddressModel from '@/entities/UserAddress/model/UserAddressModel.ts'; +import AddressAddModel from '@/features/AddressAdd/model/AddressAddModel.ts'; +import getCustomerModel from '@/shared/API/customer/model/CustomerModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; -import { USER_ADDRESS_TYPE } from '@/shared/constants/forms.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { ADDRESS_TYPE, DEFAULT_ADDRESS } from '@/shared/constants/forms.ts'; +import clearOutElement from '@/shared/utils/clearOutElement.ts'; +import determineNewAddress from '@/shared/utils/determineNewAddress.ts'; +import { arrayContainsObjectWithValue, objectHasPropertyValue } from '@/shared/utils/hasValue.ts'; +import showErrorMessage from '@/shared/utils/userMessage.ts'; import UserAddressesView from '../view/UserAddressesView.ts'; class UserAddressesModel { - private currentUser: User; - - private view: UserAddressesView; + private view = new UserAddressesView(); constructor(user: User) { - this.currentUser = user; - this.view = new UserAddressesView(); - this.createCurrentAddresses(); - this.setCreateBillingAddressHandler(); - this.setCreateShippingAddressHandler(); + this.init(user); } - private createAddresses(addresses: Address[], type: UserAddressType, defaultAddressId = ''): void { + private createCurrentAddresses(user: User): void { + const { addresses } = user; + addresses.forEach((address) => { - this.getHTML().append(new UserAddressModel(this.currentUser, address, type, defaultAddressId).getHTML()); + const KEY_TO_FIND = 'id'; + const addressesContainsID = (array: Address[]): boolean => + arrayContainsObjectWithValue(array, KEY_TO_FIND, address.id); + const defaultContainsID = (defaultAddress: Address | null): boolean => { + if (!defaultAddress) { + return false; + } + return objectHasPropertyValue(defaultAddress, KEY_TO_FIND, address.id); + }; + + const createAddress = (activeTypes: AddressTypeType[], inactiveTypes?: AddressTypeType[]): UserAddressModel => + new UserAddressModel( + address, + activeTypes, + (isDisabled: boolean) => { + this.view.toggleState(isDisabled); + }, + inactiveTypes, + ); + + const newAddress = determineNewAddress(addressesContainsID, defaultContainsID, user, createAddress); + + if (newAddress) { + this.view.getAddressesListWrapper().append(newAddress.getHTML()); + } }); } - private createCurrentAddresses(): void { - const { billingAddress, defaultBillingAddressId, defaultShippingAddressId, shippingAddress } = this.currentUser; - this.createAddresses(billingAddress, USER_ADDRESS_TYPE.BILLING, defaultBillingAddressId?.id); - this.createAddresses(shippingAddress, USER_ADDRESS_TYPE.SHIPPING, defaultShippingAddressId?.id); + private init(user: User): void { + this.createCurrentAddresses(user); + this.setCreateBillingAddressHandler(); + this.setCreateShippingAddressHandler(); + + EventMediatorModel.getInstance().subscribe( + MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, + this.redrawUserAddresses.bind(this), + ); + } + + private async redrawUserAddresses(): Promise { + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + clearOutElement(this.view.getAddressesListWrapper()); + this.createCurrentAddresses(user); + } + } catch (error) { + showErrorMessage(error); + } finally { + this.view.toggleState(false); + } } private setCreateBillingAddressHandler(): void { @@ -37,7 +84,9 @@ class UserAddressesModel { .getCreateBillingAddressButton() .getHTML() .addEventListener('click', () => { - modal.show(); // TBD Add new address feature + const newAddressForm = new AddressAddModel(ADDRESS_TYPE.BILLING, DEFAULT_ADDRESS).getHTML(); + modal.show(); + modal.setContent(newAddressForm); }); } @@ -46,7 +95,9 @@ class UserAddressesModel { .getCreateShippingAddressButton() .getHTML() .addEventListener('click', () => { - modal.show(); // TBD Add new address feature + const newAddressForm = new AddressAddModel(ADDRESS_TYPE.SHIPPING, DEFAULT_ADDRESS).getHTML(); + modal.show(); + modal.setContent(newAddressForm); }); } diff --git a/src/widgets/UserAddresses/view/UserAddressesView.ts b/src/widgets/UserAddresses/view/UserAddressesView.ts index 38760ca6..c199509a 100644 --- a/src/widgets/UserAddresses/view/UserAddressesView.ts +++ b/src/widgets/UserAddresses/view/UserAddressesView.ts @@ -1,31 +1,63 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import SVG_DETAILS from '@/shared/constants/svg.ts'; +import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; -import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; import styles from './userAddressesView.module.scss'; class UserAddressView { + private addressesListWrapper: HTMLUListElement; + private addressesWrapper: HTMLDivElement; + private billingLogo: HTMLDivElement; + private createBillingAddressButton: ButtonModel; private createShippingAddressButton: ButtonModel; + private shippingLogo: HTMLDivElement; + constructor() { + this.billingLogo = this.createBillingLogo(); + this.shippingLogo = this.createShippingLogo(); this.createBillingAddressButton = this.createCreateBillingAddressButton(); this.createShippingAddressButton = this.createCreateShippingAddressButton(); + this.addressesListWrapper = this.createAddressesListWrapper(); this.addressesWrapper = this.createHTML(); } + private createAddressesListWrapper(): HTMLUListElement { + this.addressesListWrapper = createBaseElement({ + cssClasses: [styles.addressesListWrapper], + tag: 'ul', + }); + return this.addressesListWrapper; + } + + private createBillingLogo(): HTMLDivElement { + this.billingLogo = createBaseElement({ cssClasses: [styles.billingLogo], tag: 'div' }); + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.BILL)); + this.billingLogo.append(svg); + return this.billingLogo; + } + private createCreateBillingAddressButton(): ButtonModel { this.createBillingAddressButton = new ButtonModel({ classes: [styles.createAddressButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].NEW_ADDRESS, + title: TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_BILLING_ADDRESS, }); - observeCurrentLanguage(this.createBillingAddressButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.NEW_ADDRESS); + this.createBillingAddressButton.getHTML().append(this.billingLogo); + + observeStore(selectCurrentLanguage, () => { + this.createBillingAddressButton.getHTML().title = + TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_BILLING_ADDRESS; + }); return this.createBillingAddressButton; } @@ -33,9 +65,16 @@ class UserAddressView { private createCreateShippingAddressButton(): ButtonModel { this.createShippingAddressButton = new ButtonModel({ classes: [styles.createAddressButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].NEW_ADDRESS, + title: TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_SHIPPING_ADDRESS, }); - observeCurrentLanguage(this.createShippingAddressButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.NEW_ADDRESS); + + this.createShippingAddressButton.getHTML().append(this.shippingLogo); + + observeStore(selectCurrentLanguage, () => { + this.createShippingAddressButton.getHTML().title = + TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_SHIPPING_ADDRESS; + }); + return this.createShippingAddressButton; } @@ -44,11 +83,26 @@ class UserAddressView { cssClasses: [styles.addressesWrapper, styles.hidden], tag: 'div', }); - - this.addressesWrapper.append(this.createBillingAddressButton.getHTML(), this.createShippingAddressButton.getHTML()); + this.addressesWrapper.append( + this.createBillingAddressButton.getHTML(), + this.createShippingAddressButton.getHTML(), + this.addressesListWrapper, + ); return this.addressesWrapper; } + private createShippingLogo(): HTMLDivElement { + this.shippingLogo = createBaseElement({ cssClasses: [styles.shippingLogo], tag: 'div' }); + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.DELIVERY)); + this.shippingLogo.append(svg); + return this.shippingLogo; + } + + public getAddressesListWrapper(): HTMLUListElement { + return this.addressesListWrapper; + } + public getCreateBillingAddressButton(): ButtonModel { return this.createBillingAddressButton; } @@ -68,6 +122,10 @@ class UserAddressView { public show(): void { this.addressesWrapper.classList.remove(styles.hidden); } + + public toggleState(isDisabled: boolean): void { + this.addressesListWrapper.classList.toggle(styles.disabled, isDisabled); + } } export default UserAddressView; diff --git a/src/widgets/UserAddresses/view/userAddressesView.module.scss b/src/widgets/UserAddresses/view/userAddressesView.module.scss index 7cd18f31..94cf7114 100644 --- a/src/widgets/UserAddresses/view/userAddressesView.module.scss +++ b/src/widgets/UserAddresses/view/userAddressesView.module.scss @@ -1,21 +1,25 @@ +@import 'src/app/styles/mixins'; + .addressesWrapper { position: relative; display: grid; align-items: center; grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(auto); - margin: 0 var(--small-offset); padding: var(--small-offset); - width: 60%; + width: 100%; height: max-content; - box-shadow: var(--mellow-shadow-100); font: var(--extra-regular-font); letter-spacing: 1px; color: var(--noble-gray-900); - background-color: var(--noble-gray-tr-800); + background-color: var(--steam-green-900); gap: var(--small-offset); } +.shippingLogo, +.billingLogo { + @include svg-logo; +} + .hidden { display: none; opacity: 0; @@ -23,31 +27,19 @@ } .createAddressButton { - margin: 0 auto; - border-radius: var(--medium-br); - padding: calc(var(--small-offset) / 3) var(--small-offset); - height: max-content; - max-width: max-content; - font: var(--regular-font); - letter-spacing: 1px; - color: var(--white); - background-color: var(--steam-green-800); - transition: - color 0.2s, - background-color 0.2s; - - &:focus { - background-color: var(--steam-green-700); - } + @include round-btn; +} - @media (hover: hover) { - &:hover { - background-color: var(--steam-green-700); - } - } +.addressesListWrapper { + position: relative; + display: flex; + flex-direction: column; + justify-self: center; + grid-column: 2 span; + width: 100%; + gap: var(--extra-small-offset); +} - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; - } +.disabled { + pointer-events: none; } diff --git a/src/widgets/UserInfo/model/UserInfoModel.ts b/src/widgets/UserInfo/model/UserInfoModel.ts index 4a22a891..4e5d53a2 100644 --- a/src/widgets/UserInfo/model/UserInfoModel.ts +++ b/src/widgets/UserInfo/model/UserInfoModel.ts @@ -32,18 +32,18 @@ class UserInfoModel { EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.REDRAW_USER_INFO, this.redrawUserInfo.bind(this)); } - private redrawUserInfo(): void { - getCustomerModel() - .getCurrentUser() - .then((user) => { - if (user) { - this.view.getFirstName().textContent = userInfoName(user.firstName); - this.view.getLastName().textContent = userInfoLastName(user.lastName); - this.view.getEmail().textContent = userInfoEmail(user.email); - this.view.getBirthDate().textContent = userInfoDateOfBirth(user.birthDate); - } - }) - .catch(showErrorMessage); + private async redrawUserInfo(): Promise { + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + this.view.getFirstName().textContent = userInfoName(user.firstName); + this.view.getLastName().textContent = userInfoLastName(user.lastName); + this.view.getEmail().textContent = userInfoEmail(user.email); + this.view.getBirthDate().textContent = userInfoDateOfBirth(user.birthDate); + } + } catch (error) { + showErrorMessage(error); + } } private setEditInfoButtonHandler(): boolean { @@ -58,7 +58,7 @@ class UserInfoModel { modal.setContent(personalInfo.getHTML()); } } catch (error) { - showErrorMessage(); + showErrorMessage(error); } }); return true; diff --git a/src/widgets/UserInfo/view/userInfoView.module.scss b/src/widgets/UserInfo/view/userInfoView.module.scss index 4d0aeac5..f9567b4f 100644 --- a/src/widgets/UserInfo/view/userInfoView.module.scss +++ b/src/widgets/UserInfo/view/userInfoView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .userInfoWrapper { position: relative; display: grid; @@ -7,50 +9,30 @@ margin: 0 var(--small-offset); padding: var(--small-offset); width: 60%; - box-shadow: var(--mellow-shadow-100); font: var(--extra-regular-font); line-break: break-all; letter-spacing: var(--one); color: var(--noble-gray-900); - background-color: var(--noble-gray-tr-800); + background-color: var(--steam-green-900); gap: var(--small-offset); + + @media (max-width: 768px) { + margin-left: 0; + width: 100%; + } } .keyLogo { - display: flex; - margin: 0 auto; - - svg { - z-index: 1; - width: var(--small-offset); - height: var(--small-offset); - fill: var(--noble-gray-900); - transition: 0.2s; - } + @include svg-logo; } .editPasswordButton { - z-index: 2; + @include round-btn; + grid-column: 2; grid-row: 1; margin-right: 0; margin-left: auto; - border-radius: 50%; - padding: calc(var(--tiny-offset) * 1.5); - width: max-content; - height: max-content; - background-color: var(--noble-white-100); - transition: 0.2s; - - @media (hover: hover) { - &:hover { - background-color: var(--white-tr); - - svg { - fill: var(--steam-green-800); - } - } - } } .info { @@ -60,35 +42,10 @@ } .editInfoButton { + @include green-btn; + grid-column: 2 span; grid-row: 6; - margin: 0 auto; - border-radius: var(--medium-br); - padding: calc(var(--small-offset) / 3) var(--small-offset); - height: max-content; - max-width: max-content; - font: var(--regular-font); - letter-spacing: var(--one); - color: var(--white); - 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); - } - } - - &:disabled { - background-color: var(--noble-gray-300); - pointer-events: none; - } } .hidden {