diff --git a/.validate-branch-namerc.cjs b/.validate-branch-namerc.cjs index 2c0fefe4..ffc17199 100644 --- a/.validate-branch-namerc.cjs +++ b/.validate-branch-namerc.cjs @@ -1,6 +1,5 @@ module.exports = { - pattern: - /^sprint-4|^(feat|fix|hotfix|chore|refactor|revert|docs|style|test|)\(RSS-ECOMM-\d_\d{2}\)\/[a-z]+[a-zA-Z0-9]*$/, + pattern: /^(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', }; diff --git a/package.json b/package.json index 363df682..886d6e5d 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,7 @@ "typescript": "^5.4.5", "validate-branch-name": "^1.3.0", "vite": "^5.2.11", - "vite-plugin-sass": "^0.1.0", - "vitest": "^1.6.0" + "vite-plugin-sass": "^0.1.0" }, "dependencies": { "@commercetools/api-request-builder": "^6.0.0", @@ -66,6 +65,7 @@ "@commercetools/sdk-middleware-http": "^7.0.4", "@types/hammerjs": "^2.0.45", "@types/js-cookie": "^3.0.6", + "@types/sinon": "^17.0.3", "@types/uuid": "^9.0.8", "autoprefixer": "^10.4.19", "hammerjs": "^2.0.8", @@ -73,16 +73,19 @@ "js-cookie": "^3.0.5", "materialize-css": "^1.0.0-rc.2", "modern-normalize": "^2.0.0", + "msw": "^2.3.1", "nouislider": "^15.7.1", "plop": "^4.0.1", "postcode-validator": "^3.8.20", "sharp": "^0.33.3", + "sinon": "^18.0.0", "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", - "vite-tsconfig-paths": "^4.3.2" + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^1.6.0" }, "homepage": "https://github.com/stardustmeg/ecommerce-application#readme", "license": "ISC" diff --git a/postcss.config.js b/postcss.config.js index a47ef4f9..7738160a 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { autoprefixer: {}, }, diff --git a/public/img/png/expand-arrow.png b/public/img/png/expand-arrow.png new file mode 100644 index 00000000..ba917d13 Binary files /dev/null and b/public/img/png/expand-arrow.png differ diff --git a/public/img/png/kleostroAvatar.png b/public/img/png/kleostroAvatar.png new file mode 100644 index 00000000..a3e28253 Binary files /dev/null and b/public/img/png/kleostroAvatar.png differ diff --git a/public/img/png/mikaleinikAvatar.png b/public/img/png/mikaleinikAvatar.png new file mode 100644 index 00000000..e8ebe1dc Binary files /dev/null and b/public/img/png/mikaleinikAvatar.png differ diff --git a/public/img/png/stardustmegAvatar.png b/public/img/png/stardustmegAvatar.png new file mode 100644 index 00000000..c76d64e6 Binary files /dev/null and b/public/img/png/stardustmegAvatar.png differ diff --git a/public/img/png/yulikKAvatar.png b/public/img/png/yulikKAvatar.png new file mode 100644 index 00000000..c6d55194 Binary files /dev/null and b/public/img/png/yulikKAvatar.png differ diff --git a/public/img/webp/promo-slider-1_2.webp b/public/img/webp/promo-slider-1_2.webp new file mode 100644 index 00000000..85059619 Binary files /dev/null and b/public/img/webp/promo-slider-1_2.webp differ diff --git a/public/img/webp/promo-slider-2_1.webp b/public/img/webp/promo-slider-2_1.webp new file mode 100644 index 00000000..650dd151 Binary files /dev/null and b/public/img/webp/promo-slider-2_1.webp differ diff --git a/public/img/webp/promo-slider-2_2.webp b/public/img/webp/promo-slider-2_2.webp new file mode 100644 index 00000000..a8f8d835 Binary files /dev/null and b/public/img/webp/promo-slider-2_2.webp differ diff --git a/public/img/webp/promo-slider-3_1.webp b/public/img/webp/promo-slider-3_1.webp new file mode 100644 index 00000000..d10544a8 Binary files /dev/null and b/public/img/webp/promo-slider-3_1.webp differ diff --git a/public/img/webp/promo-slider-3_2.webp b/public/img/webp/promo-slider-3_2.webp new file mode 100644 index 00000000..5505578a Binary files /dev/null and b/public/img/webp/promo-slider-3_2.webp differ diff --git a/src/app/App/model/AppModel.ts b/src/app/App/model/AppModel.ts index 0adc04b7..24a0814e 100644 --- a/src/app/App/model/AppModel.ts +++ b/src/app/App/model/AppModel.ts @@ -1,10 +1,11 @@ /* eslint-disable max-lines-per-function */ -import type { Page, PageParams, PagesType } from '@/shared/types/page.ts'; +import type { Page, 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 { showErrorMessage } from '@/shared/utils/userMessage.ts'; import FooterModel from '@/widgets/Footer/model/FooterModel.ts'; import HeaderModel from '@/widgets/Header/model/HeaderModel.ts'; @@ -14,8 +15,8 @@ class AppModel { private appView: AppView = new AppView(); constructor() { - this.initialize().catch(() => { - throw new Error('AppModel initialization error'); + this.initialize().catch((error) => { + showErrorMessage(error); }); } @@ -26,8 +27,8 @@ class AppModel { return new AboutUsPageModel(this.appView.getHTML()); }, [PAGE_ID.BLOG]: async (): Promise => { - const { default: PostListModel } = await import('@/pages/Blog/PostList/model/PostListModel.ts'); - return new PostListModel(this.appView.getHTML()); + const { default: BlogPageModel } = await import('@/pages/BlogPage/model/BlogPageModel.ts'); + return new BlogPageModel(this.appView.getHTML()); }, [PAGE_ID.CART_PAGE]: async (): Promise => { const { default: CartPageModel } = await import('@/pages/CartPage/model/CartPageModel.ts'); @@ -37,6 +38,10 @@ class AppModel { const { default: CatalogPageModel } = await import('@/pages/CatalogPage/model/CatalogPageModel.ts'); return new CatalogPageModel(this.appView.getHTML()); }, + [PAGE_ID.COOPERATION_PAGE]: async (): Promise => { + const { default: CooperationPageModel } = await import('@/pages/CooperationPage/model/CooperationPageModel.ts'); + return new CooperationPageModel(this.appView.getHTML()); + }, [PAGE_ID.DEFAULT_PAGE]: async (): Promise => { const { default: MainPageModel } = await import('@/pages/MainPage/model/MainPageModel.ts'); return new MainPageModel(this.appView.getHTML()); @@ -53,9 +58,9 @@ class AppModel { const { default: NotFoundPageModel } = await import('@/pages/NotFoundPage/model/NotFoundPageModel.ts'); return new NotFoundPageModel(this.appView.getHTML()); }, - [PAGE_ID.PRODUCT_PAGE]: async (params: PageParams): Promise => { + [PAGE_ID.PRODUCT_PAGE]: async (): Promise => { const { default: ProductPageModel } = await import('@/pages/ProductPage/model/ProductPageModel.ts'); - return new ProductPageModel(this.appView.getHTML(), params); + return new ProductPageModel(this.appView.getHTML()); }, [PAGE_ID.REGISTRATION_PAGE]: async (): Promise => { const { default: RegistrationPageModel } = await import( @@ -63,13 +68,23 @@ class AppModel { ); return new RegistrationPageModel(this.appView.getHTML()); }, + [PAGE_ID.USER_ADDRESSES_PAGE]: async (): Promise => { + const { default: UserAddressesPageModel } = await import( + '@/pages/UserAddressesPage/model/UserAddressesPageModel.ts' + ); + return new UserAddressesPageModel(this.appView.getHTML()); + }, [PAGE_ID.USER_PROFILE_PAGE]: async (): Promise => { const { default: UserProfilePageModel } = await import('@/pages/UserProfilePage/model/UserProfilePageModel.ts'); return new UserProfilePageModel(this.appView.getHTML()); }, + [PAGE_ID.WISHLIST_PAGE]: async (): Promise => { + const { default: WishlistPageModel } = await import('@/pages/WishlistPage/model/WishlistPageModel.ts'); + return new WishlistPageModel(this.appView.getHTML()); + }, }; - const routes = new Map Promise>(); + const routes = new Map Promise>(); Object.entries(routesMap).forEach(([key, value]) => routes.set(key, value)); return Promise.resolve(routes); diff --git a/src/app/App/tests/App.spec.ts b/src/app/App/tests/App.spec.ts index af57744d..a09cb434 100644 --- a/src/app/App/tests/App.spec.ts +++ b/src/app/App/tests/App.spec.ts @@ -2,6 +2,10 @@ import AppModel from '../model/AppModel.ts'; const app = new AppModel(); +/** + * @vitest-environment jsdom + */ + describe('Checking AppModel class', () => { it('application successfully created', () => { expect(app.start()).toBe(true); diff --git a/src/app/App/view/appView.module.scss b/src/app/App/view/appView.module.scss index 90a5b219..ac880872 100644 --- a/src/app/App/view/appView.module.scss +++ b/src/app/App/view/appView.module.scss @@ -4,7 +4,11 @@ flex-direction: column; justify-content: center; margin: 0 auto; - padding: var(--medium-offset) 0; + padding: calc(var(--medium-offset) * 2) 0; width: 100%; min-height: calc(100vh - calc(var(--extra-small-offset) * 7.1)); + + @media (max-width: 768px) { + padding: calc(var(--medium-offset) * 2.65) 0; + } } diff --git a/src/app/Router/helpers/helpers.ts b/src/app/Router/helpers/helpers.ts new file mode 100644 index 00000000..25450c1e --- /dev/null +++ b/src/app/Router/helpers/helpers.ts @@ -0,0 +1,9 @@ +export const remove = (url: URL, key: string | string[]): void => { + if (Array.isArray(key)) { + key.forEach((k) => url.searchParams.delete(k)); + } else { + url.searchParams.delete(key); + } +}; +export const append = (url: URL, key: string, value: string): void => url.searchParams.append(key, value); +export const set = (url: URL, key: string, value: string): void => url.searchParams.set(key, value); diff --git a/src/app/Router/model/RouterModel.ts b/src/app/Router/model/RouterModel.ts index 49b0d4f4..72d1a88f 100644 --- a/src/app/Router/model/RouterModel.ts +++ b/src/app/Router/model/RouterModel.ts @@ -1,11 +1,11 @@ -import type { PageParams, PagesType } from '@/shared/types/page'; +import type { 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 setPageTitle from '@/shared/utils/setPageTitle.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; const DEFAULT_SEGMENT = import.meta.env.VITE_APP_DEFAULT_SEGMENT; const NEXT_SEGMENT = import.meta.env.VITE_APP_NEXT_SEGMENT; @@ -45,13 +45,13 @@ class RouterModel { return; } - this.handleRequest(currentPage, currentPath); + this.handleRequest(currentPage, currentPath).catch(showErrorMessage); }); } - public static appendSearchParams(key: string, value: string): void { + public static changeSearchParams(callback: (url: URL) => void): void { const url = new URL(decodeURIComponent(window.location.href)); - url.searchParams.append(key, value); + callback(url); const path = url.pathname + url.search.toString(); window.history.pushState({ path: path.slice(NEXT_SEGMENT) }, '', path); } @@ -62,58 +62,56 @@ class RouterModel { 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 getCurrentPage(): string { + return window.location.pathname.slice(PATH_SEGMENTS_TO_KEEP).split(DEFAULT_SEGMENT)[NEXT_SEGMENT]; } public static getInstance(): RouterModel { return RouterModel.router; } - public static getSearchParams(): URLSearchParams { - return new URL(decodeURIComponent(window.location.href)).searchParams; + public static getPageID(): null | string { + return ( + (window.location.pathname + window.location.search) + .slice(NEXT_SEGMENT) + .split(DEFAULT_SEGMENT) + .join(DEFAULT_SEGMENT) + .split(DEFAULT_SEGMENT) + [NEXT_SEGMENT]?.split(SEARCH_SEGMENT)[PATH_SEGMENTS_TO_KEEP] || null + ); } - 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); + public static getSavedPath(): string { + const path = + window.location.pathname.slice(NEXT_SEGMENT).split(DEFAULT_SEGMENT).join(DEFAULT_SEGMENT) || PAGE_ID.DEFAULT_PAGE; + return path + window.location.search; + } + + public static getSearchParams(): URLSearchParams { + return new URL(decodeURIComponent(window.location.href)).searchParams; } - private async checkPageAndParams( - currentPage: string, - path: string, - ): Promise<{ hasRoute: boolean; params: PageParams } | null> { + private async checkPageAndParams(currentPage: string, path: string): Promise { const hasRoute = this.routes.has(currentPage); - 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)?.({}); + await this.routes.get(PAGE_ID.NOT_FOUND_PAGE)?.(); return null; } - return { - hasRoute, - params: { [currentPage.slice(PATH_SEGMENTS_TO_KEEP)]: { id } }, - }; + return hasRoute; } - private handleRequest(currentPage: string, path: string): void { - this.checkPageAndParams(currentPage, path) - .then((check) => { - if (check) { - this.routes.get(currentPage)?.(check.params).catch(showErrorMessage); - } - }) - .catch(showErrorMessage); + private async handleRequest(currentPage: string, path: string): Promise { + try { + await this.checkPageAndParams(currentPage, path); + this.routes.get(currentPage)?.().catch(showErrorMessage); + } catch (error) { + showErrorMessage(error); + } } public navigateTo(path: string): void { @@ -122,7 +120,7 @@ class RouterModel { PAGE_ID.DEFAULT_PAGE; if (currentPage !== getStore().getState().currentPage || currentPage === PAGE_ID.DEFAULT_PAGE) { - this.handleRequest(currentPage, path); + this.handleRequest(currentPage, path).catch(showErrorMessage); } history.pushState({ path }, '', `/${path}`); } diff --git a/src/app/styles/common.scss b/src/app/styles/common.scss index 068a977f..818a61f3 100644 --- a/src/app/styles/common.scss +++ b/src/app/styles/common.scss @@ -1,5 +1,9 @@ * { box-sizing: border-box; + + &::selection { + background-color: var(--steam-green-1000); + } } html, @@ -17,7 +21,7 @@ body { } html { - font-size: clamp(12px, 1vw, 50px); + font-size: clamp(12px, 1.1vw, 50px); } body { @@ -44,6 +48,7 @@ img { max-width: 100%; } +/* stylelint-disable-next-line selector-class-pattern */ .stop-scroll { overflow-y: hidden; } diff --git a/src/app/styles/mixins.scss b/src/app/styles/mixins.scss index be64c27c..965f7868 100644 --- a/src/app/styles/mixins.scss +++ b/src/app/styles/mixins.scss @@ -8,6 +8,46 @@ $one: var(--one); $two: var(--two); $active-color: var(--steam-green-800); +@mixin switchLabel { + position: relative; + display: inline-block; + width: var(--large-offset); + height: calc(calc(var(--small-offset) / 1.5) + var(--extra-small-offset)); + cursor: pointer; +} + +@mixin switchCheckbox { + width: 0; + height: 0; + opacity: 1; +} + +@mixin switchLabelSpan($label-span-color: var(--noble-gray-tr-800), $label-span-before-color: var(--steam-green-800)) { + position: absolute; + border-radius: calc(var(--large-br) * 2); + background-color: $label-span-color; + transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); + cursor: pointer; + pointer-events: none; + inset: 0; + + &::before { + content: ''; + position: absolute; + left: calc(var(--one) * 5); + top: 50%; + bottom: 0; + z-index: 2; + border-radius: var(--large-br); + width: calc(var(--small-offset) / 1.5); + height: calc(var(--small-offset) / 1.5); + box-shadow: var(--mellow-shadow-600); + background-color: $label-span-before-color; + transform: translateY(-50%); + transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); + } +} + @mixin link($padding: 0 calc(var(--extra-small-offset) / 2), $color: var(--noble-gray-800)) { position: relative; display: flex; diff --git a/src/app/styles/variables.scss b/src/app/styles/variables.scss index fc6de1ac..ef35b493 100644 --- a/src/app/styles/variables.scss +++ b/src/app/styles/variables.scss @@ -34,7 +34,8 @@ // fonts --small-font: 400 0.625rem 'Cerapro', sans-serif; // 10px --regular-font: 400 0.8125rem 'Cerapro', sans-serif; // 13px - --extra-regular-font: 400 1.5rem 'Cerapro', sans-serif; // 24px + --basic-regular-font: 400 1.125rem 'Cerapro', sans-serif; // 18px + --extra-regular-font: 500 1.5rem 'Cerapro', sans-serif; // 24px --medium-font: 500 1.25rem 'Cerapro', sans-serif; // 20px --extra-medium-font: 500 1.875rem 'Cerapro', sans-serif; // 30px --bold-font: 700 1rem 'Cerapro', sans-serif; // 16px @@ -42,6 +43,7 @@ --extra-bold-font: 700 2.1875rem 'Cerapro', sans-serif; // 35px --black-font: 900 2.1875rem 'Cerapro', sans-serif; // 35px --extra-black-font: 900 2.5rem 'Cerapro', sans-serif; // 40px + --super-large-font: 900 4.4rem 'Cerapro', sans-serif; // 70px body.light { // colors @@ -57,11 +59,14 @@ --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 + --noble-gray-1200: #091e420f; // about us cards + --noble-gray-1300: #ffffffd6; // about us cards modal // 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 + --noble-gray-tr-1000: #1a1a1a32; // header background // outline: --noble-gray-300: #cbcbcb; // item cards and pagination outline @@ -83,6 +88,7 @@ --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 + --steam-green-1200: #008500; // hover footer links } body.dark { @@ -91,6 +97,7 @@ --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-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-900: #46a3581a; // blog background, personal info background --steam-green-1000: #46a35937; // footer links background, pics round thingies @@ -108,6 +115,8 @@ --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 + --noble-gray-1200: #a1bdd914; // about us cards + --noble-gray-1300: #080808a8; // about us cards modal // tranparent colors: --noble-gray-tr-800: #acacacbd; // not active labels, tumblers @@ -127,6 +136,6 @@ // 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 + --steam-green-1200: #a7e599; // hover footer links } } diff --git a/src/entities/AboutFullCard/model/AboutFullCardModel.ts b/src/entities/AboutFullCard/model/AboutFullCardModel.ts new file mode 100644 index 00000000..68cb6a00 --- /dev/null +++ b/src/entities/AboutFullCard/model/AboutFullCardModel.ts @@ -0,0 +1,16 @@ +import type { AboutData } from '@/shared/types/validation/aboutData.ts'; + +import AboutShortCardModel from '@/entities/AboutShortCard/model/AboutShortCardModel.ts'; + +import AboutFullCardView from '../view/AboutFullCardView.ts'; + +class AboutFullCardModel extends AboutShortCardModel { + protected view: AboutFullCardView; + + constructor(params: AboutData) { + super(params); + this.view = new AboutFullCardView(params); + } +} + +export default AboutFullCardModel; diff --git a/src/entities/AboutFullCard/view/AboutFullCardView.ts b/src/entities/AboutFullCard/view/AboutFullCardView.ts new file mode 100644 index 00000000..0861c86b --- /dev/null +++ b/src/entities/AboutFullCard/view/AboutFullCardView.ts @@ -0,0 +1,138 @@ +import type { AboutData, AboutFeedback } from '@/shared/types/validation/aboutData'; + +import AboutShortCardView from '@/entities/AboutShortCard/view/AboutShortCardView.ts'; +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import observeStore, { selectCurrentLanguage, selectCurrentTheme } from '@/shared/Store/observer.ts'; +import ABOUT_TEXT from '@/shared/constants/about.ts'; +import { LINK_DETAIL } from '@/shared/constants/links.ts'; +import changeColor from '@/shared/utils/changeColor.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentAppTheme from '@/shared/utils/getCurrentAppTheme.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import hexToRgba from '@/shared/utils/hexToRgba.ts'; + +import styles from './aboutFullCardView.module.scss'; + +class AboutFullCardView extends AboutShortCardView { + constructor(params: AboutData) { + super(params); + this.view.classList.add(styles.aboutFullCard); + this.view.append(this.createChecklist(), this.createFeedbackList()); + } + + private createCheckItem(item: { text: string }): HTMLLIElement { + const listItem = createBaseElement({ + cssClasses: [styles.checklistItem], + tag: 'li', + }); + const label = createBaseElement({ + cssClasses: [styles.label], + innerContent: item.text, + tag: 'label', + }); + + listItem.append(label); + return listItem; + } + + private createChecklist(): HTMLUListElement { + const list = createBaseElement({ + cssClasses: [styles.checklist], + tag: 'ul', + }); + + const title = createBaseElement({ + cssClasses: [styles.title], + innerContent: ABOUT_TEXT[getCurrentLanguage()].CHECKLIST, + tag: 'h3', + }); + + changeColor(title, this.params.coverColor[getCurrentAppTheme()].color); + list.append(title); + this.params.checklist[getCurrentLanguage()].forEach((item) => { + list.append(this.createCheckItem(item)); + }); + + observeStore(selectCurrentLanguage, () => { + list.innerHTML = ''; + title.innerText = ABOUT_TEXT[getCurrentLanguage()].CHECKLIST; + list.append(title); + this.params.checklist[getCurrentLanguage()].forEach((item) => { + list.append(this.createCheckItem(item)); + }); + }); + + observeStore(selectCurrentTheme, () => { + changeColor(title, this.params.coverColor[getCurrentAppTheme()].color); + }); + return list; + } + + private createFeedbackItem(item: AboutFeedback): HTMLLIElement { + const listItem = createBaseElement({ cssClasses: [styles.feedbackListItem], tag: 'li' }); + const label = createBaseElement({ + cssClasses: [styles.label, styles.feedbackLabel], + innerContent: `— ${item.text[getCurrentLanguage()].text}`, + tag: 'label', + }); + + label.style.backgroundColor = hexToRgba(this.params.coverColor[getCurrentAppTheme()].color, 0.2); + + const href = `https://github.com/${item.from}`; + const background = `url(${`/img/png/${item.from}Avatar.png`})`; + const from = new LinkModel({ + attrs: { href, target: LINK_DETAIL.BLANK }, + classes: [styles.from], + text: item.from, + }); + + const avatar = new LinkModel({ attrs: { href, target: LINK_DETAIL.BLANK }, classes: [styles.avatar] }); + + avatar.getHTML().style.backgroundImage = background; + from.getHTML().append(avatar.getHTML()); + + changeColor(from.getHTML(), this.params.coverColor[getCurrentAppTheme()].color); + + observeStore(selectCurrentLanguage, () => { + label.innerText = `— ${item.text[getCurrentLanguage()].text}`; + }); + + observeStore(selectCurrentTheme, () => { + changeColor(from.getHTML(), this.params.coverColor[getCurrentAppTheme()].color); + label.style.backgroundColor = hexToRgba(this.params.coverColor[getCurrentAppTheme()].color, 0.2); + }); + + listItem.append(from.getHTML(), label); + return listItem; + } + + private createFeedbackList(): HTMLUListElement { + const list = createBaseElement({ + cssClasses: [styles.feedbackList], + tag: 'ul', + }); + + const title = createBaseElement({ + cssClasses: [styles.title], + innerContent: ABOUT_TEXT[getCurrentLanguage()].FEEDBACK, + tag: 'h3', + }); + + changeColor(title, this.params.coverColor[getCurrentAppTheme()].color); + list.append(title); + this.params.feedback.forEach((item) => { + list.append(this.createFeedbackItem(item)); + }); + + observeStore(selectCurrentLanguage, () => { + title.innerText = ABOUT_TEXT[getCurrentLanguage()].FEEDBACK; + }); + + observeStore(selectCurrentTheme, () => { + changeColor(title, this.params.coverColor[getCurrentAppTheme()].color); + }); + return list; + } +} + +export default AboutFullCardView; diff --git a/src/entities/AboutFullCard/view/aboutFullCardView.module.scss b/src/entities/AboutFullCard/view/aboutFullCardView.module.scss new file mode 100644 index 00000000..66af5e2f --- /dev/null +++ b/src/entities/AboutFullCard/view/aboutFullCardView.module.scss @@ -0,0 +1,103 @@ +@import 'src/app/styles/mixins'; + +.aboutFullCard { + border-radius: 0; + box-shadow: none; + background-color: var(--noble-gray-1300); + cursor: auto; + + &:active { + transform: none; + scale: none; + } + + @media (hover: hover) { + &:hover { + box-shadow: none; + transform: none; + } + } + + @media (max-width: 840px) { + max-width: 100%; + } +} + +.checklist, +.feedbackList { + display: flex; + flex-direction: column; + padding: var(--extra-small-offset); + padding-top: 0; + font: var(--regular-font); + line-height: 170%; + letter-spacing: var(--one); + color: var(--noble-gray-700); + gap: var(--extra-small-offset); +} + +.label { + position: relative; + display: flex; + align-items: center; + padding-left: var(--extra-small-offset); + gap: var(--tiny-offset); + + &::after { + content: '✔'; + position: absolute; + left: 0; + top: 0; + } +} + +.feedbackLabel { + border-radius: var(--medium-br); + border-top-left-radius: 0; + padding: var(--extra-small-offset); + + &::after { + content: ''; + position: absolute; + left: 0; + top: -0.7rem; + border-top-left-radius: 1rem; + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 100%; + width: 1.7rem; + height: 0.7rem; + background-color: inherit; + } +} + +.from { + @include link; + + display: flex; + align-items: center; + margin-bottom: var(--tiny-offset); + padding: 0; + padding-bottom: var(--two); + width: max-content; + height: max-content; + max-width: max-content; + font: var(--regular-font); + letter-spacing: var(--one); + text-transform: uppercase; + color: var(--noble-gray-500); + gap: var(--tiny-offset); +} + +.avatar { + border-radius: 50%; + width: 1.5rem; + height: 1.5rem; + background-size: cover; + transition: filter 0.2s; + + @media (hover: hover) { + &:hover { + filter: brightness(1.15); + } + } +} diff --git a/src/entities/AboutShortCard/model/AboutShortCardModel.ts b/src/entities/AboutShortCard/model/AboutShortCardModel.ts new file mode 100644 index 00000000..715219b7 --- /dev/null +++ b/src/entities/AboutShortCard/model/AboutShortCardModel.ts @@ -0,0 +1,54 @@ +import type AboutFullCardModel from '@/entities/AboutFullCard/model/AboutFullCardModel.ts'; +import type { AboutData } from '@/shared/types/validation/aboutData.ts'; + +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { aboutUsPathWithID } from '@/shared/utils/buildPathname.ts'; + +import AboutShortCardView from '../view/AboutShortCardView.ts'; + +class AboutShortCardModel { + private fullCard: AboutFullCardModel | null = null; + + protected params: AboutData; + + protected view: AboutShortCardView; + + constructor(params: AboutData) { + this.params = params; + this.view = new AboutShortCardView(params); + this.init(); + } + + private cardHandler(): void { + RouterModel.getInstance().navigateTo(aboutUsPathWithID(this.params.github.name)); + if (this.fullCard) { + modal.setContent(this.fullCard.getHTML()); + modal.show(() => RouterModel.getInstance().navigateTo(PAGE_ID.ABOUT_US_PAGE)); + modal.getView().getModalContent().scrollTo(0, 0); + } + } + + private init(): void { + this.getHTML().addEventListener('click', this.cardHandler.bind(this)); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } + + public openFullCard(): void { + const id = RouterModel.getPageID(); + + if (id === this.params.github.name) { + this.cardHandler(); + } + } + + public setFullCard(fullCard: AboutFullCardModel): void { + this.fullCard = fullCard; + } +} + +export default AboutShortCardModel; diff --git a/src/entities/AboutShortCard/view/AboutShortCardView.ts b/src/entities/AboutShortCard/view/AboutShortCardView.ts new file mode 100644 index 00000000..2c472391 --- /dev/null +++ b/src/entities/AboutShortCard/view/AboutShortCardView.ts @@ -0,0 +1,213 @@ +import type { AboutData, AboutLabel } from '@/shared/types/validation/aboutData'; + +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import observeStore, { selectCurrentLanguage, selectCurrentTheme } from '@/shared/Store/observer.ts'; +import { LINK_DETAIL } from '@/shared/constants/links.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import changeColor from '@/shared/utils/changeColor.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import createSVGUse, { changeFill, changeStroke } from '@/shared/utils/createSVGUse.ts'; +import getCurrentAppTheme from '@/shared/utils/getCurrentAppTheme.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; + +import styles from './aboutShortCardView.module.scss'; + +class AboutShortCardView { + protected params: AboutData; + + protected view: HTMLDivElement; + + constructor(params: AboutData) { + this.params = params; + this.view = this.createHTML(); + } + + private createAvatar(): LinkModel { + const avatar = new LinkModel({ + attrs: { + href: this.params.github.link, + target: LINK_DETAIL.BLANK, + }, + classes: [styles.avatar], + }); + const background = `url(${this.params.avatar})`; + avatar.getHTML().style.backgroundImage = background; + return avatar; + } + + private createCover(): HTMLDivElement { + const cover = createBaseElement({ + cssClasses: [styles.cover], + tag: 'div', + }); + + cover.style.backgroundColor = this.params.coverColor[getCurrentAppTheme()].color; + + observeStore(selectCurrentTheme, () => { + cover.style.backgroundColor = this.params.coverColor[getCurrentAppTheme()].color; + }); + return cover; + } + + private createFullName(): HTMLSpanElement { + const fullName = createBaseElement({ + cssClasses: [styles.fullName], + innerContent: this.params.userName[getCurrentLanguage()].text, + tag: 'span', + }); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.PROFILE)); + changeStroke(svg, this.params.coverColor[getCurrentAppTheme()].color); + + observeStore(selectCurrentTheme, () => { + changeStroke(svg, this.params.coverColor[getCurrentAppTheme()].color); + }); + fullName.append(svg); + + observeStore(selectCurrentLanguage, () => { + const textNode = [...fullName.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = this.params.userName[getCurrentLanguage()].text; + } + }); + return fullName; + } + + private createGithubName(): LinkModel { + const githubName = new LinkModel({ + attrs: { + href: this.params.github.link, + target: LINK_DETAIL.BLANK, + }, + classes: [styles.githubName], + text: this.params.github.name, + }); + + changeColor(githubName.getHTML(), this.params.coverColor[getCurrentAppTheme()].color); + + observeStore(selectCurrentTheme, () => { + changeColor(githubName.getHTML(), this.params.coverColor[getCurrentAppTheme()].color); + }); + + return githubName; + } + + private createGithubWrapper(): HTMLDivElement { + const githubWrapper = createBaseElement({ + cssClasses: [styles.githubWrapper], + tag: 'div', + }); + + githubWrapper.append(this.createGithubName().getHTML(), this.createAvatar().getHTML()); + return githubWrapper; + } + + private createHTML(): HTMLDivElement { + this.view = createBaseElement({ + cssClasses: [styles.card], + tag: 'div', + }); + + const bottomWrapper = createBaseElement({ + cssClasses: [styles.bottomWrapper], + tag: 'div', + }); + + bottomWrapper.append( + this.createFullName(), + this.createPosition(), + this.createShortDescription(), + this.createGithubWrapper(), + ); + this.view.append(this.createCover(), this.createLabelsList(), bottomWrapper); + return this.view; + } + + private createLabel(item: AboutLabel): HTMLLIElement { + const label = createBaseElement({ + cssClasses: [styles.label], + tag: 'li', + }); + const labelName = createBaseElement({ + cssClasses: [styles.labelName], + innerContent: item.name, + tag: 'span', + }); + + labelName.style.color = item.color[getCurrentAppTheme()].color; + label.style.backgroundColor = item.backgroundColor[getCurrentAppTheme()].color; + label.append(labelName); + + observeStore(selectCurrentTheme, () => { + labelName.style.color = item.color[getCurrentAppTheme()].color; + label.style.backgroundColor = item.backgroundColor[getCurrentAppTheme()].color; + }); + return label; + } + + private createLabelsList(): HTMLUListElement { + const labelsList = createBaseElement({ + cssClasses: [styles.labelsList], + tag: 'ul', + }); + + this.params.labels.forEach((item) => { + labelsList.append(this.createLabel(item)); + }); + + observeStore(selectCurrentLanguage, () => { + labelsList.innerHTML = ''; + this.params.labels.forEach((item) => { + labelsList.append(this.createLabel(item)); + }); + }); + + return labelsList; + } + + private createPosition(): HTMLSpanElement { + const position = createBaseElement({ + cssClasses: [styles.position], + innerContent: this.params.position[getCurrentLanguage()].text, + tag: 'span', + }); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.STAR)); + changeFill(svg, this.params.coverColor[getCurrentAppTheme()].color); + + observeStore(selectCurrentTheme, () => { + changeFill(svg, this.params.coverColor[getCurrentAppTheme()].color); + }); + + position.append(svg); + + observeStore(selectCurrentLanguage, () => { + const textNode = [...position.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = this.params.position[getCurrentLanguage()].text; + } + }); + return position; + } + + private createShortDescription(): HTMLSpanElement { + const shortDescription = createBaseElement({ + cssClasses: [styles.shortDescription], + innerContent: this.params.shortDescription[getCurrentLanguage()].text, + tag: 'span', + }); + + observeStore(selectCurrentLanguage, () => { + shortDescription.textContent = this.params.shortDescription[getCurrentLanguage()].text; + }); + return shortDescription; + } + + public getHTML(): HTMLDivElement { + return this.view; + } +} + +export default AboutShortCardView; diff --git a/src/entities/AboutShortCard/view/aboutShortCardView.module.scss b/src/entities/AboutShortCard/view/aboutShortCardView.module.scss new file mode 100644 index 00000000..c356b064 --- /dev/null +++ b/src/entities/AboutShortCard/view/aboutShortCardView.module.scss @@ -0,0 +1,154 @@ +@import 'src/app/styles/mixins'; + +.card { + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: var(--medium-br); + width: 100%; + background-color: var(--noble-gray-1200); + transition: + box-shadow 0.2s, + transform 0.2s, + scale 0.2s; + cursor: pointer; + + &:active { + transform: translateY(0); + scale: 0.95; + } + + @media (hover: hover) { + &:hover { + box-shadow: var(--mellow-shadow-300); + transform: translateY(-0.3rem); + } + } + + @media (max-width: 840px) { + max-width: 80%; + + &:nth-of-type(2n + 2) { + margin-left: auto; + } + } + + @media (max-width: 576px) { + max-width: 100%; + + &:nth-of-type(2n + 2) { + margin-left: none; + } + } +} + +.cover { + height: 2.5rem; +} + +.labelsList { + display: flex; + flex-wrap: wrap; + margin-bottom: var(--tiny-offset); + padding: var(--tiny-offset) var(--extra-small-offset) 0; + gap: var(--tiny-offset); +} + +.label { + border-radius: var(--small-br); + pointer-events: none; +} + +.labelName { + display: flex; + align-items: center; + justify-content: center; + padding: var(--two) var(--five); + font: var(--regular-font); + letter-spacing: var(--one); +} + +.bottomWrapper { + display: flex; + flex-grow: 1; + flex-direction: column; + padding: 0 var(--extra-small-offset); +} + +.fullName, +.position, +.githubName { + margin-bottom: var(--tiny-offset); + font: var(--regular-font); + letter-spacing: var(--one); +} + +.position { + display: flex; + align-items: center; + color: var(--noble-gray-700); + gap: var(--two); + + svg { + width: calc(var(--tiny-offset) * 1.5); + height: calc(var(--tiny-offset) * 1.5); + transition: fill 0.2s; + } +} + +.fullName { + display: flex; + align-items: center; + text-transform: uppercase; + color: var(--noble-gray-800); + gap: var(--two); + + svg { + width: var(--extra-small-offset); + height: var(--extra-small-offset); + transition: stroke 0.2s; + } +} + +.githubName { + @include link; + + margin-bottom: 0; + padding: 0; + padding-bottom: var(--two); + height: max-content; + max-width: max-content; + font: var(--regular-font); + text-transform: none; +} + +.githubWrapper { + display: flex; + align-items: center; + justify-content: end; + margin-top: auto; + margin-bottom: var(--tiny-offset); + gap: var(--tiny-offset); +} + +.avatar { + border-radius: 50%; + width: 1.8rem; + height: 1.8rem; + background-size: cover; + transition: filter 0.2s; + + @media (hover: hover) { + &:hover { + filter: brightness(1.15); + } + } +} + +.shortDescription { + margin: auto 0; + font: var(--regular-font); + line-height: 170%; + letter-spacing: var(--one); + color: var(--noble-gray-700); +} diff --git a/src/entities/Address/model/AddressModel.ts b/src/entities/Address/model/AddressModel.ts index 4e161abc..4efca226 100644 --- a/src/entities/Address/model/AddressModel.ts +++ b/src/entities/Address/model/AddressModel.ts @@ -3,7 +3,7 @@ import type { Address, PersonalData } from '@/shared/types/user.ts'; import CountryChoiceModel from '@/features/CountryChoice/model/CountryChoiceModel.ts'; import getStore from '@/shared/Store/Store.ts'; -import { ADDRESS_TYPE } from '@/shared/types/address.ts'; +import { ADDRESS } from '@/shared/types/address.ts'; import formattedText from '@/shared/utils/formattedText.ts'; import AddressView from '../view/AddressView.ts'; @@ -13,7 +13,7 @@ class AddressModel { private view: AddressView; - constructor(options: AddressOptions, addressType: AddressType = ADDRESS_TYPE.GENERAL) { + constructor(options: AddressOptions, addressType: AddressType = ADDRESS.GENERAL) { this.addressType = addressType; this.view = new AddressView(addressType, options); this.init(); @@ -29,10 +29,10 @@ class AddressModel { let country: string; switch (this.addressType) { - case ADDRESS_TYPE.BILLING: + case ADDRESS.BILLING: country = store.billingCountry; break; - case ADDRESS_TYPE.SHIPPING: + case ADDRESS.SHIPPING: country = store.shippingCountry; break; default: diff --git a/src/entities/Address/view/AddressView.ts b/src/entities/Address/view/AddressView.ts index 596c9af3..90e2eb96 100644 --- a/src/entities/Address/view/AddressView.ts +++ b/src/entities/Address/view/AddressView.ts @@ -1,12 +1,12 @@ import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { FORM_TEXT, FORM_TEXT_KEYS, INPUT_TYPE } from '@/shared/constants/forms.ts'; +import { FORM_TEXT, FORM_TEXT_KEY, INPUT_TYPE } from '@/shared/constants/forms.ts'; import * as FORM_FIELDS from '@/shared/constants/forms/fieldParams.ts'; import { TITLE_TEXT, TITLE_TEXT_KEYS } from '@/shared/constants/forms/text.ts'; import * as FORM_VALIDATION from '@/shared/constants/forms/validationParams.ts'; -import { ADDRESS_TYPE, type AddressOptions, type AddressType, SINGLE_ADDRESS } from '@/shared/types/address.ts'; +import { ADDRESS, type AddressOptions, type AddressType, SINGLE_ADDRESS } from '@/shared/types/address.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './addressView.module.scss'; @@ -67,10 +67,10 @@ class AddressView { const checkBoxText = createBaseElement({ cssClasses: [styles.checkboxText], - innerContent: FORM_TEXT[getStore().getState().currentLanguage].SINGLE_ADDRESS, + innerContent: FORM_TEXT[getCurrentLanguage()].SINGLE_ADDRESS, tag: 'span', }); - observeCurrentLanguage(checkBoxText, FORM_TEXT, FORM_TEXT_KEYS.SINGLE_ADDRESS); + observeCurrentLanguage(checkBoxText, FORM_TEXT, FORM_TEXT_KEY.SINGLE_ADDRESS); this.addressAsBillingCheckBox = new InputModel({ autocomplete: FORM_FIELDS.CHECKBOX.AUTOCOMPLETE, @@ -87,16 +87,18 @@ class AddressView { private createAddressByDefaultCheckbox(): HTMLLabelElement { const checkboxLabel = createBaseElement({ attributes: { - for: this.addressType === ADDRESS_TYPE.SHIPPING ? ADDRESS_TYPE.SHIPPING : ADDRESS_TYPE.BILLING, + for: this.addressType === ADDRESS.SHIPPING ? ADDRESS.SHIPPING : ADDRESS.BILLING, }, cssClasses: [styles.checkboxLabel], tag: 'label', }); + const currentLanguage = getCurrentLanguage(); + const textContent = - this.addressType === ADDRESS_TYPE.SHIPPING - ? FORM_TEXT[getStore().getState().currentLanguage].DEFAULT_SHIPPING_ADDRESS - : FORM_TEXT[getStore().getState().currentLanguage].DEFAULT_BILLING_ADDRESS; + this.addressType === ADDRESS.SHIPPING + ? FORM_TEXT[currentLanguage].DEFAULT_SHIPPING_ADDRESS + : FORM_TEXT[currentLanguage].DEFAULT_BILLING_ADDRESS; const checkBoxText = createBaseElement({ cssClasses: [styles.checkboxText], innerContent: textContent, @@ -105,14 +107,14 @@ class AddressView { observeCurrentLanguage( checkBoxText, FORM_TEXT, - this.addressType === ADDRESS_TYPE.SHIPPING - ? FORM_TEXT_KEYS.DEFAULT_SHIPPING_ADDRESS - : FORM_TEXT_KEYS.DEFAULT_BILLING_ADDRESS, + this.addressType === ADDRESS.SHIPPING + ? FORM_TEXT_KEY.DEFAULT_SHIPPING_ADDRESS + : FORM_TEXT_KEY.DEFAULT_BILLING_ADDRESS, ); this.addressByDefaultCheckBox = new InputModel({ autocomplete: FORM_FIELDS.CHECKBOX.AUTOCOMPLETE, - id: this.addressType === ADDRESS_TYPE.SHIPPING ? ADDRESS_TYPE.SHIPPING : ADDRESS_TYPE.BILLING, + id: this.addressType === ADDRESS.SHIPPING ? ADDRESS.SHIPPING : ADDRESS.BILLING, placeholder: '', type: INPUT_TYPE.CHECK_BOX, }); @@ -123,7 +125,7 @@ class AddressView { } private createCityField(): InputFieldModel { - if (this.addressType === ADDRESS_TYPE.SHIPPING) { + if (this.addressType === ADDRESS.SHIPPING) { this.cityField = new InputFieldModel(FORM_FIELDS.SHIPPING_ADDRESS_CITY, FORM_VALIDATION.ADDRESS_CITY_VALIDATE); } else { this.cityField = new InputFieldModel(FORM_FIELDS.BILLING_ADDRESS_CITY, FORM_VALIDATION.ADDRESS_CITY_VALIDATE); @@ -135,7 +137,7 @@ class AddressView { } private createCountryField(): InputFieldModel { - if (this.addressType === ADDRESS_TYPE.SHIPPING) { + if (this.addressType === ADDRESS.SHIPPING) { this.countryField = new InputFieldModel( FORM_FIELDS.SHIPPING_ADDRESS_COUNTRY, FORM_VALIDATION.ADDRESS_COUNTRY_VALIDATE, @@ -156,7 +158,7 @@ class AddressView { this.address = createBaseElement({ cssClasses: [ styles.address, - this.addressType === ADDRESS_TYPE.SHIPPING ? styles.shippingAddressWrapper : styles.billingAddressWrapper, + this.addressType === ADDRESS.SHIPPING ? styles.shippingAddressWrapper : styles.billingAddressWrapper, ], tag: 'div', }); @@ -176,7 +178,7 @@ class AddressView { } private createPostalCodeField(): InputFieldModel { - if (this.addressType === ADDRESS_TYPE.SHIPPING) { + if (this.addressType === ADDRESS.SHIPPING) { this.postalCodeField = new InputFieldModel( FORM_FIELDS.SHIPPING_ADDRESS_POSTAL_CODE, FORM_VALIDATION.ADDRESS_POSTAL_CODE_VALIDATE, @@ -194,7 +196,7 @@ class AddressView { } private createStreetField(): InputFieldModel { - if (this.addressType === ADDRESS_TYPE.SHIPPING) { + if (this.addressType === ADDRESS.SHIPPING) { this.streetField = new InputFieldModel( FORM_FIELDS.SHIPPING_ADDRESS_STREET, FORM_VALIDATION.ADDRESS_STREET_VALIDATE, @@ -214,17 +216,20 @@ class AddressView { private createTitle(): HTMLHeadingElement { let titleText: string; let key: string; + + const currentLanguage = getCurrentLanguage(); + switch (this.addressType) { - case ADDRESS_TYPE.BILLING: - titleText = TITLE_TEXT[getStore().getState().currentLanguage].BILLING_ADDRESS; + case ADDRESS.BILLING: + titleText = TITLE_TEXT[currentLanguage].BILLING_ADDRESS; key = TITLE_TEXT_KEYS.BILLING_ADDRESS; break; - case ADDRESS_TYPE.SHIPPING: - titleText = TITLE_TEXT[getStore().getState().currentLanguage].SHIPPING_ADDRESS; + case ADDRESS.SHIPPING: + titleText = TITLE_TEXT[currentLanguage].SHIPPING_ADDRESS; key = TITLE_TEXT_KEYS.SHIPPING_ADDRESS; break; default: - titleText = TITLE_TEXT[getStore().getState().currentLanguage].ADDRESS; + titleText = TITLE_TEXT[currentLanguage].ADDRESS; key = TITLE_TEXT_KEYS.ADDRESS; break; } diff --git a/src/entities/Address/view/addressView.module.scss b/src/entities/Address/view/addressView.module.scss index 61196719..857fe287 100644 --- a/src/entities/Address/view/addressView.module.scss +++ b/src/entities/Address/view/addressView.module.scss @@ -3,6 +3,7 @@ grid-column: 2 span; grid-template-columns: repeat(2, 1fr); padding: var(--extra-small-offset); + width: 100%; gap: calc(var(--extra-small-offset) * 1.5) var(--extra-small-offset); .title { diff --git a/src/entities/CountBadge/model/CountBadgeModel.ts b/src/entities/CountBadge/model/CountBadgeModel.ts new file mode 100644 index 00000000..d08d0021 --- /dev/null +++ b/src/entities/CountBadge/model/CountBadgeModel.ts @@ -0,0 +1,19 @@ +import CountBadgeView from '../view/CountBadgeView.ts'; + +class CountBadgeModel { + private view = new CountBadgeView(); + + constructor() { + this.updateBadgeCount(); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } + + public updateBadgeCount(quantity = 0): void { + this.view.updateBadgeCount(quantity); + } +} + +export default CountBadgeModel; diff --git a/src/entities/CountBadge/view/CountBadgeView.ts b/src/entities/CountBadge/view/CountBadgeView.ts new file mode 100644 index 00000000..97c3a58e --- /dev/null +++ b/src/entities/CountBadge/view/CountBadgeView.ts @@ -0,0 +1,43 @@ +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import styles from './countBadgeView.module.scss'; + +class CountBadgeView { + private countBadge: HTMLSpanElement; + + private countBadgeWrap: HTMLDivElement; + + constructor() { + this.countBadge = this.createBadge(); + this.countBadgeWrap = this.createHTML(); + } + + private createBadge(): HTMLSpanElement { + return createBaseElement({ + cssClasses: [styles.badge], + tag: 'span', + }); + } + + private createHTML(): HTMLDivElement { + this.countBadgeWrap = createBaseElement({ + cssClasses: [styles.badgeWrap], + tag: 'div', + }); + + this.countBadgeWrap.append(this.countBadge); + + return this.countBadgeWrap; + } + + public getHTML(): HTMLDivElement { + return this.countBadgeWrap; + } + + public updateBadgeCount(count: number): void { + this.countBadgeWrap.classList.toggle(styles.hidden, !count); + this.countBadge.textContent = count.toString(); + } +} + +export default CountBadgeView; diff --git a/src/entities/CountBadge/view/countBadgeView.module.scss b/src/entities/CountBadge/view/countBadgeView.module.scss new file mode 100644 index 00000000..8548d65c --- /dev/null +++ b/src/entities/CountBadge/view/countBadgeView.module.scss @@ -0,0 +1,23 @@ +.badgeWrap { + position: absolute; + right: -30%; + top: -20%; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: calc(var(--tiny-offset) * 2); + height: calc(var(--tiny-offset) * 2); + box-shadow: 0 0 0 var(--one) var(--noble-gray-tr-1000); + font: var(--regular-font); + background-color: var(--steam-green-800); +} + +.badge { + display: block; + color: var(--noble-gray-200); +} + +.hidden { + display: none; +} diff --git a/src/entities/Coupon/model/CouponModel.ts b/src/entities/Coupon/model/CouponModel.ts new file mode 100644 index 00000000..9a433e85 --- /dev/null +++ b/src/entities/Coupon/model/CouponModel.ts @@ -0,0 +1,33 @@ +import type { DeleteCallback } from '@/entities/Summary/model/SummaryModel.ts'; +import type { CartCoupon } from '@/shared/types/cart.ts'; + +import CouponView from '../view/CouponView.ts'; + +class CouponModel { + private coupon: CartCoupon; + + private view: CouponView; + + constructor(coupon: CartCoupon, deleteCallback: DeleteCallback) { + this.coupon = coupon; + this.view = new CouponView(this.coupon, deleteCallback); + this.init(); + } + + private init(): void {} + + public getCouponData(): CartCoupon { + return this.coupon; + } + + public getHTML(): HTMLLIElement { + return this.view.getHTML(); + } + + public update(value: number): void { + this.coupon.value = value; + this.view.update(value); + } +} + +export default CouponModel; diff --git a/src/entities/Coupon/test/coupon.spec.ts b/src/entities/Coupon/test/coupon.spec.ts new file mode 100644 index 00000000..0bcf8f54 --- /dev/null +++ b/src/entities/Coupon/test/coupon.spec.ts @@ -0,0 +1,30 @@ +import type { CartCoupon } from '@/shared/types/cart.ts'; + +import sinon from 'sinon'; + +import CouponModel from '../model/CouponModel.ts'; + +/** + * @vitest-environment jsdom + */ +const coupon: CartCoupon = { + coupon: { + cartDiscount: '', + discountCode: '', + id: '', + }, + value: 10, +}; + +const deleteCallback = sinon.fake(); +const couponModel = new CouponModel(coupon, deleteCallback); + +describe('Checking CustomerApi', () => { + it('should check if root is defined', () => { + expect(couponModel).toBeDefined(); + }); + + it('should check if CustomerApi is an instance of CustomerApi', () => { + expect(couponModel).toBeInstanceOf(CouponModel); + }); +}); diff --git a/src/entities/Coupon/view/CouponView.ts b/src/entities/Coupon/view/CouponView.ts new file mode 100644 index 00000000..60b6a1f4 --- /dev/null +++ b/src/entities/Coupon/view/CouponView.ts @@ -0,0 +1,55 @@ +import type { DeleteCallback } from '@/entities/Summary/model/SummaryModel'; +import type { CartCoupon } from '@/shared/types/cart'; + +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import { minusCartPrice } from '@/shared/utils/messageTemplates.ts'; + +import styles from './couponView.module.scss'; + +class CouponView { + private coupon: CartCoupon; + + private couponValue: HTMLParagraphElement; + + private deleteCallback: DeleteCallback; + + private view: HTMLLIElement; + + constructor(coupon: CartCoupon, deleteCallback: DeleteCallback) { + this.deleteCallback = deleteCallback; + this.coupon = coupon; + this.couponValue = createBaseElement({ + cssClasses: [styles.title], + innerContent: minusCartPrice(this.coupon.value.toFixed(2)), + tag: 'p', + }); + this.view = this.createHTML(); + } + + private createHTML(): HTMLLIElement { + this.view = createBaseElement({ cssClasses: [styles.couponWrap], tag: 'li' }); + const couponTitle = createBaseElement({ + cssClasses: [styles.title], + innerContent: this.coupon.coupon.discountCode, + tag: 'p', + }); + + const couponWrap = createBaseElement({ cssClasses: [styles.coupon], tag: 'div' }); + const deleteCoupon = createBaseElement({ cssClasses: [styles.deleteCoupon], tag: 'button' }); + deleteCoupon.addEventListener('click', () => this.deleteCallback(this.coupon.coupon)); + couponWrap.append(deleteCoupon, couponTitle); + this.view.append(couponWrap, this.couponValue); + + return this.view; + } + + public getHTML(): HTMLLIElement { + return this.view; + } + + public update(value: number): void { + this.couponValue.innerHTML = minusCartPrice(value.toFixed(2)); + } +} + +export default CouponView; diff --git a/src/entities/Coupon/view/couponView.module.scss b/src/entities/Coupon/view/couponView.module.scss new file mode 100644 index 00000000..2dfe2d18 --- /dev/null +++ b/src/entities/Coupon/view/couponView.module.scss @@ -0,0 +1,41 @@ +.couponWrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.coupon { + display: flex; + align-items: center; +} + +.deleteCoupon { + border-radius: var(--small-br); + padding: calc(var(--extra-small-offset) / 8); + width: calc(var(--extra-small-offset)); + height: calc(var(--extra-small-offset)); + background-image: url('../../../shared/img/svg/coupon-delete.svg'); + background-position: center; + background-size: 80%; + background-repeat: no-repeat; + background-color: transparent; + opacity: 0.6; + transition: opacity 0.3s; + cursor: pointer; + + &:hover { + opacity: 1; + } +} + +.title { + padding: var(--tiny-offset) 0; + font: var(--regular-font); + color: var(--noble-gray-800); + transition: all 0.2s; + + @media (max-width: 768px) { + color: var(--noble-gray-1100); + } +} diff --git a/src/entities/Credentials/view/CredentialsView.ts b/src/entities/Credentials/view/CredentialsView.ts index 680bbd2c..00596f82 100644 --- a/src/entities/Credentials/view/CredentialsView.ts +++ b/src/entities/Credentials/view/CredentialsView.ts @@ -1,13 +1,13 @@ import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import * as FORM_FIELDS from '@/shared/constants/forms/fieldParams.ts'; import * as FORM_CONSTANT from '@/shared/constants/forms/text.ts'; import * as FORM_VALIDATION from '@/shared/constants/forms/validationParams.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './credentialsView.module.scss'; @@ -83,7 +83,7 @@ class CredentialsView { private createTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: [styles.title], - innerContent: FORM_CONSTANT.TITLE_TEXT[getStore().getState().currentLanguage].CREDENTIALS, + innerContent: FORM_CONSTANT.TITLE_TEXT[getCurrentLanguage()].CREDENTIALS, tag: 'h3', }); observeCurrentLanguage(this.title, FORM_CONSTANT.TITLE_TEXT, FORM_CONSTANT.TITLE_TEXT_KEYS.CREDENTIALS); @@ -115,9 +115,9 @@ class CredentialsView { } public switchPasswordElementSVG(type: string): SVGSVGElement { - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); this.showPasswordElement.innerHTML = ''; - svg.append(createSVGUse(type === INPUT_TYPE.PASSWORD ? SVG_DETAILS.CLOSE_EYE : SVG_DETAILS.OPEN_EYE)); + svg.append(createSVGUse(type === INPUT_TYPE.PASSWORD ? SVG_DETAIL.CLOSE_EYE : SVG_DETAIL.OPEN_EYE)); this.showPasswordElement.append(svg); return svg; } diff --git a/src/entities/InputField/model/InputFieldModel.ts b/src/entities/InputField/model/InputFieldModel.ts index e44e0c89..8a09689e 100644 --- a/src/entities/InputField/model/InputFieldModel.ts +++ b/src/entities/InputField/model/InputFieldModel.ts @@ -18,7 +18,11 @@ class InputFieldModel { this.setInputHandler(); } - observeStore(selectCurrentLanguage, () => this.inputHandler()); + observeStore(selectCurrentLanguage, () => { + if (this.view.getErrorField()?.textContent?.length) { + this.inputHandler(); + } + }); } private inputHandler(): boolean { diff --git a/src/entities/InputField/view/InputFieldView.ts b/src/entities/InputField/view/InputFieldView.ts index aae55def..bbbccf4a 100644 --- a/src/entities/InputField/view/InputFieldView.ts +++ b/src/entities/InputField/view/InputFieldView.ts @@ -1,9 +1,9 @@ import type { InputFieldParams, InputParams, LabelParams } from '@/shared/types/form'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './inputFieldView.module.scss'; @@ -71,7 +71,7 @@ class InputFieldView { const updateLabelText = (): void => { if (labelParams?.text) { - labelText.textContent = labelParams.text[getStore().getState().currentLanguage]; + labelText.textContent = labelParams.text[getCurrentLanguage()]; } }; diff --git a/src/entities/Navigation/view/NavigationView.ts b/src/entities/Navigation/view/NavigationView.ts index 6a421fde..42a7b43c 100644 --- a/src/entities/Navigation/view/NavigationView.ts +++ b/src/entities/Navigation/view/NavigationView.ts @@ -1,7 +1,8 @@ import LinkModel from '@/shared/Link/model/LinkModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { PAGE_ID, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS } from '@/shared/constants/pages.ts'; +import { PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY } from '@/shared/constants/links.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './navigationView.module.scss'; @@ -55,10 +56,10 @@ class NavigationView { href: PAGE_ID.ABOUT_US_PAGE, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].ABOUT, + text: PAGE_LINK_TEXT[getCurrentLanguage()].ABOUT, }); - observeCurrentLanguage(this.toAboutLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.ABOUT); + observeCurrentLanguage(this.toAboutLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.ABOUT); this.navigationLinks.set(PAGE_ID.ABOUT_US_PAGE, this.toAboutLink); return this.toAboutLink; @@ -70,10 +71,10 @@ class NavigationView { href: PAGE_ID.BLOG, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].BLOG, + text: PAGE_LINK_TEXT[getCurrentLanguage()].BLOG, }); - observeCurrentLanguage(this.toBlogLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.BLOG); + observeCurrentLanguage(this.toBlogLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.BLOG); this.navigationLinks.set(PAGE_ID.BLOG, this.toBlogLink); return this.toBlogLink; @@ -85,10 +86,10 @@ class NavigationView { href: PAGE_ID.CATALOG_PAGE, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].CATALOG, + text: PAGE_LINK_TEXT[getCurrentLanguage()].CATALOG, }); - observeCurrentLanguage(this.toCatalogLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.CATALOG); + observeCurrentLanguage(this.toCatalogLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.CATALOG); this.navigationLinks.set(PAGE_ID.CATALOG_PAGE, this.toCatalogLink); return this.toCatalogLink; @@ -100,10 +101,10 @@ class NavigationView { href: PAGE_ID.LOGIN_PAGE, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].LOGIN, + text: PAGE_LINK_TEXT[getCurrentLanguage()].LOGIN, }); - observeCurrentLanguage(this.toLoginLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.LOGIN); + observeCurrentLanguage(this.toLoginLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.LOGIN); this.navigationLinks.set(PAGE_ID.LOGIN_PAGE, this.toLoginLink); return this.toLoginLink; @@ -115,10 +116,10 @@ class NavigationView { href: PAGE_ID.MAIN_PAGE, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].MAIN, + text: PAGE_LINK_TEXT[getCurrentLanguage()].MAIN, }); - observeCurrentLanguage(this.toMainLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.MAIN); + observeCurrentLanguage(this.toMainLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.MAIN); this.navigationLinks.set(PAGE_ID.MAIN_PAGE, this.toMainLink); return this.toMainLink; @@ -130,10 +131,10 @@ class NavigationView { href: PAGE_ID.REGISTRATION_PAGE, }, classes: [styles.link], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].REGISTRATION, + text: PAGE_LINK_TEXT[getCurrentLanguage()].REGISTRATION, }); - observeCurrentLanguage(this.toRegisterLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.REGISTRATION); + observeCurrentLanguage(this.toRegisterLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.REGISTRATION); this.navigationLinks.set(PAGE_ID.REGISTRATION_PAGE, this.toRegisterLink); return this.toRegisterLink; diff --git a/src/entities/PersonalInfo/view/PersonalInfoView.ts b/src/entities/PersonalInfo/view/PersonalInfoView.ts index 4be01a38..c2bbe36e 100644 --- a/src/entities/PersonalInfo/view/PersonalInfoView.ts +++ b/src/entities/PersonalInfo/view/PersonalInfoView.ts @@ -1,10 +1,10 @@ import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import * as FORM_FIELDS from '@/shared/constants/forms/fieldParams.ts'; import * as FORM_CONSTANT from '@/shared/constants/forms/text.ts'; import * as FORM_VALIDATION from '@/shared/constants/forms/validationParams.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import './personalInfoView.scss'; @@ -52,7 +52,7 @@ class PersonalInfoView { }); const titleElement = createBaseElement({ cssClasses: ['title'], - innerContent: FORM_CONSTANT.TITLE_TEXT[getStore().getState().currentLanguage].PERSONAL, + innerContent: FORM_CONSTANT.TITLE_TEXT[getCurrentLanguage()].PERSONAL, tag: 'h3', }); this.personalDataWrapper.append(titleElement); diff --git a/src/entities/Post/test/post.spec.ts b/src/entities/Post/test/post.spec.ts new file mode 100644 index 00000000..d8b31f8a --- /dev/null +++ b/src/entities/Post/test/post.spec.ts @@ -0,0 +1,22 @@ +import postsData from '@/shared/Posts/posts.ts'; +import sinon from 'sinon'; + +import PostView from '../view/PostView.ts'; + +/** + * @vitest-environment jsdom + */ + +const deleteCallback = sinon.fake(); +const posts = postsData; +const post = new PostView(posts[0], deleteCallback); + +describe('Checking post', () => { + it('should check if post is defined', () => { + expect(post).toBeDefined(); + }); + + it('should check if post is an instance of PostView', () => { + expect(post).toBeInstanceOf(PostView); + }); +}); diff --git a/src/pages/Blog/Post/view/PostView.ts b/src/entities/Post/view/PostView.ts similarity index 85% rename from src/pages/Blog/Post/view/PostView.ts rename to src/entities/Post/view/PostView.ts index 1647263e..6c350a13 100644 --- a/src/pages/Blog/Post/view/PostView.ts +++ b/src/entities/Post/view/PostView.ts @@ -1,8 +1,10 @@ -import type { Post } from '@/shared/constants/blog'; +import type { Post } from '@/shared/types/blog'; -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import formattedMinutes from '@/shared/utils/formattedTime.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './post.module.scss'; @@ -38,10 +40,12 @@ export default class PostView { } private createCardInfoHTML(): string { - const ln = getStore().getState().currentLanguage; + const ln = getCurrentLanguage(); const read = - ln === 'en' ? `Read in ${this.post.time.toString()} minutes` : `Читать за ${this.post.time.toString()} минуты`; - const readMore = ln === 'en' ? 'Read more...' : 'Читать далее...'; + ln === LANGUAGE_CHOICE.EN + ? `Read in ${this.post.time.toString()} minutes` + : `Читать за ${formattedMinutes(this.post.time)}`; + const readMore = ln === LANGUAGE_CHOICE.EN ? 'Read more...' : 'Читать далее...'; const content = `
@@ -72,7 +76,7 @@ export default class PostView { } public createPostInfoHtml(): string { - const ln = getStore().getState().currentLanguage; + const ln = getCurrentLanguage(); 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, `

`); diff --git a/src/pages/Blog/Post/view/post.module.scss b/src/entities/Post/view/post.module.scss similarity index 99% rename from src/pages/Blog/Post/view/post.module.scss rename to src/entities/Post/view/post.module.scss index 26437cd1..b831e188 100644 --- a/src/pages/Blog/Post/view/post.module.scss +++ b/src/entities/Post/view/post.module.scss @@ -18,7 +18,7 @@ display: block; margin: 0 auto; width: 100%; - height: auto; + object-fit: cover; } .dataTime { diff --git a/src/entities/ProductCard/model/ProductCardModel.ts b/src/entities/ProductCard/model/ProductCardModel.ts index 6068434d..348f1eb3 100644 --- a/src/entities/ProductCard/model/ProductCardModel.ts +++ b/src/entities/ProductCard/model/ProductCardModel.ts @@ -1,17 +1,20 @@ import type { AddCartItem, Cart } from '@/shared/types/cart.ts'; import type { Product, ProductInfoParams, Variant } from '@/shared/types/product.ts'; -import type { ShoppingList, ShoppingListProduct } from '@/shared/types/shopping-list.ts'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import ProductPriceModel from '@/entities/ProductPrice/model/ProductPriceModel.ts'; +import WishlistButtonModel from '@/features/WishlistButton/model/WishlistButtonModel.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; -import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import modal from '@/shared/Modal/model/ModalModel.ts'; -import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; -import { buildPathName } from '@/shared/utils/buildPathname.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; +import * as buildPath from '@/shared/utils/buildPathname.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { productAddedToCartMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import ProductInfoModel from '@/widgets/ProductInfo/model/ProductInfoModel.ts'; import ProductCardView from '../view/ProductCardView.ts'; @@ -27,56 +30,31 @@ class ProductCardModel { private view: ProductCardView; - constructor(params: Product, currentSize: null | string, shoppingList: ShoppingList, cart: Cart) { + private wishlistButton: WishlistButtonModel; + + constructor(params: Product, currentSize: null | string, cart: Cart) { this.params = params; - this.currentSize = currentSize; + this.currentSize = currentSize ?? this.params.variant[0].size; this.currentVariant = this.params.variant.find(({ size }) => size === currentSize) ?? this.params.variant[0]; this.view = new ProductCardView(params, currentSize); - this.price = new ProductPriceModel(this.currentVariant); - this.init(shoppingList, cart); + this.price = new ProductPriceModel({ new: this.currentVariant.discount, old: this.currentVariant.price }); + this.wishlistButton = new WishlistButtonModel(this.params); + this.init(cart); } private addProductToCartHandler(): void { getCartModel() .addProductToCart(this.getProductMeta()) .then(() => { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_ADD_PRODUCT_TO_CART, - MESSAGE_STATUS.SUCCESS, - ); + showSuccessMessage(productAddedToCartMessage(this.getProductMeta().name)); this.view.getAddToCartButton().setDisabled(); }) .catch(showErrorMessage); } - private addProductToWishListHandler(): void { - getShoppingListModel() - .addProduct(this.params.id) - .then(() => { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST, - MESSAGE_STATUS.SUCCESS, - ); - this.view.switchStateWishListButton(true); - }) - .catch(showErrorMessage); - } - - private deleteProductToWishListHandler(productInWishList: ShoppingListProduct): void { - getShoppingListModel() - .deleteProduct(productInWishList) - .then(() => { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST, - MESSAGE_STATUS.SUCCESS, - ); - this.view.switchStateWishListButton(false); - }) - .catch(showErrorMessage); - } - private getProductMeta(): AddCartItem { return { + name: this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, productId: this.params.id, quantity: 1, variantId: this.currentVariant.id, @@ -87,10 +65,10 @@ class ProductCardModel { const goDetailsPageLink = this.view.getGoDetailsPageLink(); goDetailsPageLink.getHTML().addEventListener('click', (event) => { event.preventDefault(); - const path = buildPathName(PAGE_ID.PRODUCT_PAGE, this.params.key, { + const path = buildPath.productPathWithIDAndQuery(this.params.key, { size: [this.currentSize ?? this.params.variant[0].size], + slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE) ?? '1'], }); - RouterModel.getInstance().navigateTo(path); }); } @@ -102,33 +80,25 @@ class ProductCardModel { } } - private hasProductInWishList(shoppingList: ShoppingList): void { - const result = shoppingList.products.find((product) => product.productId === this.params.id); - this.view.switchStateWishListButton(Boolean(result)); - } - - private init(shoppingList: ShoppingList, cart: Cart): void { - this.setButtonHandlers(); - this.hasProductInWishList(shoppingList); + private init(cart: Cart): void { + this.setAddToCartButtonHandler(); this.hasProductInCart(cart); this.goDetailsPageHandler(); - this.view.getBottomWrapper().append(this.price.getHTML()); this.setCardHandler(); + this.view.getBottomWrapper().append(this.price.getHTML()); + this.view.getButtonsWrapper().append(this.wishlistButton.getHTML().getHTML()); + EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.CHANGE_WISHLIST_BUTTON, () => { + this.wishlistButton.getHTML().getHTML().remove(); + this.wishlistButton = new WishlistButtonModel(this.params); + this.view.getButtonsWrapper().append(this.wishlistButton.getHTML().getHTML()); + }); } - private setButtonHandlers(): void { - const addToCartButton = this.view.getAddToCartButton(); - const switchToWishListButton = this.view.getSwitchToWishListButton(); - addToCartButton.getHTML().addEventListener('click', () => this.addProductToCartHandler()); - switchToWishListButton.getHTML().addEventListener('click', async () => { - const shoppingList = await getShoppingListModel().getShoppingList(); - const productInWishList = shoppingList.products.find((product) => product.productId === this.params.id); - if (productInWishList) { - this.deleteProductToWishListHandler(productInWishList); - } else { - this.addProductToWishListHandler(); - } - }); + private setAddToCartButtonHandler(): void { + this.view + .getAddToCartButton() + .getHTML() + .addEventListener('click', () => this.addProductToCartHandler()); } private setCardHandler(): void { @@ -138,12 +108,7 @@ class ProductCardModel { !this.view.getButtonsWrapper().contains(target) && !this.view.getMoreButton().getHTML().contains(target) ) { - const params: ProductInfoParams = { - ...this.params, - currentSize: this.currentSize, - }; - modal.show(); - modal.setContent(new ProductInfoModel(params).getHTML()); + this.openProductInfoModal(); } }); } @@ -151,6 +116,35 @@ class ProductCardModel { public getHTML(): HTMLLIElement { return this.view.getHTML(); } + + public getKey(): string { + return this.params.key; + } + + public openProductInfoModal(): void { + const params: ProductInfoParams = { + ...this.params, + currentSize: this.currentSize, + }; + const catalogPath = buildPath.catalogPathWithIDAndQuery(this.params.key, { + size: [this.currentSize ?? this.params.variant[0].size], + slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE) ?? '1'], + }); + + const wishlistPath = buildPath.wishlistPathWithIDAndQuery(this.params.key, { + size: [this.currentSize ?? this.params.variant[0].size], + slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE) ?? '1'], + }); + + const currentPath = RouterModel.getCurrentPage() === PAGE_ID.CATALOG_PAGE ? catalogPath : wishlistPath; + + const router = RouterModel.getInstance(); + const savedPath = + RouterModel.getSavedPath() === currentPath ? RouterModel.getCurrentPage() : RouterModel.getSavedPath(); + router.navigateTo(currentPath); + modal.show(() => router.navigateTo(savedPath)); + modal.setContent(new ProductInfoModel(params, savedPath).getHTML()); + } } export default ProductCardModel; diff --git a/src/entities/ProductCard/view/ProductCardView.ts b/src/entities/ProductCard/view/ProductCardView.ts index b600eca8..e159bdad 100644 --- a/src/entities/ProductCard/view/ProductCardView.ts +++ b/src/entities/ProductCard/view/ProductCardView.ts @@ -3,17 +3,16 @@ import type ProductCardParams from '@/shared/types/productCard.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import LinkModel from '@/shared/Link/model/LinkModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { MORE_TEXT } from '@/shared/constants/buttons.ts'; import { 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'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import * as buildPath from '@/shared/utils/buildPathname.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { discountPercent, discountText } from '@/shared/utils/messageTemplates.ts'; import styles from './productCardView.module.scss'; @@ -44,13 +43,10 @@ class ProductCardView { private productShortDescription: HTMLParagraphElement; - private switchToWishListButton: ButtonModel; - constructor(params: ProductCardParams, currentSize: null | string) { this.params = params; this.currentSize = currentSize; this.addToCartButton = this.createAddToCartButton(); - this.switchToWishListButton = this.createSwitchToWishListButton(); this.goDetailsPageLink = this.createGoDetailsPageLink(); this.buttonsWrapper = this.createButtonsWrapper(); this.productImage = this.createProductImage(); @@ -64,8 +60,7 @@ class ProductCardView { } private changeButtonText(productShortDescription: HTMLParagraphElement, moreButton: HTMLButtonElement): void { - const { currentLanguage } = getStore().getState(); - const moreText = MORE_TEXT[currentLanguage]; + const moreText = MORE_TEXT[getCurrentLanguage()]; const button = moreButton; productShortDescription.classList.toggle(styles.active); @@ -77,8 +72,8 @@ class ProductCardView { classes: [styles.addToCartButton], }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.CART)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.CART)); this.addToCartButton.getHTML().append(svg); return this.addToCartButton; @@ -108,18 +103,14 @@ class ProductCardView { tag: 'div', }); - this.buttonsWrapper.append( - this.addToCartButton.getHTML(), - this.switchToWishListButton.getHTML(), - this.goDetailsPageLink.getHTML(), - ); + this.buttonsWrapper.append(this.addToCartButton.getHTML(), this.goDetailsPageLink.getHTML()); 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)}%`; + const innerContent = discountPercent(currentVariant); this.discountLabel = createBaseElement({ cssClasses: [styles.discountLabel], innerContent, @@ -128,12 +119,12 @@ class ProductCardView { const discountSpan = createBaseElement({ cssClasses: [styles.discountSpan], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DISCOUNT_LABEL, + innerContent: discountText(), tag: 'span', }); observeStore(selectCurrentLanguage, () => { - discountSpan.textContent = PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DISCOUNT_LABEL; + discountSpan.textContent = discountText(); }); this.discountLabel.append(discountSpan); @@ -142,9 +133,7 @@ class ProductCardView { } private createGoDetailsPageLink(): LinkModel { - const href = `${buildPathName(PAGE_ID.PRODUCT_PAGE, this.params.key, { - size: [this.currentSize ?? this.params.variant[0].size], - })}`; + const href = `${buildPath.productPathWithIDAndQuery(this.params.key, { size: [this.currentSize ?? this.params.variant[0].size] })}`; this.goDetailsPageLink = new LinkModel({ attrs: { @@ -153,8 +142,8 @@ class ProductCardView { classes: [styles.goDetailsPageLink], }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.GO_DETAILS)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.GO_DETAILS)); this.goDetailsPageLink.getHTML().append(svg); return this.goDetailsPageLink; @@ -177,7 +166,7 @@ class ProductCardView { private createMoreButton(): ButtonModel { this.moreButton = new ButtonModel({ classes: [styles.moreButton], - text: MORE_TEXT[getStore().getState().currentLanguage].MORE, + text: MORE_TEXT[getCurrentLanguage()].MORE, }); return this.moreButton; @@ -217,7 +206,7 @@ class ProductCardView { } private createProductName(): HTMLHeadingElement { - const innerContent = this.params.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const innerContent = this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; const productName = createBaseElement({ cssClasses: [styles.productName], innerContent, @@ -225,15 +214,14 @@ class ProductCardView { }); observeStore(selectCurrentLanguage, () => { - const textContent = this.params.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const textContent = this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; productName.textContent = textContent; }); return productName; } private createProductShortDescription(): HTMLParagraphElement { - const innerContent = - this.params.description[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const innerContent = this.params.description[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; this.productShortDescription = createBaseElement({ cssClasses: [styles.productShortDescription], innerContent, @@ -241,29 +229,15 @@ class ProductCardView { }); observeStore(selectCurrentLanguage, () => { - const textContent = - this.params.description[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const textContent = this.params.description[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; this.productShortDescription.textContent = textContent; }); return this.productShortDescription; } - private createSwitchToWishListButton(): ButtonModel { - this.switchToWishListButton = new ButtonModel({ - classes: [styles.switchToWishListButton], - }); - - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.FILL_HEART)); - this.switchToWishListButton.getHTML().append(svg); - - return this.switchToWishListButton; - } - private updateMoreButtonText(moreButton: HTMLButtonElement): void { - const { currentLanguage } = getStore().getState(); - const moreText = MORE_TEXT[currentLanguage]; + const moreText = MORE_TEXT[getCurrentLanguage()]; const isActive = this.productShortDescription.classList.contains(styles.active); const button = moreButton; @@ -293,14 +267,6 @@ class ProductCardView { public getMoreButton(): ButtonModel { return this.moreButton; } - - public getSwitchToWishListButton(): ButtonModel { - return this.switchToWishListButton; - } - - public switchStateWishListButton(hasProductInWishList: boolean): void { - this.switchToWishListButton.getHTML().classList.toggle(styles.inWishList, hasProductInWishList); - } } export default ProductCardView; diff --git a/src/entities/ProductCard/view/productCardView.module.scss b/src/entities/ProductCard/view/productCardView.module.scss index 08118b0f..1a61ab3e 100644 --- a/src/entities/ProductCard/view/productCardView.module.scss +++ b/src/entities/ProductCard/view/productCardView.module.scss @@ -68,7 +68,6 @@ } .addToCartButton, -.switchToWishListButton, .goDetailsPageLink { position: relative; outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); @@ -90,16 +89,13 @@ &:active { transform: scale(0.9); } -} -.addToCartButton, -.goDetailsPageLink { @media (hover: hover) { &:hover { - outline: calc(var(--one) * 1.5) solid var(--steam-green-400); + outline: calc(var(--one) * 1.5) solid var(--steam-green-1200); svg { - fill: var(--steam-green-400); + fill: var(--steam-green-1200); } } } @@ -112,38 +108,6 @@ } } -.inWishList { - outline: calc(var(--one) * 1.5) solid var(--red-power-600); - - svg { - fill: var(--red-power-600); - } -} - -.switchToWishListButton { - @media (hover: hover) { - &:hover { - outline: calc(var(--one) * 1.5) solid var(--red-power-600); - - svg { - fill: var(--red-power-600); - } - } - } - - &.inWishList { - @media (hover: hover) { - &:hover { - outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); - - svg { - fill: var(--noble-gray-700); - } - } - } - } -} - .bottomWrapper { display: flex; flex-grow: 1; @@ -171,6 +135,7 @@ width: 100%; max-width: 90%; font: var(--regular-font); + line-height: 150%; letter-spacing: var(--one); text-align: left; text-overflow: ellipsis; diff --git a/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts b/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts index 1629a884..cc98b2e3 100644 --- a/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts +++ b/src/entities/ProductModalSlider/model/ProductModalSliderModel.ts @@ -1,11 +1,14 @@ import type { ProductInfoParams } from '@/shared/types/product.ts'; +import { set } from '@/app/Router/helpers/helpers.ts'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import Swiper from 'swiper'; import 'swiper/css'; import 'swiper/css/autoplay'; import 'swiper/css/bundle'; import 'swiper/css/navigation'; -import { Autoplay, Navigation, Thumbs } from 'swiper/modules'; +import { Autoplay, Keyboard, Navigation, Thumbs } from 'swiper/modules'; import ProductModalSliderView from '../view/ProductModalSliderView.ts'; @@ -28,14 +31,49 @@ class ProductModalSliderModel { delay: SLIDER_DELAY, }, direction: 'horizontal', + keyboard: { + enabled: true, + }, loop: true, - modules: [Autoplay, Thumbs, Navigation], + modules: [Autoplay, Thumbs, Navigation, Keyboard], navigation: { nextEl: this.view.getNextSlideButton().getHTML(), prevEl: this.view.getPrevSlideButton().getHTML(), }, slidesPerView: SLIDER_PER_VIEW, }); + this.modalSlider.slideTo(Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)) - 1); + + this.nextSlideButtonHandler(); + this.prevSlideButtonHandler(); + } + + private nextSlideButtonHandler(): void { + const nextSlideButton = this.view.getNextSlideButton(); + nextSlideButton.getHTML().addEventListener('click', () => { + const slideInSearch = Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)); + + if (slideInSearch < this.view.getModalSliderSlides().length) { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(slideInSearch + 1))); + } else { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(1))); + } + }); + } + + private prevSlideButtonHandler(): void { + const prevSlideButton = this.view.getPrevSlideButton(); + prevSlideButton.getHTML().addEventListener('click', () => { + const slideInSearch = Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)); + + if (slideInSearch > 1) { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(slideInSearch - 1))); + } else { + RouterModel.changeSearchParams((url) => + set(url, SEARCH_PARAMS_FIELD.SLIDE, String(this.view.getModalSliderSlides().length)), + ); + } + }); } public getHTML(): HTMLDivElement { diff --git a/src/entities/ProductModalSlider/view/ProductModalSliderView.ts b/src/entities/ProductModalSlider/view/ProductModalSliderView.ts index a069842c..1dfa42ea 100644 --- a/src/entities/ProductModalSlider/view/ProductModalSliderView.ts +++ b/src/entities/ProductModalSlider/view/ProductModalSliderView.ts @@ -4,7 +4,7 @@ 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 SVG_DETAIL from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; @@ -17,6 +17,8 @@ class ProductModalSliderView { private modalSlider: HTMLDivElement; + private modalSliderSlides: HTMLDivElement[] = []; + private nextSlideButton: ButtonModel; private params: ProductInfoParams; @@ -36,7 +38,7 @@ class ProductModalSliderView { private createHTML(): HTMLDivElement { this.view = createBaseElement({ - cssClasses: [styles.modalSlidersWrapper], + cssClasses: [styles.modalSliderWrapper], tag: 'div', }); @@ -88,12 +90,16 @@ class ProductModalSliderView { tag: 'div', }); - this.params.images.forEach((image) => { + this.params.images.forEach((image, index) => { const slideWrapper = createBaseElement({ + attributes: { + 'data-history': index.toString(), + }, cssClasses: ['swiper-slide', styles.modalSliderSlide], tag: 'div', }); const slide = this.createModalSliderSlideContent(image, this.params.name[0].value); + this.modalSliderSlides.push(slide); const loader = new LoaderModel(LOADER_SIZE.LARGE); loader.setAbsolutePosition(); slideWrapper.append(slide, loader.getHTML()); @@ -122,8 +128,8 @@ class ProductModalSliderView { classes: [styles.nextSlideButton], }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.ARROW_UP)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.ARROW_UP)); this.nextSlideButton.getHTML().append(svg); return this.nextSlideButton; } @@ -132,8 +138,8 @@ 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)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.ARROW_UP)); this.prevSlideButton.getHTML().append(svg); return this.prevSlideButton; } @@ -146,6 +152,10 @@ class ProductModalSliderView { return this.modalSlider; } + public getModalSliderSlides(): HTMLDivElement[] { + return this.modalSliderSlides; + } + public getNextSlideButton(): ButtonModel { return this.nextSlideButton; } diff --git a/src/entities/ProductModalSlider/view/productModalSliderView.module.scss b/src/entities/ProductModalSlider/view/productModalSliderView.module.scss index 1888039c..29580102 100644 --- a/src/entities/ProductModalSlider/view/productModalSliderView.module.scss +++ b/src/entities/ProductModalSlider/view/productModalSliderView.module.scss @@ -11,6 +11,7 @@ } .modalSliderWrapper { + position: relative; height: auto; min-height: 40rem; @@ -67,12 +68,11 @@ .navigationWrapper { position: absolute; - left: -3.5rem; - right: -3.5rem; top: 50%; z-index: 1; display: flex; justify-content: space-between; + width: 100%; transform: translateY(-50%); } diff --git a/src/entities/ProductPrice/model/ProductPriceModel.ts b/src/entities/ProductPrice/model/ProductPriceModel.ts index 95103a9c..84a8e4ec 100644 --- a/src/entities/ProductPrice/model/ProductPriceModel.ts +++ b/src/entities/ProductPrice/model/ProductPriceModel.ts @@ -1,12 +1,10 @@ -import type { Variant } from '@/shared/types/product.ts'; - import ProductPriceView from '../view/ProductPriceView.ts'; class ProductPriceModel { private view: ProductPriceView; - constructor(currentVariant: Variant) { - this.view = new ProductPriceView(currentVariant); + constructor(params: { new: number; old: number }) { + this.view = new ProductPriceView(params); this.init(); } @@ -15,6 +13,10 @@ class ProductPriceModel { public getHTML(): HTMLDivElement { return this.view.getHTML(); } + + public updatePrice(params: { new: number; old: number }): void { + this.view.updatesPrice(params); + } } export default ProductPriceModel; diff --git a/src/entities/ProductPrice/view/ProductPriceView.ts b/src/entities/ProductPrice/view/ProductPriceView.ts index a9fe893a..6226b399 100644 --- a/src/entities/ProductPrice/view/ProductPriceView.ts +++ b/src/entities/ProductPrice/view/ProductPriceView.ts @@ -1,5 +1,3 @@ -import type { Variant } from '@/shared/types/product'; - import createBaseElement from '@/shared/utils/createBaseElement.ts'; import './productPriceView.scss'; @@ -7,30 +5,28 @@ import './productPriceView.scss'; class ProductPriceView { private basicPrice: HTMLSpanElement; - private currentVariant: Variant; - private oldPrice: HTMLSpanElement; + private params: { new: number; old: number }; + private view: HTMLDivElement; - constructor(currentVariant: Variant) { - this.currentVariant = currentVariant; + constructor(params: { new: number; old: number }) { + this.params = params; this.basicPrice = this.createBasicPrice(); this.oldPrice = this.createOldPrice(); this.view = this.createHTML(); } private createBasicPrice(): HTMLSpanElement { - const innerContent = this.currentVariant.discount - ? `$${this.currentVariant.discount.toFixed(2)}` - : `$${this.currentVariant.price?.toFixed(2)}`; + const innerContent = this.getBasePrice(); // this.params.new ? `$${this.params.new.toFixed(2)}` : `$${this.params.old?.toFixed(2)}`; this.basicPrice = createBaseElement({ cssClasses: ['basicPrice'], innerContent, tag: 'span', }); - if (!this.currentVariant.discount) { + if (!this.params.new) { this.basicPrice.classList.add('gray'); } @@ -48,7 +44,7 @@ class ProductPriceView { } private createOldPrice(): HTMLSpanElement { - const innerContent = this.currentVariant.discount ? `$${this.currentVariant.price?.toFixed(2)}` : ''; + const innerContent = this.getOldPrice(); // this.params.new ? `$${this.params.old.toFixed(2)}` : ''; this.oldPrice = createBaseElement({ cssClasses: ['oldPrice'], innerContent, @@ -58,9 +54,28 @@ class ProductPriceView { return this.oldPrice; } + private getBasePrice(): string { + return this.params.new ? `$${this.params.new.toFixed(2)}` : `$${this.params.old?.toFixed(2)}`; + } + + private getOldPrice(): string { + return this.params.new ? `$${this.params.old.toFixed(2)}` : ''; + } + public getHTML(): HTMLDivElement { return this.view; } + + public updatesPrice(params: { new: number; old: number }): void { + this.params = params; + this.basicPrice.textContent = this.getBasePrice(); + this.oldPrice.textContent = this.getOldPrice(); + if (!this.params.new) { + this.basicPrice.classList.add('gray'); + } else { + this.basicPrice.classList.remove('gray'); + } + } } export default ProductPriceView; diff --git a/src/entities/ProductPrice/view/productPriceView.scss b/src/entities/ProductPrice/view/productPriceView.scss index 9e36a732..204525da 100644 --- a/src/entities/ProductPrice/view/productPriceView.scss +++ b/src/entities/ProductPrice/view/productPriceView.scss @@ -41,6 +41,9 @@ @media (max-width: 768px) { align-items: center; justify-content: center; + justify-self: end; + grid-row: 1; + width: max-content; } } } diff --git a/src/entities/PromocodeSlider/model/PromoCodeSliderModel.ts b/src/entities/PromocodeSlider/model/PromoCodeSliderModel.ts new file mode 100644 index 00000000..703f4297 --- /dev/null +++ b/src/entities/PromocodeSlider/model/PromoCodeSliderModel.ts @@ -0,0 +1,52 @@ +import observeStore, { selectIsUserLoggedIn } from '@/shared/Store/observer.ts'; +import Swiper from 'swiper'; +import 'swiper/css'; +import 'swiper/css/autoplay'; +import 'swiper/css/bundle'; +import 'swiper/css/pagination'; +import { Autoplay, Pagination } from 'swiper/modules'; + +import PromoCodeSliderView from '../view/PromoCodeSliderView.ts'; + +const SLIDER_DELAY = 10000; +const SLIDER_PER_VIEW = 1; + +class PromoCodeSliderModel { + private slider: Swiper | null = null; + + private view: PromoCodeSliderView; + + constructor() { + this.view = new PromoCodeSliderView(); + this.init(); + } + + private init(): void { + this.initSlider(); + + observeStore(selectIsUserLoggedIn, () => this.view.redrawSlider()); + } + + private initSlider(): void { + this.slider = new Swiper(this.view.getSlider(), { + autoplay: { + delay: SLIDER_DELAY, + }, + direction: 'horizontal', + loop: true, + modules: [Autoplay, Pagination], + pagination: { + clickable: true, + el: this.view.getPaginationWrapper(), + }, + slidesPerView: SLIDER_PER_VIEW, + }); + this.slider.autoplay.start(); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } +} + +export default PromoCodeSliderModel; diff --git a/src/entities/PromocodeSlider/view/PromoCodeSliderView.ts b/src/entities/PromocodeSlider/view/PromoCodeSliderView.ts new file mode 100644 index 00000000..711aadbc --- /dev/null +++ b/src/entities/PromocodeSlider/view/PromoCodeSliderView.ts @@ -0,0 +1,232 @@ +import type { User } from '@/shared/types/user'; + +import getCustomerModel from '@/shared/API/customer/model/CustomerModel.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 PROMO_SLIDER_CONTENT from '@/shared/constants/promo.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import calcUserBirthDayRange from '@/shared/utils/calcUserBirthDayRange.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { promoCodeCopiedMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; + +import styles from './promoCodeSliderView.module.scss'; + +class PromoCodeSliderView { + private paginationWrapper: HTMLDivElement; + + private slider: HTMLDivElement; + + private view: HTMLDivElement; + + constructor() { + this.paginationWrapper = this.createPaginationWrapper(); + this.slider = this.createSlider(); + this.view = this.createHTML(); + } + + private createDateSpan(index: number, currentUser?: User): HTMLSpanElement { + const currentLanguage = getCurrentLanguage(); + const date = createBaseElement({ + cssClasses: [styles.sliderDate], + tag: 'span', + }); + + const start = createBaseElement({ + cssClasses: [styles.sliderDateStart], + innerContent: ((): string => { + const promoContent = PROMO_SLIDER_CONTENT[index][currentLanguage].date; + if (currentUser) { + return `${calcUserBirthDayRange(currentUser.birthDate).start} —`; + } + if (promoContent.end) { + return `${promoContent.start} —`; + } + return promoContent.start; + })(), + tag: 'span', + }); + + const end = createBaseElement({ + cssClasses: [styles.sliderDateEnd], + innerContent: currentUser + ? calcUserBirthDayRange(currentUser.birthDate).end + : PROMO_SLIDER_CONTENT[index][currentLanguage].date.end ?? '', + tag: 'span', + }); + + this.observeLanguageChanges(start, end, index, currentUser); + + date.append(start, end); + return date; + } + + private createHTML(): HTMLDivElement { + this.view = createBaseElement({ + cssClasses: [styles.wrapper], + tag: 'div', + }); + + this.view.append(this.slider); + return this.view; + } + + private createPaginationWrapper(): HTMLDivElement { + this.paginationWrapper = createBaseElement({ + cssClasses: [styles.paginationWrapper], + tag: 'div', + }); + + return this.paginationWrapper; + } + + private createPromoCodeSpan(code: string): HTMLSpanElement { + const promoCode = createBaseElement({ + cssClasses: [styles.sliderPromoCode], + tag: 'span', + }); + + const currentPromoCode = new InputModel({ value: code }); + + currentPromoCode.getHTML().classList.add(styles.currentPromoCode); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.COPY)); + + svg.addEventListener('click', () => { + window.navigator.clipboard + .writeText(currentPromoCode.getValue()) + .then(() => showSuccessMessage(promoCodeCopiedMessage(currentPromoCode.getValue()))) + .catch(showErrorMessage); + }); + + promoCode.append(svg, currentPromoCode.getHTML()); + return promoCode; + } + + private createSlider(): HTMLDivElement { + this.slider = createBaseElement({ + cssClasses: ['swiper', styles.slider], + tag: 'div', + }); + + this.slider.append(this.createSliderWrapper(), this.paginationWrapper); + + return this.slider; + } + + private async createSliderSlideContent(index: number): Promise { + const currentLanguage = getCurrentLanguage(); + const slide = createBaseElement({ + cssClasses: [styles.sliderContent], + tag: 'div', + }); + slide.classList.add(styles[PROMO_SLIDER_CONTENT[index][currentLanguage].style]); + const { description, title } = this.createSliderSlideInfo(index); + + slide.append(title, description, this.createPromoCodeSpan(PROMO_SLIDER_CONTENT[index][currentLanguage].promoCode)); + + observeStore(selectCurrentLanguage, () => { + const currentLanguage = getCurrentLanguage(); + title.innerHTML = PROMO_SLIDER_CONTENT[index][currentLanguage].title; + description.innerHTML = PROMO_SLIDER_CONTENT[index][currentLanguage].description; + }); + + if (PROMO_SLIDER_CONTENT[index].en.date.end === null && getStore().getState().isUserLoggedIn) { + const currentUser = await getCustomerModel().getCurrentUser(); + if (currentUser) { + slide.append(this.createDateSpan(index, currentUser)); + } + } else { + slide.append(this.createDateSpan(index)); + } + + return slide; + } + + private createSliderSlideInfo(index: number): { + description: HTMLParagraphElement; + title: HTMLHeadingElement; + } { + const currentLanguage = getCurrentLanguage(); + const title = createBaseElement({ + cssClasses: [styles.sliderTitle], + innerContent: PROMO_SLIDER_CONTENT[index][currentLanguage].title, + tag: 'h3', + }); + + const description = createBaseElement({ + cssClasses: [styles.sliderDescription], + innerContent: PROMO_SLIDER_CONTENT[index][currentLanguage].description, + tag: 'p', + }); + + return { description, title }; + } + + private createSliderWrapper(): HTMLDivElement { + const sliderWrapper = createBaseElement({ + cssClasses: ['swiper-wrapper', styles.sliderWrapper], + tag: 'div', + }); + + PROMO_SLIDER_CONTENT.forEach((_, index) => { + const slideWrapper = createBaseElement({ + cssClasses: ['swiper-slide', styles.sliderSlide], + tag: 'div', + }); + this.createSliderSlideContent(index) + .then((slide) => slideWrapper.append(slide)) + .catch(showErrorMessage); + + sliderWrapper.append(slideWrapper); + }); + + return sliderWrapper; + } + + private observeLanguageChanges( + startSpan: HTMLSpanElement, + endSpan: HTMLSpanElement, + index: number, + currentUser?: User, + ): void { + const start = startSpan; + const end = endSpan; + + observeStore(selectCurrentLanguage, () => { + const promoContent = PROMO_SLIDER_CONTENT[index][getCurrentLanguage()].date; + if (currentUser) { + start.innerHTML = getStore().getState().isUserLoggedIn + ? `${calcUserBirthDayRange(currentUser.birthDate).start} —` + : promoContent.start; + end.textContent = getStore().getState().isUserLoggedIn ? calcUserBirthDayRange(currentUser.birthDate).end : ''; + } else { + start.innerHTML = `${promoContent.start}`; + end.textContent = promoContent.end ? ` — ${promoContent.end}` : ''; + } + }); + } + + public getHTML(): HTMLDivElement { + return this.view; + } + + public getPaginationWrapper(): HTMLDivElement { + return this.paginationWrapper; + } + + public getSlider(): HTMLDivElement { + return this.slider; + } + + public redrawSlider(): void { + this.slider.innerHTML = ''; + this.slider.append(this.createSliderWrapper()); + } +} + +export default PromoCodeSliderView; diff --git a/src/entities/PromocodeSlider/view/promoCodeSliderView.module.scss b/src/entities/PromocodeSlider/view/promoCodeSliderView.module.scss new file mode 100644 index 00000000..97e060de --- /dev/null +++ b/src/entities/PromocodeSlider/view/promoCodeSliderView.module.scss @@ -0,0 +1,176 @@ +.slider { + border-radius: var(--large-br); + height: max-content; + background-color: var(--steam-green-900); + cursor: default; +} + +.sliderContent { + position: relative; + padding: var(--medium-offset); +} + +.sliderTitle { + margin-bottom: var(--tiny-offset); + max-width: 80%; + font: var(--super-large-font); + letter-spacing: var(--one); + color: var(--steam-green-800); + + @media (max-width: 768px) { + max-width: 100%; + } + + @media (max-width: 600px) { + font: var(--black-font); + } +} + +.sliderDescription { + margin-bottom: var(--extra-small-offset); + max-width: 55%; + font: var(--medium-font); + letter-spacing: var(--one); + color: var(--noble-gray-800); + + @media (max-width: 768px) { + max-width: 100%; + font: var(--bold-font); + } +} + +.sliderPromoCode { + display: flex; + align-items: center; + margin-bottom: var(--extra-small-offset); + max-width: max-content; + gap: var(--tiny-offset); + + svg { + width: 1.5rem; + height: 1.5rem; + fill: transparent; + stroke: var(--steam-green-400); + transition: stroke 0.2s; + cursor: copy; + + @media (hover: hover) { + &:hover { + stroke: var(--steam-green-800); + } + } + } +} + +.currentPromoCode { + font: var(--medium-font); + letter-spacing: var(--one); + color: var(--steam-green-800); + background-color: transparent; + pointer-events: none; +} + +.paginationWrapper { + position: absolute; + right: 0; + z-index: 2; + display: flex; + margin: 0 auto; + margin-bottom: var(--tiny-offset); + max-width: max-content; + gap: var(--tiny-offset); + + > * { + border-radius: 50%; + width: 1rem; + height: 1rem; + background-color: var(--steam-green-800); + transition: + transform 0.2s, + background-color 0.2s; + cursor: pointer; + + &:active { + transform: scale(0.9); + } + + @media (hover: hover) { + &:hover { + background-color: var(--steam-green-400); + } + } + } +} + +.sliderDate { + display: flex; + gap: var(--five); +} + +.sliderDateStart, +.sliderDateEnd { + font: var(--regular-font); + letter-spacing: var(--one); + color: var(--noble-gray-800); +} + +.birthday { + height: 100%; + background-image: linear-gradient(to right, var(--white) 0%, var(--white) 20%, transparent 100%), + url('/img/webp/promo-slider-2_1.webp'); + background-position: right center; + background-size: auto 100%; + background-repeat: no-repeat; + + @media (max-width: 768px) { + background-image: linear-gradient(to right, var(--white) 0%, var(--white) 50%, transparent 100%), + url('/img/webp/promo-slider-2_2.webp'); + } +} + +.succulent { + height: 100%; + background-image: linear-gradient(to right, var(--white) 0%, var(--white) 20%, transparent 100%), + url('/img/webp/promo-slider-3_1.webp'); + background-position: right center; + background-size: auto 100%; + background-repeat: no-repeat; + + @media (max-width: 768px) { + background-image: linear-gradient(to right, var(--white) 0%, var(--white) 50%, transparent 100%), + url('/img/webp/promo-slider-3_2.webp'); + } +} + +.garden { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + background-image: radial-gradient(circle at center, var(--white) 40%, transparent 40.1%), + url('/img/webp/promo-slider-1_2.webp'); + background-position: + center center, + right center; + background-size: cover, cover; + background-repeat: no-repeat; + + h3, + p { + text-align: center; + } + + input { + width: 11rem; + } + + @media (max-width: 768px) { + background-image: radial-gradient(circle at center, var(--white) 55%, transparent 55.1%), + url('/img/webp/promo-slider-1_2.webp'); + } +} + +.sliderSlide { + height: auto; +} diff --git a/src/entities/Summary/model/SummaryModel.ts b/src/entities/Summary/model/SummaryModel.ts new file mode 100644 index 00000000..fffcd082 --- /dev/null +++ b/src/entities/Summary/model/SummaryModel.ts @@ -0,0 +1,68 @@ +import type { CartCoupon, Coupon } from '@/shared/types/cart.ts'; +import type { languageVariants } from '@/shared/types/common.ts'; + +import CouponModel from '@/entities/Coupon/model/CouponModel.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; + +import SummaryView from '../view/SummaryView.ts'; + +export type DeleteCallback = (coupon: Coupon) => Promise; + +class SummaryModel { + private coupons: CouponModel[] = []; + + private deleteCallback: DeleteCallback; + + private view: SummaryView; + + constructor(title: languageVariants, deleteCallback: DeleteCallback) { + this.deleteCallback = deleteCallback; + this.view = new SummaryView(title); + this.init(); + } + + private init(): void { + observeStore(selectCurrentLanguage, () => this.view.updateLanguage()); + } + + public addCoupon(coupon: CartCoupon): void { + const couponModel = new CouponModel(coupon, this.deleteCallback); + this.coupons.push(couponModel); + this.view.addCouponItem(couponModel); + this.updateTotal(); + } + + public getHTML(): HTMLDetailsElement { + return this.view.getHTML(); + } + + public update(coupons: CartCoupon[]): void { + coupons.forEach((discount) => { + const searchCoupon = this.coupons.find( + (coupon) => coupon.getCouponData().coupon.discountCode === discount.coupon.discountCode, + ); + if (!searchCoupon) { + this.addCoupon(discount); + } else { + searchCoupon.update(discount.value); + } + }); + this.coupons.forEach((coupon) => { + const searchDiscount = coupons.find( + (discount) => discount.coupon.discountCode === coupon.getCouponData().coupon.discountCode, + ); + if (!searchDiscount) { + this.coupons = this.coupons.filter((el) => el !== coupon); + coupon.getHTML().remove(); + } + }); + this.updateTotal(); + } + + public updateTotal(): void { + const total = this.coupons.reduce((total, coupon) => total + coupon.getCouponData().value, 0); + this.view.updateTotal(total); + } +} + +export default SummaryModel; diff --git a/src/entities/Summary/test/summary.spec.ts b/src/entities/Summary/test/summary.spec.ts new file mode 100644 index 00000000..95a44907 --- /dev/null +++ b/src/entities/Summary/test/summary.spec.ts @@ -0,0 +1,20 @@ +import sinon from 'sinon'; + +import SummaryModel from '../model/SummaryModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const deleteCallback = sinon.fake(); +const summaryModel = new SummaryModel({ en: '', ru: '' }, deleteCallback); + +describe('Checking summaryModel', () => { + it('should check if summaryModel is defined', () => { + expect(summaryModel).toBeDefined(); + }); + + it('should check if summaryModel is an instance of SummaryModel', () => { + expect(summaryModel).toBeInstanceOf(SummaryModel); + }); +}); diff --git a/src/entities/Summary/view/SummaryView.ts b/src/entities/Summary/view/SummaryView.ts new file mode 100644 index 00000000..20592294 --- /dev/null +++ b/src/entities/Summary/view/SummaryView.ts @@ -0,0 +1,90 @@ +import type CouponModel from '@/entities/Coupon/model/CouponModel'; +import type { LanguageChoiceType } from '@/shared/constants/common'; +import type { languageVariants } from '@/shared/types/common'; + +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { minusCartPrice } from '@/shared/utils/messageTemplates.ts'; + +import styles from './summaryView.module.scss'; + +class SummaryView { + private discountList: HTMLUListElement; + + private discountTotal: HTMLElement; + + private language: LanguageChoiceType; + + private title: languageVariants; + + private total = 0; + + private view: HTMLDetailsElement; + + constructor(title: languageVariants) { + this.title = title; + this.language = getCurrentLanguage(); + this.discountTotal = this.createSummaryHTML(); + this.discountList = createBaseElement({ cssClasses: [styles.couponsList], tag: 'ul' }); + this.view = this.createHTML(); + this.view.append(this.discountTotal, this.discountList); + } + + private createHTML(): HTMLDetailsElement { + this.view = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'details' }); + return this.view; + } + + private createSummaryHTML(): HTMLElement { + return createBaseElement({ cssClasses: [styles.couponsWrap], tag: 'summary' }); + } + + private getTitle(): HTMLParagraphElement { + return createBaseElement({ + cssClasses: [styles.title], + innerContent: this.title[this.language], + tag: 'p', + }); + } + + private getValue(value: number): HTMLParagraphElement { + return createBaseElement({ + cssClasses: [styles.title], + innerContent: minusCartPrice(value.toFixed(2)), + tag: 'p', + }); + } + + private updateTitle(): void { + this.discountTotal.innerHTML = ''; + const totalDiscountWrap = createBaseElement({ cssClasses: [styles.couponWrap], tag: 'div' }); + const totalDiscountTitle = this.getTitle(); + const totalDiscountValue = this.getValue(this.total); + totalDiscountWrap.append(totalDiscountTitle, totalDiscountValue); + this.discountTotal.append(totalDiscountWrap); + } + + public addCouponItem(coupon: CouponModel): void { + this.discountList.append(coupon.getHTML()); + } + + public getHTML(): HTMLDetailsElement { + return this.view; + } + + public updateLanguage(): void { + this.language = getCurrentLanguage(); + this.updateTitle(); + } + + public updateTotal(total: number): void { + if (total) { + this.total = total; + this.updateTitle(); + } else { + this.discountTotal.innerHTML = ''; + } + } +} + +export default SummaryView; diff --git a/src/entities/Summary/view/summaryView.module.scss b/src/entities/Summary/view/summaryView.module.scss new file mode 100644 index 00000000..91daba57 --- /dev/null +++ b/src/entities/Summary/view/summaryView.module.scss @@ -0,0 +1,49 @@ +.totalWrap { + display: flex; + align-items: stretch; + justify-content: space-between; + max-width: calc(var(--extra-large-offset) * 3.8); + + @media (max-width: 768px) { + width: 100%; + max-width: 100%; + } +} + +.couponsWrap { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + + &:hover p { + color: var(--steam-green-800); + } +} + +.title { + padding: var(--tiny-offset) 0; + font: var(--regular-font); + color: var(--noble-gray-800); + transition: all 0.2s; + + @media (max-width: 768px) { + color: var(--noble-gray-1100); + } +} + +.couponsWrap:not(:empty)::after { + content: ''; + display: inline-block; + width: var(--tiny-offset); + height: var(--tiny-offset); + background: url('/img/png/expand-arrow.png') no-repeat center center; + background-size: contain; +} + +.couponWrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} diff --git a/src/entities/UserAddress/model/UserAddressModel.ts b/src/entities/UserAddress/model/UserAddressModel.ts index 77e2ad1a..e1effc88 100644 --- a/src/entities/UserAddress/model/UserAddressModel.ts +++ b/src/entities/UserAddress/model/UserAddressModel.ts @@ -6,34 +6,26 @@ 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 getStore from '@/shared/Store/Store.ts'; import { setBillingCountry } from '@/shared/Store/actions.ts'; -import { USER_MESSAGE } from '@/shared/constants/confirmUserMessage.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 { ADDRESS, type AddressType } from '@/shared/constants/forms.ts'; +import { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showErrorMessage, showSuccessMessage } 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 labels: Map; private view: UserAddressView; - constructor( - address: Address, - activeTypes: AddressTypeType[], - callback: (isDisabled: boolean) => void, - inactiveTypes?: AddressTypeType[], - ) { - this.callback = callback; + constructor(address: Address, activeTypes: AddressType[], inactiveTypes?: AddressType[]) { this.currentAddress = address; this.view = new UserAddressView(address, activeTypes, inactiveTypes); this.labels = this.view.getLabels(); @@ -49,7 +41,7 @@ class UserAddressModel { try { 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); + showSuccessMessage(SERVER_MESSAGE_KEY.ADDRESS_DELETED); } catch (error) { showErrorMessage(error); } @@ -59,20 +51,20 @@ class UserAddressModel { } } - private async handleAddressType(user: User, activeType: AddressTypeType, inactive: boolean): Promise { + private async handleAddressType(user: User, activeType: AddressType, inactive: boolean): Promise { const customerModel = getCustomerModel(); const actions = { - [ADDRESS_TYPE.BILLING]: inactive + [ADDRESS.BILLING]: inactive ? CustomerModel.actionAddBillingAddress(this.currentAddress.id) : CustomerModel.actionRemoveBillingAddress(this.currentAddress), - [ADDRESS_TYPE.DEFAULT_BILLING]: inactive + [ADDRESS.DEFAULT_BILLING]: inactive ? CustomerModel.actionEditDefaultBillingAddress(this.currentAddress.id) - : CustomerModel.actionEditDefaultBillingAddress(undefined), - [ADDRESS_TYPE.DEFAULT_SHIPPING]: inactive + : CustomerModel.actionEditDefaultBillingAddress(), + [ADDRESS.DEFAULT_SHIPPING]: inactive ? CustomerModel.actionEditDefaultShippingAddress(this.currentAddress.id) - : CustomerModel.actionEditDefaultShippingAddress(undefined), - [ADDRESS_TYPE.SHIPPING]: inactive + : CustomerModel.actionEditDefaultShippingAddress(), + [ADDRESS.SHIPPING]: inactive ? CustomerModel.actionAddShippingAddress(this.currentAddress.id) : CustomerModel.actionRemoveShippingAddress(this.currentAddress), }; @@ -84,10 +76,9 @@ class UserAddressModel { } } - private async labelClickHandler(activeType: AddressTypeType, inactive?: boolean): Promise { + private async labelClickHandler(activeType: AddressType, 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 { @@ -95,7 +86,7 @@ class UserAddressModel { 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); + showSuccessMessage(SERVER_MESSAGE_KEY.ADDRESS_STATUS_CHANGED); } } catch (error) { showErrorMessage(error); @@ -112,7 +103,7 @@ class UserAddressModel { .addEventListener('click', () => { const confirmModel = new ConfirmModel( () => this.deleteAddress(address), - USER_MESSAGE[getStore().getState().currentLanguage].DELETE_ADDRESS, + USER_MESSAGE[getCurrentLanguage()].DELETE_ADDRESS, ); modal.setContent(confirmModel.getHTML()); modal.show(); @@ -141,7 +132,7 @@ class UserAddressModel { } private setLabelClickHandler(): void { - this.labels.forEach((value: { inactive?: boolean; type: AddressTypeType }, label: HTMLDivElement) => { + this.labels.forEach((value: { inactive?: boolean; type: AddressType }, label: HTMLDivElement) => { label.addEventListener('click', async () => { await this.labelClickHandler(value.type, value.inactive); }); diff --git a/src/entities/UserAddress/view/UserAddressView.ts b/src/entities/UserAddress/view/UserAddressView.ts index ff17f573..13f6b5b0 100644 --- a/src/entities/UserAddress/view/UserAddressView.ts +++ b/src/entities/UserAddress/view/UserAddressView.ts @@ -1,16 +1,17 @@ -import type { TooltipTextKeysType } from '@/shared/constants/tooltip.ts'; +import type { TooltipTextKeyType } 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 { ADDRESS_TEXT, ADDRESS_TYPE, type AddressTypeType } from '@/shared/constants/forms.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; -import TOOLTIP_TEXT, { TOOLTIP_TEXT_KEYS } from '@/shared/constants/tooltip.ts'; +import { ADDRESS, ADDRESS_TEXT, ADDRESS_TEXT_KEY, type AddressType } from '@/shared/constants/forms.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import TOOLTIP_TEXT, { TOOLTIP_TEXT_KEY } 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 getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './userAddressView.module.scss'; @@ -25,7 +26,7 @@ class UserAddressView { private editButton: ButtonModel; - private labels: Map = new Map(); + private labels: Map = new Map(); private labelsWrapper: HTMLDivElement; @@ -35,7 +36,7 @@ class UserAddressView { private view: HTMLLIElement; - constructor(address: Address, types: AddressTypeType[], inactiveTypes?: AddressTypeType[]) { + constructor(address: Address, types: AddressType[], inactiveTypes?: AddressType[]) { this.currentAddress = address; this.deleteButton = this.createDeleteButton(); this.editButton = this.createEditButton(); @@ -45,23 +46,17 @@ class UserAddressView { this.streetNameSpan = this.createStreetNameSpan(); this.labelsWrapper = this.createLabelsWrapper(); this.view = this.createHTML(types, inactiveTypes); + + this.observeStoreChanges(); } private createCitySpan(): HTMLSpanElement { this.citySpan = createBaseElement({ cssClasses: [styles.citySpan], - innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].CITY, + innerContent: ADDRESS_TEXT[getCurrentLanguage()].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, @@ -73,25 +68,25 @@ class UserAddressView { } private createCountrySpan(): HTMLSpanElement { + const currentLanguage = getCurrentLanguage(); this.countrySpan = createBaseElement({ cssClasses: [styles.countrySpan], - innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].COUNTRY, + innerContent: ADDRESS_TEXT[currentLanguage].COUNTRY, tag: 'span', }); const accentSpan = createBaseElement({ cssClasses: [styles.accentSpan], - innerContent: - findKeyByValue(COUNTRIES_LIST[getStore().getState().currentLanguage], this.currentAddress.country) ?? '', + innerContent: findKeyByValue(COUNTRIES_LIST[currentLanguage], this.currentAddress.country) ?? '', tag: 'span', }); this.countrySpan.append(accentSpan); observeStore(selectCurrentLanguage, () => { - accentSpan.innerText = - findKeyByValue(COUNTRIES_LIST[getStore().getState().currentLanguage], this.currentAddress.country) ?? ''; + const currentLanguage = getCurrentLanguage(); + accentSpan.innerText = findKeyByValue(COUNTRIES_LIST[currentLanguage], this.currentAddress.country) ?? ''; - const text = ADDRESS_TEXT[getStore().getState().currentLanguage].COUNTRY; + const text = ADDRESS_TEXT[currentLanguage].COUNTRY; const textNode = [...this.countrySpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); if (textNode) { textNode.textContent = text; @@ -103,38 +98,30 @@ class UserAddressView { private createDeleteButton(): ButtonModel { this.deleteButton = new ButtonModel({ classes: [styles.deleteButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].DELETE_ADDRESS, + title: TOOLTIP_TEXT[getCurrentLanguage()].DELETE_ADDRESS, }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.DELETE)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.DELETE)); this.deleteButton.getHTML().append(svg); - observeStore(selectCurrentLanguage, () => { - this.deleteButton.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].DELETE_ADDRESS; - }); - return this.deleteButton; } private createEditButton(): ButtonModel { this.editButton = new ButtonModel({ classes: [styles.editButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_ADDRESS, + title: TOOLTIP_TEXT[getCurrentLanguage()].EDIT_ADDRESS, }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.EDIT)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.EDIT)); this.editButton.getHTML().append(svg); - observeStore(selectCurrentLanguage, () => { - this.editButton.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_ADDRESS; - }); - return this.editButton; } - private createHTML(activeTypes: AddressTypeType[], inactiveTypes?: AddressTypeType[]): HTMLLIElement { + private createHTML(activeTypes: AddressType[], inactiveTypes?: AddressType[]): HTMLLIElement { this.view = createBaseElement({ cssClasses: [styles.addressItem], tag: 'li', @@ -167,44 +154,35 @@ class UserAddressView { return this.view; } - private createLabel(text: string, additionalStyles: string[], titleKey: TooltipTextKeysType): HTMLDivElement { + private createLabel(text: string, additionalStyles: string[], titleKey: TooltipTextKeyType): HTMLDivElement { const label = createBaseElement({ cssClasses: [styles.addressType, ...additionalStyles], innerContent: text, tag: 'div', - title: TOOLTIP_TEXT[getStore().getState().currentLanguage][titleKey], + title: TOOLTIP_TEXT[getCurrentLanguage()][titleKey], }); observeStore(selectCurrentLanguage, () => { - label.title = TOOLTIP_TEXT[getStore().getState().currentLanguage][titleKey]; + label.title = TOOLTIP_TEXT[getCurrentLanguage()][titleKey]; }); return label; } private createLabelsWrapper(): HTMLDivElement { - this.labelsWrapper = createBaseElement({ + return 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, + innerContent: ADDRESS_TEXT[getCurrentLanguage()].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, @@ -218,18 +196,10 @@ class UserAddressView { private createStreetNameSpan(): HTMLSpanElement { this.streetNameSpan = createBaseElement({ cssClasses: [styles.streetNameSpan], - innerContent: ADDRESS_TEXT[getStore().getState().currentLanguage].STREET, + innerContent: ADDRESS_TEXT[getCurrentLanguage()].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, @@ -240,33 +210,49 @@ class UserAddressView { return this.streetNameSpan; } - private setActiveAddressLabel(ActiveType: AddressTypeType, inactive?: boolean): void { + private observeStoreChanges(): void { + observeStore(selectCurrentLanguage, () => { + const text = ADDRESS_TEXT[getCurrentLanguage()].CITY; + const textNode = [...this.citySpan.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); + if (textNode) { + textNode.textContent = text; + } + + this.deleteButton.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].DELETE_ADDRESS; + this.editButton.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].EDIT_ADDRESS; + }); + + observeCurrentLanguage(this.postalCodeSpan, ADDRESS_TEXT, ADDRESS_TEXT_KEY.POSTAL_CODE); + observeCurrentLanguage(this.streetNameSpan, ADDRESS_TEXT, ADDRESS_TEXT_KEY.STREET); + } + + private setActiveAddressLabel(ActiveType: AddressType, inactive?: boolean): void { let addressType = null; switch (ActiveType) { - case ADDRESS_TYPE.BILLING: - addressType = this.createLabel(ActiveType, [styles.billing], TOOLTIP_TEXT_KEYS.SWITCH_BILLING_ADDRESS); + case ADDRESS.BILLING: + addressType = this.createLabel(ActiveType, [styles.billing], TOOLTIP_TEXT_KEY.SWITCH_BILLING_ADDRESS); this.labelsWrapper.append(addressType); break; - case ADDRESS_TYPE.SHIPPING: - addressType = this.createLabel(ActiveType, [styles.shipping], TOOLTIP_TEXT_KEYS.SWITCH_SHIPPING_ADDRESS); + case ADDRESS.SHIPPING: + addressType = this.createLabel(ActiveType, [styles.shipping], TOOLTIP_TEXT_KEY.SWITCH_SHIPPING_ADDRESS); this.labelsWrapper.append(addressType); break; - case ADDRESS_TYPE.DEFAULT_BILLING: + case ADDRESS.DEFAULT_BILLING: addressType = this.createLabel( ActiveType, [styles.defaultBilling], - TOOLTIP_TEXT_KEYS.SWITCH_DEFAULT_BILLING_ADDRESS, + TOOLTIP_TEXT_KEY.SWITCH_DEFAULT_BILLING_ADDRESS, ); this.labelsWrapper.append(addressType); break; - case ADDRESS_TYPE.DEFAULT_SHIPPING: + case ADDRESS.DEFAULT_SHIPPING: addressType = this.createLabel( ActiveType, [styles.defaultShipping], - TOOLTIP_TEXT_KEYS.SWITCH_DEFAULT_SHIPPING_ADDRESS, + TOOLTIP_TEXT_KEY.SWITCH_DEFAULT_SHIPPING_ADDRESS, ); this.labelsWrapper.append(addressType); break; @@ -302,7 +288,7 @@ class UserAddressView { return this.view; } - public getLabels(): Map { + public getLabels(): Map { return this.labels; } diff --git a/src/entities/UserAddress/view/userAddressView.module.scss b/src/entities/UserAddress/view/userAddressView.module.scss index e2dc0918..60548319 100644 --- a/src/entities/UserAddress/view/userAddressView.module.scss +++ b/src/entities/UserAddress/view/userAddressView.module.scss @@ -5,9 +5,9 @@ display: grid; align-items: center; border-radius: var(--medium-br); - padding: var(--extra-small-offset); + padding: var(--small-offset); background: var(--white-tr); - gap: var(--tiny-offset); + gap: var(--extra-small-offset) var(--tiny-offset); } .labelsWrapper { @@ -114,7 +114,7 @@ .streetNameSpan, .postalCodeSpan, .countrySpan { - font: var(--regular-font); + font: var(--basic-regular-font); letter-spacing: var(--one); word-break: break-all; color: var(--steam-green-400); diff --git a/src/features/AddressAdd/model/AddressAddModel.ts b/src/features/AddressAdd/model/AddressAddModel.ts index 56a73890..b7e73108 100644 --- a/src/features/AddressAdd/model/AddressAddModel.ts +++ b/src/features/AddressAdd/model/AddressAddModel.ts @@ -8,14 +8,13 @@ import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/Cus 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 { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import { ADDRESS_TYPE } from '@/shared/types/address.ts'; +import { ADDRESS } from '@/shared/types/address.ts'; import formattedText from '@/shared/utils/formattedText.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import AddressAddView from '../view/AddressAddView.ts'; @@ -60,7 +59,7 @@ class AddressAddModel { const { city, postalCode, streetName } = this.getFormAddressData(); return { city, - country: this.addressType === ADDRESS_TYPE.BILLING ? store.billingCountry : store.shippingCountry, + country: this.addressType === ADDRESS.BILLING ? store.billingCountry : store.shippingCountry, email, firstName, id: '', @@ -92,7 +91,7 @@ class AddressAddModel { private getAddressActions(addressId: string): MyCustomerUpdateAction[] { const addAddressAction = - this.addressType === ADDRESS_TYPE.BILLING + this.addressType === ADDRESS.BILLING ? CustomerModel.actionAddBillingAddress(addressId) : CustomerModel.actionAddShippingAddress(addressId); @@ -100,7 +99,7 @@ class AddressAddModel { } private getDefaultAddressAction(addressId: string): MyCustomerUpdateAction { - return this.addressType === ADDRESS_TYPE.BILLING + return this.addressType === ADDRESS.BILLING ? CustomerModel.actionEditDefaultBillingAddress(addressId) : CustomerModel.actionEditDefaultShippingAddress(addressId); } @@ -120,7 +119,7 @@ class AddressAddModel { private handleSuccess(): void { EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_ADDED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.ADDRESS_ADDED); modal.hide(); } diff --git a/src/features/AddressAdd/view/AddressAddView.ts b/src/features/AddressAdd/view/AddressAddView.ts index 7ffc48e5..7413cfcd 100644 --- a/src/features/AddressAdd/view/AddressAddView.ts +++ b/src/features/AddressAdd/view/AddressAddView.ts @@ -1,7 +1,7 @@ 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 getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './addressAddView.module.scss'; @@ -16,14 +16,15 @@ class AddressAddView { this.saveChangesButton = this.createSaveChangesButton(); this.cancelButton = this.createCancelButton(); this.view = this.createHTML(); + + this.saveChangesButton.setDisabled(); } private createCancelButton(): ButtonModel { - this.cancelButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.cancelButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + text: BUTTON_TEXT[getCurrentLanguage()].CANCEL, }); - return this.cancelButton; } private createHTML(): HTMLFormElement { @@ -36,15 +37,13 @@ class AddressAddView { } private createSaveChangesButton(): ButtonModel { - this.saveChangesButton = new ButtonModel({ + return new ButtonModel({ attrs: { type: BUTTON_TYPE.SUBMIT, }, classes: [styles.saveChangesButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].ADD_ADDRESS, + text: BUTTON_TEXT[getCurrentLanguage()].ADD_ADDRESS, }); - this.saveChangesButton.setDisabled(); - return this.saveChangesButton; } public getCancelButton(): ButtonModel { diff --git a/src/features/AddressAdd/view/addressAddView.module.scss b/src/features/AddressAdd/view/addressAddView.module.scss index 71fcc608..2ad5c5a8 100644 --- a/src/features/AddressAdd/view/addressAddView.module.scss +++ b/src/features/AddressAdd/view/addressAddView.module.scss @@ -8,6 +8,16 @@ padding: var(--small-offset); height: max-content; background-color: var(--noble-white-100); + + @media (max-width: 768px) { + min-width: calc(var(--extra-large-offset) * 4.5); + } + + @media (max-width: 480px) { + padding: var(--tiny-offset); + padding-bottom: calc(var(--tiny-offset) + var(--tiny-offset)); + min-width: calc(var(--extra-large-offset) * 3.5); + } } .saveChangesButton, diff --git a/src/features/AddressEdit/model/AddressEditModel.ts b/src/features/AddressEdit/model/AddressEditModel.ts index 185c1294..6677cfbb 100644 --- a/src/features/AddressEdit/model/AddressEditModel.ts +++ b/src/features/AddressEdit/model/AddressEditModel.ts @@ -6,15 +6,14 @@ import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/Cus 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 { SERVER_MESSAGE_KEY } 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 { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import AddressEditView from '../view/AddressEditView.ts'; @@ -61,7 +60,7 @@ class AddressEditModel { if (user) { await getCustomerModel().editCustomer([CustomerModel.actionEditAddress(this.createAddress(user))], user); modal.hide(); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.ADDRESS_CHANGED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.ADDRESS_CHANGED); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_ADDRESSES, ''); } } catch (error) { diff --git a/src/features/AddressEdit/view/AddressEditView.ts b/src/features/AddressEdit/view/AddressEditView.ts index 96fac017..dc5f3035 100644 --- a/src/features/AddressEdit/view/AddressEditView.ts +++ b/src/features/AddressEdit/view/AddressEditView.ts @@ -1,7 +1,7 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import { BUTTON_TEXT } from '@/shared/constants/buttons.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './addressEditView.module.scss'; @@ -16,14 +16,15 @@ class AddressEditView { this.saveChangesButton = this.createSaveChangesButton(); this.cancelButton = this.createCancelButton(); this.view = this.createHTML(); + + this.saveChangesButton.setDisabled(); } private createCancelButton(): ButtonModel { - this.cancelButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.cancelButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + text: BUTTON_TEXT[getCurrentLanguage()].CANCEL, }); - return this.cancelButton; } private createHTML(): HTMLFormElement { @@ -31,18 +32,15 @@ class AddressEditView { cssClasses: [styles.wrapper], tag: 'form', }); - this.view.append(this.saveChangesButton.getHTML(), this.cancelButton.getHTML()); return this.view; } private createSaveChangesButton(): ButtonModel { - this.saveChangesButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.saveChangesButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].SAVE_CHANGES, + text: BUTTON_TEXT[getCurrentLanguage()].SAVE_CHANGES, }); - this.saveChangesButton.setDisabled(); - return this.saveChangesButton; } public getCancelButton(): ButtonModel { diff --git a/src/features/AddressEdit/view/addressEditView.module.scss b/src/features/AddressEdit/view/addressEditView.module.scss index 71fcc608..31861608 100644 --- a/src/features/AddressEdit/view/addressEditView.module.scss +++ b/src/features/AddressEdit/view/addressEditView.module.scss @@ -3,11 +3,21 @@ .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); + padding-bottom: calc(var(--small-offset) + var(--tiny-offset)); height: max-content; background-color: var(--noble-white-100); + + @media (max-width: 768px) { + min-width: calc(var(--extra-large-offset) * 4.5); + } + + @media (max-width: 480px) { + padding: var(--tiny-offset); + padding-bottom: calc(var(--tiny-offset) + var(--tiny-offset)); + min-width: calc(var(--extra-large-offset) * 3.5); + } } .saveChangesButton, diff --git a/src/features/Breadcrumbs/model/BreadcrumbsModel.ts b/src/features/Breadcrumbs/model/BreadcrumbsModel.ts index 09fb53e7..c5318cd6 100644 --- a/src/features/Breadcrumbs/model/BreadcrumbsModel.ts +++ b/src/features/Breadcrumbs/model/BreadcrumbsModel.ts @@ -1,17 +1,30 @@ -import type { BreadCrumbLink } from '@/shared/types/link.ts'; +import type { BreadcrumbLink } from '@/shared/types/link.ts'; import BreadcrumbsView from '../view/BreadcrumbsView.ts'; class BreadcrumbsModel { - private view: BreadcrumbsView; + private breadcrumbLinksData: BreadcrumbLink[] = []; - constructor(navigationLinks: BreadCrumbLink[]) { - this.view = new BreadcrumbsView(navigationLinks); + private view = new BreadcrumbsView(); + + public addBreadcrumbLinks(linksData: BreadcrumbLink[]): void { + this.breadcrumbLinksData.push(...linksData); + this.view.drawLinks(this.breadcrumbLinksData); + } + + public clearBreadcrumbLinks(): void { + this.breadcrumbLinksData = []; + this.view.clearBreadcrumbLinks(); } public getHTML(): HTMLDivElement { return this.view.getHTML(); } + + public removeBreadcrumbLink(linkData: BreadcrumbLink): void { + this.breadcrumbLinksData = this.breadcrumbLinksData.filter((link) => link !== linkData); + this.view.drawLinks(this.breadcrumbLinksData); + } } export default BreadcrumbsModel; diff --git a/src/features/Breadcrumbs/view/BreadcrumbsView.ts b/src/features/Breadcrumbs/view/BreadcrumbsView.ts index 26d1784b..c0aa88e0 100644 --- a/src/features/Breadcrumbs/view/BreadcrumbsView.ts +++ b/src/features/Breadcrumbs/view/BreadcrumbsView.ts @@ -1,9 +1,9 @@ -import type { BreadCrumbLink } from '@/shared/types/link'; +import type { BreadcrumbLink } from '@/shared/types/link'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import LinkModel from '@/shared/Link/model/LinkModel.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; -import { formattedTextForMainAndCatalog } from '@/shared/utils/formattedText.ts'; +import formattedText from '@/shared/utils/formattedText.ts'; import styles from './breadcrumbsView.module.scss'; @@ -12,50 +12,50 @@ const DELIMITER = '>'; class BreadcrumbsView { private view: HTMLDivElement; - constructor(navigationLinks: BreadCrumbLink[]) { - this.view = this.createHTML(navigationLinks); + constructor() { + this.view = this.createHTML(); } - private createHTML(navigationLinks: BreadCrumbLink[]): HTMLDivElement { + private createHTML(): HTMLDivElement { this.view = createBaseElement({ cssClasses: [styles.breadcrumbs], tag: 'div', }); - navigationLinks.forEach((linkParams) => { - this.createLink(linkParams).getHTML(); - }); - - this.view.lastChild?.remove(); - this.view.lastElementChild?.classList.add(styles.active); - return this.view; } - private createLink(linkParams: BreadCrumbLink): LinkModel { - const link = new LinkModel({ - attrs: { - href: linkParams.link, - }, - classes: [styles.link], - text: formattedTextForMainAndCatalog(linkParams.name), - }); - - link.getHTML().addEventListener('click', (event) => { - event.preventDefault(); - RouterModel.getInstance().navigateTo(linkParams.link); - }); + public clearBreadcrumbLinks(): void { + this.view.innerHTML = ''; + } - const delimiter = createBaseElement({ - cssClasses: [styles.delimiter], - innerContent: DELIMITER, - tag: 'span', + public drawLinks(linksData: BreadcrumbLink[]): void { + this.clearBreadcrumbLinks(); + linksData.forEach((linkParams) => { + const link = new LinkModel({ + attrs: { + href: linkParams.link, + }, + classes: [styles.link], + text: formattedText(linkParams.name), + }); + + link.getHTML().addEventListener('click', (event) => { + event.preventDefault(); + RouterModel.getInstance().navigateTo(linkParams.link); + }); + + const delimiter = createBaseElement({ + cssClasses: [styles.delimiter], + innerContent: DELIMITER, + tag: 'span', + }); + + this.view.append(link.getHTML(), delimiter); }); - this.view.append(link.getHTML()); - this.view.append(delimiter); - - return link; + this.view.lastChild?.remove(); + this.view.lastElementChild?.classList.add(styles.active); } public getHTML(): HTMLDivElement { diff --git a/src/features/Breadcrumbs/view/breadcrumbsView.module.scss b/src/features/Breadcrumbs/view/breadcrumbsView.module.scss index 89200a9a..f9890400 100644 --- a/src/features/Breadcrumbs/view/breadcrumbsView.module.scss +++ b/src/features/Breadcrumbs/view/breadcrumbsView.module.scss @@ -4,6 +4,7 @@ display: flex; align-items: center; margin-bottom: var(--medium-offset); + width: 100%; gap: var(--tiny-offset); @media (max-width: 768px) { diff --git a/src/features/CountryChoice/model/CountryChoiceModel.ts b/src/features/CountryChoice/model/CountryChoiceModel.ts index 9f207305..35bd65a2 100644 --- a/src/features/CountryChoice/model/CountryChoiceModel.ts +++ b/src/features/CountryChoice/model/CountryChoiceModel.ts @@ -1,11 +1,12 @@ import getStore from '@/shared/Store/Store.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 { DATA_KEY } from '@/shared/constants/common.ts'; import COUNTRIES_LIST from '@/shared/constants/countriesList.ts'; -import { USER_ADDRESS_TYPE } from '@/shared/constants/forms.ts'; +import { USER_ADDRESS } from '@/shared/constants/forms.ts'; import formattedText from '@/shared/utils/formattedText.ts'; import getCountryIndex from '@/shared/utils/getCountryIndex.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import CountryChoiceView from '../view/CountryChoiceView.ts'; @@ -21,7 +22,7 @@ class CountryChoiceModel { private observeCurrentLanguage(item: HTMLDivElement): boolean { observeStore(selectCurrentLanguage, () => { const currentItem = item; - const currentCountriesList = COUNTRIES_LIST[getStore().getState().currentLanguage]; + const currentCountriesList = COUNTRIES_LIST[getCurrentLanguage()]; Object.entries(currentCountriesList).forEach(([countryName, countryCode]) => { if (countryCode === currentItem.id) { currentItem.textContent = countryName; @@ -39,7 +40,7 @@ class CountryChoiceModel { currentItem.addEventListener('click', () => { if (currentItem.textContent) { inputHTML.value = currentItem.textContent; - this.setCountryToStore(currentItem, inputHTML.getAttribute(DATA_KEYS.ADDRESS_TYPE) ?? ''); + this.setCountryToStore(currentItem, inputHTML.getAttribute(DATA_KEY.ADDRESS) ?? ''); const event = new Event('input'); input.dispatchEvent(event); this.view.hideCountryChoice(); @@ -57,10 +58,10 @@ class CountryChoiceModel { let action; switch (key) { - case USER_ADDRESS_TYPE.BILLING: + case USER_ADDRESS.BILLING: action = setBillingCountry; break; - case USER_ADDRESS_TYPE.SHIPPING: + case USER_ADDRESS.SHIPPING: action = setShippingCountry; break; default: @@ -76,7 +77,7 @@ class CountryChoiceModel { input.addEventListener('focus', () => this.view.showCountryChoice()); input.addEventListener('input', () => { this.view.switchVisibilityCountryItems(input); - this.setCountryToStore(input, input.getAttribute(DATA_KEYS.ADDRESS_TYPE) ?? ''); + this.setCountryToStore(input, input.getAttribute(DATA_KEY.ADDRESS) ?? ''); }); return true; } diff --git a/src/features/CountryChoice/view/CountryChoiceView.ts b/src/features/CountryChoice/view/CountryChoiceView.ts index 7be9d1b4..672d411a 100644 --- a/src/features/CountryChoice/view/CountryChoiceView.ts +++ b/src/features/CountryChoice/view/CountryChoiceView.ts @@ -1,7 +1,7 @@ -import getStore from '@/shared/Store/Store.ts'; import COUNTRIES_LIST from '@/shared/constants/countriesList.ts'; -import { KEYBOARD_KEYS } from '@/shared/constants/keyboard.ts'; +import KEYBOARD_KEY from '@/shared/constants/keyboard.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './countryChoiceView.module.scss'; @@ -25,7 +25,7 @@ class CountryChoiceView { }); document.addEventListener('keydown', (event) => { - if (event.key === KEYBOARD_KEYS.TAB && !this.getHTML().classList.contains(styles.hidden)) { + if (event.key === KEYBOARD_KEY.TAB && !this.getHTML().classList.contains(styles.hidden)) { this.hideCountryChoice(); } }); @@ -37,7 +37,7 @@ class CountryChoiceView { tag: 'div', }); - Object.entries(COUNTRIES_LIST[getStore().getState().currentLanguage]).forEach(([countryName, countryCode]) => + Object.entries(COUNTRIES_LIST[getCurrentLanguage()]).forEach(([countryName, countryCode]) => this.countryDropList.append(this.createCountryItem(countryName, countryCode)), ); diff --git a/src/features/CountryChoice/view/countryChoiceView.module.scss b/src/features/CountryChoice/view/countryChoiceView.module.scss index 92f9146e..2c32b3a3 100644 --- a/src/features/CountryChoice/view/countryChoiceView.module.scss +++ b/src/features/CountryChoice/view/countryChoiceView.module.scss @@ -94,7 +94,7 @@ visibility 0.2s; &::-webkit-scrollbar { - width: 8px; + width: var(--five); } &::-webkit-scrollbar-track { diff --git a/src/features/InputFieldValidator/test/InputFieldValidator.spec.ts b/src/features/InputFieldValidator/test/InputFieldValidator.spec.ts index fce04ee2..c81760bc 100644 --- a/src/features/InputFieldValidator/test/InputFieldValidator.spec.ts +++ b/src/features/InputFieldValidator/test/InputFieldValidator.spec.ts @@ -2,6 +2,10 @@ import type { InputFieldParams, InputFieldValidatorParams } from '@/shared/types import InputFieldValidatorModel from '../model/InputFieldValidatorModel.ts'; +/** + * @vitest-environment jsdom + */ + const validatorParams: InputFieldValidatorParams = { maxLength: 10, minLength: 2, diff --git a/src/features/InputFieldValidator/validators/validators.ts b/src/features/InputFieldValidator/validators/validators.ts index 10e3a06d..a5d96a1a 100644 --- a/src/features/InputFieldValidator/validators/validators.ts +++ b/src/features/InputFieldValidator/validators/validators.ts @@ -3,9 +3,10 @@ import type { InputFieldParams, InputFieldValidatorParams } from '@/shared/types 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 } from '@/shared/constants/forms.ts'; +import { USER_ADDRESS } from '@/shared/constants/forms.ts'; import { ERROR_MESSAGE } from '@/shared/constants/messages.ts'; import { checkInputLanguage } from '@/shared/utils/getCountryIndex.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import { maxAgeMessage, maxLengthMessage, minAgeMessage, minLengthMessage } from '@/shared/utils/messageTemplates.ts'; import { postcodeValidator } from 'postcode-validator'; @@ -45,28 +46,28 @@ export const checkMinLength = (value: string, validParams: InputFieldValidatorPa export const checkNotSpecialSymbols = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.notSpecialSymbols && !validParams.notSpecialSymbols.pattern.test(value)) { - return validParams.notSpecialSymbols.messages[getStore().getState().currentLanguage]; + return validParams.notSpecialSymbols.messages[getCurrentLanguage()]; } return true; }; export const checkRequired = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.required && value.trim() === '') { - return ERROR_MESSAGE[getStore().getState().currentLanguage].REQUIRED_FIELD; + return ERROR_MESSAGE[getCurrentLanguage()].REQUIRED_FIELD; } return true; }; export const checkRequiredSymbols = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.requiredSymbols && !validParams.requiredSymbols.pattern.test(value)) { - return validParams.requiredSymbols.messages[getStore().getState().currentLanguage]; + return validParams.requiredSymbols.messages[getCurrentLanguage()]; } return true; }; export const checkValidAge = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.validBirthday && !validParams.validBirthday.pattern.test(value)) { - return validParams.validBirthday.messages[getStore().getState().currentLanguage]; + return validParams.validBirthday.messages[getCurrentLanguage()]; } return true; }; @@ -78,7 +79,7 @@ export const checkValidCountry = (value: string, validParams: InputFieldValidato (countryName) => countryName.toLowerCase() === value.toLowerCase(), ) ) { - return ERROR_MESSAGE[getStore().getState().currentLanguage].INVALID_COUNTRY; + return ERROR_MESSAGE[getCurrentLanguage()].INVALID_COUNTRY; } } observeStore(selectCurrentLanguage, () => checkValidCountry(value, validParams)); @@ -87,7 +88,7 @@ export const checkValidCountry = (value: string, validParams: InputFieldValidato export const checkValidMail = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.validMail && !validParams.validMail.pattern.test(value)) { - return validParams.validMail.messages[getStore().getState().currentLanguage]; + return validParams.validMail.messages[getCurrentLanguage()]; } return true; }; @@ -100,14 +101,14 @@ export const checkValidPostalCode = ( if (validParams.validPostalCode && inputParams.inputParams.data) { const { billingCountry, shippingCountry } = getStore().getState(); const currentCountry = - inputParams.inputParams.data.addressType === USER_ADDRESS_TYPE.SHIPPING ? shippingCountry : billingCountry; + inputParams.inputParams.data.addressType === USER_ADDRESS.SHIPPING ? shippingCountry : billingCountry; try { const result = postcodeValidator(value, currentCountry); if (!result) { - return ERROR_MESSAGE[getStore().getState().currentLanguage].INVALID_POSTAL_CODE; + return ERROR_MESSAGE[getCurrentLanguage()].INVALID_POSTAL_CODE; } } catch (error) { - return ERROR_MESSAGE[getStore().getState().currentLanguage].WRONG_REGION; + return ERROR_MESSAGE[getCurrentLanguage()].WRONG_REGION; } } return true; @@ -115,7 +116,7 @@ export const checkValidPostalCode = ( export const checkWhitespace = (value: string, validParams: InputFieldValidatorParams): boolean | string => { if (validParams.notWhitespace && !validParams.notWhitespace.pattern.test(value)) { - return validParams.notWhitespace.messages[getStore().getState().currentLanguage]; + return validParams.notWhitespace.messages[getCurrentLanguage()]; } return true; diff --git a/src/features/Pagination/view/paginationView.module.scss b/src/features/Pagination/view/paginationView.module.scss index 2aac1f73..f550b4a1 100644 --- a/src/features/Pagination/view/paginationView.module.scss +++ b/src/features/Pagination/view/paginationView.module.scss @@ -2,7 +2,10 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: var(--tiny-offset); + margin-top: auto; + margin-left: var(--tiny-offset); + max-width: max-content; + gap: 0 var(--tiny-offset); } .pageButton { @@ -40,10 +43,6 @@ opacity: 0.5; pointer-events: none; } - - &:nth-child(1) { - margin-left: var(--tiny-offset); - } } .active { diff --git a/src/features/PasswordEdit/model/PasswordEditModel.ts b/src/features/PasswordEdit/model/PasswordEditModel.ts index 0446de62..fc878af2 100644 --- a/src/features/PasswordEdit/model/PasswordEditModel.ts +++ b/src/features/PasswordEdit/model/PasswordEditModel.ts @@ -3,11 +3,10 @@ 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'; -import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; import { INPUT_TYPE, PASSWORD_TEXT } from '@/shared/constants/forms.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import PasswordEditView from '../view/PasswordEditView.ts'; @@ -41,10 +40,10 @@ class PasswordEditModel { this.view.getOldPasswordField().getView().getValue(), this.view.getNewPasswordField().getView().getValue(), ); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_CHANGED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.PASSWORD_CHANGED); modal.hide(); } catch { - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PASSWORD_NOT_CHANGED, MESSAGE_STATUS.ERROR); + showErrorMessage(SERVER_MESSAGE_KEY.PASSWORD_NOT_CHANGED); } } }); diff --git a/src/features/PasswordEdit/view/PasswordEditView.ts b/src/features/PasswordEdit/view/PasswordEditView.ts index 84268183..3ab8182e 100644 --- a/src/features/PasswordEdit/view/PasswordEditView.ts +++ b/src/features/PasswordEdit/view/PasswordEditView.ts @@ -1,14 +1,14 @@ import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import { BUTTON_TEXT } from '@/shared/constants/buttons.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import * as FORM_FIELDS from '@/shared/constants/forms/fieldParams.ts'; import * as FORM_VALIDATION from '@/shared/constants/forms/validationParams.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './passwordEditView.module.scss'; @@ -37,14 +37,15 @@ class PasswordEditView { this.oldPasswordField = this.createOldPasswordField(); this.newPasswordField = this.createNewPasswordField(); this.view = this.createHTML(); + + this.submitButton.setDisabled(); } private createCancelButton(): ButtonModel { - this.cancelButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.cancelButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + text: BUTTON_TEXT[getCurrentLanguage()].CANCEL, }); - return this.cancelButton; } private createHTML(): HTMLFormElement { @@ -65,6 +66,9 @@ class PasswordEditView { } }); + this.switchPasswordElementSVG(INPUT_TYPE.PASSWORD, this.showOldPasswordElement); + this.switchPasswordElementSVG(INPUT_TYPE.PASSWORD, this.showNewPasswordElement); + this.view.append(this.submitButton.getHTML(), this.cancelButton.getHTML()); return this.view; } @@ -90,30 +94,24 @@ class PasswordEditView { } private createShowNewPasswordElement(): HTMLDivElement { - this.showNewPasswordElement = createBaseElement({ + return createBaseElement({ cssClasses: [styles.showPasswordElement], tag: 'div', }); - this.switchPasswordElementSVG(INPUT_TYPE.PASSWORD, this.showNewPasswordElement); - return this.showNewPasswordElement; } private createShowOldPasswordElement(): HTMLDivElement { - this.showOldPasswordElement = createBaseElement({ + return createBaseElement({ cssClasses: [styles.showPasswordElement], tag: 'div', }); - this.switchPasswordElementSVG(INPUT_TYPE.PASSWORD, this.showOldPasswordElement); - return this.showOldPasswordElement; } private createSubmitButton(): ButtonModel { - this.submitButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.submitButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].SAVE_CHANGES, + text: BUTTON_TEXT[getCurrentLanguage()].SAVE_CHANGES, }); - this.submitButton.setDisabled(); - return this.submitButton; } public getCancelButton(): ButtonModel { @@ -150,9 +148,9 @@ class PasswordEditView { public switchPasswordElementSVG(type: string, el: HTMLDivElement): SVGSVGElement { const element = el; - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); element.innerHTML = ''; - svg.append(createSVGUse(type === INPUT_TYPE.PASSWORD ? SVG_DETAILS.CLOSE_EYE : SVG_DETAILS.OPEN_EYE)); + svg.append(createSVGUse(type === INPUT_TYPE.PASSWORD ? SVG_DETAIL.CLOSE_EYE : SVG_DETAIL.OPEN_EYE)); element.append(svg); return svg; } diff --git a/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts b/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts index d055e70b..83d4fcd9 100644 --- a/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts +++ b/src/features/PersonalInfoEdit/model/PersonalInfoEditModel.ts @@ -6,12 +6,11 @@ import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/Cus 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 { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import formattedText from '@/shared/utils/formattedText.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import PersonalInfoEditView from '../view/PersonalInfoEditView.ts'; @@ -48,7 +47,7 @@ class PersonalInfoEditModel { ); modal.hide(); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_USER_INFO, ''); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.PERSONAL_INFO_CHANGED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.PERSONAL_INFO_CHANGED); } } catch (error) { showErrorMessage(error); @@ -92,7 +91,6 @@ 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 49ffbfb4..9a975ba3 100644 --- a/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts +++ b/src/features/PersonalInfoEdit/view/PersonalInfoEditView.ts @@ -1,11 +1,11 @@ import InputFieldModel from '@/entities/InputField/model/InputFieldModel.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import { BUTTON_TEXT } from '@/shared/constants/buttons.ts'; import { EMAIL } from '@/shared/constants/forms/fieldParams.ts'; import { EMAIL_VALIDATE } from '@/shared/constants/forms/validationParams.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './personalInfoEditView.module.scss'; import './personalInfoEditView.scss'; @@ -29,7 +29,7 @@ class PersonalInfoEditView { private createCancelButton(): ButtonModel { this.cancelButton = new ButtonModel({ classes: [styles.cancelButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + text: BUTTON_TEXT[getCurrentLanguage()].CANCEL, }); return this.cancelButton; } @@ -62,7 +62,7 @@ class PersonalInfoEditView { private createSaveChangesButton(): ButtonModel { this.saveChangesButton = new ButtonModel({ classes: [styles.saveChangesButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].SAVE_CHANGES, + text: BUTTON_TEXT[getCurrentLanguage()].SAVE_CHANGES, }); this.saveChangesButton.setDisabled(); return this.saveChangesButton; diff --git a/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss b/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss index dd753ca5..eec9cc6d 100644 --- a/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss +++ b/src/features/PersonalInfoEdit/view/personalInfoEditView.module.scss @@ -16,6 +16,12 @@ grid-template-columns: repeat(1, 1fr); grid-template-rows: repeat(4, auto); padding: var(--medium-offset); + min-width: calc(var(--extra-large-offset) * 4.5); + } + + @media (max-width: 480px) { + padding: var(--small-offset); + min-width: calc(var(--extra-large-offset) * 3.5); } } @@ -35,7 +41,6 @@ margin: 0 auto; padding: calc(var(--small-offset) / 3) var(--small-offset); - width: 100%; height: max-content; } diff --git a/src/features/ProductFilters/view/ProductFiltersView.ts b/src/features/ProductFilters/view/ProductFiltersView.ts index ae915464..7e0921d1 100644 --- a/src/features/ProductFilters/view/ProductFiltersView.ts +++ b/src/features/ProductFilters/view/ProductFiltersView.ts @@ -2,20 +2,22 @@ import type { SizeProductCount } from '@/shared/API/types/type'; import type { Category } from '@/shared/types/product'; import type ProductFiltersParams from '@/shared/types/productFilters'; +import { append, remove, set } from '@/app/Router/helpers/helpers.ts'; 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 { META_FILTER, META_FILTER_ID, PRICE_RANGE_LABEL, TITLE } from '@/shared/constants/filters.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import * as noUiSlider from 'nouislider'; import styles from './productFiltersView.module.scss'; @@ -66,16 +68,17 @@ class ProductFiltersView { private categoryClickHandler(parentCategory: { category: Category; count: number } | null): void { 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 { - RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.CATEGORY, parentCategory?.category.parent?.id ?? ''); - } - - RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + const handleCategoryChange = (url: URL, key: string, value: string): void => { + if (searchParams.has(key) && searchParams.get(key) === value) { + remove(url, key); + } else { + set(url, key, value); + } + }; + RouterModel.changeSearchParams((url) => { + handleCategoryChange(url, SEARCH_PARAMS_FIELD.CATEGORY, parentCategory?.category.parent?.key ?? ''); + remove(url, SEARCH_PARAMS_FIELD.PAGE); + }); this.callback(); } @@ -113,7 +116,7 @@ class ProductFiltersView { } private createCategoryLink(category: { category: Category; count: number }): LinkModel { - const text = category.category.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const text = category.category.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; const categoryLink = new LinkModel({ attrs: { href: category.category.key, @@ -132,7 +135,7 @@ class ProductFiltersView { const span = createBaseElement({ attributes: { - id: category.category.id, + id: category.category.key, }, cssClasses: [styles.categoryLinkCount], innerContent, @@ -147,7 +150,7 @@ class ProductFiltersView { this.categoryLinks.push(categoryLink); observeStore(selectCurrentLanguage, () => { - const text = category.category.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const text = category.category.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; const textNode = [...categoryLink.getHTML().childNodes].find((child) => child.nodeType === Node.TEXT_NODE); if (textNode) { textNode.textContent = text; @@ -163,7 +166,7 @@ class ProductFiltersView { list: [styles.categoryList], title: [styles.categoryTitle], }, - TITLE[getStore().getState().currentLanguage].CATEGORY, + TITLE[getCurrentLanguage()].CATEGORY, ); this.categoryList = filtersList; @@ -174,7 +177,7 @@ class ProductFiltersView { this.createCategoryItems(subcategories); observeStore(selectCurrentLanguage, () => { - filtersListTitle.textContent = TITLE[getStore().getState().currentLanguage].CATEGORY; + filtersListTitle.textContent = TITLE[getCurrentLanguage()].CATEGORY; }); return this.categoryList; @@ -224,28 +227,27 @@ class ProductFiltersView { tag: 'div', }); + const currentLanguage = getCurrentLanguage(); + const allProductsLink = this.createMetaLink( - META_FILTERS[getStore().getState().currentLanguage].ALL_PRODUCTS, - META_FILTERS_ID.ALL_PRODUCTS, - META_FILTERS.en.ALL_PRODUCTS, + META_FILTER[currentLanguage].ALL_PRODUCTS, + META_FILTER_ID.ALL_PRODUCTS, + META_FILTER.en.ALL_PRODUCTS, ); const newArrivalsLink = this.createMetaLink( - META_FILTERS[getStore().getState().currentLanguage].NEW_ARRIVALS, - META_FILTERS_ID.NEW_ARRIVALS, - META_FILTERS.en.NEW_ARRIVALS, - ); - const saleLink = this.createMetaLink( - META_FILTERS[getStore().getState().currentLanguage].SALE, - META_FILTERS_ID.SALE, - META_FILTERS.en.SALE, + META_FILTER[currentLanguage].NEW_ARRIVALS, + META_FILTER_ID.NEW_ARRIVALS, + META_FILTER.en.NEW_ARRIVALS, ); + const saleLink = this.createMetaLink(META_FILTER[currentLanguage].SALE, META_FILTER_ID.SALE, META_FILTER.en.SALE); allProductsLink.getHTML().classList.add(styles.activeLink); this.metaFilters.append(allProductsLink.getHTML(), newArrivalsLink.getHTML(), saleLink.getHTML()); observeStore(selectCurrentLanguage, () => { - allProductsLink.getHTML().textContent = META_FILTERS[getStore().getState().currentLanguage].ALL_PRODUCTS; - newArrivalsLink.getHTML().textContent = META_FILTERS[getStore().getState().currentLanguage].NEW_ARRIVALS; - saleLink.getHTML().textContent = META_FILTERS[getStore().getState().currentLanguage].SALE; + const currentLanguage = getCurrentLanguage(); + allProductsLink.getHTML().textContent = META_FILTER[currentLanguage].ALL_PRODUCTS; + newArrivalsLink.getHTML().textContent = META_FILTER[currentLanguage].NEW_ARRIVALS; + saleLink.getHTML().textContent = META_FILTER[currentLanguage].SALE; }); return this.metaFilters; @@ -264,8 +266,10 @@ class ProductFiltersView { link.getHTML().addEventListener('click', (event) => { event.preventDefault(); - RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.META, id); - RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); + RouterModel.changeSearchParams((url) => { + set(url, SEARCH_PARAMS_FIELD.META, id); + remove(url, SEARCH_PARAMS_FIELD.PAGE); + }); this.metaLinks.forEach((link) => this.switchSelectedFilter(link, false)); this.switchSelectedFilter(link, true); this.callback(); @@ -294,10 +298,12 @@ class ProductFiltersView { tag: 'span', }); + const currentLanguage = getCurrentLanguage(); + 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; + const from = PRICE_RANGE_LABEL[currentLanguage].FROM; + const to = PRICE_RANGE_LABEL[currentLanguage].TO; const priceInput = new InputModel({ autocomplete: AUTOCOMPLETE_OPTION.OFF, @@ -331,21 +337,24 @@ class ProductFiltersView { start: [min, max], }); + const currentLanguage = getCurrentLanguage(); + 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.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].FROM)?.setValue(String(min)); + this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].TO)?.setValue(String(max)); + RouterModel.changeSearchParams((url) => { + remove(url, [SEARCH_PARAMS_FIELD.MIN_PRICE, SEARCH_PARAMS_FIELD.MAX_PRICE]); + set(url, SEARCH_PARAMS_FIELD.MIN_PRICE, String(min)); + set(url, 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)); + this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].FROM)?.setValue(String(min)); + this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].TO)?.setValue(String(max)); }); return this.priceSlider; @@ -359,21 +368,24 @@ class ProductFiltersView { const title = createBaseElement({ cssClasses: [styles.priceTitle], - innerContent: TITLE[getStore().getState().currentLanguage].PRICE, + innerContent: TITLE[getCurrentLanguage()].PRICE, tag: 'h3', }); observeStore(selectCurrentLanguage, () => { - title.textContent = TITLE[getStore().getState().currentLanguage].PRICE; + title.textContent = TITLE[getCurrentLanguage()].PRICE; }); - const from = this.createPriceLabel(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM); - const to = this.createPriceLabel(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO); + const currentLanguage = getCurrentLanguage(); + + const from = this.createPriceLabel(PRICE_RANGE_LABEL[currentLanguage].FROM); + const to = this.createPriceLabel(PRICE_RANGE_LABEL[currentLanguage].TO); priceWrapper.append(title, from.priceLabel, this.priceSlider.target, to.priceLabel); observeStore(selectCurrentLanguage, () => { - from.priceSpan.textContent = PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM; - to.priceSpan.textContent = PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO; + const currentLanguage = getCurrentLanguage(); + from.priceSpan.textContent = PRICE_RANGE_LABEL[currentLanguage].FROM; + to.priceSpan.textContent = PRICE_RANGE_LABEL[currentLanguage].TO; }); return priceWrapper; } @@ -381,7 +393,7 @@ class ProductFiltersView { private createResetFiltersButton(): ButtonModel { this.resetFiltersButton = new ButtonModel({ classes: [styles.resetFiltersButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].RESET, + text: BUTTON_TEXT[getCurrentLanguage()].RESET, }); this.resetFiltersButton.getHTML().addEventListener('click', () => { @@ -389,18 +401,18 @@ class ProductFiltersView { 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) { + if (link.getHTML().id === META_FILTER.en.ALL_PRODUCTS) { this.switchSelectedFilter(link, true); } }); - RouterModel.clearSearchParams(); + RouterModel.getInstance().navigateTo(PAGE_ID.CATALOG_PAGE); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.CLEAR_CATALOG_SEARCH, ''); this.callback(); }); observeStore(selectCurrentLanguage, () => { - this.resetFiltersButton.getHTML().textContent = BUTTON_TEXT[getStore().getState().currentLanguage].RESET; + this.resetFiltersButton.getHTML().textContent = BUTTON_TEXT[getCurrentLanguage()].RESET; }); return this.resetFiltersButton; @@ -418,8 +430,10 @@ class ProductFiltersView { sizeLink.getHTML().addEventListener('click', (event) => { event.preventDefault(); - RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); - RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.SIZE, size.size); + RouterModel.changeSearchParams((url) => { + remove(url, SEARCH_PARAMS_FIELD.PAGE); + set(url, SEARCH_PARAMS_FIELD.SIZE, size.size); + }); this.sizeLinks.forEach((link) => this.switchSelectedFilter(link, false)); this.switchSelectedFilter(sizeLink, true); this.callback(); @@ -447,12 +461,21 @@ class ProductFiltersView { list: [styles.sizesList], title: [styles.sizesTitle], }, - TITLE[getStore().getState().currentLanguage].SIZE, + TITLE[getCurrentLanguage()].SIZE, ); this.sizesList = filtersList; - this.params?.sizes?.forEach((size) => { + const sortedSizes = this.params?.sizes?.sort((a, b) => { + const lastA = a.size.at(-1); + const lastB = b.size.at(-1); + if (lastA && lastB) { + return lastB.localeCompare(lastA, LANGUAGE_CHOICE.EN, { numeric: true, sensitivity: 'base' }); + } + return 0; + }); + + sortedSizes?.forEach((size) => { const li = createBaseElement({ cssClasses: [styles.sizeItem], tag: 'li', @@ -463,7 +486,7 @@ class ProductFiltersView { }); observeStore(selectCurrentLanguage, () => { - filtersListTitle.textContent = TITLE[getStore().getState().currentLanguage].SIZE; + filtersListTitle.textContent = TITLE[getCurrentLanguage()].SIZE; }); return this.sizesList; @@ -507,7 +530,7 @@ class ProductFiltersView { private redrawProductsCount(): void { this.params?.categoriesProductCount?.forEach((categoryCount) => { - const currentSpan = this.categoryCountSpan.find((span) => span.id === categoryCount.category.id) ?? null; + const currentSpan = this.categoryCountSpan.find((span) => span.id === categoryCount.category.key) ?? null; if (currentSpan) { currentSpan.innerText = `(${categoryCount.count})`; } @@ -522,38 +545,41 @@ class ProductFiltersView { } private setPriceSliderHandlers(): void { - const fromInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].FROM); - const toInput = this.priceInputs.get(PRICE_RANGE_LABEL[getStore().getState().currentLanguage].TO); + const currentLanguage = getCurrentLanguage(); + const fromInput = this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].FROM); + const toInput = this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].TO); 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 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); + const subcategories = RouterModel.getSearchParams().getAll(SEARCH_PARAMS_FIELD.SUBCATEGORY); + const currentSubcategory = subcategories?.find((key) => key === subcategory.category.key); + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === subcategory.category.key); + RouterModel.changeSearchParams((url) => remove(url, SEARCH_PARAMS_FIELD.PAGE)); if (currentSubcategory) { - const filteredSubcategories = currentSubcategories.filter((id) => id !== currentSubcategory); - if (!filteredSubcategories.length) { - RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY); + const filteredSubcategory = subcategories.filter((key) => key !== currentSubcategory); + if (!filteredSubcategory.length) { + RouterModel.changeSearchParams((url) => remove(url, SEARCH_PARAMS_FIELD.SUBCATEGORY)); if (currentLink) { this.switchSelectedFilter(currentLink, false); } } else { - 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); + RouterModel.changeSearchParams((url) => remove(url, SEARCH_PARAMS_FIELD.SUBCATEGORY)); + filteredSubcategory.forEach((key) => + RouterModel.changeSearchParams((url) => append(url, SEARCH_PARAMS_FIELD.SUBCATEGORY, key)), + ); + filteredSubcategory.forEach((key) => { + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === key); if (currentLink) { this.switchSelectedFilter(currentLink, true); } }); } } else { - RouterModel.appendSearchParams(SEARCH_PARAMS_FIELD.SUBCATEGORY, subcategory.category.id); + RouterModel.changeSearchParams((url) => append(url, SEARCH_PARAMS_FIELD.SUBCATEGORY, subcategory.category.key)); if (currentLink) { this.switchSelectedFilter(currentLink, true); } @@ -568,15 +594,18 @@ class ProductFiltersView { }, 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); + const currentLanguage = getCurrentLanguage(); + const fromInput = this.priceInputs.get(PRICE_RANGE_LABEL[currentLanguage].FROM); + const toInput = this.priceInputs.get(PRICE_RANGE_LABEL[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'); + RouterModel.changeSearchParams((url) => { + set(url, SEARCH_PARAMS_FIELD.MIN_PRICE, from?.getValue() ?? '0'); + set(url, SEARCH_PARAMS_FIELD.MAX_PRICE, to?.getValue() ?? '0'); + }); this.callback(); } @@ -621,8 +650,8 @@ class ProductFiltersView { 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); + activeFilters.categoryLinks.forEach((key) => { + const currentLink = this.categoryLinks.find((link) => link.getHTML().id === key); if (currentLink) { this.switchSelectedFilter(currentLink, true); } diff --git a/src/features/ProductSearch/model/ProductSearchModel.ts b/src/features/ProductSearch/model/ProductSearchModel.ts index faae9a5f..7b5d1115 100644 --- a/src/features/ProductSearch/model/ProductSearchModel.ts +++ b/src/features/ProductSearch/model/ProductSearchModel.ts @@ -1,3 +1,4 @@ +import { remove, set } from '@/app/Router/helpers/helpers.ts'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; @@ -19,8 +20,10 @@ class ProductSearchModel { } private handleSearchInput(): void { - RouterModel.deleteSearchParams(SEARCH_PARAMS_FIELD.PAGE); - RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.SEARCH, this.view.getSearchField().getValue()); + RouterModel.changeSearchParams((url) => { + remove(url, SEARCH_PARAMS_FIELD.PAGE); + set(url, SEARCH_PARAMS_FIELD.SEARCH, this.view.getSearchField().getValue()); + }); this.callback(); } diff --git a/src/features/ProductSearch/view/ProductSearchView.ts b/src/features/ProductSearch/view/ProductSearchView.ts index d6b2de53..37db8380 100644 --- a/src/features/ProductSearch/view/ProductSearchView.ts +++ b/src/features/ProductSearch/view/ProductSearchView.ts @@ -1,12 +1,11 @@ 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 { SORTING_TEXT } from '@/shared/constants/sorting.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './productSearchView.module.scss'; @@ -32,8 +31,7 @@ class ProductSearchView { private createSearchField(): InputModel { this.searchField = new InputModel({ - autocomplete: AUTOCOMPLETE_OPTION.ON, - placeholder: TEXT[getStore().getState().currentLanguage].SEARCH, + placeholder: SORTING_TEXT[getCurrentLanguage()].SEARCH, type: INPUT_TYPE.SEARCH, }); @@ -43,7 +41,7 @@ class ProductSearchView { } observeStore(selectCurrentLanguage, () => { - this.searchField.getHTML().placeholder = TEXT[getStore().getState().currentLanguage].SEARCH; + this.searchField.getHTML().placeholder = SORTING_TEXT[getCurrentLanguage()].SEARCH; }); this.searchField.getHTML().classList.add(styles.searchField); diff --git a/src/features/ProductSorts/view/ProductSortsView.ts b/src/features/ProductSorts/view/ProductSortsView.ts index 0f2fedce..32877fe7 100644 --- a/src/features/ProductSorts/view/ProductSortsView.ts +++ b/src/features/ProductSorts/view/ProductSortsView.ts @@ -1,12 +1,13 @@ +import { set } from '@/app/Router/helpers/helpers.ts'; 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 { DATA_KEY } from '@/shared/constants/common.ts'; import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; -import { SORTING_ID, TEXT } from '@/shared/constants/sorting.ts'; +import { SORTING_ID, SORTING_TEXT, SORTING_TEXT_KEY } from '@/shared/constants/sorting.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './productSortsView.module.scss'; @@ -45,21 +46,25 @@ class ProductSortsView { private createCurrentSortingSpan(): HTMLSpanElement { const selectedSorting = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); + const currentLanguage = getCurrentLanguage(); this.currentSortingSpan = createBaseElement({ cssClasses: [styles.currentSortingSpan], - innerContent: selectedSorting - ? selectedSorting.toUpperCase() - : TEXT[getStore().getState().currentLanguage].DEFAULT.toUpperCase(), + innerContent: + selectedSorting && selectedSorting in SORTING_TEXT + ? SORTING_TEXT[currentLanguage][selectedSorting.toUpperCase()] + : SORTING_TEXT[currentLanguage].DEFAULT.toUpperCase(), tag: 'span', }); observeStore(selectCurrentLanguage, () => { const selectedSorting = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); + const currentLanguage = getCurrentLanguage(); - this.currentSortingSpan.innerText = selectedSorting - ? selectedSorting.toUpperCase() - : TEXT[getStore().getState().currentLanguage].DEFAULT.toUpperCase(); + this.currentSortingSpan.innerText = + selectedSorting && selectedSorting in SORTING_TEXT + ? SORTING_TEXT[currentLanguage][selectedSorting.toUpperCase()] + : SORTING_TEXT[currentLanguage].DEFAULT.toUpperCase(); }); return this.currentSortingSpan; @@ -75,15 +80,15 @@ class ProductSortsView { return this.sortingWrapper; } - private createSortingLink(href: string, text: string, id: string): LinkModel { + private createSortingLink(href: string, key: string, id: string): LinkModel { const link = new LinkModel({ attrs: { - [DATA_KEYS.DIRECTION]: SortDirection.ASC, + [DATA_KEY.DIRECTION]: SortDirection.ASC, href, id, }, classes: [styles.sortingLink], - text, + text: SORTING_TEXT[getCurrentLanguage()][key], }); link.getHTML().classList.add(styles.hight); @@ -98,13 +103,11 @@ 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.currentSortingSpan.innerText = SORTING_TEXT[getCurrentLanguage()][key]; + RouterModel.changeSearchParams((url) => { + set(url, SEARCH_PARAMS_FIELD.FIELD, link.getHTML().id); + set(url, SEARCH_PARAMS_FIELD.DIRECTION, String(link.getHTML().getAttribute(DATA_KEY.DIRECTION))); + }); this.callback(); }); @@ -119,14 +122,9 @@ class ProductSortsView { tag: 'ul', }); - const defaultSortingLink = this.createSortingLink( - '', - TEXT[getStore().getState().currentLanguage].DEFAULT, - SORTING_ID.DEFAULT, - ); - - 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 defaultSortingLink = this.createSortingLink('', SORTING_TEXT_KEY.DEFAULT, SORTING_ID.DEFAULT); + const priceLink = this.createSortingLink('', SORTING_TEXT_KEY.PRICE, SORTING_ID.PRICE); + const nameLink = this.createSortingLink('', SORTING_TEXT_KEY.NAME, SORTING_ID.NAME); const initialField = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.FIELD); const initialDirection = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.DIRECTION); @@ -135,14 +133,16 @@ class ProductSortsView { if (currentLink && initialField) { currentLink?.getHTML().classList.toggle(styles.pass, initialDirection === SortDirection.DESC); - currentLink?.getHTML().classList.toggle(styles.hight, initialDirection === SortDirection.DESC); + // TBD check hight + // currentLink?.getHTML().classList.toggle(styles.hight, initialDirection === SortDirection.DESC); currentLink.getHTML().dataset.field = initialField; } observeStore(selectCurrentLanguage, () => { - defaultSortingLink.getHTML().innerText = TEXT[getStore().getState().currentLanguage].DEFAULT; - priceLink.getHTML().innerText = TEXT[getStore().getState().currentLanguage].PRICE; - nameLink.getHTML().innerText = TEXT[getStore().getState().currentLanguage].NAME; + const currentLanguage = getCurrentLanguage(); + defaultSortingLink.getHTML().innerText = SORTING_TEXT[currentLanguage].DEFAULT; + priceLink.getHTML().innerText = SORTING_TEXT[currentLanguage].PRICE; + nameLink.getHTML().innerText = SORTING_TEXT[currentLanguage].NAME; }); this.sortingList.append(defaultSortingLink.getHTML(), priceLink.getHTML(), nameLink.getHTML()); @@ -158,12 +158,12 @@ class ProductSortsView { const span = createBaseElement({ cssClasses: [styles.sortingListTitleSpan], - innerContent: TEXT[getStore().getState().currentLanguage].SORT_BY, + innerContent: SORTING_TEXT[getCurrentLanguage()].SORT_BY, tag: 'span', }); observeStore(selectCurrentLanguage, () => { - span.innerText = TEXT[getStore().getState().currentLanguage].SORT_BY; + span.innerText = SORTING_TEXT[getCurrentLanguage()].SORT_BY; }); this.sortingListTitle.addEventListener('click', () => { diff --git a/src/features/WishlistButton/model/WishlistButtonModel.ts b/src/features/WishlistButton/model/WishlistButtonModel.ts new file mode 100644 index 00000000..be31ea73 --- /dev/null +++ b/src/features/WishlistButton/model/WishlistButtonModel.ts @@ -0,0 +1,90 @@ +import type ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import type { Product } from '@/shared/types/product.ts'; +import type { ShoppingList, ShoppingListProduct } from '@/shared/types/shopping-list.ts'; + +import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { productAddedToWishListMessage, productRemovedFromWishListMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; + +import WishlistButtonView from '../view/WishlistButtonView.ts'; + +class WishlistButtonModel { + private params: Product; + + private view = new WishlistButtonView(); + + constructor(params: Product) { + this.params = params; + this.init(); + } + + private addProductToWishListHandler(): void { + getShoppingListModel() + .addProduct(this.params.id) + .then(() => { + showSuccessMessage( + productAddedToWishListMessage(this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value), + ); + this.view.switchStateWishListButton(true); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.CHANGE_WISHLIST_BUTTON, ''); + }) + .catch(showErrorMessage); + } + + private deleteProductToWishListHandler(productInWishList: ShoppingListProduct): void { + getShoppingListModel() + .deleteProduct(productInWishList) + .then(() => { + showSuccessMessage( + productRemovedFromWishListMessage( + this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, + ), + ); + this.view.switchStateWishListButton(false); + EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.CHANGE_WISHLIST_BUTTON, this.params.key); + }) + .catch(showErrorMessage); + } + + private hasProductInWishList(shoppingList: ShoppingList): void { + const result = shoppingList.products.find((product) => product.productId === this.params.id); + this.view.switchStateWishListButton(Boolean(result)); + } + + private init(): void { + getShoppingListModel() + .getShoppingList() + .then((shoppingList) => { + this.hasProductInWishList(shoppingList); + this.setButtonHandler(); + }) + .catch(showErrorMessage); + } + + private setButtonHandler(): void { + const switchToWishListButton = this.view.getHTML(); + switchToWishListButton.getHTML().addEventListener('click', async () => { + const shoppingList = await getShoppingListModel().getShoppingList(); + const productInWishList = shoppingList.products.find((product) => product.productId === this.params.id); + if (productInWishList) { + this.deleteProductToWishListHandler(productInWishList); + } else { + this.addProductToWishListHandler(); + } + }); + } + + public getHTML(): ButtonModel { + return this.view.getHTML(); + } + + public getView(): WishlistButtonView { + return this.view; + } +} + +export default WishlistButtonModel; diff --git a/src/features/WishlistButton/view/WishlistButtonView.ts b/src/features/WishlistButton/view/WishlistButtonView.ts new file mode 100644 index 00000000..e9f6a04a --- /dev/null +++ b/src/features/WishlistButton/view/WishlistButtonView.ts @@ -0,0 +1,35 @@ +import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; + +import styles from './wishlistButtonView.module.scss'; + +class WishlistButtonView { + private switchToWishListButton: ButtonModel; + + constructor() { + this.switchToWishListButton = this.createHTML(); + } + + private createHTML(): ButtonModel { + this.switchToWishListButton = new ButtonModel({ + classes: [styles.switchToWishListButton], + }); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.HEART)); + this.switchToWishListButton.getHTML().append(svg); + + return this.switchToWishListButton; + } + + public getHTML(): ButtonModel { + return this.switchToWishListButton; + } + + public switchStateWishListButton(hasProductInWishList: boolean): void { + this.switchToWishListButton.getHTML().classList.toggle(styles.inWishList, hasProductInWishList); + } +} + +export default WishlistButtonView; diff --git a/src/features/WishlistButton/view/wishlistButtonView.module.scss b/src/features/WishlistButton/view/wishlistButtonView.module.scss new file mode 100644 index 00000000..d6a71771 --- /dev/null +++ b/src/features/WishlistButton/view/wishlistButtonView.module.scss @@ -0,0 +1,59 @@ +.switchToWishListButton { + position: relative; + 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(--white-tr); + transition: outline 0.2s; + backdrop-filter: blur(10px); + + svg { + width: var(--extra-small-offset); + height: var(--extra-small-offset); + fill: transparent; + stroke: var(--noble-gray-700); + stroke-width: 4.5px; + transition: + fill 0.2s, + stroke 0.2s; + } + + &:active { + transform: scale(0.9); + } + + @media (hover: hover) { + &:hover { + outline: calc(var(--one) * 1.5) solid var(--steam-green-1200); + + svg { + fill: var(--steam-green-1200); + stroke: var(--steam-green-1200); + } + } + } + + &.inWishList { + @media (hover: hover) { + &:hover { + outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); + + svg { + fill: transparent; + stroke: var(--noble-gray-700); + } + } + } + } +} + +.inWishList { + outline: calc(var(--one) * 1.5) solid var(--steam-green-1200); + + svg { + fill: var(--steam-green-1200); + stroke: var(--steam-green-1200); + } +} diff --git a/src/pages/AboutUsPage/model/AboutUsPageModel.ts b/src/pages/AboutUsPage/model/AboutUsPageModel.ts index 98f35913..ed30619c 100644 --- a/src/pages/AboutUsPage/model/AboutUsPageModel.ts +++ b/src/pages/AboutUsPage/model/AboutUsPageModel.ts @@ -1,8 +1,14 @@ import type { Page } from '@/shared/types/page.ts'; +import AboutFullCardModel from '@/entities/AboutFullCard/model/AboutFullCardModel.ts'; +import AboutShortCardModel from '@/entities/AboutShortCard/model/AboutShortCardModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; +import isAboutData from '@/shared/types/validation/aboutData.ts'; +import getAboutData from '@/shared/utils/getAboutData.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; import AboutUsPageView from '../view/AboutUsPageView.ts'; @@ -14,8 +20,25 @@ class AboutUsPageModel implements Page { this.init(); } + private drawCards(): void { + getAboutData() + .then((data) => { + if (isAboutData(data)) { + data.forEach((card) => { + const shortCard = new AboutShortCardModel(card); + shortCard.setFullCard(new AboutFullCardModel(card)); + shortCard.openFullCard(); + this.view.getCardsList().append(shortCard.getHTML()); + }); + } + }) + .catch(showErrorMessage); + } + private init(): void { + modal.hide(); getStore().dispatch(setCurrentPage(PAGE_ID.ABOUT_US_PAGE)); + this.drawCards(); } public getHTML(): HTMLDivElement { diff --git a/src/pages/AboutUsPage/view/AboutUsPageView.ts b/src/pages/AboutUsPage/view/AboutUsPageView.ts index d0a0f4ef..df326b43 100644 --- a/src/pages/AboutUsPage/view/AboutUsPageView.ts +++ b/src/pages/AboutUsPage/view/AboutUsPageView.ts @@ -1,8 +1,17 @@ +import LinkModel from '@/shared/Link/model/LinkModel.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { LINK_DETAIL } from '@/shared/constants/links.ts'; +import { PAGE_DESCRIPTION } from '@/shared/constants/pages.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './aboutUsPageView.module.scss'; class AboutUsPageView { + private cardsList: HTMLUListElement; + private page: HTMLDivElement; private parent: HTMLDivElement; @@ -10,21 +19,63 @@ class AboutUsPageView { constructor(parent: HTMLDivElement) { this.parent = parent; this.parent.innerHTML = ''; + this.cardsList = this.createCardsList(); this.page = this.createHTML(); window.scrollTo(0, 0); } + private createCardsList(): HTMLUListElement { + this.cardsList = createBaseElement({ + cssClasses: [styles.cardsList], + tag: 'ul', + }); + return this.cardsList; + } + private createHTML(): HTMLDivElement { this.page = createBaseElement({ cssClasses: [styles.aboutUsPage], tag: 'div', }); + this.page.append(this.createTitle(), this.cardsList, this.createRSSLogo().getHTML()); this.parent.append(this.page); return this.page; } + private createRSSLogo(): LinkModel { + const logo = new LinkModel({ + attrs: { + href: 'https://rs.school', + target: LINK_DETAIL.BLANK, + }, + classes: [styles.logo], + }); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.RSS_LOGO)); + logo.getHTML().append(svg); + return logo; + } + + private createTitle(): HTMLHeadingElement { + const title = createBaseElement({ + cssClasses: [styles.title], + innerContent: PAGE_DESCRIPTION[getCurrentLanguage()].ABOUT, + tag: 'h1', + }); + + observeStore(selectCurrentLanguage, () => { + title.innerText = PAGE_DESCRIPTION[getCurrentLanguage()].ABOUT; + }); + return title; + } + + public getCardsList(): HTMLUListElement { + return this.cardsList; + } + public getHTML(): HTMLDivElement { return this.page; } diff --git a/src/pages/AboutUsPage/view/aboutUsPageView.module.scss b/src/pages/AboutUsPage/view/aboutUsPageView.module.scss index 10a217f4..36211330 100644 --- a/src/pages/AboutUsPage/view/aboutUsPageView.module.scss +++ b/src/pages/AboutUsPage/view/aboutUsPageView.module.scss @@ -15,3 +15,46 @@ opacity: 1; } } + +.title { + margin-bottom: var(--small-offset); + font: var(--black-font); + letter-spacing: var(--one); + color: var(--steam-green-1200); +} + +.cardsList { + display: flex; + justify-content: center; + gap: var(--small-offset); + + @media (max-width: 840px) { + flex-direction: column; + justify-content: center; + } +} + +.logo { + display: flex; + margin: 0 auto; + margin-top: var(--small-offset); + width: max-content; + + svg { + width: 11rem; + height: 4rem; + transition: fill 0.1s; + + use { + transition: fill 0.1s; + } + } + + @media (hover: hover) { + &:hover { + svg { + fill: var(--steam-green-1200); + } + } + } +} diff --git a/src/pages/Blog/PostList/model/PostListModel.ts b/src/pages/BlogPage/model/BlogPageModel.ts similarity index 84% rename from src/pages/Blog/PostList/model/PostListModel.ts rename to src/pages/BlogPage/model/BlogPageModel.ts index e62cc6cd..94e79474 100644 --- a/src/pages/Blog/PostList/model/PostListModel.ts +++ b/src/pages/BlogPage/model/BlogPageModel.ts @@ -1,16 +1,16 @@ -import type { Post } from '@/shared/constants/blog.ts'; +import type { Post } from '@/shared/types/blog.ts'; import type { Page } from '@/shared/types/page.ts'; -import PostView from '@/pages/Blog/Post/view/PostView.ts'; +import PostView from '@/entities/Post/view/PostView.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; -import postsData from '../../data/posts.ts'; -import BlogPageView from '../view/PostListView.ts'; +import postsData from '../../../shared/Posts/posts.ts'; +import BlogPageView from '../view/BlogPageView.ts'; -export default class PostListModel implements Page { +export default class BlogPageModel implements Page { private parent: HTMLDivElement; private postClickHandler = (post: PostView): void => { diff --git a/src/pages/BlogPage/test/blogPage.spec.ts b/src/pages/BlogPage/test/blogPage.spec.ts new file mode 100644 index 00000000..dbcbe814 --- /dev/null +++ b/src/pages/BlogPage/test/blogPage.spec.ts @@ -0,0 +1,22 @@ +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import BlogPageModel from '../model/BlogPageModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const parent = createBaseElement({ + tag: 'div', +}); +const blog = new BlogPageModel(parent); + +describe('Checking blog', () => { + it('should check if post is defined', () => { + expect(blog).toBeDefined(); + }); + + it('should check if blog is an instance of BlogPageModel', () => { + expect(blog).toBeInstanceOf(BlogPageModel); + }); +}); diff --git a/src/pages/Blog/PostList/view/PostListView.ts b/src/pages/BlogPage/view/BlogPageView.ts similarity index 77% rename from src/pages/Blog/PostList/view/PostListView.ts rename to src/pages/BlogPage/view/BlogPageView.ts index c67dd24f..6a577b0e 100644 --- a/src/pages/Blog/PostList/view/PostListView.ts +++ b/src/pages/BlogPage/view/BlogPageView.ts @@ -1,12 +1,12 @@ -import type BlogPostView from '@/pages/Blog/Post/view/PostView'; +import type BlogPostView from '@/entities/Post/view/PostView'; -import getStore from '@/shared/Store/Store.ts'; import { BLOG_DESCRIPTION } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; -import styles from './postListView.module.scss'; +import styles from './blogPageView.module.scss'; -export default class PostListView { +export default class BlogPageView { private description: HTMLParagraphElement; private page: HTMLDivElement; @@ -51,7 +51,7 @@ export default class PostListView { private createPageDescription(): HTMLParagraphElement { this.description = createBaseElement({ cssClasses: [styles.pageDescription], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].LIST_DESCRIPTION, + innerContent: BLOG_DESCRIPTION[getCurrentLanguage()].LIST_DESCRIPTION, tag: 'p', }); return this.description; @@ -60,7 +60,7 @@ export default class PostListView { private createPageTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: [styles.pageTitle], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].LIST_TITLE, + innerContent: BLOG_DESCRIPTION[getCurrentLanguage()].LIST_TITLE, tag: 'h1', }); return this.title; @@ -86,9 +86,9 @@ export default class PostListView { } public updateLanguage(): boolean { - const ln = getStore().getState().currentLanguage; - this.title.innerText = BLOG_DESCRIPTION[ln].LIST_TITLE; - this.description.innerText = BLOG_DESCRIPTION[ln].LIST_DESCRIPTION; + const currentLanguage = getCurrentLanguage(); + this.title.innerText = BLOG_DESCRIPTION[currentLanguage].LIST_TITLE; + this.description.innerText = BLOG_DESCRIPTION[currentLanguage].LIST_DESCRIPTION; return true; } } diff --git a/src/pages/Blog/PostList/view/postListView.module.scss b/src/pages/BlogPage/view/blogPageView.module.scss similarity index 100% rename from src/pages/Blog/PostList/view/postListView.module.scss rename to src/pages/BlogPage/view/blogPageView.module.scss diff --git a/src/pages/CartPage/model/CartPageModel.ts b/src/pages/CartPage/model/CartPageModel.ts index 0e1c66e8..4bfc757a 100644 --- a/src/pages/CartPage/model/CartPageModel.ts +++ b/src/pages/CartPage/model/CartPageModel.ts @@ -1,24 +1,86 @@ -import type { Cart } from '@/shared/types/cart.ts'; +import type { Cart, Coupon } from '@/shared/types/cart.ts'; import type { Page } from '@/shared/types/page.ts'; +import SummaryModel from '@/entities/Summary/model/SummaryModel.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; +import getCustomerModel from '@/shared/API/customer/model/CustomerModel.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 { SERVER_MESSAGE_KEY } 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 { CartActive } from '@/shared/types/cart.ts'; +import { promoCodeAppliedMessage, promoCodeDeleteMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import ProductOrderModel from '@/widgets/ProductOrder/model/ProductOrderModel.ts'; import CartPageView from '../view/CartPageView.ts'; +const HAPPY_BIRTHDAY = 'HAPPY-BIRTHDAY-10'; +const TITLE_SUMMARY = { + cart: { en: 'Cart Discount', ru: 'Скидка на корзину' }, + product: { en: 'Product Discount', ru: 'Скидка на продукты' }, +}; + class CartPageModel implements Page { private cart: Cart | null = null; - private changeProductHandler = (cart: Cart): void => { + private cartCouponSummary: SummaryModel; + + private productCouponSummary: SummaryModel; + + private productsItem: ProductOrderModel[] = []; + + private view: CartPageView; + + constructor(parent: HTMLDivElement) { + this.cartCouponSummary = new SummaryModel(TITLE_SUMMARY.cart, this.deleteDiscountHandler.bind(this)); + this.productCouponSummary = new SummaryModel(TITLE_SUMMARY.product, this.deleteDiscountHandler.bind(this)); + this.view = new CartPageView( + parent, + this.cartCouponSummary, + this.productCouponSummary, + this.clearCart.bind(this), + this.addDiscountHandler.bind(this), + ); + + this.init().catch(showErrorMessage); + } + + private async addDiscountHandler(discountCode: string): Promise { + if (discountCode.trim()) { + if (discountCode.trim() === HAPPY_BIRTHDAY) { + await this.checkBirthday(); + } + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getCouponButton().append(loader); + await getCartModel() + .addCoupon(discountCode) + .then((cart) => { + if (cart) { + showSuccessMessage(promoCodeAppliedMessage(discountCode)); + this.cart = cart; + this.productsItem.forEach((productItem) => { + const idLine = productItem.getProduct().lineItemId; + const updateLine = this.cart?.products.find((item) => item.lineItemId === idLine); + if (updateLine) { + productItem.setProduct(updateLine); + productItem.updateProductHandler(CartActive.UPDATE).catch(showErrorMessage); + } + }); + this.cartCouponSummary.update(this.cart.discountsCart); + this.productCouponSummary.update(this.cart.discountsProduct); + this.view.updateTotal(this.cart); + } + }) + .catch(showErrorMessage) + .finally(() => loader.remove()); + } + } + + 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); @@ -32,15 +94,42 @@ class CartPageModel implements Page { if (!this.productsItem.length) { this.view.renderEmpty(); } + this.cartCouponSummary.update(this.cart.discountsCart); + this.productCouponSummary.update(this.cart.discountsProduct); this.view.updateTotal(this.cart); - }; + } + + private async checkBirthday(): Promise { + if (!getStore().getState().isUserLoggedIn) { + throw showErrorMessage(SERVER_MESSAGE_KEY.COUPON_NEED_LOGIN); + } + const customer = await getCustomerModel().getCurrentUser(); + if (customer?.birthDate) { + if (customer?.birthDate) { + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + const birthDate = new Date(customer.birthDate); + birthDate.setFullYear(currentDate.getFullYear()); + birthDate.setHours(0, 0, 0, 0); + const startBirthdayPeriod = new Date(birthDate); + startBirthdayPeriod.setDate(birthDate.getDate() - 3); + const endBirthdayPeriod = new Date(birthDate); + endBirthdayPeriod.setDate(birthDate.getDate() + 3); + + if (currentDate >= startBirthdayPeriod && currentDate <= endBirthdayPeriod) { + return; + } + throw showErrorMessage(SERVER_MESSAGE_KEY.COUPON_WRONG_DATE); + } + } + } - private clearCart = async (): Promise => { + private async clearCart(): Promise { await getCartModel() .clearCart() .then((cart) => { this.cart = cart; - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.SUCCESSFUL_CLEAR_CART, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.SUCCESSFUL_CLEAR_CART); this.productsItem = this.productsItem.filter((productItem) => { const searchEl = this.cart?.products.find((item) => item.lineItemId === productItem.getProduct().lineItemId); if (!searchEl) { @@ -55,37 +144,32 @@ class CartPageModel implements Page { showErrorMessage(error); return this.cart; }); - }; - - private productsItem: ProductOrderModel[] = []; - - private view: CartPageView; - - constructor(parent: HTMLDivElement) { - 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 deleteDiscountHandler(coupon: Coupon): Promise { + const loader = new LoaderModel(LOADER_SIZE.SMALL).getHTML(); + this.view.getCouponButton().append(loader); + await getCartModel() + .deleteCoupon(coupon.cartDiscount) + .then((cart) => { + if (cart) { + showSuccessMessage(promoCodeDeleteMessage(coupon.discountCode)); + this.cart = cart; + this.productsItem.forEach((productItem) => { + const idLine = productItem.getProduct().lineItemId; + const updateLine = this.cart?.products.find((item) => item.lineItemId === idLine); + if (updateLine) { + productItem.setProduct(updateLine); + productItem.updateProductHandler(CartActive.UPDATE).catch(showErrorMessage); + } + }); + this.cartCouponSummary.update(this.cart.discountsCart); + this.productCouponSummary.update(this.cart.discountsProduct); + this.view.updateTotal(this.cart); + } + }) + .catch(showErrorMessage) + .finally(() => loader.remove()); } private async init(): Promise { @@ -98,14 +182,22 @@ class CartPageModel implements Page { private renderCart(): void { if (this.cart) { this.cart.products.forEach((product) => { - this.productsItem.push(new ProductOrderModel(product, this.changeProductHandler)); + this.productsItem.push(new ProductOrderModel(product, this.changeProductHandler.bind(this))); }); + this.cart.discountsCart.forEach((discount) => { + this.cartCouponSummary.addCoupon(discount); + }); + this.cart.discountsProduct.forEach((discount) => { + this.productCouponSummary.addCoupon(discount); + }); if (this.productsItem.length) { this.view.renderCart(this.productsItem); } else { this.view.renderEmpty(); } + this.cartCouponSummary.update(this.cart.discountsCart); + this.productCouponSummary.update(this.cart.discountsProduct); this.view.updateTotal(this.cart); } } diff --git a/src/pages/CartPage/test/cart.spec.ts b/src/pages/CartPage/test/cart.spec.ts new file mode 100644 index 00000000..6b1ae1ca --- /dev/null +++ b/src/pages/CartPage/test/cart.spec.ts @@ -0,0 +1,22 @@ +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import CartPageModel from '../model/CartPageModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const parent = createBaseElement({ + tag: 'div', +}); +const cart = new CartPageModel(parent); + +describe('Checking cart', () => { + it('should check if post is defined', () => { + expect(cart).toBeDefined(); + }); + + it('should check if cart is an instance of CartPageModel', () => { + expect(cart).toBeInstanceOf(CartPageModel); + }); +}); diff --git a/src/pages/CartPage/view/CartPageView.ts b/src/pages/CartPage/view/CartPageView.ts index 61485a5e..69fb60b0 100644 --- a/src/pages/CartPage/view/CartPageView.ts +++ b/src/pages/CartPage/view/CartPageView.ts @@ -1,18 +1,21 @@ +import type SummaryModel from '@/entities/Summary/model/SummaryModel'; 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 ButtonModel from '@/shared/Button/model/ButtonModel.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 USER_MESSAGE from '@/shared/constants/confirmUserMessage.ts'; +import { CART_PAGE_TITLE, PAGE_ID } from '@/shared/constants/pages.ts'; +import clearOutElement from '@/shared/utils/clearOutElement.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { cartPrice } from '@/shared/utils/messageTemplates.ts'; import styles from './cartPageView.module.scss'; @@ -23,35 +26,20 @@ type textElementsType = { 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 cartCouponSummary: SummaryModel; + + private clear: ButtonModel; private clearCallback: ClearCallback; private couponButton: HTMLButtonElement; - private discount: HTMLParagraphElement; + private discountList: HTMLUListElement; + + private discountTotal: HTMLElement; private empty: HTMLDivElement; @@ -61,6 +49,8 @@ class CartPageView { private parent: HTMLDivElement; + private productCouponSummary: SummaryModel; + private productRow: HTMLTableRowElement[] = []; private productWrap: HTMLDivElement; @@ -75,12 +65,22 @@ class CartPageView { private total: HTMLParagraphElement; + private totalDiscountTitle: HTMLParagraphElement; + private totalWrap: HTMLDivElement; - constructor(parent: HTMLDivElement, clearCallback: ClearCallback, addDiscountCallback: DiscountCallback) { - this.language = getStore().getState().currentLanguage; + constructor( + parent: HTMLDivElement, + cartCouponSummary: SummaryModel, + productCouponSummary: SummaryModel, + clearCallback: ClearCallback, + addDiscountCallback: DiscountCallback, + ) { + this.language = getCurrentLanguage(); this.parent = parent; this.parent.innerHTML = ''; + this.cartCouponSummary = cartCouponSummary; + this.productCouponSummary = productCouponSummary; this.clearCallback = clearCallback; this.addDiscountCallback = addDiscountCallback; this.page = this.createPageHTML(); @@ -88,13 +88,11 @@ class CartPageView { 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.discountTotal = createBaseElement({ cssClasses: [styles.couponsWrap], tag: 'summary' }); + this.discountList = createBaseElement({ cssClasses: [styles.couponsList], tag: 'ul' }); + this.couponButton = this.createCouponButton(); + this.totalDiscountTitle = this.createTotalDiscountTitle(); + this.clear = this.createClearButton(); this.totalWrap = this.createWrapHTML(); this.totalWrap.classList.add(styles.total); this.page.append(this.productWrap); @@ -104,77 +102,83 @@ class CartPageView { } private addTableHeader(): void { + const productsWrap = createBaseElement({ cssClasses: [styles.productsWrap], tag: 'div' }); this.table = createBaseElement({ cssClasses: [styles.table], tag: 'table' }); const thead = createBaseElement({ cssClasses: [styles.thead, styles.head], tag: 'thead' }); const tr = createBaseElement({ cssClasses: [styles.tr, styles.head], tag: 'tr' }); const thImage = createBaseElement({ cssClasses: [styles.th, styles.imgCell, styles.mainText], - innerContent: TITLE.PRODUCT[this.language], + innerContent: CART_PAGE_TITLE.PRODUCT[this.language], tag: 'th', }); - this.textElement.push({ element: thImage, textItem: TITLE.PRODUCT }); + this.textElement.push({ element: thImage, textItem: CART_PAGE_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: TITLE.PRICE[this.language], + innerContent: CART_PAGE_TITLE.PRICE[this.language], tag: 'th', }); - this.textElement.push({ element: thPrice, textItem: TITLE.PRICE }); + this.textElement.push({ element: thPrice, textItem: CART_PAGE_TITLE.PRICE }); const thQuantity = createBaseElement({ cssClasses: [styles.th, styles.quantityCell, styles.mainText], - innerContent: TITLE.QUANTITY[this.language], + innerContent: CART_PAGE_TITLE.QUANTITY[this.language], tag: 'th', }); - this.textElement.push({ element: thQuantity, textItem: TITLE.QUANTITY }); + this.textElement.push({ element: thQuantity, textItem: CART_PAGE_TITLE.QUANTITY }); const thTotal = createBaseElement({ cssClasses: [styles.th, styles.totalCell, styles.mainText], - innerContent: TITLE.TOTAL[this.language], + innerContent: CART_PAGE_TITLE.TOTAL[this.language], tag: 'th', }); - this.textElement.push({ element: thTotal, textItem: TITLE.TOTAL }); + this.textElement.push({ element: thTotal, textItem: CART_PAGE_TITLE.TOTAL }); const thDelete = this.createDeleCell(); this.tableBody = createBaseElement({ cssClasses: [styles.tbody], tag: 'tbody' }); this.table.append(thead, this.tableBody); thead.append(tr); tr.append(thImage, thProduct, thPrice, thQuantity, thTotal, thDelete); - this.productWrap.append(this.table); + productsWrap.append(this.table); + this.productWrap.append(productsWrap); } private addTotalInfo(): void { + const totalBlockWrap = createBaseElement({ cssClasses: [styles.totalsWrap], tag: 'div' }); const title = createBaseElement({ cssClasses: [styles.totalTitle, styles.border, styles.mobileHide], - innerContent: TITLE.CART_TOTAL[this.language], + innerContent: CART_PAGE_TITLE.CART_TOTAL[this.language], tag: 'p', }); - this.textElement.push({ element: title, textItem: TITLE.CART_TOTAL }); + this.textElement.push({ element: title, textItem: CART_PAGE_TITLE.CART_TOTAL }); const couponTitle = createBaseElement({ cssClasses: [styles.title, styles.mobileHide], - innerContent: TITLE.COUPON_APPLY[this.language], + innerContent: CART_PAGE_TITLE.COUPON_APPLY[this.language], tag: 'p', }); - this.textElement.push({ element: couponTitle, textItem: TITLE.COUPON_APPLY }); + this.textElement.push({ element: couponTitle, textItem: CART_PAGE_TITLE.COUPON_APPLY }); const couponWrap = this.createCouponHTML(); const subtotalWrap = this.createSubtotalHTML(); const discountWrap = this.createDiscountHTML(); const totalWrap = this.createTotalHTML(); const finalButton = createBaseElement({ cssClasses: [styles.button, styles.checkoutBtn], - innerContent: TITLE.BUTTON_CHECKOUT[this.language], + innerContent: CART_PAGE_TITLE.BUTTON_CHECKOUT[this.language], tag: 'button', }); - this.textElement.push({ element: finalButton, textItem: TITLE.BUTTON_CHECKOUT }); + this.textElement.push({ element: finalButton, textItem: CART_PAGE_TITLE.BUTTON_CHECKOUT }); const continueLink = this.createCatalogLinkHTML(); continueLink.getHTML().classList.add(styles.mobileHide); - this.totalWrap.append( + totalBlockWrap.append( title, couponTitle, couponWrap, + this.productCouponSummary.getHTML(), subtotalWrap, + this.cartCouponSummary.getHTML(), discountWrap, totalWrap, finalButton, continueLink.getHTML(), ); + this.totalWrap.append(totalBlockWrap); } private createCatalogLinkHTML(): LinkModel { @@ -183,9 +187,9 @@ class CartPageView { href: PAGE_ID.CATALOG_PAGE, }, classes: [styles.continue], - text: TITLE.CONTINUE[this.language], + text: CART_PAGE_TITLE.CONTINUE[this.language], }); - this.textElement.push({ element: link.getHTML(), textItem: TITLE.CONTINUE }); + this.textElement.push({ element: link.getHTML(), textItem: CART_PAGE_TITLE.CONTINUE }); link.getHTML().addEventListener('click', (event) => { event.preventDefault(); @@ -194,18 +198,31 @@ class CartPageView { return link; } + private createClearButton(): ButtonModel { + return new ButtonModel({ + classes: [styles.continue, styles.clear], + text: CART_PAGE_TITLE.CLEAR[this.language], + }); + } + + private createCouponButton(): HTMLButtonElement { + return createBaseElement({ + cssClasses: [styles.button, styles.applyBtn], + innerContent: CART_PAGE_TITLE.BUTTON_COUPON[this.language], + tag: 'button', + }); + } + private createCouponHTML(): HTMLDivElement { const couponWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); const couponInput = new InputModel({ - autocomplete: 'off', id: 'coupon', - placeholder: TITLE.INPUT_COUPON[this.language], - type: INPUT_TYPE.TEXT, + placeholder: CART_PAGE_TITLE.INPUT_COUPON[this.language], }); 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.textElement.push({ element: couponInput.getHTML(), textItem: CART_PAGE_TITLE.INPUT_COUPON }); + this.textElement.push({ element: this.couponButton, textItem: CART_PAGE_TITLE.BUTTON_COUPON }); this.couponButton.addEventListener('click', (evn: Event) => { evn.preventDefault(); this.addDiscountCallback(couponInput.getHTML().value); @@ -218,13 +235,9 @@ class CartPageView { 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, - ); + this.textElement.push({ element: this.clear.getHTML(), textItem: CART_PAGE_TITLE.CLEAR }); + this.clear.getHTML().addEventListener('click', () => { + const confirmModel = new ConfirmModel(() => this.clearCallback(), USER_MESSAGE[getCurrentLanguage()].CLEAR_CART); modal.setContent(confirmModel.getHTML()); modal.show(); }); @@ -232,15 +245,10 @@ class CartPageView { return tdDelete; } - private createDiscountHTML(): HTMLDivElement { - const discountWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); - 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 }); + private createDiscountHTML(): HTMLDetailsElement { + const discountWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'details' }); + discountWrap.append(this.discountTotal, this.discountList); + this.textElement.push({ element: this.totalDiscountTitle, textItem: CART_PAGE_TITLE.COUPON_DISCOUNT }); return discountWrap; } @@ -248,10 +256,10 @@ class CartPageView { const empty = createBaseElement({ cssClasses: [styles.empty, styles.hide], tag: 'div' }); const emptyTitle = createBaseElement({ cssClasses: [styles.emptyTitle], - innerContent: TITLE.EMPTY[this.language], + innerContent: CART_PAGE_TITLE.EMPTY[this.language], tag: 'p', }); - this.textElement.push({ element: emptyTitle, textItem: TITLE.EMPTY }); + this.textElement.push({ element: emptyTitle, textItem: CART_PAGE_TITLE.EMPTY }); const continueLink = this.createCatalogLinkHTML(); empty.append(emptyTitle, continueLink.getHTML()); this.page.append(empty); @@ -273,33 +281,39 @@ class CartPageView { const subtotalWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); const subtotalTitle = createBaseElement({ cssClasses: [styles.title], - innerContent: TITLE.SUBTOTAL[this.language], + innerContent: CART_PAGE_TITLE.SUBTOTAL[this.language], tag: 'p', }); subtotalWrap.append(subtotalTitle, this.subTotal); - this.textElement.push({ element: subtotalTitle, textItem: TITLE.SUBTOTAL }); + this.textElement.push({ element: subtotalTitle, textItem: CART_PAGE_TITLE.SUBTOTAL }); return subtotalWrap; } + private createTotalDiscountTitle(): HTMLParagraphElement { + return createBaseElement({ + cssClasses: [styles.title], + innerContent: CART_PAGE_TITLE.COUPON_DISCOUNT[this.language], + tag: 'p', + }); + } + private createTotalHTML(): HTMLDivElement { const totalWrap = createBaseElement({ cssClasses: [styles.totalWrap], tag: 'div' }); const totalTitle = createBaseElement({ cssClasses: [styles.totalTitle], - innerContent: TITLE.TOTAL[this.language], + innerContent: CART_PAGE_TITLE.TOTAL[this.language], tag: 'p', }); totalWrap.append(totalTitle, this.total); - this.textElement.push({ element: totalTitle, textItem: TITLE.TOTAL }); + this.textElement.push({ element: totalTitle, textItem: CART_PAGE_TITLE.TOTAL }); return totalWrap; } private createWrapHTML(): HTMLDivElement { - const wrap = createBaseElement({ + return createBaseElement({ cssClasses: [styles.wrap], tag: 'div', }); - - return wrap; } public getCouponButton(): HTMLButtonElement { @@ -311,8 +325,7 @@ class CartPageView { } public renderCart(productsItem: ProductOrderModel[]): void { - this.productWrap.innerHTML = ''; - this.totalWrap.innerHTML = ''; + clearOutElement(this.productWrap, this.totalWrap, this.discountTotal, this.discountList); this.productWrap.classList.remove(styles.hide); this.totalWrap.classList.remove(styles.hide); this.empty.classList.add(styles.hide); @@ -324,15 +337,14 @@ class CartPageView { } public renderEmpty(): void { - this.productWrap.innerHTML = ''; - this.totalWrap.innerHTML = ''; + clearOutElement(this.productWrap, this.totalWrap, this.discountTotal, this.discountList); 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.language = getCurrentLanguage(); this.textElement.forEach((textEl) => { const elHTML = textEl.element; if (elHTML instanceof HTMLInputElement) { @@ -344,9 +356,12 @@ class CartPageView { } 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)}`; + clearOutElement(this.discountTotal, this.discountList); + const totalDiscount = cart.discountsCart.reduce((acc, discount) => acc + discount.value, 0); + const subTotal = cart.total + totalDiscount; + this.subTotal.innerHTML = cartPrice(subTotal.toFixed(2)); + this.total.innerHTML = cartPrice(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 3f51d986..9107b94e 100644 --- a/src/pages/CartPage/view/cartPageView.module.scss +++ b/src/pages/CartPage/view/cartPageView.module.scss @@ -3,8 +3,6 @@ .cartPage { display: flex; flex-grow: 1; - overflow: hidden; - padding: 0 var(--small-offset); animation: show 0.2s ease-out forwards; gap: 2%; @@ -33,7 +31,10 @@ } .total { + position: sticky; + top: calc(var(--extra-small-offset) * 3.5); flex-shrink: 1; + height: max-content; @media (max-width: 768px) { position: sticky; @@ -41,11 +42,9 @@ bottom: 0; z-index: 100; margin-bottom: var(--tiny-offset); - border-radius: var(--large-br); padding: var(--small-offset); - width: 100%; box-shadow: var(--mellow-shadow-700); - background-color: var(--white-tr); + background-color: var(--noble-gray-1000); backdrop-filter: blur(10px); } } @@ -55,7 +54,14 @@ } .thead { + position: sticky; + top: calc(var(--extra-small-offset) * 3.5); width: 100%; + background: var(--noble-white-100); + + @media (max-width: 768px) { + position: static; + } } .mainText { @@ -101,6 +107,10 @@ padding: var(--tiny-offset) 0; font: var(--bold-font); color: var(--noble-gray-800); + + @media (max-width: 768px) { + color: var(--noble-gray-1100); + } } .border { @@ -111,6 +121,11 @@ padding: var(--tiny-offset) 0; font: var(--regular-font); color: var(--noble-gray-800); + transition: all 0.2s; + + @media (max-width: 768px) { + color: var(--noble-gray-1100); + } } .totalWrap { @@ -125,6 +140,24 @@ } } +.couponsWrap { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + + &:hover p { + color: var(--steam-green-800); + } +} + +.couponWrap { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + .totalPrice { padding: var(--tiny-offset) 0; font: var(--bold-font); @@ -149,6 +182,10 @@ .applyBtn { border-radius: 0 var(--small-br) var(--small-br) 0; + div { + position: absolute; + } + &:active { transform: scale(1); } @@ -176,6 +213,12 @@ $color: var(--steam-green-800); } } +.clear { + @include link(0 0, $color); + + text-transform: none; +} + .mobileHide { @media (max-width: 768px) { display: none; @@ -202,3 +245,56 @@ $color: var(--steam-green-800); .hide { display: none; } + +.couponsWrap:not(:empty)::after { + content: ''; + display: inline-block; + width: var(--tiny-offset); + height: var(--tiny-offset); + background: url('/img/png/expand-arrow.png') no-repeat center center; + background-size: contain; +} + +.deleteCoupon { + border-radius: var(--small-br); + padding: calc(var(--extra-small-offset) / 8); + width: calc(var(--extra-small-offset) * 1.2); // 24px + height: calc(var(--extra-small-offset) * 1.2); // 24px + background-image: url('../../../shared/img/svg/coupon-delete.svg'); + background-position: center; + background-size: 80%; + background-repeat: no-repeat; + background-color: transparent; + opacity: 0.6; + transition: opacity 0.2s; + cursor: pointer; + + &:hover { + opacity: 1; + transition: opacity 0.2s; + } +} + +.coupon { + display: flex; + align-items: center; +} + +.productsWrap { + padding-left: var(--small-offset); + + @media (max-width: 768px) { + overflow: hidden; + padding: 0 var(--small-offset); + } +} + +.totalsWrap { + display: flex; + flex-direction: column; + padding-right: var(--small-offset); + + @media (max-width: 768px) { + padding: 0; + } +} diff --git a/src/pages/CatalogPage/model/CatalogPageModel.ts b/src/pages/CatalogPage/model/CatalogPageModel.ts index e1c0dad7..684be925 100644 --- a/src/pages/CatalogPage/model/CatalogPageModel.ts +++ b/src/pages/CatalogPage/model/CatalogPageModel.ts @@ -8,13 +8,12 @@ import CatalogModel from '@/widgets/Catalog/model/CatalogModel.ts'; import CatalogPageView from '../view/CatalogPageView.ts'; class CatalogPageModel implements Page { - private catalog: CatalogModel = new CatalogModel(); + private catalog = new CatalogModel(); private view: CatalogPageView; constructor(parent: HTMLDivElement) { this.view = new CatalogPageView(parent); - this.init(); } diff --git a/src/pages/CooperationPage/model/CooperationPageModel.ts b/src/pages/CooperationPage/model/CooperationPageModel.ts new file mode 100644 index 00000000..62333651 --- /dev/null +++ b/src/pages/CooperationPage/model/CooperationPageModel.ts @@ -0,0 +1,44 @@ +import type { CooperationData } from '@/shared/types/validation/cooperationData.ts'; + +import getStore from '@/shared/Store/Store.ts'; +import { setCurrentPage } from '@/shared/Store/actions.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; +import isCooperationData from '@/shared/types/validation/cooperationData.ts'; +import getCooperationData from '@/shared/utils/getCooperationData.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; + +import CooperationPageView from '../view/CooperationPageView.ts'; + +class CooperationPageModel { + private view: CooperationPageView; + + constructor(parent: HTMLDivElement) { + this.view = new CooperationPageView(parent); + this.init(); + } + + private init(): void { + getCooperationData() + .then((data) => { + if (isCooperationData(data)) { + this.view.drawCooperationInfo(data); + this.observeState(data); + } + }) + .catch(showErrorMessage); + getStore().dispatch(setCurrentPage(PAGE_ID.COOPERATION_PAGE)); + } + + private observeState(data: CooperationData[]): void { + observeStore(selectCurrentLanguage, () => { + this.view.redrawCooperationInfo(data); + }); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } +} + +export default CooperationPageModel; diff --git a/src/pages/CooperationPage/view/CooperationPageView.ts b/src/pages/CooperationPage/view/CooperationPageView.ts new file mode 100644 index 00000000..a1451ab3 --- /dev/null +++ b/src/pages/CooperationPage/view/CooperationPageView.ts @@ -0,0 +1,137 @@ +import type { CooperationData } from '@/shared/types/validation/cooperationData'; + +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; + +import styles from './cooperationPageView.module.scss'; + +class CooperationPageView { + private page: HTMLDivElement; + + private parent: HTMLDivElement; + + private wrapper: HTMLDivElement; + + constructor(parent: HTMLDivElement) { + this.parent = parent; + this.parent.innerHTML = ''; + this.wrapper = this.createCooperationWrapper(); + this.page = this.createHTML(); + window.scrollTo(0, 0); + } + + private createCooperationWrapper(): HTMLDivElement { + this.wrapper = createBaseElement({ + cssClasses: [styles.cooperationWrapper], + tag: 'div', + }); + + return this.wrapper; + } + + private createDescription(description: string): HTMLParagraphElement { + const descriptionElement = createBaseElement({ + cssClasses: [styles.cooperationDescription], + tag: 'p', + }); + descriptionElement.textContent = description; + return descriptionElement; + } + + private createHTML(): HTMLDivElement { + this.page = createBaseElement({ + cssClasses: [styles.cooperationPage], + tag: 'div', + }); + + this.page.append(this.wrapper); + this.parent.append(this.page); + + return this.page; + } + + private createItem(text: string): HTMLLIElement { + const listItem = createBaseElement({ + cssClasses: [styles.cooperationListItem], + innerContent: text, + tag: 'li', + }); + return listItem; + } + + private createItemList(): HTMLUListElement { + const itemList = createBaseElement({ + cssClasses: [styles.cooperationItemList], + tag: 'ul', + }); + return itemList; + } + + private createSubtitle(subtitle: string): HTMLHeadingElement { + const subtitleElement = createBaseElement({ + cssClasses: [styles.cooperationSubtitle], + tag: 'h3', + }); + subtitleElement.textContent = subtitle; + return subtitleElement; + } + + private createTitle(title: string): HTMLHeadingElement { + const titleElement = createBaseElement({ + cssClasses: [styles.cooperationTitle], + tag: 'h2', + }); + titleElement.textContent = title; + return titleElement; + } + + public drawCooperationInfo(data: CooperationData[]): void { + const currentLanguage = getCurrentLanguage(); + data.forEach((item) => { + const section = createBaseElement({ + cssClasses: [styles.cooperationSection], + tag: 'div', + }); + const currentTitle = item[currentLanguage].title; + const currentDescription = item[currentLanguage].description; + const currentSubtitle = item[currentLanguage].subtitle; + const currentItems = item[currentLanguage].items; + if (currentTitle) { + const title = this.createTitle(currentTitle); + section.append(title); + } + + if (currentDescription) { + const title = this.createDescription(currentDescription); + section.append(title); + } + + if (currentSubtitle) { + const title = this.createSubtitle(currentSubtitle); + section.append(title); + } + + if (currentItems) { + const createItemList = this.createItemList(); + currentItems.forEach((item) => { + const listItem = this.createItem(item.text); + createItemList.append(listItem); + }); + section.append(createItemList); + } + + this.wrapper.append(section); + }); + } + + public getHTML(): HTMLDivElement { + return this.page; + } + + public redrawCooperationInfo(data: CooperationData[]): void { + this.wrapper.innerHTML = ''; + this.drawCooperationInfo(data); + } +} + +export default CooperationPageView; diff --git a/src/pages/CooperationPage/view/cooperationPageView.module.scss b/src/pages/CooperationPage/view/cooperationPageView.module.scss new file mode 100644 index 00000000..146f8263 --- /dev/null +++ b/src/pages/CooperationPage/view/cooperationPageView.module.scss @@ -0,0 +1,79 @@ +.cooperationPage { + position: relative; + display: block; + padding: 0 var(--small-offset); + animation: show 0.2s ease-out forwards; +} + +@keyframes show { + 0% { + opacity: 0; + } + + 100% { + display: block; + opacity: 1; + } +} + +.cooperationWrapper { + display: flex; + flex-direction: column; + margin: 0 auto; + max-width: 80%; + gap: var(--extra-small-offset); + + @media (max-width: 768px) { + max-width: 100%; + } +} + +.cooperationSection { + display: flex; + flex-direction: column; + border-radius: var(--medium-br); + padding: var(--small-offset); + background-color: var(--steam-green-1000); + gap: var(--extra-small-offset); +} + +.cooperationTitle { + font: var(--medium-font); + letter-spacing: var(--one); + color: var(--steam-green-800); +} + +.cooperationSubtitle { + font: var(--medium-bold-font); + letter-spacing: var(--one); + color: var(--noble-gray-1000); +} + +.cooperationDescription { + font: var(--regular-font); + line-height: 170%; + letter-spacing: var(--one); + color: var(--noble-gray-800); +} + +.cooperationItemList { + display: flex; + flex-direction: column; + gap: var(--tiny-offset); +} + +.cooperationListItem { + position: relative; + margin-left: var(--extra-small-offset); + font: var(--regular-font); + letter-spacing: var(--one); + color: var(--noble-gray-700); + + &::after { + content: '✔'; + position: absolute; + left: -1.7rem; + top: 0; + padding: 0.15rem 0.3rem; + } +} diff --git a/src/pages/LoginPage/model/LoginPageModel.ts b/src/pages/LoginPage/model/LoginPageModel.ts index a9d23cb6..1a235e41 100644 --- a/src/pages/LoginPage/model/LoginPageModel.ts +++ b/src/pages/LoginPage/model/LoginPageModel.ts @@ -4,7 +4,8 @@ import RouterModel from '@/app/Router/model/RouterModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import observeStore, { selectIsUserLoggedIn } from '@/shared/Store/observer.ts'; -import { PAGE_ID, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS } from '@/shared/constants/pages.ts'; +import { PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY } from '@/shared/constants/links.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import LoginFormModel from '@/widgets/LoginForm/model/LoginFormModel.ts'; @@ -52,7 +53,7 @@ class LoginPageModel implements Page { const registerLink = this.view.getRegisterLink().getHTML(); const registerLinkCopy = registerLink.cloneNode(true); - observeCurrentLanguage(registerLinkCopy, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.REGISTRATION); + observeCurrentLanguage(registerLinkCopy, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.REGISTRATION); registerLink.addEventListener('click', (event) => this.registerLinkHandler(event)); registerLinkCopy.addEventListener('click', (event) => this.registerLinkHandler(event)); diff --git a/src/pages/LoginPage/view/LoginPageView.ts b/src/pages/LoginPage/view/LoginPageView.ts index 9beb1616..0d02d74c 100644 --- a/src/pages/LoginPage/view/LoginPageView.ts +++ b/src/pages/LoginPage/view/LoginPageView.ts @@ -1,15 +1,14 @@ import LinkModel from '@/shared/Link/model/LinkModel.ts'; -import getStore from '@/shared/Store/Store.ts'; +import { PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY } from '@/shared/constants/links.ts'; import { PAGE_ANSWER, - PAGE_ANSWER_KEYS, + PAGE_ANSWER_KEY, PAGE_DESCRIPTION, - PAGE_DESCRIPTION_KEYS, + PAGE_DESCRIPTION_KEY, PAGE_ID, - PAGE_LINK_TEXT, - PAGE_LINK_TEXT_KEYS, } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './loginPageView.module.scss'; @@ -50,10 +49,10 @@ class LoginPageView { private createAuthDescription(): HTMLHeadingElement { this.authDescription = createBaseElement({ cssClasses: [styles.authDescription], - innerContent: PAGE_DESCRIPTION[getStore().getState().currentLanguage].LOGIN, + innerContent: PAGE_DESCRIPTION[getCurrentLanguage()].LOGIN, tag: 'h3', }); - observeCurrentLanguage(this.authDescription, PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEYS.LOGIN); + observeCurrentLanguage(this.authDescription, PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEY.LOGIN); return this.authDescription; } @@ -100,11 +99,11 @@ class LoginPageView { private createLoginSpan(): HTMLSpanElement { this.loginSpan = createBaseElement({ cssClasses: [styles.loginSpan], - innerContent: PAGE_LINK_TEXT[getStore().getState().currentLanguage].LOGIN, + innerContent: PAGE_LINK_TEXT[getCurrentLanguage()].LOGIN, tag: 'span', }); - observeCurrentLanguage(this.loginSpan, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.LOGIN); + observeCurrentLanguage(this.loginSpan, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.LOGIN); return this.loginSpan; } @@ -115,10 +114,10 @@ class LoginPageView { href: PAGE_ID.REGISTRATION_PAGE, }, classes: [styles.registerLink], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].REGISTRATION, + text: PAGE_LINK_TEXT[getCurrentLanguage()].REGISTRATION, }); - observeCurrentLanguage(this.registerLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.REGISTRATION); + observeCurrentLanguage(this.registerLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.REGISTRATION); return this.registerLink; } @@ -126,11 +125,11 @@ class LoginPageView { private createToRegisterPageWrapper(): HTMLSpanElement { this.toRegisterPageWrapper = createBaseElement({ cssClasses: [styles.toRegisterPageWrapper], - innerContent: PAGE_ANSWER[getStore().getState().currentLanguage].LOGIN, + innerContent: PAGE_ANSWER[getCurrentLanguage()].LOGIN, tag: 'span', }); - observeCurrentLanguage(this.toRegisterPageWrapper, PAGE_ANSWER, PAGE_ANSWER_KEYS.LOGIN); + observeCurrentLanguage(this.toRegisterPageWrapper, PAGE_ANSWER, PAGE_ANSWER_KEY.LOGIN); return this.toRegisterPageWrapper; } diff --git a/src/pages/MainPage/model/MainPageModel.ts b/src/pages/MainPage/model/MainPageModel.ts index 303a338d..a5be9484 100644 --- a/src/pages/MainPage/model/MainPageModel.ts +++ b/src/pages/MainPage/model/MainPageModel.ts @@ -1,28 +1,31 @@ import type { Page } from '@/shared/types/page.ts'; -import PostWidgetModel from '@/pages/Blog/PostWidget/model/PostWidgetModel.ts'; +import PromoCodeSliderModel from '@/entities/PromocodeSlider/model/PromoCodeSliderModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; +import BlogWidgetModel from '@/widgets/Blog/model/BlogWidgetModel.ts'; import MainPageView from '../view/MainPageView.ts'; class MainPageModel implements Page { - private blogWidget: PostWidgetModel; + private blogWidget: BlogWidgetModel; private parent: HTMLDivElement; + private promoCodeSlider = new PromoCodeSliderModel(); + private view: MainPageView; constructor(parent: HTMLDivElement) { this.parent = parent; this.view = new MainPageView(this.parent); - this.blogWidget = new PostWidgetModel(this.view.getHTML()); + this.blogWidget = new BlogWidgetModel(this.view.getHTML()); this.init(); } private init(): void { - this.getHTML().append(this.blogWidget.getHTML()); + this.getHTML().append(this.promoCodeSlider.getHTML(), this.blogWidget.getHTML()); getStore().dispatch(setCurrentPage(PAGE_ID.MAIN_PAGE)); } diff --git a/src/pages/NotFoundPage/model/NotFoundPageModel.ts b/src/pages/NotFoundPage/model/NotFoundPageModel.ts index 80951a20..6cc16a18 100644 --- a/src/pages/NotFoundPage/model/NotFoundPageModel.ts +++ b/src/pages/NotFoundPage/model/NotFoundPageModel.ts @@ -3,8 +3,7 @@ import type { Page } from '@/shared/types/page.ts'; import RouterModel from '@/app/Router/model/RouterModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; -import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; -import { PAGE_DESCRIPTION, PAGE_ID } from '@/shared/constants/pages.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import NotFoundPageView from '../view/NotFoundPageView.ts'; @@ -16,24 +15,9 @@ class NotFoundPageModel implements Page { this.init(); } - private createPageDescription(): string { - const { currentLanguage } = getStore().getState(); - const textDescription = PAGE_DESCRIPTION[currentLanguage][404]; - return textDescription; - } - private init(): boolean { getStore().dispatch(setCurrentPage(PAGE_ID.NOT_FOUND_PAGE)); this.toMainButtonHandler(); - this.observeStoreLanguage(); - this.view.setPageDescription(this.createPageDescription()); - return true; - } - - private observeStoreLanguage(): boolean { - observeStore(selectCurrentLanguage, () => { - this.view.setPageDescription(this.createPageDescription()); - }); return true; } diff --git a/src/pages/NotFoundPage/view/NotFoundPageView.ts b/src/pages/NotFoundPage/view/NotFoundPageView.ts index b62f09ec..fffbea6c 100644 --- a/src/pages/NotFoundPage/view/NotFoundPageView.ts +++ b/src/pages/NotFoundPage/view/NotFoundPageView.ts @@ -1,10 +1,10 @@ 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 { PAGE_DESCRIPTION_KEYS } from '@/shared/constants/pages.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEY } from '@/shared/constants/buttons.ts'; +import { PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEY } from '@/shared/constants/pages.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './notFoundPageView.module.scss'; @@ -31,6 +31,8 @@ class NotFoundPageView { this.toMainButton = this.createToMainButton(); this.page = this.createHTML(); window.scrollTo(0, 0); + + this.observeStoreChanges(); } private createHTML(): HTMLDivElement { @@ -46,37 +48,39 @@ class NotFoundPageView { } private createPageDescription(): HTMLParagraphElement { - this.description = createBaseElement({ + return createBaseElement({ cssClasses: [styles.pageDescription], + innerContent: PAGE_DESCRIPTION[getCurrentLanguage()][404], tag: 'p', }); - return this.description; } private createPageLogo(): HTMLDivElement { this.logo = createBaseElement({ cssClasses: [styles.pageLogo], tag: 'div' }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.LOGO)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.NOT_FOUND)); this.logo.append(svg); return this.logo; } private createPageTitle(): HTMLHeadingElement { - this.title = createBaseElement({ + return createBaseElement({ cssClasses: [styles.pageTitle], - innerContent: PAGE_DESCRIPTION_KEYS[404], + innerContent: PAGE_DESCRIPTION_KEY[404], tag: 'h1', }); - return this.title; } private createToMainButton(): ButtonModel { - this.toMainButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.toMainButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].BACK_TO_MAIN, + text: BUTTON_TEXT[getCurrentLanguage()].BACK_TO_MAIN, }); - observeCurrentLanguage(this.toMainButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.BACK_TO_MAIN); - return this.toMainButton; + } + + private observeStoreChanges(): void { + observeCurrentLanguage(this.description, PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEY[404]); + observeCurrentLanguage(this.toMainButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.BACK_TO_MAIN); } public getHTML(): HTMLDivElement { @@ -86,11 +90,6 @@ class NotFoundPageView { public getToMainButton(): ButtonModel { return this.toMainButton; } - - public setPageDescription(text: string): HTMLParagraphElement { - this.description.innerText = text; - return this.description; - } } export default NotFoundPageView; diff --git a/src/pages/NotFoundPage/view/notFoundPageView.module.scss b/src/pages/NotFoundPage/view/notFoundPageView.module.scss index 689641d7..eccd1c2c 100644 --- a/src/pages/NotFoundPage/view/notFoundPageView.module.scss +++ b/src/pages/NotFoundPage/view/notFoundPageView.module.scss @@ -1,3 +1,5 @@ +@import 'src/app/styles/mixins'; + .notFoundPage { position: relative; display: flex; @@ -30,21 +32,22 @@ margin: 0 auto; svg { - width: var(--large-offset); - height: var(--large-offset); - fill: var(--steam-green-800); + width: calc(var(--extra-large-offset) * 2); + height: calc(var(--extra-large-offset) * 2); } } .pageTitle { display: flex; - margin: 0 auto; + align-self: center; font: var(--extra-medium-font); letter-spacing: var(--one); color: var(--steam-green-800); } .pageDescription { + align-self: center; + padding: var(--extra-small-offset); font: var(--regular-font) '*' 1.5; letter-spacing: var(--one); text-align: center; @@ -52,25 +55,7 @@ } .toMainButton { - align-self: center; - border-radius: var(--small-br); - padding: calc(var(--extra-small-offset) / 2) var(--small-offset); - width: max-content; - font: var(--bold-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); - } + @include green-btn; - @media (hover: hover) { - &:hover { - background-color: var(--steam-green-700); - } - } + align-self: center; } diff --git a/src/pages/ProductPage/model/ProductPageModel.ts b/src/pages/ProductPage/model/ProductPageModel.ts index 6ba758e5..1aa22e3c 100644 --- a/src/pages/ProductPage/model/ProductPageModel.ts +++ b/src/pages/ProductPage/model/ProductPageModel.ts @@ -1,99 +1,121 @@ -import type { BreadCrumbLink } from '@/shared/types/link.ts'; -import type { Page, PageParams } from '@/shared/types/page.ts'; +import type { BreadcrumbLink } from '@/shared/types/link.ts'; +import type { Page } 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 LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; -import { PAGE_ID } from '@/shared/constants/pages.ts'; +import { PAGE_ID, PAGE_TITLE } from '@/shared/constants/pages.ts'; import { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; -import { buildPathName } from '@/shared/utils/buildPathname.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import * as buildPath from '@/shared/utils/buildPathname.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; import ProductInfoModel from '@/widgets/ProductInfo/model/ProductInfoModel.ts'; import ProductPageView from '../view/ProductPageView.ts'; class ProductPageModel implements Page { + private breadcrumbs = new BreadcrumbsModel(); + + private currentProduct: Product | null = null; + private view: ProductPageView; - constructor(parent: HTMLDivElement, params: PageParams) { + constructor(parent: HTMLDivElement) { this.view = new ProductPageView(parent); - this.init(params); + this.init(); } - private createNavigationLinks(currentProduct: Product): BreadCrumbLink[] { - const category = currentProduct.category[0].parent; - const subcategory = currentProduct.category[0]; - const links = [ - { - link: buildPathName(PAGE_ID.MAIN_PAGE, null, null), - name: PAGE_ID.MAIN_PAGE.slice(0, -1), - }, - { - link: buildPathName(PAGE_ID.CATALOG_PAGE, null, null), - name: PAGE_ID.CATALOG_PAGE.slice(0, -1), - }, + private createBreadcrumbLinks(currentProduct: Product | null): BreadcrumbLink[] { + const currentLanguage = getCurrentLanguage(); + const isRuLanguage = currentLanguage === LANGUAGE_CHOICE.RU; + const category = currentProduct?.category[0].parent; + const subcategory = currentProduct?.category[0]; + + const links: BreadcrumbLink[] = [ + { link: PAGE_ID.MAIN_PAGE, name: PAGE_TITLE[currentLanguage].main }, + { link: PAGE_ID.CATALOG_PAGE, name: PAGE_TITLE[currentLanguage].catalog }, ]; if (category) { links.push({ - link: buildPathName(PAGE_ID.CATALOG_PAGE, null, { category: [category.id] }), - - name: category.name[0].value, + link: buildPath.catalogPathWithQuery({ category: [category.key] }), + name: category.name[Number(isRuLanguage)].value, }); } if (subcategory && category) { links.push({ - link: buildPathName(PAGE_ID.CATALOG_PAGE, null, { category: [category.id], subcategory: [subcategory.id] }), - name: subcategory.name[0].value, + link: buildPath.catalogPathWithQuery({ subcategory: [subcategory.key] }), + name: subcategory.name[Number(isRuLanguage)].value, }); } return links; } - private init(params: PageParams): void { + private init(): void { const currentSize = RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SIZE); - + const loader = new LoaderModel(LOADER_SIZE.EXTRA_LARGE).getHTML(); + this.view.getHTML().append(loader); getProductModel() - .getProductByKey(params.product?.id ?? '') + .getProductByKey(RouterModel.getPageID() ?? '') .then((productData) => { if (productData) { - const productInfo = new ProductInfoModel({ - currentSize: currentSize ?? productData.variant[0].size, - ...productData, - }); - this.initBreadcrumbs(productData); - this.getHTML().append(productInfo.getHTML(), this.view.getFullDescriptionWrapper()); - this.view.setFullDescription( - productData.fullDescription[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, - ); - this.observeLanguage(productData.fullDescription); + this.updatePageContent(productData, currentSize); } }) - .catch(showErrorMessage); + .catch(showErrorMessage) + .finally(() => loader.remove()); getStore().dispatch(setCurrentPage(PAGE_ID.PRODUCT_PAGE)); } - private initBreadcrumbs(currentProduct: Product): void { - const links = this.createNavigationLinks(currentProduct); - this.getHTML().append(new BreadcrumbsModel(links).getHTML()); + private initBreadcrumbs(): void { + this.breadcrumbs.clearBreadcrumbLinks(); + this.breadcrumbs.addBreadcrumbLinks(this.createBreadcrumbLinks(this.currentProduct)); + this.view.getHTML().prepend(this.breadcrumbs.getHTML()); } private observeLanguage(fullDescription: localization[]): void { observeStore(selectCurrentLanguage, () => { - this.view.setFullDescription( - fullDescription[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, - ); + this.view.setFullDescription(fullDescription[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value); + this.initBreadcrumbs(); }); } + private updatePageContent(productData: Product, currentSize: null | string): void { + this.currentProduct = productData; + this.initBreadcrumbs(); + + const productPath = buildPath.productPathWithIDAndQuery(RouterModel.getPageID(), { + size: [currentSize], + slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE) || '0'], + }); + + const savedPath = + RouterModel.getSavedPath() === productPath ? RouterModel.getCurrentPage() : RouterModel.getSavedPath(); + + const productInfo = new ProductInfoModel( + { + currentSize: currentSize ?? productData.variant[0].size, + ...productData, + }, + savedPath, + ); + this.getHTML().append(productInfo.getHTML(), this.view.getFullDescriptionWrapper()); + this.view.setFullDescription( + this.currentProduct.fullDescription[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, + ); + this.observeLanguage(this.currentProduct.fullDescription); + } + public getHTML(): HTMLDivElement { return this.view.getHTML(); } diff --git a/src/pages/ProductPage/view/ProductPageView.ts b/src/pages/ProductPage/view/ProductPageView.ts index b75c70c3..49b312b1 100644 --- a/src/pages/ProductPage/view/ProductPageView.ts +++ b/src/pages/ProductPage/view/ProductPageView.ts @@ -1,7 +1,7 @@ -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { PRODUCT_INFO_TEXT } from '@/shared/constants/product.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './productPageView.module.scss'; @@ -34,12 +34,12 @@ class ProductPageView { private createFullDescriptionWrapper(): HTMLDivElement { this.fullDescriptionWrapper = createBaseElement({ cssClasses: [styles.fullDescriptionWrapper], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].FULL_DESCRIPTION, + innerContent: PRODUCT_INFO_TEXT[getCurrentLanguage()].FULL_DESCRIPTION, tag: 'div', }); observeStore(selectCurrentLanguage, () => { - const text = PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].FULL_DESCRIPTION; + const text = PRODUCT_INFO_TEXT[getCurrentLanguage()].FULL_DESCRIPTION; const textNode = [...this.fullDescriptionWrapper.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); if (textNode) { textNode.textContent = text; @@ -74,4 +74,5 @@ class ProductPageView { return this.fullDescription; } } + export default ProductPageView; diff --git a/src/pages/ProductPage/view/productPageView.module.scss b/src/pages/ProductPage/view/productPageView.module.scss index 05e281d9..c038ab1f 100644 --- a/src/pages/ProductPage/view/productPageView.module.scss +++ b/src/pages/ProductPage/view/productPageView.module.scss @@ -2,7 +2,10 @@ position: relative; display: flex; flex-direction: column; + align-items: center; + justify-content: center; padding: 0 var(--small-offset); + min-height: 40rem; animation: show 0.2s ease-out forwards; } @@ -19,11 +22,18 @@ .fullDescriptionWrapper { font: var(--regular-font); + line-height: 170%; letter-spacing: var(--one); color: var(--steam-green-400); + + @media (max-width: 768px) { + margin: 0 auto; + width: 95%; + } } .fullDescription { font: var(--regular-font); + line-height: 170%; color: var(--noble-gray-800); } diff --git a/src/pages/RegistrationPage/model/RegistrationPageModel.ts b/src/pages/RegistrationPage/model/RegistrationPageModel.ts index db00a3c5..01909812 100644 --- a/src/pages/RegistrationPage/model/RegistrationPageModel.ts +++ b/src/pages/RegistrationPage/model/RegistrationPageModel.ts @@ -4,7 +4,8 @@ import RouterModel from '@/app/Router/model/RouterModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setCurrentPage } from '@/shared/Store/actions.ts'; import observeStore, { selectIsUserLoggedIn } from '@/shared/Store/observer.ts'; -import { PAGE_ID, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS } from '@/shared/constants/pages.ts'; +import { PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY } from '@/shared/constants/links.ts'; +import { PAGE_ID } from '@/shared/constants/pages.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import RegisterFormModel from '@/widgets/RegistrationForm/model/RegistrationFormModel.ts'; @@ -38,7 +39,7 @@ class RegistrationPageModel implements Page { const loginLink = this.view.getLoginLink().getHTML(); const loginLinkCopy = loginLink.cloneNode(true); - observeCurrentLanguage(loginLinkCopy, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.LOGIN); + observeCurrentLanguage(loginLinkCopy, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.LOGIN); loginLink.addEventListener('click', (event) => this.loginLinkHandler(event)); loginLinkCopy.addEventListener('click', (event) => this.loginLinkHandler(event)); diff --git a/src/pages/RegistrationPage/view/RegistrationPageView.ts b/src/pages/RegistrationPage/view/RegistrationPageView.ts index fec17b5f..ab1e2287 100644 --- a/src/pages/RegistrationPage/view/RegistrationPageView.ts +++ b/src/pages/RegistrationPage/view/RegistrationPageView.ts @@ -1,15 +1,14 @@ import LinkModel from '@/shared/Link/model/LinkModel.ts'; -import getStore from '@/shared/Store/Store.ts'; +import { PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY } from '@/shared/constants/links.ts'; import { PAGE_ANSWER, - PAGE_ANSWER_KEYS, + PAGE_ANSWER_KEY, PAGE_DESCRIPTION, - PAGE_DESCRIPTION_KEYS, + PAGE_DESCRIPTION_KEY, PAGE_ID, - PAGE_LINK_TEXT, - PAGE_LINK_TEXT_KEYS, } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './registrationPageView.module.scss'; @@ -50,11 +49,11 @@ class RegistrationPageView { private createAuthDescription(): HTMLHeadingElement { this.authDescription = createBaseElement({ cssClasses: [styles.authDescription], - innerContent: PAGE_DESCRIPTION[getStore().getState().currentLanguage].REGISTRATION, + innerContent: PAGE_DESCRIPTION[getCurrentLanguage()].REGISTRATION, tag: 'h3', }); - observeCurrentLanguage(this.authDescription, PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEYS.REGISTRATION); + observeCurrentLanguage(this.authDescription, PAGE_DESCRIPTION, PAGE_DESCRIPTION_KEY.REGISTRATION); return this.authDescription; } @@ -105,10 +104,10 @@ class RegistrationPageView { href: PAGE_ID.LOGIN_PAGE, }, classes: [styles.loginLink], - text: PAGE_LINK_TEXT[getStore().getState().currentLanguage].LOGIN, + text: PAGE_LINK_TEXT[getCurrentLanguage()].LOGIN, }); - observeCurrentLanguage(this.loginLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.LOGIN); + observeCurrentLanguage(this.loginLink.getHTML(), PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.LOGIN); return this.loginLink; } @@ -116,11 +115,11 @@ class RegistrationPageView { private createRegisterSpan(): HTMLSpanElement { this.registerSpan = createBaseElement({ cssClasses: [styles.registerSpan], - innerContent: PAGE_LINK_TEXT[getStore().getState().currentLanguage].REGISTRATION, + innerContent: PAGE_LINK_TEXT[getCurrentLanguage()].REGISTRATION, tag: 'span', }); - observeCurrentLanguage(this.registerSpan, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEYS.REGISTRATION); + observeCurrentLanguage(this.registerSpan, PAGE_LINK_TEXT, PAGE_LINK_TEXT_KEY.REGISTRATION); return this.registerSpan; } @@ -128,11 +127,11 @@ class RegistrationPageView { private createToLoginPageWrapper(): HTMLSpanElement { this.toLoginPageWrapper = createBaseElement({ cssClasses: [styles.toLoginPageWrapper], - innerContent: PAGE_ANSWER[getStore().getState().currentLanguage].REGISTRATION, + innerContent: PAGE_ANSWER[getCurrentLanguage()].REGISTRATION, tag: 'span', }); - observeCurrentLanguage(this.toLoginPageWrapper, PAGE_ANSWER, PAGE_ANSWER_KEYS.REGISTRATION); + observeCurrentLanguage(this.toLoginPageWrapper, PAGE_ANSWER, PAGE_ANSWER_KEY.REGISTRATION); return this.toLoginPageWrapper; } diff --git a/src/pages/UserAddressesPage/model/UserAddressesPageModel.ts b/src/pages/UserAddressesPage/model/UserAddressesPageModel.ts new file mode 100644 index 00000000..cef7a57c --- /dev/null +++ b/src/pages/UserAddressesPage/model/UserAddressesPageModel.ts @@ -0,0 +1,54 @@ +import type { Page } from '@/shared/types/page.ts'; + +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 { setCurrentPage } from '@/shared/Store/actions.ts'; +import { SERVER_MESSAGE_KEY } 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 UserAddressesPageView from '../view/UserAddressesPageView.ts'; + +class UserAddressesPageModel implements Page { + private addresses: UserAddressesModel | null = null; + + private view: UserAddressesPageView | null = null; + + constructor(parent: HTMLDivElement) { + const { isUserLoggedIn } = getStore().getState(); + if (!isUserLoggedIn) { + RouterModel.getInstance().navigateTo(PAGE_ID.LOGIN_PAGE); + showErrorMessage(SERVER_MESSAGE_KEY.NEED_LOGIN); + } else { + this.view = new UserAddressesPageView(parent); + this.init().catch(showErrorMessage); + } + } + + private async init(): Promise { + try { + const user = await getCustomerModel().getCurrentUser(); + if (user) { + this.addresses = new UserAddressesModel(user); + this.view?.getHTML().append(this.addresses.getHTML()); + getStore().dispatch(setCurrentPage(PAGE_ID.USER_ADDRESSES_PAGE)); + } + } catch (error) { + showErrorMessage(SERVER_MESSAGE_KEY.NEED_LOGIN); + } + } + + public getHTML(): HTMLDivElement { + if (this.view) { + return this.view.getHTML(); + } + return createBaseElement({ + tag: 'div', + }); + } +} + +export default UserAddressesPageModel; diff --git a/src/pages/UserAddressesPage/view/UserAddressesPageView.ts b/src/pages/UserAddressesPage/view/UserAddressesPageView.ts new file mode 100644 index 00000000..dd5f03b5 --- /dev/null +++ b/src/pages/UserAddressesPage/view/UserAddressesPageView.ts @@ -0,0 +1,33 @@ +import clearOutElement from '@/shared/utils/clearOutElement.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import styles from './userAddressesPageView.module.scss'; + +class UserAddressesPageView { + private page: HTMLDivElement; + + private parent: HTMLDivElement; + + constructor(parent: HTMLDivElement) { + this.parent = parent; + clearOutElement(this.parent); + this.page = this.createHTML(); + window.scrollTo(0, 0); + } + + private createHTML(): HTMLDivElement { + this.page = createBaseElement({ + cssClasses: [styles.userAddressesPage], + tag: 'div', + }); + + this.parent.append(this.page); + + return this.page; + } + + public getHTML(): HTMLDivElement { + return this.page; + } +} +export default UserAddressesPageView; diff --git a/src/pages/UserAddressesPage/view/userAddressesPageView.module.scss b/src/pages/UserAddressesPage/view/userAddressesPageView.module.scss new file mode 100644 index 00000000..595a1dfc --- /dev/null +++ b/src/pages/UserAddressesPage/view/userAddressesPageView.module.scss @@ -0,0 +1,26 @@ +@import 'src/app/styles/mixins'; + +.userAddressesPage { + position: relative; + display: block; + margin: 0 auto; + width: 65%; + animation: show 0.2s ease-out forwards; + + @media (max-width: 768px) { + margin: 0; + padding: var(--extra-small-offset); + width: 100%; + } +} + +@keyframes show { + 0% { + opacity: 0; + } + + 100% { + display: block; + opacity: 1; + } +} diff --git a/src/pages/UserProfilePage/model/UserProfilePageModel.ts b/src/pages/UserProfilePage/model/UserProfilePageModel.ts index 3050d883..a6e09ee9 100644 --- a/src/pages/UserProfilePage/model/UserProfilePageModel.ts +++ b/src/pages/UserProfilePage/model/UserProfilePageModel.ts @@ -3,19 +3,16 @@ import type { Page } from '@/shared/types/page.ts'; 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 { setCurrentPage } from '@/shared/Store/actions.ts'; +import { SERVER_MESSAGE_KEY } 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 { showErrorMessage } from '@/shared/utils/userMessage.ts'; import UserInfoModel from '@/widgets/UserInfo/model/UserInfoModel.ts'; import UserProfilePageView from '../view/UserProfilePageView.ts'; class UserProfilePageModel implements Page { - private addresses: UserAddressesModel | null = null; - private userInfo: UserInfoModel | null = null; private view: UserProfilePageView | null = null; @@ -24,64 +21,26 @@ class UserProfilePageModel implements Page { const { isUserLoggedIn } = getStore().getState(); if (!isUserLoggedIn) { RouterModel.getInstance().navigateTo(PAGE_ID.LOGIN_PAGE); - showErrorMessage(SERVER_MESSAGE_KEYS.NEED_LOGIN); + showErrorMessage(SERVER_MESSAGE_KEY.NEED_LOGIN); } else { this.view = new UserProfilePageView(parent); this.init().catch(showErrorMessage); } } - private addressesLinkHandler(): void { - this.userInfo?.hide(); - this.addresses?.show(); - } - 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.setAccountLogoutButtonHandler(); + this.view?.getUserProfileWrapper().append(this.userInfo.getHTML()); getStore().dispatch(setCurrentPage(PAGE_ID.USER_PROFILE_PAGE)); } } catch (error) { - showErrorMessage(SERVER_MESSAGE_KEYS.NEED_LOGIN); + showErrorMessage(SERVER_MESSAGE_KEY.NEED_LOGIN); } } - private logoutHandler(): void { - localStorage.clear(); - getStore().dispatch(setAuthToken(null)); - getStore().dispatch(switchIsUserLoggedIn(false)); - getCustomerModel().logout().catch(showErrorMessage); - RouterModel.getInstance().navigateTo(PAGE_ID.LOGIN_PAGE); - } - - private personalInfoLinkHandler(): void { - this.addresses?.hide(); - this.userInfo?.show(); - } - - private setAccountLogoutButtonHandler(): void { - 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()); - } - - private setPersonalInfoLinkHandler(): void { - const personalInfoLink = this.view?.getPersonalInfoLink(); - personalInfoLink?.getHTML().addEventListener('click', () => this.personalInfoLinkHandler()); - } - public getHTML(): HTMLDivElement { if (this.view) { return this.view.getHTML(); diff --git a/src/pages/UserProfilePage/view/UserProfilePageView.ts b/src/pages/UserProfilePage/view/UserProfilePageView.ts index 10007139..12567eae 100644 --- a/src/pages/UserProfilePage/view/UserProfilePageView.ts +++ b/src/pages/UserProfilePage/view/UserProfilePageView.ts @@ -1,90 +1,23 @@ -import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; -import LinkModel from '@/shared/Link/model/LinkModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; -import { USER_PROFILE_MENU_LINK } from '@/shared/constants/links.ts'; -import { USER_INFO_MENU_LINK, USER_INFO_MENU_LINK_KEYS } from '@/shared/constants/pages.ts'; import clearOutElement from '@/shared/utils/clearOutElement.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; -import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './userProfilePageView.module.scss'; class UserProfilePageView { - private accountLogoutButton: ButtonModel; - - private accountMenu: HTMLUListElement; - - private addressesLink: LinkModel; - - private links: LinkModel[] = []; - - private ordersLink: LinkModel; - private page: HTMLDivElement; private parent: HTMLDivElement; - private personalInfoLink: LinkModel; - - private supportLink: LinkModel; - private userProfileWrapper: HTMLDivElement; - private wishListLink: LinkModel; - constructor(parent: HTMLDivElement) { this.parent = parent; clearOutElement(this.parent); - - this.accountLogoutButton = this.createLogoutButton(); - this.personalInfoLink = this.createPersonalInfoLink(); - this.addressesLink = this.createAddressesLink(); - this.ordersLink = this.createOrdersLink(); - this.supportLink = this.createSupportLink(); - this.wishListLink = this.createWishListLink(); - this.accountMenu = this.createAccountMenu(); - - this.setLinksHandlers(); this.userProfileWrapper = this.createUserProfileWrapper(); this.page = this.createHTML(); window.scrollTo(0, 0); } - private createAccountMenu(): HTMLUListElement { - this.accountMenu = createBaseElement({ - cssClasses: [styles.accountMenu], - tag: 'ul', - }); - - this.links.forEach((link) => { - const li = createBaseElement({ - cssClasses: [styles.accountMenuItem], - tag: 'li', - }); - li.append(link.getHTML()); - - this.accountMenu.append(li); - }); - this.accountMenu.append(this.accountLogoutButton.getHTML()); - return this.accountMenu; - } - - private createAddressesLink(): LinkModel { - this.addressesLink = new LinkModel({ - attrs: { - href: USER_PROFILE_MENU_LINK.ADDRESSES, - }, - classes: [styles.link], - text: USER_INFO_MENU_LINK[getStore().getState().currentLanguage].ADDRESSES, - }); - this.links.push(this.addressesLink); - - observeCurrentLanguage(this.addressesLink.getHTML(), USER_INFO_MENU_LINK, USER_INFO_MENU_LINK_KEYS.ADDRESSES); - - return this.addressesLink; - } - private createHTML(): HTMLDivElement { this.page = createBaseElement({ cssClasses: [styles.userProfilePage], @@ -97,140 +30,19 @@ class UserProfilePageView { return this.page; } - private createLogoutButton(): ButtonModel { - this.accountLogoutButton = new ButtonModel({ - classes: [styles.logoutButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].LOG_OUT, - }); - - observeCurrentLanguage(this.accountLogoutButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.LOG_OUT); - - return this.accountLogoutButton; - } - - private createOrdersLink(): LinkModel { - this.ordersLink = new LinkModel({ - attrs: { - href: USER_PROFILE_MENU_LINK.ORDERS, - }, - classes: [styles.link], - text: USER_INFO_MENU_LINK[getStore().getState().currentLanguage].ORDERS, - }); - this.links.push(this.ordersLink); - - observeCurrentLanguage(this.ordersLink.getHTML(), USER_INFO_MENU_LINK, USER_INFO_MENU_LINK_KEYS.ORDERS); - - return this.ordersLink; - } - - private createPersonalInfoLink(): LinkModel { - this.personalInfoLink = new LinkModel({ - attrs: { - href: USER_PROFILE_MENU_LINK.PERSONAL_INFO, - }, - classes: [styles.link], - text: USER_INFO_MENU_LINK[getStore().getState().currentLanguage].PERSONAL_INFO, - }); - this.links.push(this.personalInfoLink); - - observeCurrentLanguage( - this.personalInfoLink.getHTML(), - USER_INFO_MENU_LINK, - USER_INFO_MENU_LINK_KEYS.PERSONAL_INFO, - ); - this.personalInfoLink.getHTML().classList.add(styles.active); - this.personalInfoLink.setDisabled(); - return this.personalInfoLink; - } - - private createSupportLink(): LinkModel { - this.supportLink = new LinkModel({ - attrs: { - href: USER_PROFILE_MENU_LINK.SUPPORT, - }, - classes: [styles.link], - text: USER_INFO_MENU_LINK[getStore().getState().currentLanguage].SUPPORT, - }); - this.links.push(this.supportLink); - - observeCurrentLanguage(this.supportLink.getHTML(), USER_INFO_MENU_LINK, USER_INFO_MENU_LINK_KEYS.SUPPORT); - - return this.supportLink; - } - private createUserProfileWrapper(): HTMLDivElement { this.userProfileWrapper = createBaseElement({ cssClasses: [styles.userProfileWrapper], tag: 'div', }); - this.userProfileWrapper.append(this.accountMenu); return this.userProfileWrapper; } - private createWishListLink(): LinkModel { - this.wishListLink = new LinkModel({ - attrs: { - href: USER_PROFILE_MENU_LINK.WISHLIST, - }, - classes: [styles.link], - text: USER_INFO_MENU_LINK[getStore().getState().currentLanguage].WISHLIST, - }); - this.links.push(this.wishListLink); - - observeCurrentLanguage(this.wishListLink.getHTML(), USER_INFO_MENU_LINK, USER_INFO_MENU_LINK_KEYS.WISHLIST); - - return this.wishListLink; - } - - private setLinksHandlers(): boolean { - this.links.forEach((link) => { - link.getHTML().addEventListener('click', (event) => { - event.preventDefault(); - // TBD replace route with route - const route = link.getHTML().attributes.getNamedItem('href')?.value; - if (route) { - this.switchActiveLink(route); - } - }); - }); - return true; - } - - private switchActiveLink(route: string): void { - this.links.forEach((link) => { - link.getHTML().classList.remove(styles.active); - link.setEnabled(); - }); - - const currentLink = this.links.find((link) => link.getHTML().attributes.getNamedItem('href')?.value === route); - - if (currentLink) { - currentLink.getHTML().classList.add(styles.active); - currentLink.setDisabled(); - } - } - - public getAccountLogoutButton(): ButtonModel { - return this.accountLogoutButton; - } - - public getAddressesLink(): LinkModel { - return this.addressesLink; - } - public getHTML(): HTMLDivElement { return this.page; } - public getLinks(): LinkModel[] { - return this.links; - } - - public getPersonalInfoLink(): LinkModel { - return this.personalInfoLink; - } - public getUserProfileWrapper(): HTMLDivElement { return this.userProfileWrapper; } diff --git a/src/pages/UserProfilePage/view/userProfilePageView.module.scss b/src/pages/UserProfilePage/view/userProfilePageView.module.scss index 906ac94b..bd327694 100644 --- a/src/pages/UserProfilePage/view/userProfilePageView.module.scss +++ b/src/pages/UserProfilePage/view/userProfilePageView.module.scss @@ -16,71 +16,6 @@ } } -.addressesWrapper { - position: relative; - display: grid; - align-items: center; - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(6, auto); - margin: 0 var(--small-offset); - padding: var(--small-offset); - min-width: 40%; - max-width: fit-content; - box-shadow: var(--mellow-shadow-100); - font: var(--extra-regular-font); - letter-spacing: var(--one); - color: var(--noble-gray-900); - background-color: var(--noble-gray-tr-800); - gap: var(--small-offset); -} - -.address { - grid-column: 2 span; - max-width: 100%; - word-break: break-all; -} - -.accountMenu { - display: flex; - flex-flow: column wrap; - justify-content: center; - padding: var(--small-offset); - width: 25%; - height: fit-content; - background-color: var(--white); - - @media (max-width: 768px) { - margin-bottom: var(--small-offset); - width: 100%; - } -} - -.accountMenuItem { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: fit-content; -} - -.logoutButton { - @include green-btn; - - margin: var(--small-offset) auto; - padding: calc(var(--small-offset) / 3) var(--small-offset); -} - -.link { - @include link; - - align-self: center; - height: calc(var(--large-offset) * 0.8); -} - -.active { - @include active; -} - @keyframes show { 0% { opacity: 0; diff --git a/src/pages/WishlistPage/model/WishlistPageModel.ts b/src/pages/WishlistPage/model/WishlistPageModel.ts new file mode 100644 index 00000000..8807c3f5 --- /dev/null +++ b/src/pages/WishlistPage/model/WishlistPageModel.ts @@ -0,0 +1,69 @@ +import type { BreadcrumbLink } from '@/shared/types/link.ts'; +import type { Page } from '@/shared/types/page.ts'; + +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import BreadcrumbsModel from '@/features/Breadcrumbs/model/BreadcrumbsModel.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; +import getStore from '@/shared/Store/Store.ts'; +import { setCurrentPage } from '@/shared/Store/actions.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { PAGE_ID, PAGE_TITLE } from '@/shared/constants/pages.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; + +import WishlistPageView from '../view/WishlistPageView.ts'; + +class WishlistPageModel implements Page { + private view: WishlistPageView; + + constructor(parent: HTMLDivElement) { + this.view = new WishlistPageView(parent); + this.init(); + } + + private createBreadcrumbLinksData(): BreadcrumbLink[] { + const currentLanguage = getCurrentLanguage(); + return [ + { link: PAGE_ID.MAIN_PAGE, name: PAGE_TITLE[currentLanguage].main }, + { link: PAGE_ID.CATALOG_PAGE, name: PAGE_TITLE[currentLanguage].catalog }, + { link: PAGE_ID.WISHLIST_PAGE, name: PAGE_TITLE[currentLanguage].wishlist }, + ]; + } + + private init(): void { + this.observeLanguage(); + this.initBreadcrumbs(); + modal.hide(); + getStore().dispatch(setCurrentPage(PAGE_ID.WISHLIST_PAGE)); + EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.CHANGE_WISHLIST_BUTTON, (key) => { + if (RouterModel.getCurrentPage() === PAGE_ID.WISHLIST_PAGE) { + this.view.removeProductCard(String(key)); + modal.hide(() => RouterModel.getInstance().navigateTo(PAGE_ID.WISHLIST_PAGE)); + } + }); + } + + private initBreadcrumbs(): void { + const breadcrumbsContainer = this.view.getBreadcrumbsContainer(); + const breadcrumbs = new BreadcrumbsModel(); + breadcrumbs.addBreadcrumbLinks(this.createBreadcrumbLinksData()); + + while (breadcrumbsContainer.firstChild) { + breadcrumbsContainer.removeChild(breadcrumbsContainer.firstChild); + } + breadcrumbsContainer.appendChild(breadcrumbs.getHTML()); + } + + private observeLanguage(): void { + observeStore(selectCurrentLanguage, () => { + this.initBreadcrumbs(); + }); + } + + public getHTML(): HTMLDivElement { + return this.view.getHTML(); + } +} + +export default WishlistPageModel; diff --git a/src/pages/WishlistPage/view/WishlistPageView.ts b/src/pages/WishlistPage/view/WishlistPageView.ts new file mode 100644 index 00000000..588c7172 --- /dev/null +++ b/src/pages/WishlistPage/view/WishlistPageView.ts @@ -0,0 +1,151 @@ +import type { OptionsRequest, ProductWithCount } from '@/shared/API/types/type.ts'; +import type { Cart } from '@/shared/types/cart.ts'; +import type { ShoppingList } from '@/shared/types/shopping-list'; + +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import ProductCardModel from '@/entities/ProductCard/model/ProductCardModel.ts'; +import getCartModel from '@/shared/API/cart/model/CartModel.ts'; +import getProductModel from '@/shared/API/product/model/ProductModel.ts'; +import FilterProduct from '@/shared/API/product/utils/filter.ts'; +import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; +import { FilterFields } from '@/shared/API/types/type.ts'; +import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; +import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; +import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; +import MEDIATOR_EVENT from '@/shared/constants/events.ts'; +import { EMPTY_PRODUCT } from '@/shared/constants/product.ts'; +import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; +import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; + +import styles from './wishlistPageView.module.scss'; + +class WishlistPageView { + private breadcrumbsContainer: HTMLDivElement; + + private page: HTMLDivElement; + + private parent: HTMLDivElement; + + private productCards: ProductCardModel[] = []; + + private wishlist: HTMLUListElement; + + constructor(parent: HTMLDivElement) { + this.parent = parent; + this.parent.innerHTML = ''; + this.breadcrumbsContainer = this.createBreadcrumbsContainer(); + this.wishlist = this.createWishlist(); + this.page = this.createHTML(); + window.scrollTo(0, 0); + } + + private createBreadcrumbsContainer(): HTMLDivElement { + this.breadcrumbsContainer = createBaseElement({ + cssClasses: [styles.breadcrumbsContainer], + tag: 'div', + }); + return this.breadcrumbsContainer; + } + + private createHTML(): HTMLDivElement { + this.page = createBaseElement({ + cssClasses: [styles.wishlistPage], + tag: 'div', + }); + + this.page.prepend(this.breadcrumbsContainer); + this.page.append(this.wishlist); + this.parent.append(this.page); + + return this.page; + } + + private createWishlist(): HTMLUListElement { + this.wishlist = createBaseElement({ + cssClasses: [styles.wishlist], + tag: 'ul', + }); + + this.drawWishlist().then(this.openProductInfo.bind(this)).catch(showErrorMessage); + EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.REDRAW_WISHLIST, this.drawWishlist.bind(this)); + observeStore(selectCurrentLanguage, () => { + if (this.wishlist.classList.contains(styles.emptyList)) { + this.wishlist.textContent = EMPTY_PRODUCT[getCurrentLanguage()].EMPTY; + } + }); + + return this.wishlist; + } + + private drawWishlistItems(shoppingList: ShoppingList, cart: Cart, products: ProductWithCount): void { + shoppingList.products.forEach((product) => { + const currentProduct = products.products.find((item) => item.id === product.productId); + if (currentProduct) { + const productCard = new ProductCardModel(currentProduct, null, cart); + this.productCards.push(productCard); + this.wishlist.append(productCard.getHTML()); + } + }); + } + + private openProductInfo(): void { + if (RouterModel.getPageID()) { + this.productCards.find((productCard) => productCard.getKey() === RouterModel.getPageID())?.openProductInfoModal(); + } + } + + public async drawWishlist(): Promise { + this.wishlist.innerHTML = ''; + const loader = new LoaderModel(LOADER_SIZE.EXTRA_LARGE); + loader.setAbsolutePosition(); + this.wishlist.append(loader.getHTML()); + const [shoppingList, cart] = await Promise.all([ + getShoppingListModel().getShoppingList(), + getCartModel().getCart(), + ]); + + const filter = new FilterProduct(); + shoppingList.products.forEach((product) => { + filter.addFilter(FilterFields.ID, product.productId); + }); + const options: OptionsRequest = { + filter, + limit: shoppingList.products.length, + }; + const products = await getProductModel().getProducts(options); + loader.getHTML().remove(); + this.drawWishlistItems(shoppingList, cart, products); + this.switchEmptyList(!shoppingList.products.length); + } + + public getBreadcrumbsContainer(): HTMLDivElement { + return this.breadcrumbsContainer; + } + + public getHTML(): HTMLDivElement { + return this.page; + } + + public getWishlist(): HTMLUListElement { + return this.wishlist; + } + + public removeProductCard(key: string): void { + const currentProductCard = this.productCards.find((productCard) => productCard.getKey() === key); + if (currentProductCard) { + currentProductCard.getHTML().remove(); + this.productCards = this.productCards.filter((productCard) => productCard.getKey() !== key); + this.switchEmptyList(!this.productCards.length); + } + } + + public switchEmptyList(isEmpty: boolean): void { + this.wishlist.classList.toggle(styles.emptyList, isEmpty); + if (isEmpty) { + this.wishlist.textContent = EMPTY_PRODUCT[getCurrentLanguage()].EMPTY; + } + } +} +export default WishlistPageView; diff --git a/src/pages/WishlistPage/view/wishlistPageView.module.scss b/src/pages/WishlistPage/view/wishlistPageView.module.scss new file mode 100644 index 00000000..86570218 --- /dev/null +++ b/src/pages/WishlistPage/view/wishlistPageView.module.scss @@ -0,0 +1,71 @@ +@import 'src/app/styles/mixins'; + +.wishlistPage { + position: relative; + display: flex; + flex-direction: column; + padding: 0 var(--small-offset); + animation: show 0.2s ease-out forwards; +} + +@keyframes show { + 0% { + opacity: 0; + } + + 100% { + display: flex; + opacity: 1; + } +} + +.wishlist { + position: relative; + display: grid; + align-items: stretch; + justify-content: center; + order: 2; + grid-template-columns: repeat(3, auto); + height: max-content; + min-height: 20.438rem; + font-size: var(--regular-font); + letter-spacing: var(--one); + text-align: center; + color: var(--steam-green-500); + gap: var(--small-offset); + + @media (max-width: 970px) { + grid-template-columns: repeat(2, max-content); + } + + @media (max-width: 690px) { + grid-template-columns: repeat(1, 1fr); + } + + @media (min-width: 5300px) { + grid-template-columns: repeat(4, max-content); + } + + @media (min-width: 6600px) { + grid-template-columns: repeat(5, max-content); + } + + &.emptyList { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 12rem; + height: 100%; + min-height: 20.438rem; + background: url('../../../shared/img/png/notFound.png'); + background-position: center 0; + background-size: calc(var(--extra-large-offset) * 2); + background-repeat: no-repeat; + gap: var(--tiny-offset); + } +} + +.goToCatalogLink { + @include link(calc(var(--extra-small-offset) / 2) 0); +} diff --git a/src/shared/API/cart/CartApi.ts b/src/shared/API/cart/CartApi.ts index 9c4ec6cb..2716c694 100644 --- a/src/shared/API/cart/CartApi.ts +++ b/src/shared/API/cart/CartApi.ts @@ -1,4 +1,4 @@ -import type { AddCartItem, Cart, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; +import type { Cart } from '@/shared/types/cart.ts'; import type { CartPagedQueryResponse, Cart as CartResponse, @@ -12,41 +12,13 @@ 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 { +export default class CartApi { private client: ApiClient; constructor() { this.client = getApiClient(); } - public async addProduct(cart: Cart, addCartItem: AddCartItem): Promise { - const data = await this.client - .apiRoot() - .me() - .carts() - .withId({ ID: cart.id }) - .post({ - body: { - actions: [ - { - action: Actions.addLineItem, - productId: addCartItem.productId, - quantity: addCartItem.quantity, - variantId: addCartItem.variantId, - }, - ], - version: cart.version, - }, - }) - .execute(); - return data; - } - public async create(): Promise> { const myCart: MyCartDraft = { currency: CURRENCY, @@ -79,49 +51,6 @@ export class CartApi { return data; } - public async deleteProduct(cart: Cart, product: CartProduct): Promise { - const data = await this.client - .apiRoot() - .me() - .carts() - .withId({ ID: cart.id }) - .post({ - body: { - actions: [ - { - action: Actions.removeLineItem, - lineItemId: product.lineItemId, - }, - ], - version: cart.version, - }, - }) - .execute(); - return data; - } - - public async editProductCount(cart: Cart, editCartItem: EditCartItem): Promise { - const data = await this.client - .apiRoot() - .me() - .carts() - .withId({ ID: cart.id }) - .post({ - body: { - actions: [ - { - action: Actions.changeLineItemQuantity, - lineItemId: editCartItem.lineId, - quantity: editCartItem.quantity, - }, - ], - version: cart.version, - }, - }) - .execute(); - return data; - } - public async getActiveCart(): Promise> { const data = await this.client.apiRoot().me().activeCart().get().execute(); return data; @@ -137,14 +66,14 @@ export class CartApi { return data; } - public async setAnonymousId(cart: Cart, actions: CartSetAnonymousIdAction): Promise { + public async setAnonymousId(cart: Cart, actions: CartSetAnonymousIdAction[]): Promise { const data = await this.client .apiRoot() .carts() .withId({ ID: cart.id }) .post({ body: { - actions: [actions], + actions, version: cart.version, }, }) @@ -168,11 +97,3 @@ export class CartApi { return data; } } - -const createCartApi = (): CartApi => new CartApi(); - -const cartApi = createCartApi(); - -export default function getCartApi(): CartApi { - return cartApi; -} diff --git a/src/shared/API/cart/model/CartModel.ts b/src/shared/API/cart/model/CartModel.ts index 4d619b46..70f81a94 100644 --- a/src/shared/API/cart/model/CartModel.ts +++ b/src/shared/API/cart/model/CartModel.ts @@ -1,9 +1,10 @@ -import type { AddCartItem, Cart, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; +import type { AddCartItem, Cart, CartCoupon, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; import type { CartPagedQueryResponse, Cart as CartResponse, CartSetAnonymousIdAction, ClientResponse, + DiscountedTotalPricePortion, LineItem, MyCartUpdateAction, } from '@commercetools/platform-sdk'; @@ -11,18 +12,22 @@ import type { 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 { showErrorMessage } from '@/shared/utils/userMessage.ts'; import type { OptionsRequest } from '../../types/type.ts'; +import getDiscountModel from '../../discount/model/DiscountModel.ts'; import getProductModel from '../../product/model/ProductModel.ts'; import FilterProduct from '../../product/utils/filter.ts'; import { Attribute, FilterFields } from '../../types/type.ts'; import { isCart, isCartPagedQueryResponse, isClientResponse } from '../../types/validation.ts'; -import getCartApi, { type CartApi } from '../CartApi.ts'; +import CartApi from '../CartApi.ts'; enum ACTIONS { addDiscountCode = 'addDiscountCode', + addLineItem = 'addLineItem', + changeLineItemQuantity = 'changeLineItemQuantity', + removeDiscountCode = 'removeDiscountCode', removeLineItem = 'removeLineItem', setAnonymousId = 'setAnonymousId', } @@ -33,23 +38,34 @@ export class CartModel { private cart: Cart | null = null; - private isSetAnonymousId = false; - private root: CartApi; constructor() { - this.root = getCartApi(); - this.getCart().catch(showErrorMessage); + this.root = new CartApi(); + this.getCart() + .then(() => { + const { anonymousId } = getStore().getState(); + if (anonymousId && this.cart?.anonymousId !== anonymousId) { + this.updateCartCustomer(anonymousId).catch(showErrorMessage); + } + }) + .catch(showErrorMessage); } private adaptCart(data: CartResponse): Cart { - if (data.anonymousId && !getStore().getState().authToken) { + const { anonymousId, authToken } = getStore().getState(); + if (data.anonymousId && !authToken) { getStore().dispatch(setAnonymousCartId(data.id)); + } + if (data.anonymousId && !authToken && !anonymousId) { getStore().dispatch(setAnonymousId(data.anonymousId)); } - const discount = data.discountOnTotalPrice?.discountedAmount?.centAmount; return { - discounts: discount ? discount / PRICE_FRACTIONS : 0, + anonymousId: data.anonymousId || null, + discountsCart: data.discountOnTotalPrice?.includedDiscounts.length + ? this.adaptCartDiscount(data.discountOnTotalPrice?.includedDiscounts) + : [], + discountsProduct: this.adaptProductDiscount(data.lineItems) || [], id: data.id, products: data.lineItems.map((lineItem) => this.adaptLineItem(lineItem)), total: data.totalPrice.centAmount / PRICE_FRACTIONS || 0, @@ -57,20 +73,45 @@ export class CartModel { }; } + private adaptCartDiscount(discounts: DiscountedTotalPricePortion[]): CartCoupon[] { + const result: CartCoupon[] = []; + const allDiscounts = getDiscountModel().getAllCoupons(); + discounts.forEach((discount) => { + const findDiscount = allDiscounts.find((el) => el.id === discount.discount.id); + if (findDiscount) { + result.push({ + coupon: findDiscount, + value: discount.discountedAmount.centAmount / PRICE_FRACTIONS || 0, + }); + } + }); + + return result; + } + private adaptLineItem(product: LineItem): CartProduct { const price = product.price.discounted?.value.centAmount ? product.price.discounted?.value.centAmount : product.price.value.centAmount; + const priceCoupon = + product.discountedPricePerQuantity.length && + product.discountedPricePerQuantity[0].discountedPrice.value.centAmount + ? product.discountedPricePerQuantity[0].discountedPrice.value.centAmount + : 0; const result: CartProduct = { images: product.variant.images?.length ? product.variant.images[0].url : '', key: product.productKey || '', lineItemId: product.id, name: [], price: price / PRICE_FRACTIONS || 0, + priceCouponDiscount: priceCoupon / PRICE_FRACTIONS || 0, productId: product.productId || '', quantity: product.quantity || 0, size: null, - totalPrice: product.totalPrice.centAmount / PRICE_FRACTIONS || 0, + totalPrice: priceCoupon + ? (price * product.quantity) / PRICE_FRACTIONS || 0 + : product.totalPrice.centAmount / PRICE_FRACTIONS || 0, + totalPriceCouponDiscount: priceCoupon ? product.totalPrice.centAmount / PRICE_FRACTIONS || 0 : 0, }; result.name.push(...getProductModel().adaptLocalizationValue(product.name)); if (product.variant.attributes) { @@ -82,6 +123,39 @@ export class CartModel { return result; } + private adaptProductDiscount(lineItems: LineItem[]): CartCoupon[] { + const result: CartCoupon[] = []; + const allDiscounts = getDiscountModel().getAllCoupons(); + lineItems.forEach((lineItem) => { + lineItem.discountedPricePerQuantity.forEach((discountItem) => { + const { quantity } = discountItem; + discountItem.discountedPrice.includedDiscounts.forEach((discount) => { + const findDiscount = allDiscounts.find((el) => el.id === discount.discount.id); + if (findDiscount) { + result.push({ + coupon: findDiscount, + value: (discount.discountedAmount.centAmount * quantity) / PRICE_FRACTIONS || 0, + }); + } + }); + }); + }); + + const uniqueCoupons: CartCoupon[] = result.reduce((acc: CartCoupon[], curr: CartCoupon) => { + const existingCoupon = acc.find((coupon) => coupon.coupon.id === curr.coupon.id); + + if (existingCoupon) { + existingCoupon.value += curr.value; + } else { + acc.push(curr); + } + + return acc; + }, []); + + return uniqueCoupons; + } + private async deleteOtherCarts(data: ClientResponse): Promise { if (this.cart) { const carts: Cart[] = []; @@ -97,20 +171,10 @@ export class CartModel { this.callback.forEach((callback) => (this.cart !== null ? callback(this.cart) : null)); } - private async getAnonymousCart(anonymousCartId: string, anonymousId: string): Promise { + private async getAnonymousCart(anonymousCartId: 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; @@ -118,7 +182,9 @@ export class CartModel { private getCartFromData(data: CartResponse | ClientResponse): Cart { let cart: Cart = { - discounts: 0, + anonymousId: null, + discountsCart: [], + discountsProduct: [], id: '', products: [], total: 0, @@ -149,6 +215,20 @@ export class CartModel { return this.cart; } + private async updateCartCustomer(anonymousId: string): Promise { + if (this.cart) { + const actions: CartSetAnonymousIdAction[] = [ + { + action: ACTIONS.setAnonymousId, + anonymousId, + }, + ]; + const dataSetId = await this.root.setAnonymousId(this.cart, actions); + this.cart = this.getCartFromData(dataSetId); + } + return this.cart; + } + public async addCoupon(discountCode: string): Promise { if (!this.cart) { this.cart = await this.getCart(); @@ -198,7 +278,19 @@ export class CartModel { } public async addProductToCart(addCartItem: AddCartItem): Promise { - const data = await this.root.addProduct(await this.getCart(), addCartItem); + if (!this.cart) { + this.cart = await this.getCart(); + } + + const actions: MyCartUpdateAction[] = [ + { + action: ACTIONS.addLineItem, + productId: addCartItem.productId, + quantity: addCartItem.quantity, + variantId: addCartItem.variantId, + }, + ]; + const data = await this.root.updateCart(this.cart, actions); this.cart = this.getCartFromData(data); this.dispatchUpdate(); return this.cart; @@ -237,11 +329,35 @@ export class CartModel { }); } - public async deleteProductFromCart(products: CartProduct): Promise { + public async deleteCoupon(id: string): Promise { + if (!this.cart) { + this.cart = await this.getCart(); + } + const action: MyCartUpdateAction[] = [ + { + action: ACTIONS.removeDiscountCode, + discountCode: { + id, + typeId: 'discount-code', + }, + }, + ]; + const data = await this.root.updateCart(this.cart, action); + this.cart = this.getCartFromData(data); + return this.cart; + } + + public async deleteProductFromCart(cartItem: CartProduct): Promise { if (!this.cart) { this.cart = await this.getCart(); } - const data = await this.root.deleteProduct(this.cart, products); + const actions: MyCartUpdateAction[] = [ + { + action: ACTIONS.removeLineItem, + lineItemId: cartItem.lineItemId, + }, + ]; + const data = await this.root.updateCart(this.cart, actions); this.cart = this.getCartFromData(data); this.dispatchUpdate(); return this.cart; @@ -251,7 +367,16 @@ export class CartModel { if (!this.cart) { this.cart = await this.getCart(); } - const data = await this.root.editProductCount(this.cart, editCartItem); + + const actions: MyCartUpdateAction[] = [ + { + action: ACTIONS.changeLineItemQuantity, + lineItemId: editCartItem.lineId, + quantity: editCartItem.quantity, + }, + ]; + + const data = await this.root.updateCart(this.cart, actions); this.cart = this.getCartFromData(data); this.dispatchUpdate(); return this.cart; @@ -261,10 +386,7 @@ export class CartModel { if (!this.cart) { 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); + this.cart = await this.getAnonymousCart(anonymousCartId); } if (!this.cart) { this.cart = await this.getUserCart(); diff --git a/src/shared/API/cart/tests/cart.spec.ts b/src/shared/API/cart/tests/cart.spec.ts new file mode 100644 index 00000000..23c37fd6 --- /dev/null +++ b/src/shared/API/cart/tests/cart.spec.ts @@ -0,0 +1,161 @@ +import type { AddCartItem, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; + +import CartApi from '../CartApi.ts'; +import getCartModel, { CartModel } from '../model/CartModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const root = new CartApi(); + +describe('Checking CartApi', () => { + it('should check if root is defined', () => { + expect(root).toBeDefined(); + }); + + it('should check if CartApi is an instance of CartApi', () => { + expect(root).toBeInstanceOf(CartApi); + }); +}); + +describe('Checking CartModel', () => { + const cartModel = getCartModel(); + it('should check if root is defined', () => { + expect(cartModel).toBeDefined(); + }); + + it('should check if CartModel is an instance of CartModel', () => { + expect(cartModel).toBeInstanceOf(CartModel); + }); + + it('should get cart', async () => { + const cart = await cartModel.getCart(); + expect(cart).toBeDefined(); + expect(cart).toHaveProperty('anonymousId'); + expect(cart).toHaveProperty('discountsCart'); + expect(cart).toHaveProperty('discountsProduct'); + expect(cart).toHaveProperty('id'); + expect(cart).toHaveProperty('products'); + expect(cart).toHaveProperty('total'); + expect(cart).toHaveProperty('version'); + }); + + it('should add coupon', async () => { + const cart = await cartModel.getCart(); + expect(cart).toBeDefined(); + const cartCoupon = await cartModel.addCoupon('coupon'); + expect(cartCoupon).toBeDefined(); + expect(cartCoupon).toHaveProperty('anonymousId'); + expect(cartCoupon).toHaveProperty('discountsCart'); + expect(cartCoupon).toHaveProperty('discountsProduct'); + expect(cartCoupon).toHaveProperty('id'); + expect(cartCoupon).toHaveProperty('products'); + expect(cartCoupon).toHaveProperty('total'); + expect(cartCoupon).toHaveProperty('version'); + }); + + it('should add product to cart', async () => { + const cart = await cartModel.getCart(); + expect(cart).toBeDefined(); + const lineItem: AddCartItem = { + name: '', + productId: 'd72d63d8-b116-4948-8b4f-035ea3adbb39', + quantity: 1, + variantId: 1, + }; + const cartProduct = await cartModel.addProductToCart(lineItem); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); + + it('should clear cart in model', () => { + const cart = cartModel.clear(); + expect(cart).equal(true); + }); + + it('should clear cart', async () => { + const cart = await cartModel.getCart(); + expect(cart).toBeDefined(); + const cartProduct = await cartModel.clearCart(); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); + + it('should create cart', async () => { + const cartProduct = await cartModel.create(); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); + + it('should delete coupon', async () => { + const cartProduct = await cartModel.deleteCoupon(''); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); + + it('should delete product from cart', async () => { + const cartItem: CartProduct = { + images: '', + key: '', + lineItemId: '', + name: [], + price: 0, + priceCouponDiscount: 0, + productId: '', + quantity: 0, + size: null, + totalPrice: 0, + totalPriceCouponDiscount: 0, + }; + const cartProduct = await cartModel.deleteProductFromCart(cartItem); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); + + it('should edit product count', async () => { + const editCartItem: EditCartItem = { + lineId: '', + quantity: 0, + }; + const cartProduct = await cartModel.editProductCount(editCartItem); + expect(cartProduct).toBeDefined(); + expect(cartProduct).toHaveProperty('anonymousId'); + expect(cartProduct).toHaveProperty('discountsCart'); + expect(cartProduct).toHaveProperty('discountsProduct'); + expect(cartProduct).toHaveProperty('id'); + expect(cartProduct).toHaveProperty('products'); + expect(cartProduct).toHaveProperty('total'); + expect(cartProduct).toHaveProperty('version'); + }); +}); diff --git a/src/shared/API/cart/tests/cartApi.spec.ts b/src/shared/API/cart/tests/cartApi.spec.ts deleted file mode 100644 index 54640f00..00000000 --- a/src/shared/API/cart/tests/cartApi.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import getCartApi, { CartApi } from '../CartApi.ts'; - -const root = getCartApi(); - -describe('Checking CartApi', () => { - it('should check if root is defined', () => { - expect(root).toBeDefined(); - }); - - it('should check if CartApi is an instance of CartApi', () => { - expect(root).toBeInstanceOf(CartApi); - }); -}); diff --git a/src/shared/API/customer/CustomerApi.ts b/src/shared/API/customer/CustomerApi.ts index 2c7e9959..5ac9ff19 100644 --- a/src/shared/API/customer/CustomerApi.ts +++ b/src/shared/API/customer/CustomerApi.ts @@ -26,7 +26,7 @@ const CART_MERGE_MODE = 'MergeWithExistingCustomerCart'; const CART_TYPE_ID = 'cart'; const EMAIL = 'email'; -export class CustomerApi { +export default class CustomerApi { private client: ApiClient; constructor() { @@ -118,12 +118,6 @@ export class CustomerApi { return data; } - public async deleteCustomer(ID: string, version: number): Promise> { - const data = await this.client.adminRoot().customers().withId({ ID }).delete({ queryArgs: { version } }).execute(); - await this.logoutUser(); - return data; - } - public async editCustomer(actions: MyCustomerUpdateAction[], version: number): Promise> { const data = await this.client.apiRoot().me().post({ body: { actions, version } }).execute(); return data; @@ -189,11 +183,3 @@ export class CustomerApi { return data; } } - -const createCustomerApi = (): CustomerApi => new CustomerApi(); - -const customerApi = createCustomerApi(); - -export default function getCustomerApi(): CustomerApi { - return customerApi; -} diff --git a/src/shared/API/customer/model/CustomerModel.ts b/src/shared/API/customer/model/CustomerModel.ts index bea7fe43..77ebc13f 100644 --- a/src/shared/API/customer/model/CustomerModel.ts +++ b/src/shared/API/customer/model/CustomerModel.ts @@ -17,7 +17,7 @@ import { isCustomerResponse, isCustomerSignInResultResponse, } from '../../types/validation.ts'; -import getCustomerApi, { type CustomerApi } from '../CustomerApi.ts'; +import CustomerApi from '../CustomerApi.ts'; const CUSTOMER_FIELD = 'customer'; @@ -43,7 +43,7 @@ export class CustomerModel { private root: CustomerApi; constructor() { - this.root = getCustomerApi(); + this.root = new CustomerApi(); } public static actionAddAddress(address: Address): MyCustomerUpdateAction { @@ -233,11 +233,6 @@ export class CustomerModel { return this.getCustomerFromData(data); } - public async deleteCustomer(customer: User): Promise { - const data = await this.root.deleteCustomer(customer.id, customer.version); - return this.getCustomerFromData(data) !== null; - } - public async editCustomer(actions: MyCustomerUpdateAction[], customer: User): Promise { const data = await this.root.editCustomer(actions, customer.version); return this.getCustomerFromData(data); diff --git a/src/shared/API/customer/tests/Customer.spec.ts b/src/shared/API/customer/tests/Customer.spec.ts index e051c3b3..29b4077c 100644 --- a/src/shared/API/customer/tests/Customer.spec.ts +++ b/src/shared/API/customer/tests/Customer.spec.ts @@ -2,6 +2,10 @@ import type { Address, User } from '@/shared/types/user.ts'; import getCustomerModel, { CustomerModel } from '../model/CustomerModel.ts'; +/** + * @vitest-environment jsdom + */ + const customerModel = getCustomerModel(); describe('Checking Customer Model', () => { @@ -25,7 +29,7 @@ describe('Checking Customer Model', () => { id: '1', lastName: 'Smith', locale: 'en', - password: 'Qqq11', + password: 'Qqq11qq11', shippingAddress: [], version: 0, }; @@ -53,13 +57,8 @@ describe('Checking Customer Model', () => { }); it('should return true for valid email', async () => { - const result = await customerModel.hasEmail('jane@doe.com'); - expect(result?.email).toBe('jane@doe.com'); - }); - - it('should return false for invalid email', async () => { - const result = await customerModel.hasEmail('gettingstarted@example.com'); - expect(result).toBe(null); + const result = await customerModel.hasEmail(user.email); + expect(result?.email).toBe(user.email); }); it('should register a new customer', async () => { @@ -159,11 +158,4 @@ describe('Checking Customer Model', () => { expect(editPassword).toHaveProperty('version'); } }); - - it('should delete the customer', async () => { - if (editPassword) { - const deleteCustomer = await customerModel.deleteCustomer(editPassword); - expect(deleteCustomer).toBe(true); - } - }); }); diff --git a/src/shared/API/customer/tests/CustomerApi.spec.ts b/src/shared/API/customer/tests/CustomerApi.spec.ts index caa9c9ef..94d05b17 100644 --- a/src/shared/API/customer/tests/CustomerApi.spec.ts +++ b/src/shared/API/customer/tests/CustomerApi.spec.ts @@ -1,6 +1,10 @@ -import getCustomerApi, { CustomerApi } from '../CustomerApi.ts'; +import CustomerApi from '../CustomerApi.ts'; -const root = getCustomerApi(); +/** + * @vitest-environment jsdom + */ + +const root = new CustomerApi(); describe('Checking CustomerApi', () => { it('should check if root is defined', () => { diff --git a/src/shared/API/discount/DiscountApi.ts b/src/shared/API/discount/DiscountApi.ts new file mode 100644 index 00000000..6a310b1c --- /dev/null +++ b/src/shared/API/discount/DiscountApi.ts @@ -0,0 +1,25 @@ +import type { + ClientResponse, + DiscountCodePagedQueryResponse, + ProductDiscountPagedQueryResponse, +} from '@commercetools/platform-sdk'; + +import getApiClient, { type ApiClient } from '../sdk/client.ts'; + +export default class DiscountApi { + private client: ApiClient; + + constructor() { + this.client = getApiClient(); + } + + public async getCoupons(): Promise> { + const data = await this.client.apiRoot().discountCodes().get().execute(); + return data; + } + + public async getDiscounts(): Promise> { + const data = await this.client.apiRoot().productDiscounts().get().execute(); + return data; + } +} diff --git a/src/shared/API/discount/model/DiscountModel.ts b/src/shared/API/discount/model/DiscountModel.ts new file mode 100644 index 00000000..98daf32d --- /dev/null +++ b/src/shared/API/discount/model/DiscountModel.ts @@ -0,0 +1,90 @@ +import type { Coupon } from '@/shared/types/cart.ts'; +import type { Category } from '@/shared/types/product.ts'; +import type { + ClientResponse, + DiscountCode, + DiscountCodePagedQueryResponse, + ProductDiscountPagedQueryResponse, +} from '@commercetools/platform-sdk'; + +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; + +import getProductModel from '../../product/model/ProductModel.ts'; +import { + isClientResponse, + isDiscountCodePagedQueryResponse, + isProductDiscountPagedQueryResponse, +} from '../../types/validation.ts'; +import DiscountApi from '../DiscountApi.ts'; + +export class DiscountModel { + private coupons: Coupon[] = []; + + private root: DiscountApi; + + constructor() { + this.root = new DiscountApi(); + this.init().catch(showErrorMessage); + } + + private adaptCoupon(data: DiscountCode): Coupon { + return { + cartDiscount: data.id, + discountCode: data.code, + id: data.cartDiscounts[0].id, + }; + } + + private getCategorySaleFromData( + data: ClientResponse, + categories: Category[], + ): Category[] { + const result: Category[] = []; + if (isClientResponse(data) && isProductDiscountPagedQueryResponse(data.body)) { + const allReferences = data.body.results.flatMap((result) => result.references); + allReferences.forEach((rule) => { + if (rule.typeId === 'category') { + const categorySale = categories.find((category) => category.id === rule.id); + if (categorySale) { + result.push(categorySale); + } + } + }); + } + return result; + } + + private getCouponsFromData(data: ClientResponse): Coupon[] { + const coupons: Coupon[] = []; + if (isClientResponse(data) && isDiscountCodePagedQueryResponse(data.body)) { + coupons.push(...data.body.results.map((el) => this.adaptCoupon(el))); + } + return coupons; + } + + private async init(): Promise { + if (!this.coupons.length) { + const data = await this.root.getCoupons(); + this.coupons = this.getCouponsFromData(data); + } + return this.coupons; + } + + public getAllCoupons(): Coupon[] { + return this.coupons; + } + + public async getCategorySales(): Promise { + const data = await this.root.getDiscounts(); + const categories = await getProductModel().getCategories(); + return this.getCategorySaleFromData(data, categories); + } +} + +const createDiscountModel = (): DiscountModel => new DiscountModel(); + +const discountModel = createDiscountModel(); + +export default function getDiscountModel(): DiscountModel { + return discountModel; +} diff --git a/src/shared/API/product/ProductApi.ts b/src/shared/API/product/ProductApi.ts index 31ff209b..e1ff50c2 100644 --- a/src/shared/API/product/ProductApi.ts +++ b/src/shared/API/product/ProductApi.ts @@ -5,14 +5,15 @@ import type { ProductProjectionPagedSearchResponse, } from '@commercetools/platform-sdk'; -import { DEFAULT_PAGE, MAX_PRICE, MIN_PRICE, PRICE_FRACTIONS, PRODUCT_LIMIT } from '@/shared/constants/product.ts'; +import { DEFAULT_PAGE, MAX_PRICE, MIN_PRICE, PRODUCT_LIMIT } from '@/shared/constants/product.ts'; + +import type { OptionsRequest, PriceRange } from '../types/type.ts'; import getApiClient, { type ApiClient } from '../sdk/client.ts'; -import { type OptionsRequest } from '../types/type.ts'; +import { getDefaultPriceRange } from './utils/filter.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', @@ -21,7 +22,7 @@ enum Facets { enum QueryParams { range = 'range', } -export class ProductApi { +export default class ProductApi { private client: ApiClient; constructor() { @@ -63,25 +64,25 @@ export class ProductApi { return data; } - public async getProducts(options?: OptionsRequest): Promise> { + public async getProducts( + options?: OptionsRequest, + productsPriceRange?: PriceRange, + ): 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 filterQuery = filter?.getFilter(productsPriceRange); + const priceRange = filter ? filter.getPriceRange(productsPriceRange) : getDefaultPriceRange(); const fuzzyLevel = this.getFuzzyLevel(search?.value ? search?.value : ''); - const data = await this.client .apiRoot() .productProjections() .search() .get({ queryArgs: { - facet: [Facets.category, Facets.size, `${Facets.price}:${QueryParams.range}(${min} to ${max})`], + facet: [Facets.category, Facets.size, priceRange], limit, markMatchingVariants: true, offset: (page - 1) * PRODUCT_LIMIT, - ...(search && { [`${Search}.${search.locale}`]: search.value }), + ...(search && { [`${Search}.${search.locale}`]: `*${search.value}*` }), ...(search && { fuzzy: true }), ...(search?.value && { fuzzyLevel }), ...(sort && { sort: makeSortRequest(sort) }), @@ -94,11 +95,3 @@ export class ProductApi { return data; } } - -const createProductApi = (): ProductApi => new ProductApi(); - -const productApi = createProductApi(); - -export default function getProductApi(): ProductApi { - return productApi; -} diff --git a/src/shared/API/product/model/ProductModel.ts b/src/shared/API/product/model/ProductModel.ts index 0e7972e1..3c61de4b 100644 --- a/src/shared/API/product/model/ProductModel.ts +++ b/src/shared/API/product/model/ProductModel.ts @@ -14,8 +14,6 @@ import type { import { PRICE_FRACTIONS } from '@/shared/constants/product.ts'; import { getLevel, getSize } from '@/shared/utils/size.ts'; -import type { ProductApi } from '../ProductApi.ts'; - import { Attribute, type CategoriesProductCount, @@ -40,7 +38,7 @@ import { isRangeFacetResult, isTermFacetResult, } from '../../types/validation.ts'; -import getProductApi from '../ProductApi.ts'; +import ProductApi from '../ProductApi.ts'; enum ProductConstant { categoriesId = 'categories.id', @@ -52,10 +50,15 @@ enum ProductConstant { export class ProductModel { private categories: Category[] = []; + private priceRange: PriceRange = { + max: 0, + min: 0, + }; + private root: ProductApi; constructor() { - this.root = getProductApi(); + this.root = new ProductApi(); } private adaptCategoryPagedQueryToClient(data: CategoryPagedQueryResponse): Category[] { @@ -268,6 +271,9 @@ export class ProductModel { }); } } + if (this.priceRange.min === 0 && this.priceRange.max === 0) { + this.priceRange = priceRange; + } return priceRange; } @@ -395,7 +401,7 @@ export class ProductModel { public async getProducts(options?: OptionsRequest): Promise { await getProductModel().getCategories(); - const data = await this.root.getProducts(options); + const data = await this.root.getProducts(options, this.priceRange); const products = this.getProductsFromData(data); if (options?.sort) { this.sortVariants(products, options?.sort); @@ -413,6 +419,10 @@ export class ProductModel { }; return result; } + + public getProductsPriceRange(): PriceRange { + return this.priceRange; + } } const createProductModel = (): ProductModel => new ProductModel(); diff --git a/src/shared/API/product/tests/Product.spec.ts b/src/shared/API/product/tests/Product.spec.ts index c1a54a83..49634190 100644 --- a/src/shared/API/product/tests/Product.spec.ts +++ b/src/shared/API/product/tests/Product.spec.ts @@ -2,6 +2,10 @@ import { type localization } from '@/shared/types/product.ts'; import getProductModel, { ProductModel } from '../model/ProductModel.ts'; +/** + * @vitest-environment jsdom + */ + const productModel = getProductModel(); describe('Checking Product Model', () => { diff --git a/src/shared/API/product/tests/ProductApi.spec.ts b/src/shared/API/product/tests/ProductApi.spec.ts index fda6af90..fbc31dd7 100644 --- a/src/shared/API/product/tests/ProductApi.spec.ts +++ b/src/shared/API/product/tests/ProductApi.spec.ts @@ -1,6 +1,10 @@ -import getProductApi, { ProductApi } from '../ProductApi.ts'; +import ProductApi from '../ProductApi.ts'; -const root = getProductApi(); +/** + * @vitest-environment jsdom + */ + +const root = new ProductApi(); describe('Checking ProductApi', () => { it('should check if root is defined', () => { diff --git a/src/shared/API/product/tests/filter.spec.ts b/src/shared/API/product/tests/filter.spec.ts new file mode 100644 index 00000000..a474bad6 --- /dev/null +++ b/src/shared/API/product/tests/filter.spec.ts @@ -0,0 +1,69 @@ +import type { PriceRange } from '../../types/type.ts'; + +import { FilterFields } from '../../types/type.ts'; +import FilterProduct from '../utils/filter.ts'; + +/** + * @vitest-environment jsdom + */ + +describe('Checking filter', () => { + const filter = new FilterProduct(); + it('should check if FilterProduct is defined', () => { + expect(filter).toBeDefined(); + }); + + it('should check if filter is an instance of FilterProduct', () => { + expect(filter).toBeInstanceOf(FilterProduct); + }); + + it('should return category filter', () => { + const filter = new FilterProduct(); + filter.addFilter(FilterFields.CATEGORY, 'id'); + const result = filter.getFilter(); + expect(result).toEqual(['categories.id:subtree("id")', 'variants.price.centAmount: range(0 to *)']); + }); + + it('should return product filter', () => { + const filter = new FilterProduct(); + filter.addFilter(FilterFields.ID, 'id'); + const result = filter.getFilter(); + expect(result).toEqual(['id:"id"', 'variants.price.centAmount: range(0 to *)']); + }); + + it('should return new_arrival filter', () => { + const filter = new FilterProduct(); + filter.addFilter(FilterFields.NEW_ARRIVAL, 'true'); + const result = filter.getFilter(); + expect(result).toEqual(['variants.attributes.new_arrival:true', 'variants.price.centAmount: range(0 to *)']); + }); + + it('should return price filter', () => { + const filter = new FilterProduct(); + const priceMinMax: PriceRange = { + max: 160, + min: 0, + }; + const price: PriceRange = { + max: 10, + min: 5, + }; + filter.addFilter(FilterFields.PRICE, price); + const result = filter.getFilter(priceMinMax); + expect(result).toEqual(['variants.price.centAmount: range(500 to 1000)']); + }); + + it('should return sale filter', () => { + const filter = new FilterProduct(); + filter.addFilter(FilterFields.SALE, 'true'); + const result = filter.getFilter(); + expect(result).toEqual(['variants.price.centAmount: range(0 to *)', 'variants.prices.discounted:exists']); + }); + + it('should return sale filter', () => { + const filter = new FilterProduct(); + filter.addFilter(FilterFields.SIZE, 'M'); + const result = filter.getFilter(); + expect(result).toEqual(['variants.attributes.size.key:"M"', 'variants.price.centAmount: range(0 to *)']); + }); +}); diff --git a/src/shared/API/product/utils/filter.ts b/src/shared/API/product/utils/filter.ts index cd4594d5..6fff88a9 100644 --- a/src/shared/API/product/utils/filter.ts +++ b/src/shared/API/product/utils/filter.ts @@ -12,8 +12,8 @@ export default class FilterProduct { private newArrival = ''; private price: PriceRange = { - max: MAX_PRICE, - min: MIN_PRICE, + max: 0, + min: 0, }; private sale = ''; @@ -55,7 +55,7 @@ export default class FilterProduct { return this.getFilter(); } - public getFilter(): string[] { + public getFilter(productsPriceRange?: PriceRange): string[] { const result = []; if (this.id.length) { result.push(`${FilterFields.ID}:${this.id.map((id) => `"${id}"`).join(',')}`); @@ -70,9 +70,7 @@ export default class FilterProduct { result.push(this.newArrival); } if (this.price) { - result.push( - `${FilterFields.PRICE}: range(${Math.round(this.price.min * PRICE_FRACTIONS)} to ${Math.round(this.price.max * PRICE_FRACTIONS)})`, - ); + result.push(this.getPriceRange(productsPriceRange)); } if (this.sale) { result.push(this.sale); @@ -80,7 +78,17 @@ export default class FilterProduct { return result; } - public getPriceRange(): PriceRange { - return this.price; + public getPriceRange(productsPriceRange?: PriceRange): string { + const min = Math.round(this.price.min * PRICE_FRACTIONS); + const max = + this.price.max && productsPriceRange && this.price.max !== productsPriceRange.max + ? Math.round(this.price.max * PRICE_FRACTIONS) + : MAX_PRICE; + + return `${FilterFields.PRICE}: range(${min} to ${max})`; } } + +export function getDefaultPriceRange(): string { + return `${FilterFields.PRICE}: range(${MIN_PRICE} to ${MAX_PRICE})`; +} diff --git a/src/shared/API/sdk/client.ts b/src/shared/API/sdk/client.ts index 0fe1aa04..bc4c7f0a 100644 --- a/src/shared/API/sdk/client.ts +++ b/src/shared/API/sdk/client.ts @@ -2,7 +2,6 @@ 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, @@ -29,13 +28,10 @@ const URL_HTTP = 'https://api.europe-west1.gcp.commercetools.com'; const USE_SAVE_TOKEN = true; const httpMiddlewareOptions: HttpMiddlewareOptions = { - fetch, host: URL_HTTP, }; export class ApiClient { - private adminConnection: ByProjectKeyRequestBuilder; - private anonymConnection: ByProjectKeyRequestBuilder | null = null; private authConnection: ByProjectKeyRequestBuilder | null = null; @@ -62,9 +58,6 @@ export class ApiClient { } else { this.anonymConnection = this.createAnonymConnection(); } - this.adminConnection = this.createAdminConnection(); - - this.init().catch(showErrorMessage); } private addAuthMiddleware( @@ -100,16 +93,6 @@ export class ApiClient { } } - private createAdminConnection(): ByProjectKeyRequestBuilder { - const defaultOptions = this.getDefaultOptions(); - const client = this.getDefaultClient(); - - client.withClientCredentialsFlow(defaultOptions); - - this.adminConnection = this.getConnection(client.build()); - return this.adminConnection; - } - private createAnonymConnection(): ByProjectKeyRequestBuilder { const defaultOptions = this.getDefaultOptions(TokenType.ANONYM); const client = this.getDefaultClient(); @@ -122,7 +105,6 @@ export class ApiClient { anonymousId, }, }; - client.withAnonymousSessionFlow(anonymOptions); getStore().dispatch(setAnonymousId(anonymousId)); this.anonymConnection = this.getConnection(client.build()); @@ -161,7 +143,6 @@ export class ApiClient { clientId: this.clientID, clientSecret: this.clientSecret, }, - fetch, host: URL_AUTH, projectKey: this.projectKey, scopes: this.scopes, @@ -169,19 +150,6 @@ export class ApiClient { }; } - private async init(): Promise { - await this.apiRoot() - .get() - .execute() - .catch((error: Error) => { - showErrorMessage(error); - }); - } - - public adminRoot(): ByProjectKeyRequestBuilder { - return this.adminConnection; - } - public apiRoot(): ByProjectKeyRequestBuilder { let client = (this.authConnection && this.isAuth) || (this.authConnection && !this.anonymConnection) diff --git a/src/shared/API/sdk/token-cache/token-cache.ts b/src/shared/API/sdk/token-cache/token-cache.ts index 80644e58..97f55503 100644 --- a/src/shared/API/sdk/token-cache/token-cache.ts +++ b/src/shared/API/sdk/token-cache/token-cache.ts @@ -1,7 +1,7 @@ import type { TokenCache, TokenStore } from '@commercetools/sdk-client-v2'; import getStore from '@/shared/Store/Store.ts'; -import { setAnonymToken, setAuthToken } from '@/shared/Store/actions.ts'; +import { setAnonymousToken, setAuthToken } from '@/shared/Store/actions.ts'; import type { TokenTypeType } from '../../types/type.ts'; @@ -19,8 +19,8 @@ export class MyTokenCache implements TokenCache { constructor(name: string) { this.name = name; const soreData = getStore().getState(); - if (this.name === TokenType.ANONYM && soreData.anonymToken) { - this.myCache = soreData.anonymToken; + if (this.name === TokenType.ANONYM && soreData.anonymousToken) { + this.myCache = soreData.anonymousToken; } else if (this.name === TokenType.AUTH && soreData.authToken) { this.myCache = soreData.authToken; } @@ -29,7 +29,7 @@ export class MyTokenCache implements TokenCache { private saveToken(): void { if (this.myCache.token) { if (this.name === TokenType.ANONYM) { - getStore().dispatch(setAnonymToken(this.myCache)); + getStore().dispatch(setAnonymousToken(this.myCache)); } else if (this.name === TokenType.AUTH) { getStore().dispatch(setAuthToken(this.myCache)); } @@ -43,7 +43,7 @@ export class MyTokenCache implements TokenCache { token: '', }; if (this.name === TokenType.ANONYM) { - getStore().dispatch(setAnonymToken(null)); + getStore().dispatch(setAnonymousToken(null)); } else if (this.name === TokenType.AUTH) { getStore().dispatch(setAuthToken(null)); } @@ -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 { @@ -65,9 +65,9 @@ export class MyTokenCache implements TokenCache { const createTokenCache = (name: string): MyTokenCache => new MyTokenCache(name); -const anonymTokenCache = createTokenCache(TokenType.ANONYM); +const anonymousTokenCache = createTokenCache(TokenType.ANONYM); const authTokenCache = createTokenCache(TokenType.AUTH); export default function getTokenCache(tokenType?: TokenTypeType): MyTokenCache { - return tokenType === TokenType.AUTH ? authTokenCache : anonymTokenCache; + return tokenType === TokenType.AUTH ? authTokenCache : anonymousTokenCache; } diff --git a/src/shared/API/shopping-list/ShoppingListApi.ts b/src/shared/API/shopping-list/ShoppingListApi.ts index d57e0369..55fe17e4 100644 --- a/src/shared/API/shopping-list/ShoppingListApi.ts +++ b/src/shared/API/shopping-list/ShoppingListApi.ts @@ -8,9 +8,14 @@ import type { ShoppingListSetAnonymousIdAction, } from '@commercetools/platform-sdk'; +import { DEFAULT_PAGE, PRODUCT_LIMIT } from '@/shared/constants/product.ts'; + +import type { OptionsRequest } from '../types/type.ts'; + +import makeSortRequest from '../product/utils/sort.ts'; import getApiClient, { type ApiClient } from '../sdk/client.ts'; -export class ShoppingListApi { +export default class ShoppingListApi { private client: ApiClient; constructor() { @@ -92,13 +97,39 @@ export class ShoppingListApi { return data; } - public async get(): Promise> { - const data = await this.client.apiRoot().me().shoppingLists().get().execute(); + public async get(options?: OptionsRequest): Promise> { + const { limit = PRODUCT_LIMIT, page = DEFAULT_PAGE, sort } = options || {}; + const data = await this.client + .apiRoot() + .me() + .shoppingLists() + .get({ + queryArgs: { + limit, + offset: (page - 1) * PRODUCT_LIMIT, + ...(sort && { sort: makeSortRequest(sort) }), + withTotal: true, + }, + }) + .execute(); return data; } - public async getAnonymList(ID: string): Promise> { - const data = await this.client.apiRoot().shoppingLists().withId({ ID }).get().execute(); + public async getAnonymList(ID: string, options?: OptionsRequest): Promise> { + const { limit = PRODUCT_LIMIT, page = DEFAULT_PAGE, sort } = options || {}; + const data = await this.client + .apiRoot() + .shoppingLists() + .withId({ ID }) + .get({ + queryArgs: { + limit, + offset: (page - 1) * PRODUCT_LIMIT, + ...(sort && { sort: makeSortRequest(sort) }), + withTotal: true, + }, + }) + .execute(); return data; } @@ -120,11 +151,3 @@ export class ShoppingListApi { return data; } } - -const createShoppingListApi = (): ShoppingListApi => new ShoppingListApi(); - -const shoppingListApi = createShoppingListApi(); - -export default function getShoppingListApi(): ShoppingListApi { - return shoppingListApi; -} diff --git a/src/shared/API/shopping-list/model/ShoppingListModel.ts b/src/shared/API/shopping-list/model/ShoppingListModel.ts index ff15a043..39f35c40 100644 --- a/src/shared/API/shopping-list/model/ShoppingListModel.ts +++ b/src/shared/API/shopping-list/model/ShoppingListModel.ts @@ -10,7 +10,9 @@ import type { import getStore from '@/shared/Store/Store.ts'; import { setAnonymousId, setAnonymousShopListId } from '@/shared/Store/actions.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; + +import type { OptionsRequest } from '../../types/type.ts'; import { isClientResponse, @@ -18,21 +20,32 @@ import { isShoppingList, isShoppingListPagedQueryResponse, } from '../../types/validation.ts'; -import getShoppingListApi, { type ShoppingListApi } from '../ShoppingListApi.ts'; +import ShoppingListApi from '../ShoppingListApi.ts'; enum ACTIONS { addLineItem = 'addLineItem', setAnonymousId = 'setAnonymousId', } +type ShoppingListChangeHandler = (shoppingList: ShoppingList) => void; + export class ShoppingListModel { private root: ShoppingListApi; private shoppingList: ShoppingList | null = null; + private subscribers: ShoppingListChangeHandler[] = []; + constructor() { - this.root = getShoppingListApi(); - this.getShoppingList().catch(showErrorMessage); + this.root = new ShoppingListApi(); + this.getShoppingList() + .then(() => { + const { anonymousId } = getStore().getState(); + if (anonymousId && this.shoppingList?.anonymousId !== anonymousId) { + this.updateListCustomer(anonymousId).catch(showErrorMessage); + } + }) + .catch(showErrorMessage); } private adaptLineItem(product: ShoppingListLineItem): ShoppingListProduct { @@ -44,11 +57,15 @@ export class ShoppingListModel { } private adaptShopList(data: ShoppingListResponse): ShoppingList { - if (data.anonymousId && !getStore().getState().authToken) { - getStore().dispatch(setAnonymousId(data.anonymousId)); + const { anonymousId, authToken } = getStore().getState(); + if (data.anonymousId && !authToken) { getStore().dispatch(setAnonymousShopListId(data.id)); } + if (data.anonymousId && !authToken && !anonymousId) { + getStore().dispatch(setAnonymousId(data.anonymousId)); + } return { + anonymousId: data.anonymousId || null, id: data.id, products: data.lineItems.map((lineItem) => this.adaptLineItem(lineItem)), version: data.version, @@ -57,17 +74,13 @@ export class ShoppingListModel { private async getAnonymousShoppingList( anonymousShopListId: string, - anonymousId: string, + options?: OptionsRequest, ): Promise { - const dataAnonymList = await this.root.getAnonymList(anonymousShopListId); + const dataAnonymList = await this.root.getAnonymList(anonymousShopListId, options); 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); + this.shoppingList = anonymShoppingList; + this.notifySubscribers(); } return this.shoppingList; } @@ -76,6 +89,7 @@ export class ShoppingListModel { data: ClientResponse | ShoppingListResponse, ): ShoppingList { let cart: ShoppingList = { + anonymousId: null, id: '', products: [], version: 0, @@ -90,8 +104,8 @@ export class ShoppingListModel { return cart; } - private async getUserShoppingLists(): Promise { - const data = await this.root.get(); + private async getUserShoppingLists(options?: OptionsRequest): Promise { + const data = await this.root.get(options); if (data.body.count === 0) { const newShopList = await this.root.create(); this.shoppingList = this.getShopListFromData(newShopList); @@ -100,6 +114,7 @@ export class ShoppingListModel { } else { this.shoppingList = this.getShopListFromData(data); } + this.notifySubscribers(); return this.shoppingList; } @@ -127,6 +142,30 @@ export class ShoppingListModel { await Promise.all(otherShopLists.map((id) => this.root.deleteShopList(id))); } this.shoppingList = this.getShopListFromData(lastShopList); + this.notifySubscribers(); + return this.shoppingList; + } + + private notifySubscribers(): void { + if (this.shoppingList) { + this.subscribers.forEach((handler) => { + if (this.shoppingList) { + handler(this.shoppingList); + } + }); + } + } + + private async updateListCustomer(anonymousId: string): Promise { + if (this.shoppingList) { + const actions: ShoppingListSetAnonymousIdAction = { + action: ACTIONS.setAnonymousId, + anonymousId, + }; + const dataUserList = await this.root.setAnonymousId(this.shoppingList, actions); + this.shoppingList = this.getShopListFromData(dataUserList); + this.notifySubscribers(); + } return this.shoppingList; } @@ -143,6 +182,7 @@ export class ShoppingListModel { const data = await this.root.addProduct(this.shoppingList, actions); this.shoppingList = this.getShopListFromData(data); + this.notifySubscribers(); return this.shoppingList; } @@ -154,6 +194,7 @@ export class ShoppingListModel { public async create(): Promise { const newShoppingList = await this.root.create(); this.shoppingList = this.getShopListFromData(newShoppingList); + this.notifySubscribers(); return this.shoppingList; } @@ -163,22 +204,30 @@ export class ShoppingListModel { } const data = await this.root.deleteProduct(this.shoppingList, products); this.shoppingList = this.getShopListFromData(data); - + this.notifySubscribers(); return this.shoppingList; } - public async getShoppingList(): Promise { + public async getShoppingList(options?: OptionsRequest): Promise { if (!this.shoppingList) { const { anonymousId, anonymousShopListId } = getStore().getState(); if (anonymousShopListId && anonymousId) { - this.shoppingList = await this.getAnonymousShoppingList(anonymousShopListId, anonymousId); + this.shoppingList = await this.getAnonymousShoppingList(anonymousShopListId, options); } if (!this.shoppingList) { - this.shoppingList = await this.getUserShoppingLists(); + this.shoppingList = await this.getUserShoppingLists(options); } + this.notifySubscribers(); } return this.shoppingList; } + + public subscribe(handler: ShoppingListChangeHandler): void { + this.subscribers.push(handler); + if (this.shoppingList) { + handler(this.shoppingList); + } + } } const createShoppingListModel = (): ShoppingListModel => new ShoppingListModel(); diff --git a/src/shared/API/shopping-list/test/shoppingList.spec.ts b/src/shared/API/shopping-list/test/shoppingList.spec.ts new file mode 100644 index 00000000..e6d7abf4 --- /dev/null +++ b/src/shared/API/shopping-list/test/shoppingList.spec.ts @@ -0,0 +1,69 @@ +import type { ShoppingListProduct } from '@/shared/types/shopping-list.ts'; + +import ShoppingListApi from '../ShoppingListApi.ts'; +import getShoppingListModel, { ShoppingListModel } from '../model/ShoppingListModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const root = new ShoppingListApi(); + +describe('Checking ShoppingListApi', () => { + it('should check if root is defined', () => { + expect(root).toBeDefined(); + }); + + it('should check if root is an instance of ShoppingListApi', () => { + expect(root).toBeInstanceOf(ShoppingListApi); + }); +}); + +describe('Checking ShoppingListModel', () => { + const shopModel = getShoppingListModel(); + it('should check if shopModel is defined', () => { + expect(shopModel).toBeDefined(); + }); + + it('should check if shopModel is an instance of ShoppingListModel', () => { + expect(shopModel).toBeInstanceOf(ShoppingListModel); + }); + + it('should get ShopList', async () => { + const shop = await shopModel.getShoppingList(); + expect(shop).toBeDefined(); + expect(shop).toHaveProperty('anonymousId'); + expect(shop).toHaveProperty('id'); + expect(shop).toHaveProperty('products'); + expect(shop).toHaveProperty('version'); + }); + + it('should add product to ShopList', async () => { + const shop = await shopModel.getShoppingList(); + expect(shop).toBeDefined(); + const shopProduct = await shopModel.addProduct(''); + expect(shopProduct).toBeDefined(); + expect(shopProduct).toHaveProperty('anonymousId'); + expect(shopProduct).toHaveProperty('id'); + expect(shopProduct).toHaveProperty('products'); + expect(shopProduct).toHaveProperty('version'); + }); + + it('should clear shopModel in model', () => { + const shop = shopModel.clear(); + expect(shop).equal(true); + }); + + it('should delete product from shopModel', async () => { + const product: ShoppingListProduct = { + lineItemId: '', + productId: '', + }; + const shopProduct = await shopModel.deleteProduct(product); + expect(shopProduct).toBeDefined(); + expect(shopProduct).toHaveProperty('anonymousId'); + expect(shopProduct).toHaveProperty('id'); + expect(shopProduct).toHaveProperty('products'); + expect(shopProduct).toHaveProperty('version'); + }); +}); diff --git a/src/shared/API/types/validation.ts b/src/shared/API/types/validation.ts index 2dd9e480..2be23a7b 100644 --- a/src/shared/API/types/validation.ts +++ b/src/shared/API/types/validation.ts @@ -7,11 +7,12 @@ import type { Customer, CustomerPagedQueryResponse, CustomerSignInResult, + DiscountCodePagedQueryResponse, ErrorResponse, FacetRange, FacetTerm, LocalizedString, - Product, + ProductDiscountPagedQueryResponse, ProductPagedQueryResponse, ProductProjection, ProductProjectionPagedQueryResponse, @@ -20,7 +21,6 @@ import type { ShoppingListPagedQueryResponse, TermFacetResult, } from '@commercetools/platform-sdk'; -import type { TokenStore } from '@commercetools/sdk-client-v2'; export function isClientResponse(data: unknown): data is ClientResponse { return Boolean( @@ -78,21 +78,6 @@ export function isCategoryPagedQueryResponse(data: unknown): data is CategoryPag ); } -export function isProductPagedQueryResponse(data: unknown): data is ProductPagedQueryResponse { - return Boolean( - typeof data === 'object' && - data && - 'count' in data && - typeof data.count === 'number' && - 'limit' in data && - typeof data.limit === 'number' && - 'total' in data && - typeof data.total === 'number' && - 'results' in data && - typeof Array.isArray(data.results), - ); -} - export function isLocalizationObj(data: unknown): data is LocalizedString { return Boolean(typeof data === 'object' && data && Object.keys(data).every((key) => typeof key === 'string')); } @@ -108,27 +93,6 @@ export function isAttributePlainEnumValue(data: unknown): data is AttributePlain ); } -export function isProductResponse(data: unknown): data is Product { - return Boolean( - typeof data === 'object' && - data && - 'id' in data && - typeof data.id === 'string' && - 'key' in data && - typeof data.id === 'string' && - 'masterData' in data && - typeof data.masterData === 'object' && - data.masterData !== null && - 'staged' in data.masterData && - typeof data.masterData.staged === 'object' && - data.masterData.staged !== null && - 'categories' in data.masterData.staged && - 'description' in data.masterData.staged && - 'name' in data.masterData.staged && - 'variants' in data.masterData.staged, - ); -} - export function isProductProjectionPagedSearchResponse(data: unknown): data is ProductPagedQueryResponse { return Boolean(typeof data === 'object' && data && 'facets' in data && typeof data.facets === 'object'); } @@ -159,25 +123,6 @@ export function isProductProjection(data: unknown): data is ProductProjection { ); } -export function isProductProjectionPagedQueryResponseWithFacet( - data: unknown, -): data is ProductProjectionPagedQueryResponse { - return Boolean( - typeof data === 'object' && - data && - 'count' in data && - typeof data.count === 'number' && - 'limit' in data && - typeof data.limit === 'number' && - 'total' in data && - typeof data.total === 'number' && - 'facets' in data && - typeof data.facets === 'object' && - 'results' in data && - Array.isArray(data.results), - ); -} - export function isRangeFacetResult(data: unknown): data is RangeFacetResult { return Boolean( typeof data === 'object' && data && 'ranges' in data && Array.isArray(data.ranges) && data.ranges.length, @@ -230,19 +175,6 @@ export function isErrorResponse(data: unknown): data is ErrorResponse { ); } -export function isTokenType(data: unknown): data is TokenStore { - return Boolean( - typeof data === 'object' && - data && - 'expirationTime' in data && - typeof data.expirationTime === 'string' && - 'refreshToken' in data && - typeof data.refreshToken === 'string' && - 'token' in data && - typeof data.token === 'string', - ); -} - export function isCart(data: unknown): data is Cart { return Boolean( typeof data === 'object' && @@ -298,3 +230,33 @@ export function isShoppingListPagedQueryResponse(data: unknown): data is Shoppin Array.isArray(data.results), ); } + +export function isDiscountCodePagedQueryResponse(data: unknown): data is DiscountCodePagedQueryResponse { + return Boolean( + typeof data === 'object' && + data && + 'count' in data && + typeof data.count === 'number' && + 'limit' in data && + typeof data.limit === 'number' && + 'total' in data && + typeof data.total === 'number' && + 'results' in data && + Array.isArray(data.results), + ); +} + +export function isProductDiscountPagedQueryResponse(data: unknown): data is ProductDiscountPagedQueryResponse { + return Boolean( + typeof data === 'object' && + data && + 'count' in data && + typeof data.count === 'number' && + 'limit' in data && + typeof data.limit === 'number' && + 'total' in data && + typeof data.total === 'number' && + 'results' in data && + Array.isArray(data.results), + ); +} diff --git a/src/shared/Button/tests/Button.spec.ts b/src/shared/Button/tests/Button.spec.ts index dd47b6cc..391ae399 100644 --- a/src/shared/Button/tests/Button.spec.ts +++ b/src/shared/Button/tests/Button.spec.ts @@ -1,5 +1,9 @@ import ButtonModel from '../model/ButtonModel.ts'; +/** + * @vitest-environment jsdom + */ + const button = new ButtonModel({ action: { key: 'click', diff --git a/src/shared/Confirm/model/ConfirmModel.ts b/src/shared/Confirm/model/ConfirmModel.ts index 749981b4..5471ba1d 100644 --- a/src/shared/Confirm/model/ConfirmModel.ts +++ b/src/shared/Confirm/model/ConfirmModel.ts @@ -1,7 +1,7 @@ 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 { showErrorMessage } from '@/shared/utils/userMessage.ts'; import ConfirmView from '../view/ConfirmView.ts'; diff --git a/src/shared/Confirm/view/ConfirmView.ts b/src/shared/Confirm/view/ConfirmView.ts index bcec0507..eb848e01 100644 --- a/src/shared/Confirm/view/ConfirmView.ts +++ b/src/shared/Confirm/view/ConfirmView.ts @@ -1,7 +1,7 @@ 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 { BUTTON_TEXT, BUTTON_TEXT_KEY } from '@/shared/constants/buttons.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './confirmView.module.scss'; @@ -25,10 +25,10 @@ class ConfirmView { private createCancelButton(): ButtonModel { this.cancelButton = new ButtonModel({ classes: [styles.cancelButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CANCEL, + text: BUTTON_TEXT[getCurrentLanguage()].CANCEL, }); - observeCurrentLanguage(this.cancelButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.CANCEL); + observeCurrentLanguage(this.cancelButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.CANCEL); return this.cancelButton; } @@ -36,10 +36,10 @@ class ConfirmView { private createConfirmButton(): ButtonModel { this.confirmButton = new ButtonModel({ classes: [styles.confirmButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].CONFIRM, + text: BUTTON_TEXT[getCurrentLanguage()].CONFIRM, }); - observeCurrentLanguage(this.confirmButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.CONFIRM); + observeCurrentLanguage(this.confirmButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.CONFIRM); return this.confirmButton; } diff --git a/src/shared/Confirm/view/confirmView.module.scss b/src/shared/Confirm/view/confirmView.module.scss index 0b03f335..86d7857c 100644 --- a/src/shared/Confirm/view/confirmView.module.scss +++ b/src/shared/Confirm/view/confirmView.module.scss @@ -4,13 +4,11 @@ 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); + background-color: var(--white-tr); gap: var(--extra-small-offset); } diff --git a/src/shared/Input/tests/Input.spec.ts b/src/shared/Input/tests/Input.spec.ts index 5928e9bd..19924ef5 100644 --- a/src/shared/Input/tests/Input.spec.ts +++ b/src/shared/Input/tests/Input.spec.ts @@ -2,6 +2,10 @@ import type { InputParams } from '@/shared/types/form.ts'; import InputModel from '../model/InputModel.ts'; +/** + * @vitest-environment jsdom + */ + const params: InputParams = { autocomplete: 'on', id: 'password', diff --git a/src/shared/Input/view/InputView.ts b/src/shared/Input/view/InputView.ts index d8a00c87..b8cbe671 100644 --- a/src/shared/Input/view/InputView.ts +++ b/src/shared/Input/view/InputView.ts @@ -1,5 +1,7 @@ import type { InputParams } from '@/shared/types/form'; +import { AUTOCOMPLETE_OPTION } from '@/shared/constants/common.ts'; +import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; class InputView { @@ -12,14 +14,14 @@ class InputView { private createHTML(attrs: InputParams): HTMLInputElement { this.input = createBaseElement({ attributes: { - autocomplete: attrs.autocomplete, + autocomplete: attrs.autocomplete ?? AUTOCOMPLETE_OPTION.ON, id: attrs.id || '', lang: attrs.lang || '', max: String(attrs.max || 0), min: String(attrs.min || 0), placeholder: attrs.placeholder || '', step: String(attrs.step || 1), - type: attrs.type, + type: attrs.type ?? INPUT_TYPE.TEXT, value: attrs.value || '', }, tag: 'input', diff --git a/src/shared/Modal/model/ModalModel.ts b/src/shared/Modal/model/ModalModel.ts index 0226ae95..a711ee94 100644 --- a/src/shared/Modal/model/ModalModel.ts +++ b/src/shared/Modal/model/ModalModel.ts @@ -7,8 +7,16 @@ class ModalModel { return this.view.getHTML(); } - public hide(): void { - this.view.hide(); + public getView(): ModalView { + return this.view; + } + + public hide(callback?: () => void): void { + if (callback) { + this.view.hide(callback); + } else { + this.view.hide(); + } } public removeContent(): void { @@ -19,8 +27,12 @@ class ModalModel { this.view.setContent(content); } - public show(): void { - this.view.show(); + public show(callback?: () => void): void { + if (callback) { + this.view.show(callback); + } else { + this.view.show(); + } } } diff --git a/src/shared/Modal/view/ModalView.ts b/src/shared/Modal/view/ModalView.ts index 41527372..4926adf8 100644 --- a/src/shared/Modal/view/ModalView.ts +++ b/src/shared/Modal/view/ModalView.ts @@ -4,6 +4,8 @@ import createBaseElement from '@/shared/utils/createBaseElement.ts'; import styles from './modalView.module.scss'; class ModalView { + private callback: () => void = () => {}; + private modal: HTMLDivElement; private modalContent: HTMLDivElement; @@ -15,15 +17,21 @@ class ModalView { this.modalOverlay = this.createModalOverlay(); this.modal = this.createHTML(); - document.addEventListener('click', ({ target }) => { - if (target instanceof HTMLDivElement && !this.modalContent.contains(target)) { - this.hide(); + document.addEventListener('click', (event) => { + if (event.target === this.modalOverlay && !this.modalContent.classList.contains(styles.modalContent_hidden)) { + this.hide(this.callback); + this.callback = (): void => {}; } }); document.addEventListener('keydown', ({ key }) => { - if (!this.modalContent.classList.contains(styles.modalContent_hidden) && key === 'Escape') { - this.hide(); + if ( + !this.modalContent.classList.contains(styles.modalContent_hidden) && + key === 'Escape' && + !this.modalContent.classList.contains(styles.modalContent_hidden) + ) { + this.hide(this.callback); + this.callback = (): void => {}; } }); } @@ -58,6 +66,10 @@ class ModalView { return this.modalOverlay; } + private setCallback(callback: () => void): void { + this.callback = callback; + } + public getHTML(): HTMLDivElement { return this.modal; } @@ -70,7 +82,8 @@ class ModalView { return this.modalOverlay; } - public hide(): void { + public hide(callback?: () => void): void { + callback?.(); this.modal.classList.add(styles.modal_hidden); this.modalOverlay.classList.add(styles.modalOverlay_hidden); this.modalContent.classList.add(styles.modalContent_hidden); @@ -86,7 +99,11 @@ class ModalView { this.modalContent.append(content); } - public show(): void { + public show(callback?: () => void): void { + if (callback) { + this.setCallback(callback); + } + this.modal.classList.remove(styles.modal_hidden); this.modalOverlay.classList.remove(styles.modalOverlay_hidden); this.modalContent.classList.remove(styles.modalContent_hidden); diff --git a/src/shared/Modal/view/modalView.module.scss b/src/shared/Modal/view/modalView.module.scss index 999fd7d9..e84088b0 100644 --- a/src/shared/Modal/view/modalView.module.scss +++ b/src/shared/Modal/view/modalView.module.scss @@ -8,8 +8,10 @@ opacity: 1; visibility: visible; transition: - opacity 0.3s, - visibility 0.3s; + opacity 0.2s, + visibility 0.2s, + backdrop-filter 0.2s; + backdrop-filter: blur(5px); &_hidden { opacity: 0; @@ -27,9 +29,8 @@ opacity: 1; visibility: visible; transition: - opacity 0.3s, - visibility 0.3s; - backdrop-filter: blur(5px); + opacity 0.2s, + visibility 0.2s; &_hidden { opacity: 0; @@ -41,9 +42,11 @@ position: absolute; left: 50%; top: 50%; + overflow: hidden scroll; border: var(--two) solid var(--noble-black-500); border-radius: var(--border-radius); max-width: 95%; + max-height: 80vh; font: var(--small-font); letter-spacing: var(--one); color: var(--steam-green-500); @@ -52,13 +55,33 @@ visibility: visible; transform: translate(-50%, -50%); transition: - opacity 0.3s, - visibility 0.3s; + opacity 0.2s, + visibility 0.2s; &_hidden { opacity: 0; visibility: hidden; } + + &::-webkit-scrollbar { + border-radius: var(--medium-br); + width: var(--five); + } + + &::-webkit-scrollbar-track { + border-radius: var(--medium-br); + background: var(--white-tr); + } + + &::-webkit-scrollbar-thumb { + border-radius: var(--medium-br); + background-color: var(--steam-green-800); + cursor: pointer; + } + + @media (max-width: 768px) { + width: max-content; + } } .stopScroll { diff --git a/src/pages/Blog/data/posts.ts b/src/shared/Posts/posts.ts similarity index 65% rename from src/pages/Blog/data/posts.ts rename to src/shared/Posts/posts.ts index 32a8e289..639141ea 100644 --- a/src/pages/Blog/data/posts.ts +++ b/src/shared/Posts/posts.ts @@ -1,4 +1,4 @@ -import type { Post } from '@/shared/constants/blog'; +import type { Post } from '../types/blog'; const postsData: Post[] = [ { @@ -24,7 +24,7 @@ const postsData: Post[] = [ }, { content: { - en: `
What does watering my plants do?

Water provides structural support, cools your plant down, and moves minerals to all the right places.

Think of plant cells as water balloons. When they are filled with water, they become stiff, and your plant stands upright. When there is a lack of water, the cells deflate, and the plant looks wilted—a clear sign your plant needs more water if paired with dry potting mix. Plants also produce cellulose that help them keep their shape, but water pressure (water flowing through the plant) helps plants gain and retain their shape better than cellulose alone.

When you water your plant, an invisible process called transpiration occurs. The sun evaporates water from the leaves through their stomatal pores, causing water loss in the leaf. That’s great, because available water will go where it’s needed most. Ideally, the water is pulled up from the roots, but if the roots are dry, water is taken from the leaves themselves.

How often should I water my plants?

Much like different plants need varying amounts of light, different plants need varying amounts of water. To get a sense of how much water your plants might need, think of your houseplants’ natural environments: are they hot and dry, or rainy and tropical?

Desert natives like succulents like to stay dry and will benefit from less frequent waterings. Succulents come from hot arid environments, prefer to be watered less frequently than plants from tropical habitats, and have a physical characteristic that relates to their moisture-storing capacity. For example fleshy leaves, thick stems, or rhizomes. Some have shallow root systems, indicative of growing in places where rain is infrequent and rarely enough to soak deep into the ground. When you water succulent plants make sure their potting mix dries out completely afterward and wait a few weeks before watering again.

Unlike succulent plants, tropical plants like the Monstera deliciosa or Bird's Nest Fern are used to frequent rain showers in their natural environments. They did not adapt succulent characteristics to store water and tolerate drought. These leafy plants will thrive with more frequent waterings, about once a week or so.

How much should I water my plants?

In addition to the variety of the plant, the size will also determine how much water it needs. Potting soil is like a sponge. In smaller pots with less soil, the soil will dry out faster than in larger pots with lots of soil. If you have two of the same plant and one is larger than the other, one will need water more often than the other.

How to Water

Step 1: Check your potting soil to determine if it is dry. Most plants benefit from drying out completely between waterings; some moisture-loving plants like ferns can be watered again when the soil is mostly dry.

Step 2: If the soil is dry, fill a watering can or vessel with room temperature water. Some plants can be sensitive to tap water; try leaving your water out overnight before watering.

Step 3: Water the potting mix evenly around the plant. You want to saturate the soil but not create mud. Avoid splashing water onto your plant's foliage, which could cause fungal or bacterial spots. (Unless, of course, your plant is an epiphyte without soil.)

Step 4: Water up to one-fourth or one-third of the volume of your planter. For planters without a drainage hole, be especially mindful of how much water you're using. For planters with a drainage hole, water until you see excess water drain out of the bottom of the planter. You can let the water sit in the saucer or cache pot for 15-30 minutes, allowing the plant's roots to soak more up, then discard it.

Should I set a watering schedule?

We’re inclined to ‘stay hydrated’ but plants can drown if they are flooded with too much water. That’s what we call overwatering. If potting soil is left too wet for too long, your plant can start drooping leaves or get root rot. On the other hand, if your plant’s soil is consistently too dry, you’re likely underwatering.

So what should you do? Be flexible in your plant care habits. Don't stick to strict schedule—watering on exactly the same day every week may do more harm than good. Use that day to check in on your plants instead, watering only those that need it.

Pro tip: It is easier to add water to potting soil than to subtract it. If you're worried you might overwater your plants, err on the side of underwatering instead.

Do the seasons impact how much water my plants need?

The seasonal changes outside impact your plants' growth inside. During the summer growing season, the sun is stronger and out longer. Most houseplants, including succulents, will benefit from more frequent waterings. Succulents—that happily went a month without water while semi-dormant in the winter thanks to shorter days with less light—might need to be watered every week come summer. While tropical plants might need water twice a week, compared to every 1–2 weeks in winter.

What else should I know about watering houseplants?

There are some golden rules to keep in mind for watering your plants:

– Most houseplants prefer warm or tepid water over cold water, which can shock your plant. Warm water absorbs into soil best.

– Some houseplants are sensitive to tap water. Let water sit overnight for chlorine to dissipate before using.

– Plants in large planters dry out more slowly than plants in small planters because of the volume of potting soil.

– Try not to splash water onto your plant’s leaves when watering. Fun fact: Most tropical plants have waxy leaves because the rainfall in their natural environment, the rainforest, can be excessive. Waxy leaves helps water slide off and avoids risk of fungal infections.

– Expect to water plants more often in brighter light and less often in lower light, unless they are a drought-tolerant succulent.

– If you're afraid to overwater, look out for visible signs of thirst first, like wrinkling leaves for succulent plants or drooping stems for tropical plants, paired with dry potting soil.

`, + en: `
What does watering my plants do?

Water provides structural support, cools your plant down, and moves minerals to all the right places.

Think of plant cells as water balloons. When they are filled with water, they become stiff, and your plant stands upright. When there is a lack of water, the cells deflate, and the plant looks wilted—a clear sign your plant needs more water if paired with dry potting mix. Plants also produce cellulose that help them keep their shape, but water pressure (water flowing through the plant) helps plants gain and retain their shape better than cellulose alone.

When you water your plant, an invisible process called transpiration occurs. The sun evaporates water from the leaves through their stomatal pores, causing water loss in the leaf. That’s great, because available water will go where it’s needed most. Ideally, the water is pulled up from the roots, but if the roots are dry, water is taken from the leaves themselves.

How often should I water my plants?

Much like different plants need varying amounts of light, different plants need varying amounts of water. To get a sense of how much water your plants might need, think of your houseplants’ natural environments: are they hot and dry, or rainy and tropical?

Desert natives like succulents like to stay dry and will benefit from less frequent waterings. Succulents come from hot arid environments, prefer to be watered less frequently than plants from tropical habitats, and have a physical characteristic that relates to their moisture-storing capacity. For example, fleshy leaves, thick stems, or rhizomes. Some have shallow root systems, indicative of growing in places where rain is infrequent and rarely enough to soak deep into the ground. When you water succulent plants make sure their potting mix dries out completely afterward and wait a few weeks before watering again.

Unlike succulent plants, tropical plants like the Monstera deliciosa or Bird's Nest Fern are used to frequent rain showers in their natural environments. They did not adapt succulent characteristics to store water and tolerate drought. These leafy plants will thrive with more frequent waterings, about once a week or so.

How much should I water my plants?

In addition to the variety of the plant, the size will also determine how much water it needs. Potting soil is like a sponge. In smaller pots with less soil, the soil will dry out faster than in larger pots with lots of soil. If you have two of the same plant and one is larger than the other, one will need water more often than the other.

How to Water

Step 1: Check your potting soil to determine if it is dry. Most plants benefit from drying out completely between waterings; some moisture-loving plants like ferns can be watered again when the soil is mostly dry.

Step 2: If the soil is dry, fill a watering can or vessel with room temperature water. Some plants can be sensitive to tap water; try leaving your water out overnight before watering.

Step 3: Water the potting mix evenly around the plant. You want to saturate the soil but not create mud. Avoid splashing water onto your plant's foliage, which could cause fungal or bacterial spots. (Unless, of course, your plant is an epiphyte without soil.)

Step 4: Water up to one-fourth or one-third of the volume of your planter. For planters without a drainage hole, be especially mindful of how much water you're using. For planters with a drainage hole, water until you see excess water drain out of the bottom of the planter. You can let the water sit in the saucer or cache pot for 15-30 minutes, allowing the plant's roots to soak more up, then discard it.

Should I set a watering schedule?

We’re inclined to ‘stay hydrated’ but plants can drown if they are flooded with too much water. That’s what we call overwatering. If potting soil is left too wet for too long, your plant can start drooping leaves or get root rot. On the other hand, if your plant’s soil is consistently too dry, you’re likely underwatering.

So, what should you do? Be flexible in your plant care habits. Don't stick to a strict schedule — watering on exactly the same day every week may do more harm than good. Use that day to check in on your plants instead, watering only those that need it.

Pro tip: It is easier to add water to potting soil than to subtract it. If you're worried you might overwater your plants, err on the side of underwatering instead.

Do the seasons impact how much water my plants need?

The seasonal changes outside impact your plants' growth inside. During the summer growing season, the sun is stronger and out longer. Most houseplants, including succulents, will benefit from more frequent waterings. Succulents—that happily went a month without water while semi-dormant in the winter thanks to shorter days with less light—might need to be watered every week come summer. While tropical plants might need water twice a week, compared to every 1–2 weeks in winter.

What else should I know about watering houseplants?

There are some golden rules to keep in mind for watering your plants:

– Most houseplants prefer warm or tepid water over cold water, which can shock your plant. Warm water absorbs into soil best.

– Some houseplants are sensitive to tap water. Let water sit overnight for chlorine to dissipate before using.

– Plants in large planters dry out more slowly than plants in small planters because of the volume of potting soil.

– Try not to splash water onto your plant’s leaves when watering. Fun fact: Most tropical plants have waxy leaves because the rainfall in their natural environment, the rainforest, can be excessive. Waxy leaves help water slide off and avoid risk of fungal infections.

– Expect to water plants more often in brighter light and less often in lower light, unless they are a drought-tolerant succulent.

– If you're afraid to overwater, look out for visible signs of thirst first, like wrinkling leaves for succulent plants or drooping stems for tropical plants, paired with dry potting soil.

`, ru: `
Что делает полив моих растений?

Вода обеспечивает структурную поддержку, охлаждает ваше растение и перемещает минералы в нужные места.

Представьте клетки растения как водяные шарики. Когда они наполнены водой, они становятся жесткими, и ваше растение держится прямо. Когда воды не хватает, клетки опускаются, и растение выглядит вялым—явный признак того, что растению нужна больше воды, особенно если почвенная смесь сухая. Растения также производят целлюлозу, которая помогает им сохранять форму, но давление воды (протекающая вода через растение) помогает растениям лучше удерживать и поддерживать форму, чем только целлюлоза.

Когда вы поливаете свое растение, происходит невидимый процесс, называемый транспирацией. Солнце испаряет воду с листьев через их устьица, вызывая потерю влаги в листьях. Это замечательно, потому что доступная вода будет перемещаться туда, где она нужна больше всего. Идеально, вода тянется из корней, но если корни сухие, вода берется из самих листьев.

Как часто мне следует поливать свои растения?

Точно так же, как разные растения нуждаются в разном количестве света, разные растения нуждаются в разном количестве воды. Чтобы понять, сколько воды могут нужно ваши растения, обратитесь к естественной среде обитания ваших комнатных растений: они находятся в жарком и сухом месте или в дождливом тропическом?

Как суккуленты, происходящие из горячих аридных сред, предпочитают оставаться сухими и будут выигрывать от менее частых поливов. Суккуленты происходят из жарких аридных мест, предпочитают быть поливаемыми реже, чем растения из тропических местообитаний, и имеют физические особенности, связанные с их способностью к накоплению влаги. Например, мясистые листья, толстые стебли или клубни. У некоторых есть поверхностные корни, указывающие на рост в местах, где дождь редкий и редко проникает глубоко в почву. После полива суккулентов убедитесь, что почвенная смесь полностью высохла, прежде чем поливать снова через несколько недель.

В отличие от суккулентов, тропические растения, такие как Monstera deliciosa или папоротник птичье гнездо, привыкли к частым дождевым ливням в своих природных средах обитания. Они не приспособились к суккулентным характеристикам для накопления воды и терпят засушливость. Эти листовые растения будут процветать с более частыми поливами, примерно раз в неделю.

Сколько воды нужно моим растениям?

Помимо разнообразия растений, их размер также определит, сколько воды им нужно. Почвенная смесь похожа на губку. В меньших горшках с меньшим количеством почвы почва будет быстрее высыхать, чем в больших горшках с большим количеством почвы. Если у вас два одинаковых растения, а одно из них больше другого, одно будет нуждаться в поливе чаще, чем другое.

Как поливать

Шаг 1: Проверьте свою почвенную смесь, чтобы определить, она ли сухая. Большинству растений выгодно полностью высыхать между поливами; некоторые растения, любящие влагу, такие как папоротники, можно поливать снова, когда почвенная смесь почти высохла.

Шаг 2: Если почва сухая, возьмите кувшин или другой сосуд с водой комнатной температуры. Некоторые растения могут быть чувствительны к водопроводной воде; попробуйте оставить воду на ночь перед поливом.

Шаг 3: Равномерно полейте почвенную смесь вокруг растения. Вы хотите насытить почву, но не создавать грязь. Избегайте попадания влаги на листву растения, что может вызвать грибковые или бактериальные пятна. (За исключением, конечно, ваше растение эпифит без почвы.)

Шаг 4: Поливайте до одной четверти или трети объема вашего горшка. Для горшков без отверстий для стока особенно важно следить за тем, сколько воды вы используете. Для горшков с отверстием для стока поливайте до тех пор, пока из нижней части горшка не вытечет лишняя вода. Вы можете оставить воду в поддоне или кэш-поте на 15-30 минут, чтобы корни растения впитали больше, а затем вылейте ее.

Следует ли мне устанавливать график полива?

Мы склонны 'держать себя в гидратации', но растения могут задохнуться, если их затопить слишком много воды. Это то, что мы называем переувлажнением. Если почвенная смесь остается слишком мокрой слишком долго, у вашего растения могут начаться опадающие листья или гниль корней. С другой стороны, если почва вашего растения постоянно слишком сухая, вероятно, вы недополиваете.

Что же вам делать? Будьте гибкими в своих привычках по уходу за растениями. Не придерживайтесь строгого расписания—поливая в точно один и тот же день каждую неделю, вы можете причинить больше вреда, чем пользы. Используйте этот день, чтобы проверить свои растения, поливая только те, которым это нужно.

Подсказка профессионала: легче добавить воды в почву, чем откачивать ее. Если вы боитесь переполива, лучше недополивайте.

Влияют ли сезоны на потребность моих растений в воде?

Сезонные изменения на улице влияют на рост ваших растений в помещении. В течение летнего сезона роста, солнце сильнее и дольше на улице. Большинству комнатных растений, включая суккуленты, будет полезнее более частое поливание. Суккуленты, которые счастливо обходились без воды целый месяц во время полуспящего состояния зимой благодаря коротким дням с меньшим светом, могут потребовать полива раз в неделю летом. В то время как тропические растения могут требовать полива дважды в неделю, в сравнении с раз в 1-2 недели зимой.

Что еще я должен знать о поливе комнатных растений?

Существуют некоторые золотые правила, которые стоит иметь в виду при поливе ваших растений:

– Большинство комнатных растений предпочитают теплую или теплую воду перед холодной водой, которая может шокировать ваше растение. Теплая вода лучше всего впитывается в почву.

– Некоторые комнатные растения чувствительны к водопроводной воде. Оставьте воду на ночь, чтобы хлор успел испариться, прежде чем поливать.

– Растения в больших горшках высыхают медленнее, чем растения в маленьких горшках из-за объема почвы.

– При поливе старайтесь не попадать водой на листья растения. Интересный факт: большинство тропических растений имеют восковые листья из-за чрезмерных осадков в их естественной среде обитания, тропическом лесу. Восковые листья помогают воде стекать и предотвращают риск грибковых инфекций.

– Ожидайте, что растения потребуют более частого полива при ярком освещении и менее частого при низком освещении, если они не являются засухоустойчивыми суккулентами.

– Если вы боитесь переполива, обращайте внимание на видимые признаки жажды, например, морщинистые листья для суккулентных растений или опущенные стебли для тропических растений, в сочетании с сухой почвой.

`, }, date: { @@ -34,7 +34,7 @@ const postsData: Post[] = [ id: 'bl-post-02', image: 'img/webp/bl-post-02.webp', shortDescription: { - en: 'Water is amazing. Made up of hydrogen and oxygen, it’s literally responsible for all life on Earth. Watering your plant is a no-brainer, but how much and how often can be more tricky to tell. Luckily, we have a few ideas on watering for optimum plant health.', + en: 'Water is amazing. Made up of hydrogen and oxygen, it’s literally responsible for all life on Earth. Watering your plant is a no-brainer, but how much and how often can be trickier to tell. Luckily, we have a few ideas on watering for optimum plant health.', ru: 'Вода - удивительное явление. Состоящая из водорода и кислорода, она буквально отвечает за все живое на Земле. Поливать растение - дело нехитрое, но вот сколько и как часто - это уже сложнее. К счастью, у нас есть несколько идей по поводу полива для оптимального здоровья растений.', }, time: 6, @@ -45,8 +45,8 @@ const postsData: Post[] = [ }, { content: { - en: `

Whether you’ll be spending a long weekend at the beach or a full month abroad, we’re sharing our top tips and tricks for keeping your houseplants happy and healthy while you’re away.

It only takes a little bit of time to prep your plants so you can focus on more important things, like strong sunscreen and a good book!

Tips to Keep Plants Alive While You’re Away

1. Tweak light and temperature

The more sunlight your plant receives, the more thirsty it will be over time. This is for a few reasons, the biggest being that plants utilize the most water during a process called transpiration, and the rate of transpiration is dependent on, and increases with, the amount of sunlight the plant receives.

So the more natural light your plant is getting, the more water it’ll need. To help your plants from wilting while you’re away from lack of water, you can move them a little bit further away from their source of natural light. Place them in the middle of the room so that the heat and light from the windows does not dry them out as fast as usual. Once you return, you can move your plants back to their usual spot. If you don’t want to relocate plants, you can also draw a sheer curtain over the window.

If your plants were not receiving plenty of light to begin with, due to obstructed windows or the season, you can decide to keep your plants where they are. A good way to determine is to ask yourself how often you have to water a plantif it's every week, you might want to adjust its placement if you’ll be gone for a longer period of time. But if it's every other week, you may find no need to move them.

And as always—whether you’re home or away—never leave an air conditioning or heating system blasting on or near a houseplant. Although a luxury for humans, A/Cs and heaters tends to rob your indoor environment of the humidity most tropical plants crave.

2. Maintain moisture

If you plan to be away for a week or less, watering your plants thoroughly before departure will be sufficient. Make sure you are only watering plants with dry or mostly dry potting soil. Let any excess water drain from your potted plant before you’re on your way, so the potting soil is moist but your plants are not sitting in a saucer of water, which could attract pests or lead to root rot.

Note this is only necessary for plants that need to be watered once a week or more. Drought-tolerant houseplants, like succulents and cacti, will be fine for a week or two without water. And during the winter months, when plant growth slows and some plants go dormant, you may skip watering them altogether.

If you plan to be away for more than a full week, there are a couple of ways to prepare your plants. Try one of the tips below or a combination—depending on the length of your trip, the variety of plant, and the time of year.

Continue to keep in mind: how often do I usually water this plant during this time? Since you won’t be home to keep an eye on your plants, you want to avoid overwatering them before you go!

1. Add lava rocks, mulch, or wood chips to the top of your plant’s soil to help hold moisture before or after giving dry soil a good soaking. Damp newspaper can also do the trick. This will help the soil stay moist for longer.

2. Water your plant thoroughly and then cover with a clear plastic bag to just below the lip of the planter, creating a makeshift greenhouse. Make sure to cut a couple slits in the plastic to allow for ample air circulation... plants need to breathe, too! Use sticks (or leftover chopsticks) to hold the bag up and away from the foliage. You want to make sure no foliage is touching the bag.

3. Line a shallow tray with small rocks and fill the tray up with water to slightly beneath the top of the rocks. Set your planter on top of the rocks—the base of the planter should not be touching or sitting directly in the idle water but right above it. This will help to increase humidity and moisture levels, but should not lead to over-watering or root rot.

4. Transport your humidity-loving plants, like ferns and air plants, to your bathroom (provided you have a window that receives some natural light) or another small room and group them together. The smaller the room, the easier it is for your plants to maintain humidity and moisture.

5. DIY a self-watering system with capillary wicks or empty bottles:

– Submerge one end of the capillary wick in a basin of water (choose the size of the water container based on how long you’ll be away for) and the other end of the wick into your plant’s potting mix. Your plant will pull the water it needs through the wick while you're away. (This is our team's preferred method for extended periods of time away from our plants.)

– Upcycle old plastic or glass bottles by filling the bottle with water and puncturing the bottle top. Make sure the hole is small enough that water will be released slowly, over time. Flip your filled bottle upside down and stick the top of the bottle, with the punctured bottle top, deep into your plant’s potting soil.

6. Call on a friend. If you’re going to be away for an extended period of time (over a month) and have a friend that’s willing to water your houseplants for you—take them up on the offer! Leave your friend with clear written instructions, or walk them through your care routine a week or two beforehand. We won’t judge if you ask them for photo updates while you’re gone... Just make sure to bring them back a souvenir (or send them a new plant as a thank you when you return.)

3. Forgo the fertilizer

If you occasionally use fertilizer, make sure to hold off on fertilizing your houseplants until you return from your trip. Do not fertilize your plants in the weeks prior to your departure. You’ll want your plants to grow as slowly as possible while you’re away, which will help your plants conserve energy and water.

4. Do some light pruning

In addition to pruning off any dead, dying, or unhealthy-looking foliage, you can prune off any buds and flowers, which usually require more frequent waterings to stay healthy.

Pro tip: The tricks above, in particular ways to "water" while away, mostly apply to tropical plants. When it comes to drought-tolerant plants like varieties of succulents, ZZ plants, or snake plants, they can go over a month without water, especially if placed out of direct light. If you’re an avid traveler, succulent plants are the houseplants for you.

Whatever preparation you take, give yourself a big pat on the back when you return to happy and healthy houseplants. They missed you, too.

`, - ru: `

Будь то длительные выходные на пляже или полный месяц за границей, мы делимся нашими лучшими советами и хитростями по уходу за комнатными растениями, чтобы они оставались счастливыми и здоровыми, пока вы отсутствуете.

За подготовку растений требуется всего немного времени, чтобы вы могли сконцентрироваться на более важных вещах, таких как сильный солнцезащитный крем и хорошая книга!

Tips to Keep Plants Alive While You’re Away

1. Регулируйте освещение и температуру

Чем больше солнечного света получает ваше растение, тем больше оно будет испытывать жажду со временем. Это объясняется несколькими причинами, главная из которых состоит в том, что растения используют больше воды в процессе транспирации, и скорость транспирации зависит от количества солнечного света, которое получает растение.

Чем больше естественного света получает ваше растение, тем больше воды ему потребуется. Чтобы помочь вашим растениям избежать засыхания из-за недостатка воды во время вашего отсутствия, вы можете переместить их немного дальше от источника естественного света. Поместите их посередине комнаты, чтобы тепло и свет от окон не высушили их так быстро, как обычно. По возвращении вы можете вернуть растения на прежнее место. Если вы не хотите перемещать растения, вы также можете опустить тонкую занавеску на окно.

Если ваши растения изначально не получали достаточно света из-за загороженных окон или времени года, вы можете решить оставить их на месте. Хорошим способом определить, стоит ли переместить растение, является вопрос о том, как часто вы обычно поливаете растение — если каждую неделю, вам может потребоваться изменить его расположение, если вы уезжаете надолго. Но если вы поливаете его раз в две недели, то, возможно, перемещать его не требуется.

И как всегда — будь то дома или вдали — никогда не оставляйте включенным кондиционер или отопление рядом с комнатными растениями. Хотя это роскошь для людей, кондиционеры и обогреватели часто уменьшают влажность воздуха в помещении, которую большинство тропических растений так нуждаются.

2. Сохраняйте влажность

Если вы планируете отсутствовать неделю или менее, обильное поливание ваших растений перед отъездом будет достаточным. Убедитесь, что поливаете только растения с сухой или в основном сухой почвой. Дайте стечь лишней воде из вашего цветочного горшка перед отправлением, чтобы почва была влажной, но ваши растения не стояли в поддоне с водой, что может привлечь вредителей или привести к гниению корней.

Это необходимо только для растений, которые нужно поливать раз в неделю или чаще. Тропические комнатные растения, устойчивые к засухе, такие как суккуленты и кактусы, продержатся неделю или две без воды. А зимой, когда растения перестают активно расти и некоторые засыпают, поливать их можно пропустить вообще.

Если вы планируете отсутствовать более недели, есть несколько способов подготовить ваши растения. Попробуйте один из нижеперечисленных советов или их комбинацию — в зависимости от продолжительности вашей поездки, разнообразия растений и времени года.

Продолжайте помнить: как часто я обычно поливаю это растение в это время? Поскольку вы не будете дома, чтобы следить за своими растениями, вам нужно избегать их переувлажнения перед отъездом!

1. Добавьте лавовые камни, мульчу или древесные опилки на верхнюю часть почвы вашего растения, чтобы сохранить влагу. Сырая газета тоже подойдет. Это поможет сохранить влажность почвы на дольше.

2. Обильно полейте растение и затем накройте его прозрачным полиэтиленовым мешком немного ниже края горшка, создавая импровизированную теплицу. Обязательно сделайте несколько прорезей в пленке для обеспечения достаточной циркуляции воздуха... растения тоже нужно дышать! Используйте палки (или оставшиеся палочки для суши) для поддержания мешка на расстоянии от листвы. Убедитесь, что ни один лист не касается пленки.

3. Выложите неглубокую подставку мелкими камнями и наполните ее водой чуть ниже верхнего уровня камней. Поставьте ваш горшок на камни — дно горшка не должно касаться воды, но находиться чуть выше нее. Это поможет повысить уровень влажности и влажность, но не приведет к переувлажнению или гниению корней.

4. Перенесите свои растения, любящие влажность, такие как папоротники и воздушные растения, в ванную комнату (при наличии окна, получающего естественный свет) или другое небольшое помещение и сгруппируйте их вместе. Чем меньше помещение, тем проще растениям поддерживать влажность и уровень влажности.

5. Сделайте самостоятельную систему автоматического полива с помощью капиллярных фитилей или пустых бутылок:

– Погрузите один конец капиллярного фитиля в бассейн с водой (выберите размер контейнера с водой в зависимости от того, насколько долго вы будете отсутствовать), а другой конец фитиля — в почву вашего растения. Растение будет тянуть воду через фитиль, пока вы будете в отъезде. (Это предпочтительный метод нашей команды для долгих периодов отсутствия у растений.)

– Переработайте старые пластиковые или стеклянные бутылки, наполнив их водой и прокололи верхнюю часть. Убедитесь, что отверстие достаточно маленькое, чтобы вода выходила медленно, постепенно. Переверните заполненную бутылку вверх дном и утопите ее верхнюю часть с проколотой крышкой глубоко в почву вашего растения.

6. Попросите помощи друга. Если вы планируете отсутствовать продолжительное время (более месяца) и у вас есть друг, который согласен поливать ваши комнатные растения — воспользуйтесь его предложением! Оставьте другу четкие письменные инструкции или проведите с ним инструктаж за неделю или две до отъезда. Не стесняйтесь попросить его делать фотоотчеты во время вашего отсутствия... Просто не забудьте привезти ему сувенир (или подарите новое растение) в знак благодарности, когда вернетесь.

3. Откажитесь от удобрений

Если вы иногда используете удобрения, убедитесь, что отложили их на время отсутствия. Не удобряйте растения за недели до вашего отъезда. Вы хотите, чтобы ваши растения росли как можно медленнее, пока вы отсутствуете, что поможет им сберечь энергию и воду.

4. Проведите легкую обрезку

Помимо удаления мертвых, умирающих или нездорово выглядящих листьев, вы можете убрать почки и цветы, которые обычно требуют более частого полива, чтобы оставаться здоровыми.

Совет: Описанные выше хитрости, особенно способы "полива" во время отсутствия, в основном применяются к тропическим растениям. Когда дело доходит до растений, устойчивых к засухе, таких как разные виды суккулентов, растений ZZ или декоративных сансевьерий, они могут продержаться более месяца без воды, особенно если стоят в тени. Если вы страстный путешественник, суккуленты — это ваш выбор для комнатных растений.

Какими бы приготовлениями вы ни занялись, похвалите себя, когда вернетесь к счастливым и здоровым комнатным растениям. Они тоже скучали по вам.

`, + en: `

Whether you’ll be spending a long weekend at the beach or a full month abroad, we’re sharing our top tips and tricks for keeping your houseplants happy and healthy while you’re away.

It only takes a little bit of time to prep your plants so you can focus on more important things, like strong sunscreen and a good book!

Tips to Keep Plants Alive While You’re Away

1. Tweak light and temperature

The more sunlight your plant receives, the thirstier it will be over time. This is for a few reasons, the biggest being that plants utilize the most water during a process called transpiration, and the rate of transpiration is dependent on, and increases with, the amount of sunlight the plant receives.

So, the more natural light your plant is getting, the more water it’ll need. To help your plants from wilting while you’re away from lack of water, you can move them a little bit further away from their source of natural light. Place them in the middle of the room so that the heat and light from the windows does not dry them out as fast as usual. Once you return, you can move your plants back to their usual spot. If you don’t want to relocate plants, you can also draw a sheer curtain over the window.

If your plants were not receiving plenty of light to begin with, due to obstructed windows or the season, you can decide to keep your plants where they are. A good way to determine is to ask yourself how often you have to water a plantif it's every week, you might want to adjust its placement if you’ll be gone for a longer period of time. But if it's every other week, you may find no need to move them.

And as always—whether you’re home or away—never leave an air conditioning or heating system blasting on or near a houseplant. Although a luxury for humans, A/Cs and heaters tends to rob your indoor environment of the humidity most tropical plants crave.

2. Maintain moisture

If you plan to be away for a week or less, watering your plants thoroughly before departure will be sufficient. Make sure you are only watering plants with dry or mostly dry potting soil. Let any excess water drain from your potted plant before you’re on your way, so the potting soil is moist, but your plants are not sitting in a saucer of water, which could attract pests or lead to root rot.

Note this is only necessary for plants that need to be watered once a week or more. Drought-tolerant houseplants, like succulents and cacti, will be fine for a week or two without water. And during the winter months, when plant growth slows and some plants go dormant, you may skip watering them altogether.

If you plan to be away for more than a full week, there are a couple of ways to prepare your plants. Try one of the tips below or a combination—depending on the length of your trip, the variety of a plant, and the time of year.

Continue to keep in mind: how often do I usually water this plant during this time? Since you won’t be home to keep an eye on your plants, you want to avoid overwatering them before you go!

1. Add lava rocks, mulch, or wood chips to the top of your plant’s soil to help hold moisture before or after giving dry soil a good soaking. Damp newspaper can also do the trick. This will help the soil stay moist for longer.

2. Water your plant thoroughly and then cover it with a clear plastic bag to just below the lip of the planter, creating a makeshift greenhouse. Make sure to cut a couple slits in the plastic to allow for ample air circulation... plants need to breathe, too! Use sticks (or leftover chopsticks) to hold the bag up and away from the foliage. You want to make sure no foliage is touching the bag.

3. Line a shallow tray with small rocks and fill the tray up with water to slightly beneath the top of the rocks. Set your planter on top of the rocks—the base of the planter should not be touching or sitting directly in the idle water but right above it. This will help to increase humidity and moisture levels, but should not lead to over-watering or root rot.

4. Transport your humidity-loving plants, like ferns and air plants, to your bathroom (provided you have a window that receives some natural light) or another small room and group them together. The smaller the room, the easier it is for your plants to maintain humidity and moisture.

5. DIY a self-watering system with capillary wicks or empty bottles:

– Submerge one end of the capillary wick in a basin of water (choose the size of the water container based on how long you’ll be away for) and the other end of the wick into your plant’s potting mix. Your plant will pull the water it needs through the wick while you're away. (This is our team's preferred method for extended periods of time away from our plants.)

– Upcycle old plastic or glass bottles by filling the bottle with water and puncturing the bottle top. Make sure the hole is small enough that water will be released slowly, over time. Flip your filled bottle upside down and stick the top of the bottle, with the punctured bottle top, deep into your plant’s potting soil.

6. Call on a friend. If you’re going to be away for an extended period of time (over a month) and have a friend that’s willing to water your houseplants for you—take them up on the offer! Leave your friend with clear written instructions or walk them through your care routine a week or two beforehand. We won’t judge if you ask them for photo updates while you’re gone... Just make sure to bring them back a souvenir (or send them a new plant as a thank you when you return.)

3. Forgo the fertilizer

If you occasionally use fertilizer, make sure to hold off on fertilizing your houseplants until you return from your trip. Do not fertilize your plants in the weeks prior to your departure. You’ll want your plants to grow as slowly as possible while you’re away, which will help your plants conserve energy and water.

4. Do some light pruning

In addition to pruning off any dead, dying, or unhealthy-looking foliage, you can prune off any buds and flowers, which usually require more frequent waterings to stay healthy.

Pro tip: The tricks above, in particular ways to "water" while away, mostly apply to tropical plants. When it comes to drought-tolerant plants like varieties of succulents, ZZ plants, or snake plants, they can go over a month without water, especially if placed out of direct light. If you’re an avid traveler, succulent plants are the houseplants for you.

Whatever preparation you take, give yourself a big pat on the back when you return to happy and healthy houseplants. They missed you, too.

`, + ru: `

Будь то длительные выходные на пляже или полный месяц за границей, мы делимся нашими лучшими советами и хитростями по уходу за комнатными растениями, чтобы они оставались счастливыми и здоровыми, пока вы отсутствуете.

За подготовку растений требуется всего немного времени, чтобы вы могли сконцентрироваться на более важных вещах, таких как сильный солнцезащитный крем и хорошая книга!

Советы по уходу за растениями во время вашего отсутствия

1. Регулируйте освещение и температуру

Чем больше солнечного света получает ваше растение, тем больше оно будет испытывать жажду со временем. Это объясняется несколькими причинами, главная из которых состоит в том, что растения используют больше воды в процессе транспирации, и скорость транспирации зависит от количества солнечного света, которое получает растение.

Чем больше естественного света получает ваше растение, тем больше воды ему потребуется. Чтобы помочь вашим растениям избежать засыхания из-за недостатка воды во время вашего отсутствия, вы можете переместить их немного дальше от источника естественного света. Поместите их посередине комнаты, чтобы тепло и свет от окон не высушили их так быстро, как обычно. По возвращении вы можете вернуть растения на прежнее место. Если вы не хотите перемещать растения, вы также можете опустить тонкую занавеску на окно.

Если ваши растения изначально не получали достаточно света из-за загороженных окон или времени года, вы можете решить оставить их на месте. Хорошим способом определить, стоит ли переместить растение, является вопрос о том, как часто вы обычно поливаете растение — если каждую неделю, вам может потребоваться изменить его расположение, если вы уезжаете надолго. Но если вы поливаете его раз в две недели, то, возможно, перемещать его не требуется.

И как всегда — будь то дома или вдали — никогда не оставляйте включенным кондиционер или отопление рядом с комнатными растениями. Хотя это роскошь для людей, кондиционеры и обогреватели часто уменьшают влажность воздуха в помещении, которую большинство тропических растений так нуждаются.

2. Сохраняйте влажность

Если вы планируете отсутствовать неделю или менее, обильное поливание ваших растений перед отъездом будет достаточным. Убедитесь, что поливаете только растения с сухой или в основном сухой почвой. Дайте стечь лишней воде из вашего цветочного горшка перед отправлением, чтобы почва была влажной, но ваши растения не стояли в поддоне с водой, что может привлечь вредителей или привести к гниению корней.

Это необходимо только для растений, которые нужно поливать раз в неделю или чаще. Тропические комнатные растения, устойчивые к засухе, такие как суккуленты и кактусы, продержатся неделю или две без воды. А зимой, когда растения перестают активно расти и некоторые засыпают, поливать их можно пропустить вообще.

Если вы планируете отсутствовать более недели, есть несколько способов подготовить ваши растения. Попробуйте один из нижеперечисленных советов или их комбинацию — в зависимости от продолжительности вашей поездки, разнообразия растений и времени года.

Продолжайте помнить: как часто я обычно поливаю это растение в это время? Поскольку вы не будете дома, чтобы следить за своими растениями, вам нужно избегать их переувлажнения перед отъездом!

1. Добавьте лавовые камни, мульчу или древесные опилки на верхнюю часть почвы вашего растения, чтобы сохранить влагу. Сырая газета тоже подойдет. Это поможет сохранить влажность почвы на дольше.

2. Обильно полейте растение и затем накройте его прозрачным полиэтиленовым мешком немного ниже края горшка, создавая импровизированную теплицу. Обязательно сделайте несколько прорезей в пленке для обеспечения достаточной циркуляции воздуха... растения тоже нужно дышать! Используйте палки (или оставшиеся палочки для суши) для поддержания мешка на расстоянии от листвы. Убедитесь, что ни один лист не касается пленки.

3. Выложите неглубокую подставку мелкими камнями и наполните ее водой чуть ниже верхнего уровня камней. Поставьте ваш горшок на камни — дно горшка не должно касаться воды, но находиться чуть выше нее. Это поможет повысить уровень влажности и влажность, но не приведет к переувлажнению или гниению корней.

4. Перенесите свои растения, любящие влажность, такие как папоротники и воздушные растения, в ванную комнату (при наличии окна, получающего естественный свет) или другое небольшое помещение и сгруппируйте их вместе. Чем меньше помещение, тем проще растениям поддерживать влажность и уровень влажности.

5. Сделайте самостоятельную систему автоматического полива с помощью капиллярных фитилей или пустых бутылок:

– Погрузите один конец капиллярного фитиля в бассейн с водой (выберите размер контейнера с водой в зависимости от того, насколько долго вы будете отсутствовать), а другой конец фитиля — в почву вашего растения. Растение будет тянуть воду через фитиль, пока вы будете в отъезде. (Это предпочтительный метод нашей команды для долгих периодов отсутствия у растений.)

– Переработайте старые пластиковые или стеклянные бутылки, наполнив их водой и прокололи верхнюю часть. Убедитесь, что отверстие достаточно маленькое, чтобы вода выходила медленно, постепенно. Переверните заполненную бутылку вверх дном и утопите ее верхнюю часть с проколотой крышкой глубоко в почву вашего растения.

6. Попросите помощи друга. Если вы планируете отсутствовать продолжительное время (более месяца) и у вас есть друг, который согласен поливать ваши комнатные растения — воспользуйтесь его предложением! Оставьте другу четкие письменные инструкции или проведите с ним инструктаж за неделю или две до отъезда. Не стесняйтесь попросить его делать фотоотчеты во время вашего отсутствия... Просто не забудьте привезти ему сувенир (или подарите новое растение) в знак благодарности, когда вернетесь.

3. Откажитесь от удобрений

Если вы иногда используете удобрения, убедитесь, что отложили их на время отсутствия. Не удобряйте растения за недели до вашего отъезда. Вы хотите, чтобы ваши растения росли как можно медленнее, пока вы отсутствуете, что поможет им сберечь энергию и воду.

4. Проведите легкую обрезку

Помимо удаления мертвых, умирающих или нездорово выглядящих листьев, вы можете убрать почки и цветы, которые обычно требуют более частого полива, чтобы оставаться здоровыми.

Совет: Описанные выше хитрости, особенно способы "полива" во время отсутствия, в основном применяются к тропическим растениям. Когда дело доходит до растений, устойчивых к засухе, таких как разные виды суккулентов, растений ZZ или декоративных сансевьерий, они могут продержаться более месяца без воды, особенно если стоят в тени. Если вы страстный путешественник, суккуленты — это ваш выбор для комнатных растений.

Какими бы приготовлениями вы ни занялись, похвалите себя, когда вернетесь к счастливым и здоровым комнатным растениям. Они тоже скучали по вам.

`, }, date: { en: 'September 20', @@ -60,14 +60,14 @@ const postsData: Post[] = [ }, time: 4, title: { - en: 'How To Keep Your Plants Alive While On Vacation', + en: 'How To Keep Your Plants Alive While on Vacation', ru: 'Как сохранить растения живыми во время отпуска', }, }, { content: { - en: `

Why Do Leaves Turn Yellow?

Yellow leaves on plants mean different things depending on the variety, and what other symptoms the plant is showing. Overwatering, underwatering, mineral deficiency, temperature stress, and so on can all be the cause. Identifying the specific symptoms your plant is showing can help you determine the cause and take appropriate action.

How to Fix Yellow Leaves

Below, we go into the solution to get rid of yellowing leaves by first identifying the symptoms displayed by the plant which can help pinpoint the cause.

Leaves that are Yellow, Curling, and Drooping

If you notice your plant has yellow leaves that are curling inwards and the soil is dry to the touch, it's likely under-watered. You might also see older leaves falling off. To fix this, the solution is to water your plant.

Yellow Leaves Fading to Green or Turning Bright Yellow

Are the leaves turning bright yellow and is the soil wet? You might even notice blackened stem bases or fungus gnats. This is a sign of overwatering. You can correct this by letting the soil dry out or repotting the plant in dry soil.

Irregular Yellow Spots or Leaf Deformities

Irregular yellowing with potential leaf deformities is usually caused either by a pest or a mineral deficiency. If no pests are visible, then this is likely caused by a mineral deficiency, usually calcium or boron. The solution is to fertilize once a month, or repot your plant to provide fresh potting soil. Fresh potting soil contains new nutrients.

Whole Plant Yellowing (May or May Not Drop Leaves)

Most likely a temperature issue — it’s either too cold or too hot for your plant where it is placed. This will usually be a more pale yellow or whitish yellow. Temperature will flux around the plant too much or will be obvious, like a radiator or a draft. It could also be a fertilizer issue. If no obvious temperature causes are present and the soil seems normal, try a little fertilizer.

Whole Plant Semi-Yellowing (Without Leaf Drop)

A “general malaise” of a plant turning yellow means that it’s either pot-bound—the roots have no room to expand—or your plant is in the early stages of a fertilizer deficiency. The solution is to repot to a bigger pot, or try a little fertilizer.

Only Mature Leaves are Turning Yellow

As plants mature and grow, older leaves can age-out, start to yellow, and eventually fall from your plant. This is natural leaf shedding. If your plant is happy and healthy otherwise, and only older, mature leaves are yellowing and dropping, there is no need to worry!

`, - ru: `

Почему листья становятся желтыми?

Желтые листья на растениях могут означать разные вещи в зависимости от их вида и других симптомов, которые проявляет растение. Переизбыток полива, недостаток полива, дефицит минералов, стресс от температуры и так далее могут быть причиной. Определение конкретных симптомов, которые проявляет ваше растение, поможет вам определить причину и принять соответствующие меры.

Как исправить желтые листья

Ниже мы рассмотрим решение по устранению желтеющих листьев, сначала определив симптомы, которые проявляет растение, что поможет выявить причину.

Листья желтые, завитые и опущенные

Если вы замечаете, что у вашего растения желтые листья завиваются внутрь, а почва сухая на ощупь, вероятно, оно подверглось недостатку полива. Вы также можете заметить, что старые листья начинают опадать. Чтобы исправить это, нужно полить ваше растение.

Желтые листья становятся зелеными или ярко-желтыми

Если листья становятся ярко-желтыми и почва влажная, вы также можете заметить чернение основания стеблей или грибковых мошек. Это признак переизбытка полива. Вы можете исправить это, дав почве высохнуть или пересадив растение в сухую почву.

Неправильные желтые пятна или деформация листьев

Неправильное желтеющее окрашивание с возможной деформацией листьев обычно вызвано либо вредителем, либо дефицитом минералов. Если вредители не видны, то скорее всего причина в дефиците минералов, обычно кальция или бора. Решением будет удобрять раз в месяц, или пересадить ваше растение в свежую почву для посадки. Свежая почвенная смесь содержит новые питательные вещества.

Все растение желтеет (могут или не могут опускаться листья)

Скорее всего проблема с температурой — она либо слишком низкая, либо слишком высокая для вашего растения там, где оно находится. Это обычно более бледло-желтый или беловато-желтый оттенок. Температура слишком сильно колеблется вокруг растения или является очевидной, как радиатор или сквозняк. Это также может быть связано с уровнем удобрений. Если очевидные причины, связанные с температурой, отсутствуют, и почва кажется нормальной, попробуйте добавить немного удобрений.

Все растение полурасцветает (без опадения листьев)

“Общая невроза” растения, желтеющего, означает, что оно либо слишком тесно в горшке — корням не хватает места для расширения, либо ваше растение находится в начальной стадии дефицита удобрений. Решением будет пересадить в более крупный горшок или попробовать добавить немного удобрений.

Желтеют только старые листья

По мере роста и зрелости растения старые листья могут устаревать, начать желтеть и, в конечном итоге, выпадать. Это естественное опадание листьев. Если ваше растение чувствует себя хорошо и здорово, и только старые, зрелые листья желтеют и опадают, нет причин для беспокойства!

`, + en: `

Why Do Leaves Turn Yellow?

Yellow leaves on plants mean different things depending on the variety, and what other symptoms the plant is showing. Overwatering, underwatering, mineral deficiency, temperature stress, and so on can all be the cause. Identifying the specific symptoms your plant is showing can help you determine the cause and take appropriate action.

How to Fix Yellow Leaves

Below, we go into the solution to get rid of yellowing leaves by first identifying the symptoms displayed by the plant which can help pinpoint the cause.

Leaves that are Yellow, Curling, and Drooping

If you notice your plant has yellow leaves that are curling inwards and the soil is dry to the touch, it's likely under-watered. You might also see older leaves falling off. To fix this, the solution is to water your plant.

Yellow Leaves Fading to Green or Turning Bright Yellow

Are the leaves turning bright yellow and is the soil wet? You might even notice blackened stem bases or fungus gnats. This is a sign of overwatering. You can correct this by letting the soil dry out or repotting the plant in dry soil.

Irregular Yellow Spots or Leaf Deformities

Irregular yellowing with potential leaf deformities is usually caused either by a pest or a mineral deficiency. If no pests are visible, then this is likely caused by a mineral deficiency, usually calcium or boron. The solution is to fertilize once a month, or repot your plant to provide fresh potting soil. Fresh potting soil contains new nutrients.

Whole Plant Yellowing (May or May Not Drop Leaves)

Most likely a temperature issue — it’s either too cold or too hot for your plant where it is placed. This will usually be a paler yellow or whitish yellow. Temperature will flux around the plant too much or will be obvious, like a radiator or a draft. It could also be a fertilizer issue. If no obvious temperature causes are present and the soil seems normal, try a little fertilizer.

Whole Plant Semi-Yellowing (Without Leaf Drop)

A “general malaise” of a plant turning yellow means that it’s either pot-bound—the roots have no room to expand—or your plant is in the early stages of a fertilizer deficiency. The solution is to repot to a bigger pot or try a little fertilizer.

Only Mature Leaves are Turning Yellow

As plants mature and grow, older leaves can age-out, start to yellow, and eventually fall from your plant. This is natural leaf shedding. If your plant is happy and healthy otherwise, and only older, mature leaves are yellowing and dropping, there is no need to worry!

`, + ru: `

Почему листья становятся желтыми?

Желтые листья на растениях могут означать разные вещи в зависимости от их вида и других симптомов, которые проявляет растение. Переизбыток полива, недостаток полива, дефицит минералов, стресс от температуры и так далее могут быть причиной. Определение конкретных симптомов, которые проявляет ваше растение, поможет вам определить причину и принять соответствующие меры.

Как исправить желтые листья

Ниже мы рассмотрим решение по устранению желтеющих листьев, сначала определив симптомы, которые проявляет растение, что поможет выявить причину.

Листья желтые, завитые и опущенные

Если вы замечаете, что у вашего растения желтые листья завиваются внутрь, а почва сухая на ощупь, вероятно, оно подверглось недостатку полива. Вы также можете заметить, что старые листья начинают опадать. Чтобы исправить это, нужно полить ваше растение.

Желтые листья становятся зелеными или ярко-желтыми

Если листья становятся ярко-желтыми и почва влажная, вы также можете заметить чернение основания стеблей или грибковых мошек. Это признак переизбытка полива. Вы можете исправить это, дав почве высохнуть или пересадив растение в сухую почву.

Неправильные желтые пятна или деформация листьев

Неправильное желтеющее окрашивание с возможной деформацией листьев обычно вызвано либо вредителем, либо дефицитом минералов. Если вредители не видны, то скорее всего причина в дефиците минералов, обычно кальция или бора. Решением будет удобрять раз в месяц, или пересадить ваше растение в свежую почву для посадки. Свежая почвенная смесь содержит новые питательные вещества.

Все растение желтеет (могут или не могут опускаться листья)

Скорее всего проблема с температурой — она либо слишком низкая, либо слишком высокая для вашего растения там, где оно находится. Это обычно более бледно-желтый или беловато-желтый оттенок. Температура слишком сильно колеблется вокруг растения или является очевидной, как радиатор или сквозняк. Это также может быть связано с уровнем удобрений. Если очевидные причины, связанные с температурой, отсутствуют, и почва кажется нормальной, попробуйте добавить немного удобрений.

Все растение полурасцветает (без опадения листьев)

“Общая невроза” растения, желтеющего, означает, что оно либо слишком тесно в горшке — корням не хватает места для расширения, либо ваше растение находится в начальной стадии дефицита удобрений. Решением будет пересадить в более крупный горшок или попробовать добавить немного удобрений.

Желтеют только старые листья

По мере роста и зрелости растения старые листья могут устаревать, начать желтеть и, в итоге, выпадать. Это естественное опадание листьев. Если ваше растение чувствует себя хорошо и здорово, и только старые, зрелые листья желтеют и опадают, нет причин для беспокойства!

`, }, date: { en: 'September 29', @@ -81,14 +81,14 @@ const postsData: Post[] = [ }, time: 3, title: { - en: 'Five Causes For Your Plant’s Yellow Leaves', + en: 'Five Causes for Your Plant’s Yellow Leaves', ru: 'Пять причин пожелтения листьев ваших растений', }, }, { content: { - en: `

When you bring your new plant home in its nursery grow pot, you might be tempted to pot it from grow pot into a planter right away. However, you'll be more successful if you let your new plant acclimate to its new environment first. Why?

It might sound strange to say about a plant but given the potential stress of acclimating to a new environment—adjusting to different light, levels of humidity, and temperature—you don’t want to unroot your plant at the same exact time. Think of its grow pot as allowing it to staying safe in its original home for a while.

So first, place your plant in the spot you plan to keep it and let it acclimate for about 2-3 weeks. (If it’s the the spring–summer growing season, you can shave off a few of those days to get it in a new planter sooner. More on that below.)

After this adjustment period, you can decide to leave as is or fully pot it into a decorative planter. Remember, potting a plant early on is optional: as long as it looks healthy and the roots have space to keep growing, you don’t need to lift a finger!

We break down different plant parent preferences below, and what to look when assessing if it’s time to pot your plant.

Why some prefer nursery grow pots

Some plant parents prefer to keep their plants potted in their nursery grow pots within decorative planters for months, as long as the plant still has room to grow. They do this for a variety of reasons, some of which you’ll find below:

  • Ease of watering: Grow pots have drainage holes so you don’t have to be as mindful when you water
  • Added planter choice: Grow pots give you the ability to use decorative planters that don’t have drainage holes (à la cachepot) - the decorative planter serves as a saucer, catching excess water that drains out
  • Easier to move: Easily move your plant from one decorative pot to another when refreshing your decor, without added weight
  • Time of year: If it’s fall or winter, the plant is semi-dormant and growing slow, so it doesn’t need more room or new nutrients

About that last bullet… did you know even indoor plants can be on a seasonal schedule? The best time to repot your plant, be it introducing a new planter or simply providing fresh new soil, is during the springsummer growing season. This is when plants will have the energy—thanks to more sun and longer days—to make use of the new nutrients in fresh soil and grow into the extra space of a new planter.

When to consider fully potting

So now that you know you can keep your plant in its grow pot if you prefer, here are some reasons why you’d want to eventually pot it into its planter, outside of aesthetic preferences.

  • It’s grown up: You know your plant has outgrown the nursery grow pot if...
  • Roots are growing through the drainage holes at the bottom of the grow pot
  • Roots are pushing the plant up, out of the grow pot
  • It’s top heavy, and falls over easily
  • It’s growing slower than normal (outside of winter dormancy)
  • The size of the plant is three times or more the size of the grow pot
  • Dry potting mix: Your plant’s potting mix dries out more quickly than usual, requiring more frequent waterings
  • It’s the season: Your plant could use fresh potting mix and more space for the spring–summer growing season
How to pot your plant

When the time comes to move your plant from its nursery grow pot into its planter, here's what you'll want handy in addition to your plant and planter:

  • Fresh potting mix
  • Lava rocks or similar, if your planter does not have a drainage hole

We carry apartment-friendly sized bags of indoor potting mixes, as well as lava rocks, here. Or use your favorite all-purpose indoor potting soil for houseplants and container gardens. River rocks or gravel can be substituted for lava rocks. Essentially you’re looking for something to create crevices at the bottom of the planter for excess water to pool into, away from the plant’s roots. To learn more about why this is necessary, check out our video on drainage here!

Now that you have your supplies, here’s the steps you need to take to move your plant from its grow pot into its earthenware planter. (Prefer a visual? Watch our plant expert go through the motions here!)

Steps to pot your plant

1. Remove plant from nursery grow pot
Turn your new plant sideways, hold it gently by the stems or leaves, and tap the bottom of its grow pot until the plant slides out. You might need to give it a bit of help with a couple gentle tugs on the base of the stems. If it’s very secure, you can also cut through the plastic grow pot with a pair of scissors.

2. Loosen the roots
Now that you’ve removed the grow pot, loosen the plant’s roots gently with your hands. You can prune off any threadlike roots that are extra long, just make sure to leave the thicker roots at the base of the foliage. If your plant is root bound – the roots are growing in very tight circles around the base of the plant – unbind the roots as best you can and give them a trim.

3. Remove some potting mix
Remove about one third or more of the potting mix currently surrounding the plant. As it grew in its grow pot, your plant removed some of the nutrients in the current mix, so you'll want to give it fresh mix if you're potting it anyway!

4. Add new potting mix
Pour a layer of fresh potting soil into the plant’s new planter and pack it down, removing any air pockets. If your planter does not have a drainage hole, layer the bottom with lava rocks or similar before adding the potting mix to create crevices for the extra water to pool into.

5. Add your plant
Set your plant that you removed from the grow pot on top of the fresh layer of mix in the new planter, making sure it's centered, then add potting mix around the plant until it is secure. Be sure not to pack too much soil into the planter, as you want the roots to breathe.

6. Water and enjoy
Even out the potting soil on top, water well, and enjoy!

We’ve got you!

For some, the mantra ‘plant care is self care’ means the convenience of leaving it in the nursery grow pot, while for others, it may mean getting your hands dirty to repot. We say: do what works best for you! Do not be afraid to try different methods for different plants. And if you’re overwhelmed and unsure—ask us. We’re here to help.

`, - ru: `

Когда вы привозите свое новое растение домой в его посадочном горшке из питомника, вас может подманить идея сразу пересадить его из посадочного горшка в горшок для цветов. Однако вы будете более успешны, если сначала дадите вашему новому растению привыкнуть к новой среде. Почему?

Может показаться странным говорить так об растении, но учитывая потенциальный стресс от приспособления к новой среде — адаптации к различному свету, уровню влажности и температуре — вы не хотите одновременно вытаскивать растение из корней. Представьте его посадочный горшок как место, позволяющее ему оставаться в безопасности в своем первоначальном доме какое-то время.

Итак, сначала поместите ваше растение на том месте, где вы планируете его держать, и позвольте ему привыкнуть около 2-3 недель. (Если это весенне-летний сезон роста, вы можете сократить несколько из этих дней, чтобы пересадить его в новый горшок раньше. Подробнее об этом ниже.)

После этого периода приспособления вы можете решить оставить все как есть или полностью пересадить его в декоративный горшок. Помните, что пересадка растения в начальной стадии — это необязательно: пока оно выглядит здоровым и у корней есть пространство для роста, вам не нужно приложить усилий!

Мы разберем различные предпочтения родителей растений ниже и что следует учитывать при оценке, когда пришло время пересадить ваше растение.

Почему некоторые предпочитают посадочные горшки для питомников

Некоторые родители растений предпочитают оставлять свои растения в посадочных горшках для питомников в декоративных горшках в течение месяцев, пока у растения есть место для роста. Они делают это по разным причинам, некоторые из которых вы найдете ниже:

  • Простота полива: В посадочных горшках есть дренажные отверстия, поэтому вам не нужно быть столь внимательными при поливе
  • Дополнительный выбор горшков: Посадочные горшки позволяют использовать декоративные горшки без дренажных отверстий (à la кашпо) - декоративный горшок служит блюдцем, улавливая излишнюю воду, которая вытекает
  • Удобство перемещения: Легко перемещать ваше растение из одного декоративного горшка в другой при обновлении вашего интерьера, без дополнительного веса
  • Время года: Если это осень или зима, растение полуспящее и медленно растет, поэтому оно не нуждается в большем пространстве или новых питательных веществах

Кстати о последнем... знали ли вы, что даже комнатные растения могут иметь сезонное расписание? Лучшее время для пересадки вашего растения, будь то введение нового горшка или просто обеспечение новой свежей почвы, приходится на весеннелетний сезон роста. В это время растения будут иметь энергию — благодаря большему солнцу и длинным дням — для использования новых питательных веществ в свежей почве и роста в дополнительном пространстве нового горшка.

Когда следует рассмотреть полную пересадку

Итак, теперь, когда вы знаете, что можете оставить свое растение в посадочном горшке, если хотите, вот несколько причин, по которым вы захотите в конечном итоге пересадить его в горшок, вне эстетических предпочтений.

  • Оно выросло: Вы знаете, что ваше растение выросло из посадочного горшка для питомника, если...
  • Корни растут через дренажные отверстия внизу посадочного горшка
  • Корни выталкивают растение вверх, из посадочного горшка
  • Оно неустойчиво и легко падает
  • Оно растет медленнее обычного (за пределами зимней спячки)
  • Размер растения в три или более раз больше размера посадочного горшка
  • Сухая почвенная смесь: Почвенная смесь вашего растения быстрее высыхает, чем обычно, требуя более частого полива
  • Это сезон: Вашему растению нужна свежая почвенная смесь и больше места для весенне-летнего сезона роста
Как пересадить ваше растение

Когда наступает время перенести ваше растение из посадочного горшка для питомника в его горшок, вот что вам понадобится помимо вашего растения и горшка:

  • Свежая почвенная смесь
  • Лавовые камни или аналогичные, если ваш горшок не имеет дренажного отверстия

Мы предлагаем сумки с подходящими для квартир размерами внутренних почвенных смесей, а также лавовые камни, здесь. Или используйте вашу любимую универсальную внутреннюю почву для домашних растений и контейнерных садов. Речные камни или гравий могут быть заменены лавовыми камнями. В основном, вам нужно что-то, чтобы создать углубления внизу горшка для стока излишней воды, вдали от корней растения. Чтобы узнать больше о том, почему это необходимо, посмотрите наше видео о дренаже здесь!

Теперь, когда у вас есть все необходимое, вот шаги, которые вам нужно предпринять, чтобы перенести ваше растение из посадочного горшка в его горшок из глины. (Предпочитаете визуальное изображение? Посмотрите, как наш эксперт по растениям выполняет процесс здесь!)

Шаги по пересадке вашего растения

1. Извлеките растение из посадочного горшка для питомника
Поверните ваше новое растение боком, держите его нежно за стебли или листья и постукивайте по дну его посадочного горшка до тех пор, пока растение не выскальзывает. Вам может потребоваться немного помощи, немного потянув за основание стеблей. Если он очень надежно фиксирован, вы также можете прорезать пластиковый посадочный горшок ножницами.

2. Разомните корни
Теперь, когда вы извлекли посадочный горшок, осторожно разомните корни растения руками. Вы можете обрезать все длинные корни, оставив только более толстые корни у основания листвы. Если корни вашего растения слишком плотно свиты – они растут в очень тесных кругах вокруг основания растения – расплетите их как можно лучше и обрежьте.

3. Удалите немного почвенной смеси
Удалите примерно треть или больше почвенной смеси, которая сейчас окружает растение. Поскольку ваше растение росло в посадочном горшке, оно потребовало некоторого количества питательных веществ из текущей смеси, поэтому вы захотите дать ему свежую смесь, если все равно будете его пересаживать!

4. Добавьте новую почвенную смесь
Налейте слой свежей почвы в новый горшок для растения и уплотните ее, удалив любые воздушные полости. Если ваш горшок не имеет дренажного отверстия, на дно налейте лавовые камни или что-то подобное перед добавлением почвенной смеси, чтобы создать углубления для стока лишней воды.

5. Поместите ваше растение
Поместите ваше растение, которое вы извлекли из посадочного горшка, поверх свежего слоя смеси в новом горшке, убедившись, что оно находится по центру, затем добавьте почву вокруг растения, пока оно не будет надежно закреплено. Обратите внимание, что вы не должны слишком плотно уплотнять почву в горшке, так как корни должны дышать.

6. Поливайте и наслаждайтесь
Выровняйте почву сверху, хорошо полейте водой и наслаждайтесь!

Мы здесь для вас!

Для кого-то мантра ‘забота о растениях — это забота о себе’ означает удобство оставить его в посадочном горшке для питомника, а для других — это может означать поработать руками, чтобы пересадить его. Мы говорим: делайте то, что лучше всего подходит вам! Не бойтесь пробовать разные методы для разных растений. И если вы чувствуете себя подавленными и неуверенными — спросите у нас. Мы здесь, чтобы помочь.

`, + en: `

When you bring your new plant home in its nursery grow pot, you might be tempted to pot it from grow pot into a planter right away. However, you'll be more successful if you let your new plant acclimate to its new environment first. Why?

It might sound strange to say about a plant but given the potential stress of acclimating to a new environment—adjusting to different light, levels of humidity, and temperature—you don’t want to unroot your plant at the same exact time. Think of its grow pot as allowing it to stay safe in its original home for a while.

So first, place your plant in the spot you plan to keep it and let it acclimate for about 2-3 weeks. (If it’s the the spring–summer growing season, you can shave off a few of those days to get it in a new planter sooner. More on that below.)

After this adjustment period, you can decide to leave as is or fully pot it into a decorative planter. Remember, potting a plant early on is optional: as long as it looks healthy and the roots have space to keep growing, you don’t need to lift a finger!

We break down different plant parent preferences below, and what to look for when assessing if it’s time to pot your plant.

Why some prefer nursery grow pots

Some plant parents prefer to keep their plants potted in their nursery grow pots within decorative planters for months, as long as the plant still has room to grow. They do this for a variety of reasons, some of which you’ll find below:

  • Ease of watering: Grow pots have drainage holes so you don’t have to be as mindful when you water
  • Added planter choice: Grow pots give you the ability to use decorative planters that don’t have drainage holes (à la cachepot) - the decorative planter serves as a saucer, catching excess water that drains out
  • Easier to move: Easily move your plant from one decorative pot to another when refreshing your decor, without added weight
  • Time of year: If it’s fall or winter, the plant is semi-dormant and growing slow, so it doesn’t need more room or new nutrients

About that last bullet… did you know even indoor plants can be on a seasonal schedule? The best time to repot your plant, be it introducing a new planter or simply providing fresh new soil, is during the springsummer growing season. This is when plants will have the energy—thanks to more sun and longer days—to make use of the new nutrients in fresh soil and grow into the extra space of a new planter.

When to consider fully potting

So now that you know you can keep your plant in its grow pot if you prefer, here are some reasons why you’d want to eventually pot it into its planter, outside of aesthetic preferences.

  • It’s grown up: You know your plant has outgrown the nursery grow pot if...
  • Roots are growing through the drainage holes at the bottom of the grow pot
  • Roots are pushing the plant up, out of the grow pot
  • It’s top heavy, and falls over easily
  • It’s growing slower than normal (outside of winter dormancy)
  • The size of the plant is three times or more the size of the grow pot
  • Dry potting mix: Your plant’s potting mix dries out more quickly than usual, requiring more frequent waterings
  • It’s the season: Your plant could use fresh potting mix and more space for the spring–summer growing season
How to pot your plant

When the time comes to move your plant from its nursery grow pot into its planter, here's what you'll want handy in addition to your plant and planter:

  • Fresh potting mix
  • Lava rocks or similar, if your planter does not have a drainage hole

We carry apartment-friendly sized bags of indoor potting mixes, as well as lava rocks, here. Or use your favorite all-purpose indoor potting soil for houseplants and container gardens. River rocks or gravel can be substituted for lava rocks. Essentially, you’re looking for something to create crevices at the bottom of the planter for excess water to pool into, away from the plant’s roots. To learn more about why this is necessary, check out our video on drainage here!

Now that you have your supplies, here’s the steps you need to take to move your plant from its grow pot into its earthenware planter. (Prefer a visual? Watch our plant expert go through the motions here!)

Steps to pot your plant

1. Remove plant from nursery grow pot
Turn your new plant sideways, hold it gently by the stems or leaves, and tap the bottom of its grow pot until the plant slides out. You might need to give it a bit of help with a couple gentle tugs on the base of the stems. If it’s very secure, you can also cut through the plastic grow pot with a pair of scissors.

2. Loosen the roots
Now that you’ve removed the grow pot, loosen the plant’s roots gently with your hands. You can prune off any threadlike roots that are extra-long, just make sure to leave the thicker roots at the base of the foliage. If your plant is root bound – the roots are growing in very tight circles around the base of the plant – unbind the roots as best you can and give them a trim.

3. Remove some potting mix
Remove about one third or more of the potting mix currently surrounding the plant. As it grew in its grow pot, your plant removed some of the nutrients in the current mix, so you'll want to give it fresh mix if you're potting it anyway!

4. Add new potting mix
Pour a layer of fresh potting soil into the plant’s new planter and pack it down, removing any air pockets. If your planter does not have a drainage hole, layer the bottom with lava rocks or similar before adding the potting mix to create crevices for the extra water to pool into.

5. Add your plant
Set your plant that you removed from the grow pot on top of the fresh layer of mix in the new planter, making sure it's centered, then add potting mix around the plant until it is secure. Be sure not to pack too much soil into the planter, as you want the roots to breathe.

6. Water and enjoy
Even out the potting soil on top, water well, and enjoy!

We’ve got you!

For some, the mantra ‘plant care is self-care’ means the convenience of leaving it in the nursery grow pot, while for others, it may mean getting your hands dirty to repot. We say: do what works best for you! Do not be afraid to try different methods for different plants. And if you’re overwhelmed and unsure—ask us. We’re here to help.

`, + ru: `

Когда вы привозите свое новое растение домой в его посадочном горшке из питомника, вас может подманить идея сразу пересадить его из посадочного горшка в горшок для цветов. Однако вы будете более успешны, если сначала дадите вашему новому растению привыкнуть к новой среде. Почему?

Может показаться странным говорить так об растении, но учитывая потенциальный стресс от приспособления к новой среде — адаптации к различному свету, уровню влажности и температуре — вы не хотите одновременно вытаскивать растение из корней. Представьте его посадочный горшок как место, позволяющее ему оставаться в безопасности в своем первоначальном доме какое-то время.

Итак, сначала поместите ваше растение на том месте, где вы планируете его держать, и позвольте ему привыкнуть около 2-3 недель. (Если это весенне-летний сезон роста, вы можете сократить несколько из этих дней, чтобы пересадить его в новый горшок раньше. Подробнее об этом ниже.)

После этого периода приспособления вы можете решить оставить все как есть или полностью пересадить его в декоративный горшок. Помните, что пересадка растения в начальной стадии — это необязательно: пока оно выглядит здоровым и у корней есть пространство для роста, вам не нужно приложить усилий!

Мы разберем различные предпочтения родителей растений ниже и что следует учитывать при оценке, когда пришло время пересадить ваше растение.

Почему некоторые предпочитают посадочные горшки для питомников

Некоторые родители растений предпочитают оставлять свои растения в посадочных горшках для питомников в декоративных горшках в течение месяцев, пока у растения есть место для роста. Они делают это по разным причинам, некоторые из которых вы найдете ниже:

  • Простота полива: В посадочных горшках есть дренажные отверстия, поэтому вам не нужно быть столь внимательными при поливе
  • Дополнительный выбор горшков: Посадочные горшки позволяют использовать декоративные горшки без дренажных отверстий (à la кашпо) - декоративный горшок служит блюдцем, улавливая излишнюю воду, которая вытекает
  • Удобство перемещения: Легко перемещать ваше растение из одного декоративного горшка в другой при обновлении вашего интерьера, без дополнительного веса
  • Время года: Если это осень или зима, растение полуспящее и медленно растет, поэтому оно не нуждается в большем пространстве или новых питательных веществах

Кстати, о последнем... знали ли вы, что даже комнатные растения могут иметь сезонное расписание? Лучшее время для пересадки вашего растения, будь то введение нового горшка или просто обеспечение новой свежей почвы, приходится на весеннелетний сезон роста. В это время растения будут иметь энергию — благодаря большему солнцу и длинным дням — для использования новых питательных веществ в свежей почве и роста в дополнительном пространстве нового горшка.

Когда следует рассмотреть полную пересадку

Итак, теперь, когда вы знаете, что можете оставить свое растение в посадочном горшке, если хотите, вот несколько причин, по которым вы захотите в итоге пересадить его в горшок, вне эстетических предпочтений.

  • Оно выросло: Вы знаете, что ваше растение выросло из посадочного горшка для питомника, если...
  • Корни растут через дренажные отверстия внизу посадочного горшка
  • Корни выталкивают растение вверх, из посадочного горшка
  • Оно неустойчиво и легко падает
  • Оно растет медленнее обычного (за пределами зимней спячки)
  • Размер растения в три или более раз больше размера посадочного горшка
  • Сухая почвенная смесь: Почвенная смесь вашего растения быстрее высыхает, чем обычно, требуя более частого полива
  • Это сезон: Вашему растению нужна свежая почвенная смесь и больше места для весенне-летнего сезона роста
Как пересадить ваше растение

Когда наступает время перенести ваше растение из посадочного горшка для питомника в его горшок, вот что вам понадобится помимо вашего растения и горшка:

  • Свежая почвенная смесь
  • Лавовые камни или аналогичные, если ваш горшок не имеет дренажного отверстия

Мы предлагаем сумки с подходящими для квартир размерами внутренних почвенных смесей, а также лавовые камни, здесь. Или используйте вашу любимую универсальную внутреннюю почву для домашних растений и контейнерных садов. Речные камни или гравий могут быть заменены лавовыми камнями. В основном, вам нужно что-то, чтобы создать углубления внизу горшка для стока излишней воды, вдали от корней растения. Чтобы узнать больше о том, почему это необходимо, посмотрите наше видео о дренаже здесь!

Теперь, когда у вас есть все необходимое, вот шаги, которые вам нужно предпринять, чтобы перенести ваше растение из посадочного горшка в его горшок из глины. (Предпочитаете визуальное изображение? Посмотрите, как наш эксперт по растениям выполняет процесс здесь!)

Шаги по пересадке вашего растения

1. Извлеките растение из посадочного горшка для питомника
Поверните ваше новое растение боком, держите его нежно за стебли или листья и постукивайте по дну его посадочного горшка до тех пор, пока растение не выскальзывает. Вам может потребоваться немного помощи, немного потянув за основание стеблей. Если он очень надежно фиксирован, вы также можете прорезать пластиковый посадочный горшок ножницами.

2. Разомните корни
Теперь, когда вы извлекли посадочный горшок, осторожно разомните корни растения руками. Вы можете обрезать все длинные корни, оставив только более толстые корни у основания листвы. Если корни вашего растения слишком плотно свиты – они растут в очень тесных кругах вокруг основания растения – расплетите их как можно лучше и обрежьте.

3. Удалите немного почвенной смеси
Удалите примерно треть или больше почвенной смеси, которая сейчас окружает растение. Поскольку ваше растение росло в посадочном горшке, оно потребовало некоторого количества питательных веществ из текущей смеси, поэтому вы захотите дать ему свежую смесь, если все равно будете его пересаживать!

4. Добавьте новую почвенную смесь
Налейте слой свежей почвы в новый горшок для растения и уплотните ее, удалив любые воздушные полости. Если ваш горшок не имеет дренажного отверстия, на дно налейте лавовые камни или что-то подобное перед добавлением почвенной смеси, чтобы создать углубления для стока лишней воды.

5. Поместите ваше растение
Поместите ваше растение, которое вы извлекли из посадочного горшка, поверх свежего слоя смеси в новом горшке, убедившись, что оно находится по центру, затем добавьте почву вокруг растения, пока оно не будет надежно закреплено. Обратите внимание, что вы не должны слишком плотно уплотнять почву в горшке, так как корни должны дышать.

6. Поливайте и наслаждайтесь
Выровняйте почву сверху, хорошо полейте водой и наслаждайтесь!

Мы здесь для вас!

Для кого-то мантра ‘забота о растениях — это забота о себе’ означает удобство оставить его в посадочном горшке для питомника, а для других — это может означать поработать руками, чтобы пересадить его. Мы говорим: делайте то, что лучше всего подходит вам! Не бойтесь пробовать разные методы для разных растений. И если вы чувствуете себя подавленными и неуверенными — спросите у нас. Мы здесь, чтобы помочь.

`, }, date: { en: 'October 5', @@ -97,7 +97,7 @@ const postsData: Post[] = [ id: 'bl-post-05', image: 'img/webp/bl-post-05.webp', shortDescription: { - en: 'So your plant arrived in its nursery grow pot—now what?', + en: 'So, your plant arrived in its nursery grow pot—now what?', ru: 'Итак, ваше растение прибыло в горшке из питомника - что теперь?', }, time: 7, @@ -150,7 +150,7 @@ const postsData: Post[] = [ }, { content: { - en: `

Calcium is used to fortify plant cell walls, much like it fortifies our bones and teeth. When we use ground or tap water, often rich in calcium and other minerals, it has a tendency to precipitate out and become a solid. As water evaporates, the calcium is left behind, forming a white, chalky residue. You may have seen calcium buildup or deposits in your bathroom, in the shower, even the kitchen sink. In plants, you’ll find it on leaves that got wet or on porous ceramic pots like terracotta. On ceramics, there’s no harm in leaving it, especially if the rustic look is your thing. On leaves though, while harmless, can look ugly and possibly clog pores when it builds up.

Science & Solutions

Calcium is an “alkali-earth” metal with low density, resistant to heating and dissolving, and never occurs pure in nature. “Alkali” because it forms alkaline solutions with water and other elements. The term “earth” refers to non-metallic substances, like rocks and chalk. This is not entirely accurate, as we now know that we can in fact extract calcium in its purest form from the “earth”, which is a metal that reacts with air and substances around it.

Because calcium has a low solubility in water, it is so easily left behind when water dries up. Its alkali nature means it can react with acids to become soluble. If you want to rid your plants or pots of calcium buildup, use a simple acid like lemon juice or vinegar to dissolve the calcium salts. We recommend mixing 1 parts lemon juice to 3 parts water (a 25% solution) OR 1 parts vinegar to 4 parts water (a 20% solution). Wipe the leaves with a rag soaked in this solution and the calcium should come right off. Repeat until it has all been removed.

`, + en: `

Calcium is used to fortify plant cell walls, much like it fortifies our bones and teeth. When we use ground or tap water, often rich in calcium and other minerals, it tends to precipitate out and become a solid. As water evaporates, the calcium is left behind, forming a white, chalky residue. You may have seen calcium buildup or deposits in your bathroom, in the shower, even the kitchen sink. In plants, you’ll find it on leaves that got wet or on porous ceramic pots like terracotta. On ceramics, there’s no harm in leaving it, especially if the rustic look is your thing. On leaves though, while harmless, can look ugly and possibly clog pores when it builds up.

Science & Solutions

Calcium is an “alkali-earth” metal with low density, resistant to heating and dissolving, and never occurs pure in nature. “Alkali” because it forms alkaline solutions with water and other elements. The term “earth” refers to non-metallic substances, like rocks and chalk. This is not entirely accurate, as we now know that we can in fact extract calcium in its purest form from the “earth”, which is a metal that reacts with air and substances around it.

Because calcium has a low solubility in water, it is so easily left behind when water dries up. Its alkali nature means it can react with acids to become soluble. If you want to rid your plants or pots of calcium buildup, use a simple acid like lemon juice or vinegar to dissolve the calcium salts. We recommend mixing 1 parts lemon juice with 3 parts water (a 25% solution) OR 1 parts vinegar to 4 parts water (a 20% solution). Wipe the leaves with a rag soaked in this solution and the calcium should come right off. Repeat until it has all been removed.

`, ru: `

Кальций используется для укрепления клеточных стенок растений, так же как он укрепляет наши кости и зубы. Когда мы используем грунтовую или водопроводную воду, часто богатую кальцием и другими минералами, кальций имеет тенденцию высаживаться и становиться твердым. При испарении воды кальций остаётся на поверхности, образуя белый меловой налёт. Возможно, вы видели отложения кальция в ванной комнате, в душе или даже на кухонной раковине. На растениях вы найдёте его на мокрых листьях или на пористой керамической посуде, такой как терракота. На керамике никакого вреда в том, чтобы это оставить, особенно если вам нравится рустиковый вид. На листьях, хотя это безвредно, может выглядеть некрасиво и, возможно, забивать поры, когда накапливается.

Наука и решения

Кальций — это «земно-алкалийный» металл с низкой плотностью, устойчивый к нагреванию и растворению, и никогда не встречается в чистом виде в природе. «Алкалий» потому, что он образует щёлочные растворы с водой и другими элементами. Термин «земля» относится к неметаллическим веществам, таким как камни и мел. Это не совсем точно, поскольку мы сейчас знаем, что на самом деле мы можем извлекать кальций в его чистейшей форме из «земли», это металл, который реагирует с воздухом и веществами вокруг него.

Поскольку кальций плохо растворим в воде, он так легко остаётся, когда вода испаряется. Его щёлочная природа означает, что он может реагировать с кислотами, чтобы стать растворимым. Если вы хотите избавиться от отложений кальция на ваших растениях или горшках, используйте простую кислоту, такую как лимонный сок или уксус, чтобы растворить соли кальция. Мы рекомендуем смешивать 1 часть лимонного сока с 3 частями воды (25% раствор) ИЛИ 1 часть уксуса с 4 частями воды (20% раствор). Протрите листья тряпкой, смоченной в этом растворе, и кальций должен будет легко смыться. Повторяйте, пока всё не будет удалено.

`, }, date: { diff --git a/src/shared/Posts/test/posts.spec.ts b/src/shared/Posts/test/posts.spec.ts new file mode 100644 index 00000000..a074472f --- /dev/null +++ b/src/shared/Posts/test/posts.spec.ts @@ -0,0 +1,16 @@ +import postsData from '../posts.ts'; + +/** + * @vitest-environment jsdom + */ + +describe('Checking posts', () => { + const posts = postsData; + it('should check if post is defined', () => { + expect(posts).toBeDefined(); + }); + + it('should check if posts is an instance of CartPageModel', () => { + expect(Array.isArray(posts)).toBe(true); + }); +}); diff --git a/src/shared/ScrollToTop/view/ScrollToTopView.ts b/src/shared/ScrollToTop/view/ScrollToTopView.ts index 868b629f..5d04eb99 100644 --- a/src/shared/ScrollToTop/view/ScrollToTopView.ts +++ b/src/shared/ScrollToTop/view/ScrollToTopView.ts @@ -1,10 +1,10 @@ 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 SVG_DETAIL from '@/shared/constants/svg.ts'; import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './scrollToTopView.module.scss'; @@ -18,15 +18,15 @@ class ScrollToTopView { private createButton(): ButtonModel { this.button = new ButtonModel({ classes: [styles.scrollToTopButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].SCROLL_TO_TOP, + title: TOOLTIP_TEXT[getCurrentLanguage()].SCROLL_TO_TOP, }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.ARROW_UP)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.ARROW_UP)); this.button.getHTML().append(svg); observeStore(selectCurrentLanguage, () => { - this.button.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].SCROLL_TO_TOP; + this.button.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].SCROLL_TO_TOP; }); return this.button; diff --git a/src/shared/ServerMessage/model/ServerMessageModel.ts b/src/shared/ServerMessage/model/ServerMessageModel.ts index 96a78000..d066a756 100644 --- a/src/shared/ServerMessage/model/ServerMessageModel.ts +++ b/src/shared/ServerMessage/model/ServerMessageModel.ts @@ -1,12 +1,16 @@ -import type { MessageStatusType, ServerMessageKeysType } from '@/shared/constants/messages.ts'; +import type UserMessage from '@/shared/types/userMessage.ts'; import ServerMessageView from '../view/ServerMessageView.ts'; class ServerMessageModel { private view: ServerMessageView = new ServerMessageView(); - public showServerMessage(key: ServerMessageKeysType, status: MessageStatusType, message?: string): boolean { - return this.view.setServerMessage(key, status, message); + public showServerMessage(params: UserMessage): boolean { + if (!params?.status) { + return false; + } + + return this.view.setServerMessage(params.status, params.key, params.message); } } diff --git a/src/shared/ServerMessage/test/ServerMessage.spec.ts b/src/shared/ServerMessage/test/ServerMessage.spec.ts index 3406f2f5..797b5003 100644 --- a/src/shared/ServerMessage/test/ServerMessage.spec.ts +++ b/src/shared/ServerMessage/test/ServerMessage.spec.ts @@ -1,6 +1,10 @@ import serverMessageModel from '../model/ServerMessageModel.ts'; import ServerMessageView from '../view/ServerMessageView.ts'; +/** + * @vitest-environment jsdom + */ + describe('check serverMessageModel', () => { it('serverMessageModel instance should be defined', () => { expect(serverMessageModel).toBeDefined(); diff --git a/src/shared/ServerMessage/view/ServerMessageView.ts b/src/shared/ServerMessage/view/ServerMessageView.ts index cb839264..c09b23df 100644 --- a/src/shared/ServerMessage/view/ServerMessageView.ts +++ b/src/shared/ServerMessage/view/ServerMessageView.ts @@ -1,12 +1,12 @@ -import type { MessageStatusType, ServerMessageKeysType } from '@/shared/constants/messages.ts'; +import type { MessageStatusType, ServerMessageKeyType } from '@/shared/constants/messages.ts'; -import getStore from '@/shared/Store/Store.ts'; import SERVER_MESSAGE_ANIMATE_DETAILS, { SERVER_MESSAGE_PROGRESS_BAR_ANIMATE_DETAILS_END, SERVER_MESSAGE_PROGRESS_BAR_ANIMATE_DETAILS_START, } from '@/shared/constants/animations.ts'; import { MESSAGE_STATUS, SERVER_MESSAGE } from '@/shared/constants/messages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './serverMessageView.module.scss'; @@ -78,12 +78,23 @@ class ServerMessageView { return this.serverWrapper; } - public setServerMessage(keyOrMessage: ServerMessageKeysType, status: MessageStatusType, message?: string): boolean { + public setServerMessage(status: MessageStatusType, keyOrMessage?: ServerMessageKeyType, message?: string): boolean { + const currentLanguage = getCurrentLanguage(); + this.serverWrapper.classList.toggle(styles.error, status === MESSAGE_STATUS.ERROR); this.serverWrapper.classList.toggle(styles.success, status === MESSAGE_STATUS.SUCCESS); - this.serverMessage.textContent = SERVER_MESSAGE[getStore().getState().currentLanguage][keyOrMessage]; - this.serverMessage.textContent = message || this.serverMessage.textContent; + let userMessage = ''; + + if (keyOrMessage) { + userMessage = SERVER_MESSAGE[currentLanguage][keyOrMessage] || ''; + } + + if (message) { + userMessage += message; + } + + this.serverMessage.textContent = userMessage; this.startAnimation(); return true; diff --git a/src/shared/ServerMessage/view/serverMessageView.module.scss b/src/shared/ServerMessage/view/serverMessageView.module.scss index 2e6d2669..acad31b8 100644 --- a/src/shared/ServerMessage/view/serverMessageView.module.scss +++ b/src/shared/ServerMessage/view/serverMessageView.module.scss @@ -1,6 +1,6 @@ .serverMessageWrapper { position: fixed; - right: 0; + left: 0; top: 20%; z-index: 15; border: var(--two) solid var(--steam-green-800); @@ -13,7 +13,7 @@ word-break: break-word; text-align: center; background-color: var(--white); - transform: translate(110%, -80%); + transform: translateX(-110%); } .progressBar { diff --git a/src/shared/Store/Store.ts b/src/shared/Store/Store.ts index 9b2d996c..684b3840 100644 --- a/src/shared/Store/Store.ts +++ b/src/shared/Store/Store.ts @@ -1,10 +1,10 @@ import type { Action, State } from './reducer.ts'; import type { Reducer, ReduxStore } from './types'; -import initialState from '../constants/initialState.ts'; import { parseToLoad } from '../services/helper.ts'; import { STORAGE_KEY, saveCurrentStateToLocalStorage } from '../services/localStorage.ts'; import { setCurrentPage } from './actions.ts'; +import initialState from './initialState.ts'; import { rootReducer } from './reducer.ts'; export class Store implements ReduxStore { diff --git a/src/shared/Store/actions.ts b/src/shared/Store/actions.ts index 7407d431..c75a568a 100644 --- a/src/shared/Store/actions.ts +++ b/src/shared/Store/actions.ts @@ -4,7 +4,7 @@ import type { LanguageChoiceType } from '../constants/common.ts'; import type { PageIdType } from '../constants/pages.ts'; const ACTION = { - SET_ANONYM_TOKEN: 'setAnonymToken', + SET_ANONYM_TOKEN: 'setAnonymousToken', SET_ANONYMOUS_CART_ID: 'setAnonymousCartId', SET_ANONYMOUS_ID: 'setAnonymousId', SET_ANONYMOUS_SHOP_LIST_ID: 'setAnonymousShopListId', @@ -36,7 +36,7 @@ export const setAnonymousShopListId = ( type: ACTION.SET_ANONYMOUS_SHOP_LIST_ID, }); -export const setAnonymToken = ( +export const setAnonymousToken = ( value: TokenStore | null, ): ActionWithPayload => ({ payload: value, diff --git a/src/shared/constants/initialState.ts b/src/shared/Store/initialState.ts similarity index 59% rename from src/shared/constants/initialState.ts rename to src/shared/Store/initialState.ts index 889c3655..c1b712e6 100644 --- a/src/shared/constants/initialState.ts +++ b/src/shared/Store/initialState.ts @@ -1,15 +1,16 @@ -import type { State } from '../Store/reducer'; +import type { State } from './reducer.ts'; -import { PAGE_ID } from './pages.ts'; +import { LANGUAGE_CHOICE } from '../constants/common.ts'; +import { PAGE_ID } from '../constants/pages.ts'; const initialState: State = { - anonymToken: null, anonymousCartId: null, anonymousId: null, anonymousShopListId: null, + anonymousToken: null, authToken: null, billingCountry: '', - currentLanguage: 'en', + currentLanguage: LANGUAGE_CHOICE.EN, currentPage: PAGE_ID.DEFAULT_PAGE, defaultCountry: '', isAppThemeLight: true, diff --git a/src/shared/Store/observer.ts b/src/shared/Store/observer.ts index 0d874897..5534b580 100644 --- a/src/shared/Store/observer.ts +++ b/src/shared/Store/observer.ts @@ -58,4 +58,6 @@ export const selectIsUserLoggedIn = (state: State): boolean => state.isUserLogge export const selectCurrentPage = (state: State): null | string => state.currentPage; +export const selectCurrentTheme = (state: State): boolean => state.isAppThemeLight; + export default observeStore; diff --git a/src/shared/Store/reducer.ts b/src/shared/Store/reducer.ts index 6fa55541..190516a7 100644 --- a/src/shared/Store/reducer.ts +++ b/src/shared/Store/reducer.ts @@ -7,10 +7,10 @@ import type * as actions from './actions.ts'; import type { Reducer } from './types.ts'; export interface State { - anonymToken: TokenStore | null; anonymousCartId: null | string; anonymousId: null | string; anonymousShopListId: null | string; + anonymousToken: TokenStore | null; authToken: TokenStore | null; billingCountry: string; currentLanguage: LanguageChoiceType; @@ -26,10 +26,10 @@ type InferValueTypes = T extends { [key: string]: infer U } ? U : never; export type Action = ReturnType>; export const rootReducer: Reducer = (state: State, action: Action): State => { switch (action.type) { - case 'setAnonymToken': + case 'setAnonymousToken': return { ...state, - anonymToken: action.payload, + anonymousToken: action.payload, }; case 'setAuthToken': return { diff --git a/src/shared/Store/test.spec.ts b/src/shared/Store/test.spec.ts index b174d754..deee3955 100644 --- a/src/shared/Store/test.spec.ts +++ b/src/shared/Store/test.spec.ts @@ -6,6 +6,10 @@ import * as actions from './actions.ts'; import * as observer from './observer.ts'; import { rootReducer } from './reducer.ts'; +/** + * @vitest-environment jsdom + */ + describe('Checking Store', () => { const mockStore = getStore(); it('should check if store is defined', () => { @@ -54,10 +58,10 @@ vi.mock('./Store.ts', async (importOriginal) => { return { ...actual, getState: (): State => ({ - anonymToken: null, anonymousCartId: null, anonymousId: null, anonymousShopListId: null, + anonymousToken: null, authToken: null, billingCountry: '', currentLanguage: 'en', @@ -171,10 +175,10 @@ describe('rootReducer', () => { beforeEach(() => { initialState = { - anonymToken: null, anonymousCartId: null, anonymousId: null, anonymousShopListId: null, + anonymousToken: null, authToken: null, billingCountry: '', currentLanguage: 'en', diff --git a/src/shared/constants/about.ts b/src/shared/constants/about.ts new file mode 100644 index 00000000..655a9945 --- /dev/null +++ b/src/shared/constants/about.ts @@ -0,0 +1,12 @@ +const ABOUT_TEXT = { + en: { + CHECKLIST: 'Checklist: ', + FEEDBACK: 'Feedback: ', + }, + ru: { + CHECKLIST: 'Чеклист: ', + FEEDBACK: 'Отзывы: ', + }, +} as const; + +export default ABOUT_TEXT; diff --git a/src/shared/constants/animations.ts b/src/shared/constants/animations.ts index 704fcd0c..03c18477 100644 --- a/src/shared/constants/animations.ts +++ b/src/shared/constants/animations.ts @@ -1,9 +1,9 @@ const SERVER_MESSAGE_ANIMATE_PARAMS = [ - { transform: 'translateX(110%)' }, - { transform: 'translateX(-10%)' }, - { transform: 'translateX(-10%)' }, - { opacity: 1, transform: 'translateX(-10%)' }, - { opacity: 0, transform: 'translate(-10%, -110%)' }, + { transform: 'translateX(-120%)' }, + { transform: 'translateX(10%)' }, + { transform: 'translateX(10%)' }, + { opacity: 1, transform: 'translateX(10%)' }, + { opacity: 0, transform: 'translate(10%, -100%)' }, ]; const SERVER_MESSAGE_ANIMATE_DETAILS = { diff --git a/src/shared/constants/buttons.ts b/src/shared/constants/buttons.ts index f17587d8..e36c6ec9 100644 --- a/src/shared/constants/buttons.ts +++ b/src/shared/constants/buttons.ts @@ -1,6 +1,4 @@ export const BUTTON_TYPE = { - BUTTON: 'button', - RESET: 'reset', SUBMIT: 'submit', } as const; @@ -17,7 +15,7 @@ export const BUTTON_TEXT = { LOGIN: 'Login', REGISTRATION: 'Register', RESET: 'Reset', - SAVE_CHANGES: 'Save changes', + SAVE_CHANGES: 'Save', }, ru: { ADD_ADDRESS: 'Добавить адрес', @@ -35,7 +33,7 @@ export const BUTTON_TEXT = { }, } as const; -export const BUTTON_TEXT_KEYS = { +export const BUTTON_TEXT_KEY = { ADD_ADDRESS: 'ADD_ADDRESS', ADD_PRODUCT: 'ADD_PRODUCT', BACK_TO_MAIN: 'BACK_TO_MAIN', @@ -50,8 +48,6 @@ export const BUTTON_TEXT_KEYS = { SAVE_CHANGES: 'SAVE_CHANGES', } as const; -export type ButtonTextKeysType = (typeof BUTTON_TEXT_KEYS)[keyof typeof BUTTON_TEXT_KEYS]; - export const IS_DISABLED = { DISABLED: true, ENABLED: false, @@ -60,11 +56,9 @@ export const IS_DISABLED = { export const MORE_TEXT = { en: { HIDE: 'Hide', MORE: 'More' }, ru: { HIDE: 'Скрыть', MORE: 'Подробнее' }, -}; +} as const; -export const MORE_TEXT_KEYS = { +export const MORE_TEXT_KEY = { HIDE: 'HIDE', MORE: 'MORE', -}; - -export type MoreTextKeysType = (typeof MORE_TEXT_KEYS)[keyof typeof MORE_TEXT_KEYS]; +} as const; diff --git a/src/shared/constants/common.ts b/src/shared/constants/common.ts index f3e7f2a6..1fe8cd13 100644 --- a/src/shared/constants/common.ts +++ b/src/shared/constants/common.ts @@ -8,13 +8,13 @@ export const LANGUAGE_CHOICE = { RU: 'ru', } as const; -export const DATA_KEYS = { - ADDRESS_TYPE: 'data-addressType', +export type LanguageChoiceType = (typeof LANGUAGE_CHOICE)[keyof typeof LANGUAGE_CHOICE]; + +export const DATA_KEY = { + ADDRESS: 'data-addressType', 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 index 75b40b4f..8b87d47b 100644 --- a/src/shared/constants/confirmUserMessage.ts +++ b/src/shared/constants/confirmUserMessage.ts @@ -1,4 +1,4 @@ -export const USER_MESSAGE = { +const USER_MESSAGE = { en: { CLEAR_CART: 'Are you sure you want to clear the cart?', CONFIRM: 'Are you sure you want to proceed?', @@ -11,9 +11,4 @@ export const USER_MESSAGE = { }, } 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]; +export default USER_MESSAGE; diff --git a/src/shared/constants/events.ts b/src/shared/constants/events.ts index 64ee591b..e5c3675a 100644 --- a/src/shared/constants/events.ts +++ b/src/shared/constants/events.ts @@ -1,8 +1,10 @@ const MEDIATOR_EVENT = { + CHANGE_WISHLIST_BUTTON: 'CHANGE_WISHLIST_BUTTON', CLEAR_CATALOG_SEARCH: 'CLEAR_CATALOG_SEARCH', REDRAW_PRODUCTS: 'REDRAW_PRODUCTS', REDRAW_USER_ADDRESSES: 'REDRAW_USER_ADDRESSES', REDRAW_USER_INFO: 'REDRAW_USER_INFO', + REDRAW_WISHLIST: 'REDRAW_WISHLIST', } as const; export default MEDIATOR_EVENT; diff --git a/src/shared/constants/filters.ts b/src/shared/constants/filters.ts index 202b697d..d5e23023 100644 --- a/src/shared/constants/filters.ts +++ b/src/shared/constants/filters.ts @@ -22,7 +22,7 @@ export const PRICE_RANGE_LABEL = { }, } as const; -export const META_FILTERS = { +export const META_FILTER = { en: { ALL_PRODUCTS: 'all', NEW_ARRIVALS: 'new', @@ -35,15 +35,15 @@ export const META_FILTERS = { }, } as const; -export const META_FILTERS_KEY = { +const META_FILTER_KEY = { ALL_PRODUCTS: 'ALL_PRODUCTS', NEW_ARRIVALS: 'NEW_ARRIVALS', SALE: 'SALE', }; -export type MetaFiltersType = (typeof META_FILTERS_KEY)[keyof typeof META_FILTERS_KEY]; +export type MetaFilterKeyType = (typeof META_FILTER_KEY)[keyof typeof META_FILTER_KEY]; -export const META_FILTERS_ID = { +export const META_FILTER_ID = { ALL_PRODUCTS: 'all-products', NEW_ARRIVALS: 'new-arrivals', SALE: 'sale', diff --git a/src/shared/constants/forms.ts b/src/shared/constants/forms.ts index 744b16f5..235f10b6 100644 --- a/src/shared/constants/forms.ts +++ b/src/shared/constants/forms.ts @@ -23,7 +23,7 @@ export const FORM_TEXT = { }, } as const; -export const FORM_TEXT_KEYS = { +export const FORM_TEXT_KEY = { DEFAULT_BILLING_ADDRESS: 'DEFAULT_BILLING_ADDRESS', DEFAULT_SHIPPING_ADDRESS: 'DEFAULT_SHIPPING_ADDRESS', SINGLE_ADDRESS: 'SINGLE_ADDRESS', @@ -33,45 +33,19 @@ export const DEFAULT_ADDRESS = { setDefault: true, }; -export type FormTextKeysType = (typeof FORM_TEXT_KEYS)[keyof typeof FORM_TEXT_KEYS]; - -export const USER_COUNTRY_ADDRESS = { - BILLING: 'billing', - SHIPPING: 'shipping', -} as const; - -export const USER_ADDRESS_TYPE = { +export const USER_ADDRESS = { BILLING: 'billing', SHIPPING: 'shipping', } as const; -export type UserAddressType = (typeof USER_ADDRESS_TYPE)[keyof typeof USER_ADDRESS_TYPE]; - -export const ADDRESS_TYPE = { +export const ADDRESS = { 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 type AddressType = (typeof ADDRESS)[keyof typeof ADDRESS]; export const USER_POSTAL_CODE = { BILLING_POSTAL_CODE: 'billing_PostalCode', @@ -97,3 +71,10 @@ export const ADDRESS_TEXT = { STREET: 'Адрес: ', }, } as const; + +export const ADDRESS_TEXT_KEY = { + CITY: 'CITY', + COUNTRY: 'COUNTRY', + POSTAL_CODE: 'POSTAL_CODE', + STREET: 'STREET', +}; diff --git a/src/shared/constants/forms/fieldParams.ts b/src/shared/constants/forms/fieldParams.ts index b06396e2..e6b9f23c 100644 --- a/src/shared/constants/forms/fieldParams.ts +++ b/src/shared/constants/forms/fieldParams.ts @@ -1,4 +1,4 @@ -import { USER_ADDRESS_TYPE } from '../forms.ts'; +import { USER_ADDRESS } from '../forms.ts'; export const EMAIL = { inputParams: { @@ -145,7 +145,7 @@ export const SHIPPING_ADDRESS_COUNTRY = { inputParams: { autocomplete: 'off', data: { - addressType: USER_ADDRESS_TYPE.SHIPPING, + addressType: USER_ADDRESS.SHIPPING, }, placeholder: 'Canada', type: 'text', @@ -162,7 +162,7 @@ export const SHIPPING_ADDRESS_POSTAL_CODE = { inputParams: { autocomplete: 'off', data: { - addressType: USER_ADDRESS_TYPE.SHIPPING, + addressType: USER_ADDRESS.SHIPPING, }, placeholder: 'A1B 2C3', type: 'text', @@ -207,7 +207,7 @@ export const BILLING_ADDRESS_COUNTRY = { inputParams: { autocomplete: 'off', data: { - addressType: USER_ADDRESS_TYPE.BILLING, + addressType: USER_ADDRESS.BILLING, }, placeholder: 'Canada', type: 'text', @@ -224,7 +224,7 @@ export const BILLING_ADDRESS_POSTAL_CODE = { inputParams: { autocomplete: 'off', data: { - addressType: USER_ADDRESS_TYPE.BILLING, + addressType: USER_ADDRESS.BILLING, }, placeholder: 'A1B 2C3', type: 'text', @@ -237,23 +237,6 @@ export const BILLING_ADDRESS_POSTAL_CODE = { }, } as const; -export const INPUT = [ - EMAIL, - EMAIL_NOT_LABEL_TEXT, - PASSWORD, - FIRST_NAME, - LAST_NAME, - BIRTHDAY, - SHIPPING_ADDRESS_STREET, - SHIPPING_ADDRESS_CITY, - SHIPPING_ADDRESS_COUNTRY, - SHIPPING_ADDRESS_POSTAL_CODE, - BILLING_ADDRESS_STREET, - BILLING_ADDRESS_CITY, - BILLING_ADDRESS_COUNTRY, - BILLING_ADDRESS_POSTAL_CODE, -]; - export const CHECKBOX = { AUTOCOMPLETE: 'off', BILLING_ID: 'billingDefault', diff --git a/src/shared/constants/forms/validationParams.ts b/src/shared/constants/forms/validationParams.ts index bc1fc04e..677eaef3 100644 --- a/src/shared/constants/forms/validationParams.ts +++ b/src/shared/constants/forms/validationParams.ts @@ -14,10 +14,6 @@ export const EMAIL_VALIDATE = { }, } as const; -export const FOOTER_EMAIL_VALIDATE = { - required: false, -} as const; - export const PASSWORD_VALIDATE = { minLength: 8, notWhitespace: { @@ -27,7 +23,7 @@ export const PASSWORD_VALIDATE = { required: true, requiredSymbols: { messages: { - en: 'Password must contain English letters, at least one letter in upper and lower case and at least one number', + en: 'Password must contain English letters, at least one letter in upper and lower case, and at least one number', ru: 'Пароль должен содержать английские буквы, как минимум одну букву в верхнем и нижнем регистре, а также хотя бы одну цифру', }, pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+/, @@ -112,15 +108,3 @@ export const ADDRESS_POSTAL_CODE_VALIDATE = { required: true, validPostalCode: true, }; - -export const INPUT_VALIDATION = [ - EMAIL_VALIDATE, - PASSWORD_VALIDATE, - FIRST_NAME_VALIDATE, - LAST_NAME_VALIDATE, - BIRTHDAY_VALIDATE, - ADDRESS_STREET_VALIDATE, - ADDRESS_CITY_VALIDATE, - ADDRESS_COUNTRY_VALIDATE, - ADDRESS_POSTAL_CODE_VALIDATE, -]; diff --git a/src/shared/constants/keyboard.ts b/src/shared/constants/keyboard.ts index 5af32cb0..c87fc14a 100644 --- a/src/shared/constants/keyboard.ts +++ b/src/shared/constants/keyboard.ts @@ -1,7 +1,7 @@ -export const KEYBOARD_KEYS = { +const KEYBOARD_KEY = { ENTER: 'Enter', ESC: 'Escape', TAB: 'Tab', } as const; -export type KeyboardKeysType = (typeof KEYBOARD_KEYS)[keyof typeof KEYBOARD_KEYS]; +export default KEYBOARD_KEY; diff --git a/src/shared/constants/links.ts b/src/shared/constants/links.ts index e6c783dd..6173f1c2 100644 --- a/src/shared/constants/links.ts +++ b/src/shared/constants/links.ts @@ -1,12 +1,31 @@ -/* eslint-disable import/prefer-default-export */ -export const USER_PROFILE_MENU_LINK = { - ADDRESSES: '#addresses', - ORDERS: '#orders', - PERSONAL_INFO: '#personal-info', - SUPPORT: '#support', - WISHLIST: '#wishlist', +export const LINK_DETAIL = { + BLANK: '_blank', } as const; -export const LINK_DETAILS = { - BLANK: '_blank', +export const PAGE_LINK_TEXT = { + en: { + ABOUT: 'About us', + BLOG: 'Blog', + CATALOG: 'Catalog', + LOGIN: 'Login', + MAIN: 'Main', + REGISTRATION: 'Register', + }, + ru: { + ABOUT: 'О нас', + BLOG: 'Блог', + CATALOG: 'Каталог', + LOGIN: 'Вход', + MAIN: 'Главная', + REGISTRATION: 'Регистрация', + }, +} as const; + +export const PAGE_LINK_TEXT_KEY = { + ABOUT: 'ABOUT', + BLOG: 'BLOG', + CATALOG: 'CATALOG', + LOGIN: 'LOGIN', + MAIN: 'MAIN', + REGISTRATION: 'REGISTRATION', } as const; diff --git a/src/shared/constants/messages.ts b/src/shared/constants/messages.ts index 0cedca64..0ce682da 100644 --- a/src/shared/constants/messages.ts +++ b/src/shared/constants/messages.ts @@ -7,65 +7,62 @@ export const MESSAGE_STATUS = { export type MessageStatusType = (typeof MESSAGE_STATUS)[keyof typeof MESSAGE_STATUS]; -export const MESSAGE_STATUS_KEYS = { - ERROR: 'ERROR', - SUCCESS: 'SUCCESS', -} as const; - -export type MessageStatusKeysType = (typeof MESSAGE_STATUS_KEYS)[keyof typeof MESSAGE_STATUS_KEYS]; - 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!', + BAD_REQUEST: 'Sorry, something went wrong. Try again later', + COUPON_NEED_LOGIN: 'You must be logged in to apply this promo code', + COUPON_WRONG_DATE: 'You can only apply this promo code 3 days before and after your birthday', 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', + PASSWORD_CHANGED: 'Password has been changed successfully', + PASSWORD_NOT_CHANGED: '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_ADD_COUPON_TO_CART: ' has been applied to your cart successfully', + SUCCESSFUL_ADD_PRODUCT_TO_CART: ' has been added to your cart', + SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: ' has been added 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_COPY_PROMO_CODE_TO_CLIPBOARD: ' has been copied to clipboard', + SUCCESSFUL_COPY_TO_CLIPBOARD: ' has been copied to clipboard', + SUCCESSFUL_DELETE_COUPON_FROM_CART: ' has been removed from your cart successfully', + SUCCESSFUL_DELETE_PRODUCT_FROM_CART: ' has been deleted from your cart', + SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: ' has been deleted 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', + USER_EXISTS: 'User with this email already exists, please, check your email', }, ru: { ADDRESS_ADDED: 'Адрес успешно добавлен', ADDRESS_CHANGED: 'Адрес успешно изменен', ADDRESS_DELETED: 'Адрес успешно удален', ADDRESS_STATUS_CHANGED: 'Статус адреса успешно изменен', - BAD_REQUEST: 'Извините, что-то пошло не так. Попробуйте позже.', - COPY_TO_CLIPBOARD: 'SKU скопирован в буфер обмена', - GREETING: 'Здравствуйте! Добро пожаловать в наш магазин. Приятных покупок!', + BAD_REQUEST: 'Извините, что-то пошло не так. Попробуйте позже', + COUPON_NEED_LOGIN: 'Вам нужно войти, чтобы применить этот промокод', + COUPON_WRONG_DATE: 'Вы можете применить этот промокод только за 3 дня до и после дня рождения', INCORRECT_PASSWORD: 'Пожалуйста, введите правильный пароль', INVALID_COUPON: 'Неверный купон', INVALID_EMAIL: 'Пользователь с таким адресом не существует. Пожалуйста, сначала зарегистрируйтесь', LANGUAGE_CHANGED: 'Настройки языка успешно обновлены', NEED_LOGIN: 'Вам нужно войти, чтобы перейти на эту страницу', - PASSWORD_CHANGED: 'Ваш пароль успешно изменен', - PASSWORD_NOT_CHANGED: 'Ваш пароль не был изменен. Пожалуйста, попробуйте ещё раз', + PASSWORD_CHANGED: 'Пароль успешно изменен', + PASSWORD_NOT_CHANGED: 'Пароль не был изменен. Пожалуйста, попробуйте ещё раз', 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_ADD_COUPON_TO_CART: ' успешно применен к корзине', + SUCCESSFUL_ADD_PRODUCT_TO_CART: ' уже в корзине', + SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST: ' уже в избранном', + SUCCESSFUL_CLEAR_CART: 'Корзина успешно очищена', + SUCCESSFUL_COPY_PROMO_CODE_TO_CLIPBOARD: ' скопирован в буфер обмена', + SUCCESSFUL_COPY_TO_CLIPBOARD: ' успешно скопирован в буфер обмена', + SUCCESSFUL_DELETE_COUPON_FROM_CART: ' успешно удален из корзины', + SUCCESSFUL_DELETE_PRODUCT_FROM_CART: ' больше не в корзине', + SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: ' больше не в избранном', SUCCESSFUL_LOGIN: 'Добро пожаловать в наш магазин. Приятных покупок!', SUCCESSFUL_REGISTRATION: 'Регистрация прошла успешно', SUCCESSFUL_SUBSCRIBE: 'Вы успешно подписались на рассылку новостей', @@ -73,14 +70,14 @@ export const SERVER_MESSAGE: Record> }, } as const; -export const SERVER_MESSAGE_KEYS: Record = { +export const SERVER_MESSAGE_KEY: 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', + COUPON_NEED_LOGIN: 'COUPON_NEED_LOGIN', + COUPON_WRONG_DATE: 'COUPON_WRONG_DATE', INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', INVALID_COUPON: 'INVALID_COUPON', INVALID_EMAIL: 'INVALID_EMAIL', @@ -93,7 +90,9 @@ export const SERVER_MESSAGE_KEYS: Record = { 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_PROMO_CODE_TO_CLIPBOARD: 'SUCCESSFUL_COPY_PROMO_CODE_TO_CLIPBOARD', SUCCESSFUL_COPY_TO_CLIPBOARD: 'SUCCESSFUL_COPY_TO_CLIPBOARD', + SUCCESSFUL_DELETE_COUPON_FROM_CART: 'SUCCESSFUL_DELETE_COUPON_FROM_CART', SUCCESSFUL_DELETE_PRODUCT_FROM_CART: 'SUCCESSFUL_DELETE_PRODUCT_FROM_CART', SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST: 'SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST', SUCCESSFUL_LOGIN: 'SUCCESSFUL_LOGIN', @@ -102,7 +101,7 @@ export const SERVER_MESSAGE_KEYS: Record = { USER_EXISTS: 'USER_EXISTS', } as const; -export type ServerMessageKeysType = (typeof SERVER_MESSAGE_KEYS)[keyof typeof SERVER_MESSAGE_KEYS]; +export type ServerMessageKeyType = (typeof SERVER_MESSAGE_KEY)[keyof typeof SERVER_MESSAGE_KEY]; export const ERROR_MESSAGE = { en: { @@ -118,10 +117,3 @@ export const ERROR_MESSAGE = { WRONG_REGION: 'Извините, но мы еще не доставляем в ваш регион', }, } as const; - -export const ERROR_MESSAGE_KEYS = { - INVALID_COUNTRY: 'INVALID_COUNTRY', - INVALID_POSTAL_CODE: 'INVALID_POSTAL_CODE', - REQUIRED_FIELD: 'REQUIRED_FIELD', - WRONG_REGION: 'WRONG_REGION', -} as const; diff --git a/src/shared/constants/pages.ts b/src/shared/constants/pages.ts index 5e5478ab..f975d42b 100644 --- a/src/shared/constants/pages.ts +++ b/src/shared/constants/pages.ts @@ -4,10 +4,11 @@ export const PAGE_TITLE: Record> = { en: { 404: '404', about: 'About us', - address: 'Address', + addresses: 'Addresses', blog: 'Blog', cart: 'Cart', catalog: 'Catalog', + cooperation: 'Cooperation', login: 'Login', main: 'Main', product: 'Product', @@ -18,10 +19,11 @@ export const PAGE_TITLE: Record> = { ru: { 404: '404', about: 'О нас', - address: 'Адрес', + addresses: 'Адреса', blog: 'Блог', cart: 'Корзина', catalog: 'Каталог', + cooperation: 'Сотрудничество', login: 'Вход', main: 'Главная', product: 'Товар', @@ -31,46 +33,18 @@ export const PAGE_TITLE: Record> = { }, } as const; -export const PAGE_LINK_TEXT = { - en: { - ABOUT: 'About us', - BLOG: 'Blog', - CATALOG: 'Catalog', - LOGIN: 'Login', - MAIN: 'Main', - REGISTRATION: 'Register', - }, - ru: { - ABOUT: 'О нас', - BLOG: 'Блог', - CATALOG: 'Каталог', - LOGIN: 'Вход', - MAIN: 'Главная', - REGISTRATION: 'Регистрация', - }, -} as const; - -export const PAGE_LINK_TEXT_KEYS = { - ABOUT: 'ABOUT', - BLOG: 'BLOG', - CATALOG: 'CATALOG', - LOGIN: 'LOGIN', - MAIN: 'MAIN', - REGISTRATION: 'REGISTRATION', -} as const; - export const PAGE_DESCRIPTION = { en: { 404: 'This is not the page you are looking for. Please go back to the main page.', + ABOUT: 'Mad Wizards Team 🧙🏻🪄✨', BLOG: 'Blog', - GREETING: 'Hi, ', LOGIN: 'Enter your email and password to login.', REGISTRATION: 'Enter your information to register.', }, ru: { 404: 'Это не та страница, которую вы ищете. Пожалуйста, вернитесь на главную страницу.', + ABOUT: 'Команда Бешеных Магов 🧙🏻🪄✨', BLOG: 'Блог', - GREETING: 'Привет, ', LOGIN: 'Введите свой адрес электронной почты и пароль для входа.', REGISTRATION: 'Введите свои данные для регистрации.', }, @@ -79,7 +53,7 @@ export const PAGE_DESCRIPTION = { 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', + '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_TITLE: 'Your Journey to Plant Parenthood', WIDGET_DESCRIPTIONS: 'This is where we share our experiences with all green friend lovers', WIDGET_TITLE: 'Our Blog Posts', @@ -93,7 +67,7 @@ export const BLOG_DESCRIPTION = { }, } as const; -export const PAGE_DESCRIPTION_KEYS = { +export const PAGE_DESCRIPTION_KEY = { 404: '404', BLOG: 'BLOG', GREETING: 'GREETING', @@ -101,101 +75,72 @@ export const PAGE_DESCRIPTION_KEYS = { REGISTRATION: 'REGISTRATION', } as const; -export type PageDescriptionKeysType = (typeof PAGE_DESCRIPTION_KEYS)[keyof typeof PAGE_DESCRIPTION_KEYS]; - export const PAGE_ANSWER = { en: { - LOGIN: `Don't have an account yet?`, - REGISTRATION: `Already have an account?`, + LOGIN: "Don't have an account yet?", + REGISTRATION: 'Already have an account?', }, ru: { - LOGIN: `Ещё нет аккаунта?`, - REGISTRATION: `Уже есть аккаунт?`, + LOGIN: 'Ещё нет аккаунта?', + REGISTRATION: 'Уже есть аккаунт?', }, } as const; -export const PAGE_ANSWER_KEYS = { +export const PAGE_ANSWER_KEY = { LOGIN: 'LOGIN', REGISTRATION: 'REGISTRATION', } as const; export const PAGE_ID = { ABOUT_US_PAGE: 'about', - ADDRESS: 'address', BLOG: 'blog', CART_PAGE: 'cart', CATALOG_PAGE: 'catalog', + COOPERATION_PAGE: 'cooperation', DEFAULT_PAGE: '', LOGIN_PAGE: 'login', MAIN_PAGE: 'main', NOT_FOUND_PAGE: '404', PRODUCT_PAGE: 'product', REGISTRATION_PAGE: 'register', + USER_ADDRESSES_PAGE: 'addresses', USER_PROFILE_PAGE: 'profile', WISHLIST_PAGE: 'wishlist', } as const; +export type PageIdType = (typeof PAGE_ID)[keyof typeof PAGE_ID]; + export const USER_INFO_TEXT = { en: { - BILLING: ' (billing)', DATE_OF_BIRTH: 'Date of Birth: ', - DEFAULT_BILLING_ADDRESS: ' (default billing)', - DEFAULT_SHIPPING_ADDRESS: ' (default shipping)', EMAIL: 'Email: ', LAST_NAME: 'Last Name: ', NAME: 'First Name: ', - SHIPPING: ' (shipping)', }, ru: { - BILLING: ' (оплата)', DATE_OF_BIRTH: 'Дата рождения: ', - DEFAULT_BILLING_ADDRESS: ' (по умолчанию - для оплаты)', - DEFAULT_SHIPPING_ADDRESS: ' (по умолчанию - для доставки)', EMAIL: 'Электронная почта: ', LAST_NAME: 'Фамилия: ', NAME: 'Имя: ', - SHIPPING: ' (доставка)', }, } as const; -export const USER_INFO_TEXT_KEYS = { - BILLING: 'BILLING', - DATE_OF_BIRTH: 'DATE_OF_BIRTH', - DEFAULT_BILLING_ADDRESS: 'DEFAULT_BILLING_ADDRESS', - DEFAULT_SHIPPING_ADDRESS: 'DEFAULT_SHIPPING_ADDRESS', - EMAIL: 'EMAIL', - LAST_NAME: 'LAST_NAME', - NAME: 'NAME', - SHIPPING: 'SHIPPING', -} as const; - -export type UserInfoTextKeysType = (typeof USER_INFO_TEXT_KEYS)[keyof typeof USER_INFO_TEXT_KEYS]; - -export const USER_INFO_MENU_LINK = { - en: { - ADDRESSES: 'Addresses', - ORDERS: 'Orders', - PERSONAL_INFO: 'Personal Info', - SUPPORT: 'Support', - WISHLIST: 'Wishlist', +export const CART_PAGE_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: 'Apply Coupon', ru: 'Применить купон' }, + COUPON_DISCOUNT: { en: 'Cart Discount', ru: 'Скидка на корзину' }, + EMPTY: { + en: "Oops! Looks like you haven't added any items to your cart yet", + ru: 'Ой! Похоже, вы еще не добавили товар в корзину', }, - ru: { - ADDRESSES: 'Адреса', - ORDERS: 'Заказы', - PERSONAL_INFO: 'Персональные данные', - SUPPORT: 'Поддержка', - WISHLIST: 'Избранное', - }, -} as const; - -export const USER_INFO_MENU_LINK_KEYS = { - ADDRESSES: 'ADDRESSES', - ORDERS: 'ORDERS', - PERSONAL_INFO: 'PERSONAL_INFO', - SUPPORT: 'SUPPORT', - WISHLIST: 'WISHLIST', + 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: 'Итого' }, } as const; - -export type UserInfoMenuLinkKeysType = (typeof USER_INFO_MENU_LINK_KEYS)[keyof typeof USER_INFO_MENU_LINK_KEYS]; - -export type PageIdType = (typeof PAGE_ID)[keyof typeof PAGE_ID]; diff --git a/src/shared/constants/product.ts b/src/shared/constants/product.ts index ea8f9f37..26d1e9d6 100644 --- a/src/shared/constants/product.ts +++ b/src/shared/constants/product.ts @@ -2,7 +2,7 @@ export const PRICE_FRACTIONS = 100; export const PRODUCT_LIMIT = 9; export const DEFAULT_PAGE = 1; export const MIN_PRICE = 0; -export const MAX_PRICE = 1000000; +export const MAX_PRICE = '*'; export const CURRENCY = 'USD'; export const EMPTY_PRODUCT = { @@ -24,6 +24,7 @@ export const SEARCH_PARAMS_FIELD = { PAGE: 'page', SEARCH: 'search', SIZE: 'size', + SLIDE: 'slide', SUBCATEGORY: 'subcategory', } as const; @@ -46,7 +47,7 @@ export const PRODUCT_INFO_TEXT = { }, } as const; -export const PRODUCT_INFO_TEXT_KEYS = { +export const PRODUCT_INFO_TEXT_KEY = { CATEGORY: 'CATEGORY', DIFFICULTY: 'DIFFICULTY', DISCOUNT_LABEL: 'DISCOUNT_LABEL', diff --git a/src/shared/constants/promo.ts b/src/shared/constants/promo.ts new file mode 100644 index 00000000..84e21f0e --- /dev/null +++ b/src/shared/constants/promo.ts @@ -0,0 +1,70 @@ +const PROMO_SLIDER_CONTENT = [ + { + en: { + date: { + end: '2024-06-30', + start: '2024-06-01', + }, + description: `Everything for garden and
plants care with 10% discount
`, + promoCode: 'SUMMER-SALE-10', + style: 'garden', + title: `SUMMER
SALE`, + }, + ru: { + date: { + end: '2024-06-30', + start: '2024-06-01', + }, + description: `Все для сада и ухода за
растениями со скидкой в 10%
`, + promoCode: 'SUMMER-SALE-10', + style: 'garden', + title: `ЛЕТНЯЯ
АКЦИЯ`, + }, + }, + { + en: { + date: { + end: null, + start: 'The promotion is valid 3 days before and 3 days after your birthday', + }, + description: 'For your birthday 10% discount
off the entire cart', + promoCode: 'HAPPY-BIRTHDAY-10', + style: 'birthday', + title: `BIRTHDAY
PRESENT`, + }, + ru: { + date: { + end: null, + start: 'Акция действует 3 дня до и 3 дня после дня рождения', + }, + description: 'В честь вашего дня рождения скидка 10%
от общей суммы товаров', + promoCode: 'HAPPY-BIRTHDAY-10', + style: 'birthday', + title: `ПОДАРОК
ИМЕНИННИКУ`, + }, + }, + { + en: { + date: { + end: '2024-06-15', + start: '2024-06-01', + }, + description: 'For all products from the
Succulents category', + promoCode: 'SUCCULENT-SALE-15', + style: 'succulent', + title: `SUCCULENTS
FOR EVERYONE!`, + }, + ru: { + date: { + end: '2024-06-15', + start: '2024-06-01', + }, + description: 'На все товары из
категории Суккуленты', + promoCode: 'SUCCULENT-SALE-15', + style: 'succulent', + title: `КАЖДОМУ ПО
СУККУЛЕНТУ!`, + }, + }, +]; + +export default PROMO_SLIDER_CONTENT; diff --git a/src/shared/constants/sorting.ts b/src/shared/constants/sorting.ts index 7145bbb6..73c57e5b 100644 --- a/src/shared/constants/sorting.ts +++ b/src/shared/constants/sorting.ts @@ -1,4 +1,4 @@ -export const TEXT = { +export const SORTING_TEXT: Record> = { en: { DEFAULT: 'Default', NAME: 'Name', @@ -15,7 +15,7 @@ export const TEXT = { }, } as const; -export const TEXT_KEYS = { +export const SORTING_TEXT_KEY: Record = { DEFAULT: 'DEFAULT', NAME: 'NAME', PRICE: 'PRICE', @@ -23,8 +23,6 @@ export const TEXT_KEYS = { SORT_BY: 'SORT_BY', } as const; -export type TextKeysType = (typeof TEXT_KEYS)[keyof typeof TEXT_KEYS]; - export const SORTING_ID = { DEFAULT: 'default', NAME: 'name', diff --git a/src/shared/constants/svg.ts b/src/shared/constants/svg.ts index b7c75ea9..bee6ef48 100644 --- a/src/shared/constants/svg.ts +++ b/src/shared/constants/svg.ts @@ -1,16 +1,17 @@ -const SVG_DETAILS = { +const SVG_DETAIL = { ARROW_UP: 'arrowUp', BILL: 'bill', - BIN: 'bin', CART: 'cart', + CLOSE: 'close', CLOSE_EYE: 'closeEye', COPY: 'copy', DARK: 'dark', DELETE: 'delete', DELIVERY: 'delivery', EDIT: 'edit', - FILL_HEART: 'heartFill', GO_DETAILS: 'arrow', + HEART: 'heart', + HOUSE: 'house', KEY: 'key', LEAVES: 'leaves', LIGHT: 'light', @@ -18,13 +19,13 @@ const SVG_DETAILS = { NOT_FOUND: 'notFound', OPEN_EYE: 'openEye', PROFILE: 'userCircle', + RSS_LOGO: 'rssLogo', + STAR: 'star', SVG_URL: 'http://www.w3.org/2000/svg', SWITCH_LANGUAGE: { en: 'en', ru: 'ru', }, - TRUCK: 'truck', - WALLET: 'wallet', } as const; -export default SVG_DETAILS; +export default SVG_DETAIL; diff --git a/src/shared/constants/tooltip.ts b/src/shared/constants/tooltip.ts index 4e6ef6f6..b8929158 100644 --- a/src/shared/constants/tooltip.ts +++ b/src/shared/constants/tooltip.ts @@ -2,12 +2,11 @@ const TOOLTIP_TEXT: Record> = { en: { ADD_BILLING_ADDRESS: 'Add new billing address', ADD_SHIPPING_ADDRESS: 'Add new shipping address', - DELETE_ADDRESS: 'Delete address completely', + DELETE_ADDRESS: 'Delete address', 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', @@ -16,11 +15,10 @@ const TOOLTIP_TEXT: Record> = { ru: { ADD_BILLING_ADDRESS: 'Добавить новый адрес выставления счетов', ADD_SHIPPING_ADDRESS: 'Добавить новый адрес доставки', - DELETE_ADDRESS: 'Удалить адрес полностью', + DELETE_ADDRESS: 'Удалить адрес', EDIT_ADDRESS: 'Изменить адрес', EDIT_PASSWORD: 'Изменить пароль', SCROLL_TO_TOP: 'Наверх', - SWITCH_ADDRESS_STATUS: 'Изменить статус адреса', SWITCH_BILLING_ADDRESS: 'Изменить статус адреса выставления счетов', SWITCH_DEFAULT_BILLING_ADDRESS: 'Изменить статус адреса выставления счетов по умолчанию', SWITCH_DEFAULT_SHIPPING_ADDRESS: 'Изменить статус адреса доставки по умолчанию', @@ -28,20 +26,19 @@ const TOOLTIP_TEXT: Record> = { }, } as const; -export const TOOLTIP_TEXT_KEYS = { +export const TOOLTIP_TEXT_KEY = { 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]; +export type TooltipTextKeyType = (typeof TOOLTIP_TEXT_KEY)[keyof typeof TOOLTIP_TEXT_KEY]; export default TOOLTIP_TEXT; diff --git a/src/shared/img/svg/bin.svg b/src/shared/img/svg/bin.svg deleted file mode 100644 index 709bfa15..00000000 --- a/src/shared/img/svg/bin.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/shared/img/svg/coupon-delete.svg b/src/shared/img/svg/coupon-delete.svg new file mode 100644 index 00000000..b74c9150 --- /dev/null +++ b/src/shared/img/svg/coupon-delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/shared/img/svg/drop.svg b/src/shared/img/svg/drop.svg deleted file mode 100644 index 42d5a616..00000000 --- a/src/shared/img/svg/drop.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/shared/img/svg/flower.svg b/src/shared/img/svg/flower.svg deleted file mode 100644 index 38f5c53a..00000000 --- a/src/shared/img/svg/flower.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/img/svg/github.svg b/src/shared/img/svg/github.svg deleted file mode 100644 index 471c8d9d..00000000 --- a/src/shared/img/svg/github.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/shared/img/svg/heartFill.svg b/src/shared/img/svg/heart.svg similarity index 100% rename from src/shared/img/svg/heartFill.svg rename to src/shared/img/svg/heart.svg diff --git a/src/shared/img/svg/heartOutline.svg b/src/shared/img/svg/heartOutline.svg deleted file mode 100644 index dff34254..00000000 --- a/src/shared/img/svg/heartOutline.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/shared/img/svg/house.svg b/src/shared/img/svg/house.svg index 05834259..b57f366a 100644 --- a/src/shared/img/svg/house.svg +++ b/src/shared/img/svg/house.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/src/shared/img/svg/leaves.svg b/src/shared/img/svg/leaves.svg index 50b5f595..340ea8b9 100644 --- a/src/shared/img/svg/leaves.svg +++ b/src/shared/img/svg/leaves.svg @@ -1,2 +1,2 @@ -leaf + diff --git a/src/shared/img/svg/plant.svg b/src/shared/img/svg/plant.svg deleted file mode 100644 index cecc5817..00000000 --- a/src/shared/img/svg/plant.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/shared/img/svg/rssLogo.svg b/src/shared/img/svg/rssLogo.svg new file mode 100644 index 00000000..618095e2 --- /dev/null +++ b/src/shared/img/svg/rssLogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/shared/img/svg/search.svg b/src/shared/img/svg/search.svg deleted file mode 100644 index 05834259..00000000 --- a/src/shared/img/svg/search.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/shared/img/svg/settings.svg b/src/shared/img/svg/settings.svg deleted file mode 100644 index 05834259..00000000 --- a/src/shared/img/svg/settings.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/shared/img/svg/star.svg b/src/shared/img/svg/star.svg new file mode 100644 index 00000000..0d006b64 --- /dev/null +++ b/src/shared/img/svg/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/img/svg/truck.svg b/src/shared/img/svg/truck.svg deleted file mode 100644 index 4872b693..00000000 --- a/src/shared/img/svg/truck.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/shared/img/svg/wallet.svg b/src/shared/img/svg/wallet.svg deleted file mode 100644 index 23a30d27..00000000 --- a/src/shared/img/svg/wallet.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/shared/services/localStorage.spec.ts b/src/shared/services/localStorage.spec.ts index deb0083e..ed36a224 100644 --- a/src/shared/services/localStorage.spec.ts +++ b/src/shared/services/localStorage.spec.ts @@ -1,5 +1,9 @@ import { clearLocalStorage, saveCurrentStateToLocalStorage } from './localStorage.ts'; +/** + * @vitest-environment jsdom + */ + describe('Checking Local Storage', () => { it('{} should return {}', () => { expect(saveCurrentStateToLocalStorage({})).toStrictEqual({}); diff --git a/src/shared/types/address.ts b/src/shared/types/address.ts index dbb0d38d..99d00ed9 100644 --- a/src/shared/types/address.ts +++ b/src/shared/types/address.ts @@ -1,10 +1,10 @@ -export const ADDRESS_TYPE = { +export const ADDRESS = { BILLING: 'billing', GENERAL: 'general', SHIPPING: 'shipping', } as const; -export type AddressType = (typeof ADDRESS_TYPE)[keyof typeof ADDRESS_TYPE]; +export type AddressType = (typeof ADDRESS)[keyof typeof ADDRESS]; export const SINGLE_ADDRESS = 'asBilling'; diff --git a/src/shared/constants/blog.ts b/src/shared/types/blog.ts similarity index 100% rename from src/shared/constants/blog.ts rename to src/shared/types/blog.ts diff --git a/src/shared/types/cart.ts b/src/shared/types/cart.ts index ca9b7f28..828885a9 100644 --- a/src/shared/types/cart.ts +++ b/src/shared/types/cart.ts @@ -1,7 +1,9 @@ import type { SizeType, localization } from './product.ts'; export interface Cart { - discounts: number; + anonymousId: null | string; + discountsCart: CartCoupon[]; + discountsProduct: CartCoupon[]; id: string; products: CartProduct[]; total: number; @@ -14,13 +16,16 @@ export interface CartProduct { lineItemId: string; name: localization[]; price: number; + priceCouponDiscount: number; productId: string; quantity: number; size: SizeType | null; totalPrice: number; + totalPriceCouponDiscount: number; } export interface AddCartItem { + name: string; productId: string; quantity: number; variantId: number; @@ -35,4 +40,16 @@ export enum CartActive { DELETE = 'delete', MINUS = 'minus', PLUS = 'plus', + UPDATE = 'update', +} + +export interface Coupon { + cartDiscount: string; + discountCode: string; + id: string; +} + +export interface CartCoupon { + coupon: Coupon; + value: number; } diff --git a/src/shared/types/form.ts b/src/shared/types/form.ts index c5c721aa..dc196244 100644 --- a/src/shared/types/form.ts +++ b/src/shared/types/form.ts @@ -1,13 +1,13 @@ export interface InputParams { - autocomplete: 'off' | 'on'; + autocomplete?: 'off' | 'on'; data?: Record; id?: string; lang?: string; max?: null | number; min?: null | number; - placeholder: null | string; + placeholder?: null | string; step?: null | number; - type: 'checkbox' | 'color' | 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text'; + type?: 'checkbox' | 'color' | 'date' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text'; value?: null | string; } diff --git a/src/shared/types/link.ts b/src/shared/types/link.ts index 73a546ea..82f1c654 100644 --- a/src/shared/types/link.ts +++ b/src/shared/types/link.ts @@ -4,7 +4,7 @@ export interface LinkAttributes { text?: string; } -export interface BreadCrumbLink { +export interface BreadcrumbLink { link: string; name: string; } diff --git a/src/shared/types/page.ts b/src/shared/types/page.ts index 03b46266..f239832f 100644 --- a/src/shared/types/page.ts +++ b/src/shared/types/page.ts @@ -4,6 +4,7 @@ export interface Page { export interface PageParams { catalog?: { + id: string; searchParams: string; }; product?: { @@ -12,4 +13,4 @@ export interface PageParams { }; } -export type PagesType = Map Promise>; +export type PagesType = Map Promise>; diff --git a/src/shared/types/product.ts b/src/shared/types/product.ts index 1443ab42..40f7c3f2 100644 --- a/src/shared/types/product.ts +++ b/src/shared/types/product.ts @@ -18,15 +18,29 @@ export const SIZE = { XL: 'XL', } as const; +export type SizeType = (typeof SIZE)[keyof typeof SIZE]; + 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 const DIFFICULTY = { + en: { + 1: 'Almost unkillable', + 2: 'Needs some love', + 3: 'For botanical ninjas', + }, + ru: { + 1: 'Почти неубиваемое', + 2: 'Нужно немножко любви', + 3: 'Для ниндзя ботаников', + }, +} as const; + export interface Variant { discount: number; id: number; diff --git a/src/shared/types/productFilters.ts b/src/shared/types/productFilters.ts index 233e3e25..acda1681 100644 --- a/src/shared/types/productFilters.ts +++ b/src/shared/types/productFilters.ts @@ -1,5 +1,5 @@ import type { PriceRange, SizeProductCount } from '../API/types/type.ts'; -import type { MetaFiltersType } from '../constants/filters.ts'; +import type { MetaFilterKeyType } from '../constants/filters.ts'; import type { Category, Product } from './product.ts'; interface ProductFiltersParams { @@ -17,7 +17,7 @@ interface ProductFiltersParams { export interface SelectedFilters { category: Set; - metaFilter: MetaFiltersType; + metaFilter: MetaFilterKeyType; price: { max: number; min: number; diff --git a/src/shared/types/shopping-list.ts b/src/shared/types/shopping-list.ts index c78c62e1..e7c0bfe2 100644 --- a/src/shared/types/shopping-list.ts +++ b/src/shared/types/shopping-list.ts @@ -1,4 +1,5 @@ export interface ShoppingList { + anonymousId: null | string; id: string; products: ShoppingListProduct[]; version: number; diff --git a/src/shared/types/userMessage.ts b/src/shared/types/userMessage.ts new file mode 100644 index 00000000..43c0f3c7 --- /dev/null +++ b/src/shared/types/userMessage.ts @@ -0,0 +1,9 @@ +import type { MessageStatusType, ServerMessageKeyType } from '../constants/messages'; + +interface UserMessage { + key?: ServerMessageKeyType; + message?: string; + status: MessageStatusType; +} + +export default UserMessage; diff --git a/src/shared/types/validation/aboutData.ts b/src/shared/types/validation/aboutData.ts new file mode 100644 index 00000000..004871f2 --- /dev/null +++ b/src/shared/types/validation/aboutData.ts @@ -0,0 +1,106 @@ +interface AboutText { + en: { + text: string; + }; + ru: { + text: string; + }; +} + +export interface AboutLabel { + backgroundColor: AboutColor; + color: AboutColor; + name: string; +} + +interface AboutGithub { + link: string; + name: string; +} + +interface AboutColor { + false: { + color: string; + }; + true: { + color: string; + }; +} + +export interface AboutFeedback { + from: string; + text: AboutText; +} + +interface AboutChecklist { + en: { + text: string; + }[]; + ru: { + text: string; + }[]; +} + +export interface AboutData { + avatar: string; + checklist: AboutChecklist; + coverColor: AboutColor; + feedback: AboutFeedback[]; + github: AboutGithub; + labels: AboutLabel[]; + position: AboutText; + shortDescription: AboutText; + userName: AboutText; +} + +const isAboutText = (data: unknown): data is AboutText => + typeof data === 'object' && + data !== null && + 'en' in data && + 'ru' in data && + 'text' && + typeof data.en === 'object' && + typeof data.ru === 'object' && + data.en !== null && + data.ru !== null && + 'text' in data.en && + 'text' in data.ru && + typeof data.en.text === 'string' && + typeof data.ru.text === 'string'; + +const isAboutData = (data: unknown): data is AboutData[] => { + if (!Array.isArray(data)) { + return false; + } + + return data.every( + (item: AboutData) => + typeof item.avatar === 'string' && + typeof item.coverColor === 'object' && + 'false' in item.coverColor && + 'true' in item.coverColor && + typeof item.coverColor.false === 'object' && + typeof item.coverColor.true === 'object' && + typeof item.coverColor.false.color === 'string' && + typeof item.coverColor.true.color === 'string' && + typeof item.github === 'object' && + 'link' in item.github && + 'name' in item.github && + typeof item.github.name === 'string' && + typeof item.github.link === 'string' && + typeof item.checklist === 'object' && + 'en' in item.checklist && + 'ru' in item.checklist && + typeof item.checklist.en === 'object' && + typeof item.checklist.ru === 'object' && + Array.isArray(item.checklist.en) && + Array.isArray(item.checklist.ru) && + isAboutText(item.userName) && + isAboutText(item.position) && + isAboutText(item.shortDescription) && + Array.isArray(item.labels) && + Array.isArray(item.feedback), + ); +}; + +export default isAboutData; diff --git a/src/shared/types/validation/cooperationData.ts b/src/shared/types/validation/cooperationData.ts new file mode 100644 index 00000000..9a12a689 --- /dev/null +++ b/src/shared/types/validation/cooperationData.ts @@ -0,0 +1,61 @@ +interface CooperationListItem { + text: string; +} + +interface CooperationItem { + description?: string; + items?: CooperationListItem[]; + subtitle?: string; + title?: string; +} + +export interface CooperationData { + en: CooperationItem; + ru: CooperationItem; +} + +const isCooperationItem = (data: unknown): data is CooperationItem => { + let result = true; + if (data === null || typeof data !== 'object') { + result = false; + return result; + } + + if ('description' in data && typeof data.description === 'string') { + result = true; + } + + if ('title' in data && typeof data.title === 'string') { + result = true; + } + + if ('subtitle' in data && typeof data.subtitle === 'string') { + result = true; + } + + if ('items' in data && Array.isArray(data.items)) { + data.items.forEach((item: CooperationListItem) => { + result = 'text' in item && typeof item.text === 'string'; + }); + } + + return result; +}; + +const isCooperationData = (data: unknown): data is CooperationData[] => { + let result = true; + if (!Array.isArray(data)) { + return false; + } + data.forEach((item: CooperationData) => { + if ('en' in item && 'ru' in item) { + result = isCooperationItem(item.en) && isCooperationItem(item.ru); + } else { + result = false; + } + }); + + return result; +}; + +export default isCooperationData; diff --git a/src/shared/utils/baseComponent.ts b/src/shared/utils/baseComponent.ts deleted file mode 100644 index 27721635..00000000 --- a/src/shared/utils/baseComponent.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { isNotNullable } from './isNullable.ts'; - -export type Props = { - tag?: keyof HTMLElementTagNameMap; - txt?: string; -} & Partial>; - -export type ElementFnProps = Omit, 'tag'>; - -export class BaseComponent { - protected children: BaseComponent[] = []; - - protected node: T; - - constructor(p: Props, ...children: (BaseComponent | HTMLElement | null)[]) { - const currentProp = p; - currentProp.textContent = p.txt; - const node = document.createElement(p.tag ?? 'div') as T; - Object.assign(node, p); - this.node = node; - if (children) { - this.appendChildren(children.filter(isNotNullable)); - } - } - - public addClass(classNameClassName: string): void { - this.node.classList.add(classNameClassName); - } - - public append(child: BaseComponent | HTMLElement): void { - if (child instanceof BaseComponent) { - this.children.push(child); - this.node.append(child.getNode()); - } else { - this.node.append(child); - } - } - - public appendChildren(children: (BaseComponent | HTMLElement | null)[]): void { - children.filter(isNotNullable).forEach((element) => { - this.append(element); - }); - } - - public destroy(): void { - this.destroyAllChildren(); - this.node.remove(); - } - - public destroyAllChildren(): void { - this.children.reduce((_, child) => { - child.destroy(); - return null; - }, null); - this.children.length = 0; - } - - public getNode(): T { - return this.node; - } - - public removeClass(className: string): void { - this.node.classList.remove(className); - } - - public stc(text: string): void { - this.node.textContent = text; - } - - public toggleClass(classSurname: string): void { - this.node.classList.toggle(classSurname); - } -} diff --git a/src/shared/utils/buildPathname.ts b/src/shared/utils/buildPathname.ts index 4f41af0a..c8c53583 100644 --- a/src/shared/utils/buildPathname.ts +++ b/src/shared/utils/buildPathname.ts @@ -1,11 +1,25 @@ -// eslint-disable-next-line import/prefer-default-export -export const buildPathName = ( +import { PAGE_ID } from '../constants/pages.ts'; + +type QueryParamsType = { + [key: string]: (null | string)[]; +}; + +type BuildQuery = (queryParams: QueryParamsType | null) => string; + +type BuildPathWithID = (endpoint: null | string, id: null | string) => string; +type BuildPathWithQuery = (endpoint: null | string, queryParams: QueryParamsType | null) => string; +type BuildPathWithIDAndQuery = ( endpoint: null | string, id: null | string, - queryParams: { [key: string]: (null | string)[] } | null, -): string => { + queryParams: QueryParamsType | null, +) => string; + +const buildPathWithID: BuildPathWithID = (endpoint, id = null) => + `${endpoint ? `${endpoint}` : ''}${id ? `/${id}` : ''}`; + +const buildQuery: BuildQuery = (queryParams) => { if (!queryParams) { - return `${endpoint ? `${endpoint}` : ''}${id ? `${id} ` : ''}`; + return ''; } const queryString = Object.entries(queryParams) @@ -13,5 +27,24 @@ export const buildPathName = ( .map(([key, values]) => `${key}=${values.filter(Boolean).join('_')}`) .join('&'); - return `${endpoint ? `${endpoint}` : ''}${id ? `/${id}` : ''}${queryString ? `${`?${queryString}`}` : ''}`; + return queryString ? `?${queryString}` : ''; }; + +const buildPathWithQuery: BuildPathWithQuery = (endpoint, queryParams = null) => { + const queryPart = buildQuery(queryParams); + return `${endpoint}${queryPart}`; +}; + +const buildPathWithIDAndQuery: BuildPathWithIDAndQuery = (endpoint, id = null, queryParams = null) => { + const pathWithID = buildPathWithID(endpoint, id); + const queryPart = buildQuery(queryParams); + return `${pathWithID}${queryPart}`; +}; + +export const aboutUsPathWithID = buildPathWithID.bind(null, PAGE_ID.ABOUT_US_PAGE); + +export const catalogPathWithQuery = buildPathWithQuery.bind(null, PAGE_ID.CATALOG_PAGE); + +export const catalogPathWithIDAndQuery = buildPathWithIDAndQuery.bind(null, PAGE_ID.CATALOG_PAGE); +export const productPathWithIDAndQuery = buildPathWithIDAndQuery.bind(null, PAGE_ID.PRODUCT_PAGE); +export const wishlistPathWithIDAndQuery = buildPathWithIDAndQuery.bind(null, PAGE_ID.WISHLIST_PAGE); diff --git a/src/shared/utils/calcUserBirthDayRange.ts b/src/shared/utils/calcUserBirthDayRange.ts new file mode 100644 index 00000000..98782f3c --- /dev/null +++ b/src/shared/utils/calcUserBirthDayRange.ts @@ -0,0 +1,42 @@ +const getDaysInMonth = (date: Date): number => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + +const calcUserBirthDayRange = (birthDay: string): { end: string; start: string } => { + const birthDate = new Date(birthDay); + const today = new Date(); + + const start = new Date(today.getFullYear(), birthDate.getMonth(), birthDate.getDate() - 3); + const end = new Date(today.getFullYear(), birthDate.getMonth(), birthDate.getDate() + 3); + + if (start.getDate() < 1) { + start.setMonth(start.getMonth() - 1); + } + + if (start.getMonth() < 0) { + start.setFullYear(start.getFullYear() - 1); + start.setMonth(12); + } + + if (end.getDate() > getDaysInMonth(end)) { + end.setMonth(end.getMonth() + 1); + } + + if (end.getMonth() > 11) { + end.setFullYear(end.getFullYear() + 1); + end.setMonth(1); + } + + if (start.getMonth() < today.getMonth()) { + start.setFullYear(start.getFullYear() + 1); + } + + if (end.getMonth() < today.getMonth()) { + end.setFullYear(end.getFullYear() + 1); + } + + const endDate = end.toISOString().split('T')[0]; + const startDate = start.toISOString().split('T')[0]; + + return { end: endDate, start: startDate }; +}; + +export default calcUserBirthDayRange; diff --git a/src/shared/utils/changeColor.ts b/src/shared/utils/changeColor.ts new file mode 100644 index 00000000..a920768f --- /dev/null +++ b/src/shared/utils/changeColor.ts @@ -0,0 +1,6 @@ +const changeColor = (element: HTMLElement, color: string): void => { + const currentEl = element; + currentEl.style.color = color; +}; + +export default changeColor; diff --git a/src/shared/utils/createSVGUse.ts b/src/shared/utils/createSVGUse.ts index 8cdd9ac9..ef96a137 100644 --- a/src/shared/utils/createSVGUse.ts +++ b/src/shared/utils/createSVGUse.ts @@ -4,4 +4,12 @@ const createSVGUse = (id: string): SVGUseElement => { return use; }; +export const changeFill = (svg: SVGSVGElement, color: string): void => { + svg.setAttribute('fill', color); +}; + +export const changeStroke = (svg: SVGSVGElement, color: string): void => { + svg.setAttribute('stroke', color); +}; + export default createSVGUse; diff --git a/src/shared/utils/determineNewAddress.ts b/src/shared/utils/determineNewAddress.ts index 8f686b30..09fbf3c3 100644 --- a/src/shared/utils/determineNewAddress.ts +++ b/src/shared/utils/determineNewAddress.ts @@ -3,13 +3,13 @@ import type UserAddressModel from '@/entities/UserAddress/model/UserAddressModel import type { Address, User } from '../types/user'; -import { ADDRESS_TYPE, type AddressTypeType } from '../constants/forms.ts'; +import { ADDRESS, type AddressType } from '../constants/forms.ts'; const determineNewAddress = ( addressesContainsID: (array: Address[]) => boolean, defaultContainsID: (defaultAddress: Address | null) => boolean, user: User, - createAddress: (activeTypes: AddressTypeType[], inactiveTypes?: AddressTypeType[]) => UserAddressModel, + createAddress: (activeTypes: AddressType[], inactiveTypes?: AddressType[]) => UserAddressModel, ): UserAddressModel => { const { billingAddress, defaultBillingAddressId, defaultShippingAddressId, shippingAddress } = user; switch (true) { @@ -17,110 +17,60 @@ const determineNewAddress = ( 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, - ]); + return createAddress([ADDRESS.SHIPPING, ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING, ADDRESS.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], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.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], - ); + return createAddress([ADDRESS.SHIPPING, ADDRESS.DEFAULT_BILLING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.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], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.SHIPPING, ADDRESS.DEFAULT_BILLING], [ADDRESS.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], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.SHIPPING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.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], - ); + return createAddress([ADDRESS.DEFAULT_BILLING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.BILLING, ADDRESS.SHIPPING]); case addressesContainsID(billingAddress) && addressesContainsID(shippingAddress): // billing, shipping - return createAddress( - [ADDRESS_TYPE.BILLING, ADDRESS_TYPE.SHIPPING], - [ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.SHIPPING], [ADDRESS.DEFAULT_BILLING, ADDRESS.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], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING], [ADDRESS.SHIPPING, ADDRESS.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], - ); + return createAddress([ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING], [ADDRESS.SHIPPING, ADDRESS.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], - ); + return createAddress([ADDRESS.SHIPPING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.BILLING, ADDRESS.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], - ); + return createAddress([ADDRESS.SHIPPING, ADDRESS.DEFAULT_SHIPPING], [ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING]); case addressesContainsID(billingAddress): // billing - return createAddress( - [ADDRESS_TYPE.BILLING], - [ADDRESS_TYPE.DEFAULT_SHIPPING, ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.DEFAULT_BILLING], - ); + return createAddress([ADDRESS.BILLING], [ADDRESS.DEFAULT_SHIPPING, ADDRESS.SHIPPING, ADDRESS.DEFAULT_BILLING]); case addressesContainsID(shippingAddress): // shipping - return createAddress( - [ADDRESS_TYPE.SHIPPING], - [ADDRESS_TYPE.DEFAULT_SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], - ); + return createAddress([ADDRESS.SHIPPING], [ADDRESS.DEFAULT_SHIPPING, ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING]); case defaultContainsID(defaultBillingAddressId): // defaultBilling - return createAddress( - [ADDRESS_TYPE.DEFAULT_BILLING], - [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], - ); + return createAddress([ADDRESS.DEFAULT_BILLING], [ADDRESS.SHIPPING, ADDRESS.BILLING, ADDRESS.DEFAULT_SHIPPING]); case defaultContainsID(defaultShippingAddressId): // defaultShipping - return createAddress( - [ADDRESS_TYPE.DEFAULT_SHIPPING], - [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING], - ); + return createAddress([ADDRESS.DEFAULT_SHIPPING], [ADDRESS.SHIPPING, ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING]); default: // None - return createAddress( - [], - [ADDRESS_TYPE.SHIPPING, ADDRESS_TYPE.BILLING, ADDRESS_TYPE.DEFAULT_BILLING, ADDRESS_TYPE.DEFAULT_SHIPPING], - ); + return createAddress([], [ADDRESS.SHIPPING, ADDRESS.BILLING, ADDRESS.DEFAULT_BILLING, ADDRESS.DEFAULT_SHIPPING]); } }; diff --git a/src/shared/utils/formattedText.ts b/src/shared/utils/formattedText.ts index 37c6169c..651faaed 100644 --- a/src/shared/utils/formattedText.ts +++ b/src/shared/utils/formattedText.ts @@ -5,23 +5,4 @@ const formattedText = (text: string): string => .map((word) => (word[0] ? word[0].toUpperCase() + word.slice(1).toLowerCase() : '')) .join(' '); -export const formattedTextForMainAndCatalog = (text: string): string => { - const exceptionMap: { [key: string]: string } = { - catalo: 'catalog', - mai: 'main', - }; - - return text - .trim() - .split(' ') - .map((word) => { - const lowerCaseWord = word.toLowerCase(); - if (Object.prototype.hasOwnProperty.call(exceptionMap, lowerCaseWord)) { - return exceptionMap[lowerCaseWord]; - } - return word[0] ? word[0].toUpperCase() + word.slice(1).toLowerCase() : ''; - }) - .join(' '); -}; - export default formattedText; diff --git a/src/shared/utils/formattedTime.ts b/src/shared/utils/formattedTime.ts new file mode 100644 index 00000000..5664421a --- /dev/null +++ b/src/shared/utils/formattedTime.ts @@ -0,0 +1,13 @@ +const formattedMinutes = (minutes: number): string => { + const lastDigit = minutes % 10; + let result = `${minutes} минут`; + if (lastDigit === 1 && minutes !== 11) { + result = `${minutes} минуту`; + } else if (lastDigit !== 1 && (minutes < 5 || minutes > 20)) { + result = `${minutes} минуты`; + } + + return result; +}; + +export default formattedMinutes; diff --git a/src/shared/utils/getAboutData.ts b/src/shared/utils/getAboutData.ts new file mode 100644 index 00000000..a096e196 --- /dev/null +++ b/src/shared/utils/getAboutData.ts @@ -0,0 +1,9 @@ +const ABOUT_URL = 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/about/about.json'; + +const getAboutData = async (): Promise => { + const response = await fetch(ABOUT_URL); + const data: unknown = await response.json(); + return data; +}; + +export default getAboutData; diff --git a/src/shared/utils/getCooperationData.ts b/src/shared/utils/getCooperationData.ts new file mode 100644 index 00000000..8fa9977a --- /dev/null +++ b/src/shared/utils/getCooperationData.ts @@ -0,0 +1,9 @@ +const COOPERATION_URL = 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/cooperation/cooperation.json'; + +const getCooperationData = async (): Promise => { + const response = await fetch(COOPERATION_URL); + const data: unknown = await response.json(); + return data; +}; + +export default getCooperationData; diff --git a/src/shared/utils/getCurrentAppTheme.ts b/src/shared/utils/getCurrentAppTheme.ts new file mode 100644 index 00000000..23037a46 --- /dev/null +++ b/src/shared/utils/getCurrentAppTheme.ts @@ -0,0 +1,4 @@ +import getStore from '../Store/Store.ts'; + +const getCurrentAppTheme = (): 'false' | 'true' => (getStore().getState().isAppThemeLight ? 'true' : 'false'); +export default getCurrentAppTheme; diff --git a/src/shared/utils/getCurrentLanguage.ts b/src/shared/utils/getCurrentLanguage.ts new file mode 100644 index 00000000..b2705769 --- /dev/null +++ b/src/shared/utils/getCurrentLanguage.ts @@ -0,0 +1,7 @@ +import type { LanguageChoiceType } from '../constants/common.ts'; + +import getStore from '../Store/Store.ts'; + +const getCurrentLanguage = (): LanguageChoiceType => getStore().getState().currentLanguage; + +export default getCurrentLanguage; diff --git a/src/shared/utils/hexToRgba.ts b/src/shared/utils/hexToRgba.ts new file mode 100644 index 00000000..dc513278 --- /dev/null +++ b/src/shared/utils/hexToRgba.ts @@ -0,0 +1,8 @@ +const hexToRgba = (hex: string, alpha?: number): string => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +export default hexToRgba; diff --git a/src/shared/utils/isKeyOf.ts b/src/shared/utils/isKeyOf.ts index 44fbc5e8..e5984c46 100644 --- a/src/shared/utils/isKeyOf.ts +++ b/src/shared/utils/isKeyOf.ts @@ -1,12 +1,6 @@ -import type MetaFilters from '../types/productFilters.ts'; -import type { UserCredentials } from '../types/user'; - -import getStore from '../Store/Store.ts'; import { PAGE_TITLE } from '../constants/pages.ts'; +import getCurrentLanguage from './getCurrentLanguage.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; +const keyExistsInPageTitle = (key: string): boolean => key in PAGE_TITLE[getCurrentLanguage()]; -export const keyExistsInPageTitle = (key: string): boolean => key in PAGE_TITLE[getStore().getState().currentLanguage]; +export default keyExistsInPageTitle; diff --git a/src/shared/utils/isNullable.ts b/src/shared/utils/isNullable.ts deleted file mode 100644 index 14809dcc..00000000 --- a/src/shared/utils/isNullable.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isNullable(value: unknown): value is null | undefined { - return value == null; -} - -export function isNotNullable(value: T): value is NonNullable { - return value != null; -} diff --git a/src/shared/utils/messageTemplates.ts b/src/shared/utils/messageTemplates.ts index dd3095d5..5e3f0631 100644 --- a/src/shared/utils/messageTemplates.ts +++ b/src/shared/utils/messageTemplates.ts @@ -1,8 +1,10 @@ -import getStore from '../Store/Store.ts'; +import type { Variant } from '../types/product.ts'; + import { LANGUAGE_CHOICE } from '../constants/common.ts'; import { SERVER_MESSAGE } from '../constants/messages.ts'; -import { PAGE_DESCRIPTION, USER_INFO_TEXT } from '../constants/pages.ts'; -import { ADDRESS_TYPE } from '../types/address.ts'; +import { USER_INFO_TEXT } from '../constants/pages.ts'; +import { PRODUCT_INFO_TEXT } from '../constants/product.ts'; +import getCurrentLanguage from './getCurrentLanguage.ts'; const textTemplate = (beginning: string, variable: number | string, end?: string): string => { const start = beginning ? `${beginning} ` : ''; @@ -10,26 +12,51 @@ const textTemplate = (beginning: string, variable: number | string, end?: string return `${start}${variable}${ending}`; }; -export const addressTemplate = (streetName = '', city = '', country: null | string = null, postalCode = ''): string => - `${streetName}, ${city}, ${country}, ${postalCode}`; +export const cartPrice = (price: string): string => `$${price}`; + +export const minusCartPrice = (price: string): string => `-$${price}`; + +export const discountText = (): string => PRODUCT_INFO_TEXT[getCurrentLanguage()].DISCOUNT_LABEL; + +export const discountPercent = (currentVariant: Variant): string => + `${Math.round((1 - currentVariant.discount / currentVariant.price) * 100)}%`; + +export const productAddedToCartMessage = (name: string): string => + textTemplate(name, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_ADD_PRODUCT_TO_CART); + +export const productRemovedFromCartMessage = (name: string): string => + textTemplate(name, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_DELETE_PRODUCT_FROM_CART); + +export const productAddedToWishListMessage = (name: string): string => + textTemplate(name, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST); + +export const productRemovedFromWishListMessage = (name: string): string => + textTemplate(name, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST); + +export const promoCodeAppliedMessage = (promocode: string): string => + textTemplate(promocode, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_ADD_COUPON_TO_CART); -export const userInfoName = (name: string): string => - textTemplate(USER_INFO_TEXT[getStore().getState().currentLanguage].NAME, name); +export const promoCodeDeleteMessage = (promocode: string): string => + textTemplate(promocode, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_DELETE_COUPON_FROM_CART); + +export const promoCodeCopiedMessage = (promocode: string): string => + textTemplate(promocode, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_COPY_PROMO_CODE_TO_CLIPBOARD); + +export const SKUCopiedMessage = (key: string): string => + textTemplate(`SKU ${key}`, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_COPY_TO_CLIPBOARD); + +export const userInfoName = (name: string): string => textTemplate(USER_INFO_TEXT[getCurrentLanguage()].NAME, name); export const userInfoLastName = (name: string): string => - textTemplate(USER_INFO_TEXT[getStore().getState().currentLanguage].LAST_NAME, name); + textTemplate(USER_INFO_TEXT[getCurrentLanguage()].LAST_NAME, name); -export const userInfoEmail = (email: string): string => - textTemplate(USER_INFO_TEXT[getStore().getState().currentLanguage].EMAIL, email); +export const userInfoEmail = (email: string): string => textTemplate(USER_INFO_TEXT[getCurrentLanguage()].EMAIL, email); export const userInfoDateOfBirth = (date: string): string => - textTemplate(USER_INFO_TEXT[getStore().getState().currentLanguage].DATE_OF_BIRTH, date); - -export const greeting = (name: string): string => - textTemplate(PAGE_DESCRIPTION[getStore().getState().currentLanguage].GREETING, name, '!'); + textTemplate(USER_INFO_TEXT[getCurrentLanguage()].DATE_OF_BIRTH, date); -export const createGreetingMessage = (): string => - `${SERVER_MESSAGE[getStore().getState().currentLanguage].SUCCESSFUL_LOGIN}`; +export const createGreetingMessage = (name: string): string => + textTemplate(`Hi, ${name}!`, SERVER_MESSAGE[getCurrentLanguage()].SUCCESSFUL_LOGIN); const maxLengthMessageRu = (maxLength: number): string => textTemplate('Максимальная длина не должна превышать', maxLength, ' символов'); @@ -38,35 +65,21 @@ const maxLengthMessageEn = (maxLength: number): string => textTemplate('Maximum length should not exceed', maxLength, ' characters'); export const maxLengthMessage = (maxLength: number): string => - getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN - ? maxLengthMessageEn(maxLength) - : maxLengthMessageRu(maxLength); + getCurrentLanguage() === LANGUAGE_CHOICE.EN ? maxLengthMessageEn(maxLength) : maxLengthMessageRu(maxLength); const maxAgeRu = (maxAge: number): string => textTemplate('Вам должно быть не более', maxAge, ' лет'); -export const defaultBillingAddress = (address: string): string => - textTemplate('', address, USER_INFO_TEXT[getStore().getState().currentLanguage].DEFAULT_BILLING_ADDRESS); - -export const defaultShippingAddress = (address: string): string => - textTemplate('', address, USER_INFO_TEXT[getStore().getState().currentLanguage].DEFAULT_SHIPPING_ADDRESS); - -export const billingAddressMessage = (address: string): string => - textTemplate('', address, USER_INFO_TEXT[getStore().getState().currentLanguage].BILLING); - -export const shippingAddressMessage = (address: string): string => - textTemplate('', address, USER_INFO_TEXT[getStore().getState().currentLanguage].SHIPPING); - const maxAgeEn = (maxAge: number): string => textTemplate('You must be at most', maxAge, ' years old'); export const maxAgeMessage = (maxAge: number): string => - getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? maxAgeEn(maxAge) : maxAgeRu(maxAge); + getCurrentLanguage() === LANGUAGE_CHOICE.EN ? maxAgeEn(maxAge) : maxAgeRu(maxAge); const minAgeRu = (minAge: number): string => textTemplate('Вам должно быть не менее', minAge, ' лет'); const minAgeEn = (minAge: number): string => textTemplate('You must be at least', minAge, ' years old'); export const minAgeMessage = (minAge: number): string => - getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN ? minAgeEn(minAge) : minAgeRu(minAge); + getCurrentLanguage() === LANGUAGE_CHOICE.EN ? minAgeEn(minAge) : minAgeRu(minAge); const minLengthMessageRu = (minLength: number): string => textTemplate('Минимальная длина должна быть не менее', minLength, ' символов'); @@ -75,17 +88,4 @@ const minLengthMessageEn = (minLength: number): string => textTemplate('Minimum length should be at least', minLength, ' characters'); export const minLengthMessage = (minLength: number): string => - getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN - ? minLengthMessageEn(minLength) - : minLengthMessageRu(minLength); - -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 text; - } -} + getCurrentLanguage() === LANGUAGE_CHOICE.EN ? minLengthMessageEn(minLength) : minLengthMessageRu(minLength); diff --git a/src/shared/utils/observeCurrentLanguage.ts b/src/shared/utils/observeCurrentLanguage.ts index f770bc77..fd5961ea 100644 --- a/src/shared/utils/observeCurrentLanguage.ts +++ b/src/shared/utils/observeCurrentLanguage.ts @@ -1,5 +1,5 @@ -import getStore from '../Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '../Store/observer.ts'; +import getCurrentLanguage from './getCurrentLanguage.ts'; const observeCurrentLanguage = ( el: HTMLElement | Node, @@ -11,7 +11,7 @@ const observeCurrentLanguage = ( if (textNode) { observeStore(selectCurrentLanguage, () => { - textNode.textContent = map[getStore().getState().currentLanguage][text]; + textNode.textContent = map[getCurrentLanguage()][text]; }); } return true; diff --git a/src/shared/utils/setPageTitle.ts b/src/shared/utils/setPageTitle.ts index 705f7705..98b13ce8 100644 --- a/src/shared/utils/setPageTitle.ts +++ b/src/shared/utils/setPageTitle.ts @@ -1,9 +1,9 @@ -import getStore from '../Store/Store.ts'; import { PAGE_ID, PAGE_TITLE } from '../constants/pages.ts'; -import { keyExistsInPageTitle } from './isKeyOf.ts'; +import getCurrentLanguage from './getCurrentLanguage.ts'; +import keyExistsInPageTitle from './isKeyOf.ts'; const appTitle = (projectTitle: string, currentPageTitle: string): string => { - const { currentLanguage } = getStore().getState(); + const currentLanguage = getCurrentLanguage(); if (keyExistsInPageTitle(currentPageTitle)) { return `${projectTitle} | ${PAGE_TITLE[currentLanguage][currentPageTitle]}`; } diff --git a/src/shared/utils/tags.ts b/src/shared/utils/tags.ts deleted file mode 100644 index 8b368d7f..00000000 --- a/src/shared/utils/tags.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BaseComponent, type ElementFnProps } from './baseComponent.ts'; - -export const span = (props: ElementFnProps, ...children: BaseComponent[]): BaseComponent => - new BaseComponent({ ...props, tag: 'span' }, ...children); - -export const main = (props: ElementFnProps, ...children: BaseComponent[]): BaseComponent => - new BaseComponent({ ...props, tag: 'main' }, ...children); - -export const label = (props: ElementFnProps, ...children: BaseComponent[]): BaseComponent => - new BaseComponent({ ...props, tag: 'label' }, ...children); - -export const input = (props: ElementFnProps & Partial): BaseComponent => - new BaseComponent({ ...props, tag: 'input' }); - -export const iconFromCode = (props: ElementFnProps, code: string): BaseComponent => - new BaseComponent({ ...props, innerHTML: code, tag: 'i' }); - -export const h2 = (className: string, txt: string): BaseComponent => - new BaseComponent({ className, tag: 'h2', txt }); - -export const h3 = (className: string, txt: string): BaseComponent => - new BaseComponent({ className, tag: 'h3', txt }); - -export const div = ( - props: ElementFnProps, - ...children: (BaseComponent | HTMLElement | null)[] -): BaseComponent => new BaseComponent(props, ...children); - -export const a = (props: ElementFnProps, ...children: BaseComponent[]): BaseComponent => - new BaseComponent({ ...props, tag: 'a' }, ...children); - -export const img = ({ alt = '', className = '', src = '' }): BaseComponent => - new BaseComponent({ - alt, - className, - src, - tag: 'img', - }); diff --git a/src/shared/utils/tests.spec.ts b/src/shared/utils/tests.spec.ts index 7cb74b1d..4b4c57b3 100644 --- a/src/shared/utils/tests.spec.ts +++ b/src/shared/utils/tests.spec.ts @@ -1,15 +1,15 @@ import findAddressIndex from './address.ts'; -import { BaseComponent } from './baseComponent.ts'; import BaseElement from './baseElement.ts'; import clearOutElement from './clearOutElement.ts'; import createBaseElement from './createBaseElement.ts'; import createSVGUse from './createSVGUse.ts'; import formattedText from './formattedText.ts'; import getCountryIndex from './getCountryIndex.ts'; -import { isKeyOfUserData } from './isKeyOf.ts'; -import { isNotNullable, isNullable } from './isNullable.ts'; import observeCurrentLanguage from './observeCurrentLanguage.ts'; -import { a, div, h2, h3, iconFromCode, img, input, label, main, span } from './tags.ts'; + +/** + * @vitest-environment jsdom + */ describe('Checking formattedText function', () => { it('should return formatted text for basic functionality', () => { @@ -73,11 +73,6 @@ const elem = createBaseElement({ tag: 'div', }); -const baseComponent = new BaseComponent({ - tag: 'div', - txt: 'test', -}); - describe('Checking createBaseElement function', () => { it('should create an element', () => { expect(elem).toBeDefined(); @@ -92,24 +87,6 @@ describe('Checking createBaseElement function', () => { }); }); -describe('Checking isNullable function', () => { - it('should return true', () => { - expect(isNullable(null)).toBe(true); - }); - it('should return false', () => { - expect(isNullable('test')).toBe(false); - }); -}); - -describe('Checking isNotNullable function', () => { - it('should return true', () => { - expect(isNotNullable(null)).toBe(false); - }); - it('should return false', () => { - expect(isNotNullable('test')).toBe(true); - }); -}); - describe('Checking createSVGUse function', () => { it('should create an element', () => { expect(createSVGUse('test')).toBeDefined(); @@ -190,94 +167,6 @@ describe('Checking BaseElement class', () => { }); }); -describe('Checking BaseComponent class', () => { - it('getNode should return instance of HTMLDivElement', () => { - expect(baseComponent.getNode()).toBeInstanceOf(HTMLDivElement); - }); - - it('destroy should return true', () => { - baseComponent.destroy(); - expect(baseComponent.getNode().parentElement).toBe(null); - }); - - it('destroyAllChildren should return true', () => { - baseComponent.destroyAllChildren(); - expect(baseComponent.getNode().children.length).toBe(0); - }); - - it('append should return true', () => { - baseComponent.append(elem); - expect(baseComponent.getNode().contains(elem)).toBe(true); - }); - - it('appendChildren should return true', () => { - baseComponent.appendChildren([elem]); - expect(baseComponent.getNode().contains(elem)).toBe(true); - }); - - it('set text content should return test', () => { - baseComponent.stc('test'); - expect(baseComponent.getNode().textContent).toBe('test'); - }); - - it('add css class should return test', () => { - baseComponent.addClass('test'); - expect(baseComponent.getNode().classList.contains('test')).toBe(true); - }); - - it('remove css class should return test', () => { - baseComponent.removeClass('test'); - expect(baseComponent.getNode().classList.contains('test')).toBe(false); - }); - - it('toggle css class should return test', () => { - baseComponent.toggleClass('test'); - expect(baseComponent.getNode().classList.contains('test')).toBe(true); - }); -}); - -describe('Checking tags functions', () => { - it('span should return instance of HTMLSpanElement', () => { - expect(span({ txt: 'test' }).getNode()).toBeInstanceOf(HTMLSpanElement); - }); - - it('main should return instance of HTMLDivElement', () => { - expect(main({ txt: 'test' }).getNode()).toBeInstanceOf(HTMLElement); - }); - - it('label should return instance of HTMLLabelElement', () => { - expect(label({ txt: 'test' }).getNode()).toBeInstanceOf(HTMLLabelElement); - }); - - it('input should return instance of HTMLInputElement', () => { - expect(input({ txt: 'test' }).getNode()).toBeInstanceOf(HTMLInputElement); - }); - - it('iconFromCode should return instance of HTMLElement', () => { - expect(iconFromCode({ txt: 'test' }, 'test').getNode()).toBeInstanceOf(HTMLElement); - }); - - it('h2 should return instance of HTMLHeadingElement', () => { - expect(h2('test', 'test').getNode()).toBeInstanceOf(HTMLHeadingElement); - }); - - it('h3 should return instance of HTMLHeadingElement', () => { - expect(h3('test', 'test').getNode()).toBeInstanceOf(HTMLHeadingElement); - }); - - it('div should return instance of HTMLDivElement', () => { - expect(div({ txt: 'test' }, null).getNode()).toBeInstanceOf(HTMLDivElement); - }); - - it('a should return instance of HTMLAnchorElement', () => { - expect(a({ txt: 'test' }).getNode()).toBeInstanceOf(HTMLAnchorElement); - }); - - it('img should return instance of HTMLImageElement', () => { - expect(img({ src: 'test' }).getNode()).toBeInstanceOf(HTMLImageElement); - }); -}); - describe('Checking getCountryIndex function', () => { it('Afghanistan country should return AF', () => { expect(getCountryIndex('Afghanistan')).toBe('AF'); @@ -288,17 +177,6 @@ describe('Checking getCountryIndex function', () => { }); }); -const userData = { - email: 'user@example.com', - password: 'test', -}; - -describe('Checking isKeyOfUserData function', () => { - it('Email should return true', () => { - expect(isKeyOfUserData(userData, 'email')).toBe(true); - }); -}); - describe('Checking observeCurrentLanguage function', () => { it('should return true', () => { expect(observeCurrentLanguage(document.body, {}, 'test')).toBe(true); diff --git a/src/shared/utils/userMessage.ts b/src/shared/utils/userMessage.ts index 6e698487..90e0948b 100644 --- a/src/shared/utils/userMessage.ts +++ b/src/shared/utils/userMessage.ts @@ -1,16 +1,52 @@ import serverMessageModel from '../ServerMessage/model/ServerMessageModel.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '../constants/messages.ts'; +import { MESSAGE_STATUS, SERVER_MESSAGE_KEY } from '../constants/messages.ts'; -const showErrorMessage = (param: unknown): boolean => { +export const showErrorMessage = (param: unknown): boolean => { if (param instanceof Error) { - return serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR, param.message); + return serverMessageModel.showServerMessage({ + key: SERVER_MESSAGE_KEY.BAD_REQUEST, + message: param.message, + status: MESSAGE_STATUS.ERROR, + }); } - if (typeof param === 'string' && param in SERVER_MESSAGE_KEYS) { - return serverMessageModel.showServerMessage(param, MESSAGE_STATUS.ERROR); + if (typeof param === 'string' && param in SERVER_MESSAGE_KEY) { + return serverMessageModel.showServerMessage({ + key: param, + status: MESSAGE_STATUS.ERROR, + }); } - return serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR); + if (typeof param === 'string') { + return serverMessageModel.showServerMessage({ + key: SERVER_MESSAGE_KEY.BAD_REQUEST, + message: param, + status: MESSAGE_STATUS.ERROR, + }); + } + + return serverMessageModel.showServerMessage({ + key: SERVER_MESSAGE_KEY.BAD_REQUEST, + status: MESSAGE_STATUS.ERROR, + }); }; -export default showErrorMessage; +export const showSuccessMessage = (param: unknown): boolean => { + if (typeof param === 'string' && param in SERVER_MESSAGE_KEY) { + return serverMessageModel.showServerMessage({ + key: param, + status: MESSAGE_STATUS.SUCCESS, + }); + } + + if (typeof param === 'string') { + return serverMessageModel.showServerMessage({ + message: param, + status: MESSAGE_STATUS.SUCCESS, + }); + } + + return serverMessageModel.showServerMessage({ + status: MESSAGE_STATUS.SUCCESS, + }); +}; diff --git a/src/test/mocks/handler.ts b/src/test/mocks/handler.ts new file mode 100644 index 00000000..5eec6968 --- /dev/null +++ b/src/test/mocks/handler.ts @@ -0,0 +1,12 @@ +import cart from './handlers/cart.ts'; +import category from './handlers/category.ts'; +import customer from './handlers/customer.ts'; +import discount from './handlers/discount.ts'; +import product from './handlers/product.ts'; +import project from './handlers/project.ts'; +import shoppingList from './handlers/shopping-list.ts'; +import token from './handlers/token.ts'; + +const handlers = [...token, ...category, ...product, ...cart, ...customer, ...discount, ...shoppingList, ...project]; + +export default handlers; diff --git a/src/test/mocks/handlers/cart.ts b/src/test/mocks/handlers/cart.ts new file mode 100644 index 00000000..d53a0a6b --- /dev/null +++ b/src/test/mocks/handlers/cart.ts @@ -0,0 +1,257 @@ +import { HttpResponse, http } from 'msw'; + +const carts = { + anonymousId: 'f6b6183e-cc73-4b19-a65e-f408fc785e89', + cartState: 'Merged', + createdAt: '2024-06-14T15:51:18.227Z', + createdBy: { + anonymousId: 'c3eeabe1-a0fb-49a6-ab10-f3bf19d933c8', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + customLineItems: [], + customerId: 'a9a1b24b-a467-4850-9a1c-c4030dff9949', + deleteDaysAfterLastModification: 2, + directDiscounts: [], + discountCodes: [], + id: '311ee12e-a1a3-4ec2-b69e-0e6f03fcba36', + inventoryMode: 'None', + itemShippingAddresses: [], + lastMessageSequenceNumber: 1, + lastModifiedAt: '2024-06-16T08:44:18.913Z', + lastModifiedBy: { + anonymousId: 'f6b6183e-cc73-4b19-a65e-f408fc785e89', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + lineItems: [ + { + addedAt: '2024-06-14T15:51:41.583Z', + discountedPricePerQuantity: [], + id: '0d56624b-946b-4ac0-b6a7-178f06a3472a', + lastModifiedAt: '2024-06-14T15:51:41.583Z', + lineItemMode: 'Standard', + name: { + en: 'Zamioculcas', + ru: 'Замиокулькас', + }, + perMethodTaxRate: [], + price: { + id: '48eacec5-a0bc-4302-92da-654f37b9d0d3', + key: '79357868957074_1', + value: { + centAmount: 1500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + priceMode: 'Platform', + productId: '8247d798-ca84-4b90-ae1c-d7e5490f58b7', + productKey: '79357868957074', + productType: { + id: '986435e1-45b9-4f14-bcee-9ae3921e5d3c', + typeId: 'product-type', + version: 30, + }, + quantity: 1, + state: [ + { + quantity: 1, + state: { + id: '8baf5a56-683c-4e5f-a411-0ce8e62cc86e', + typeId: 'state', + }, + }, + ], + taxedPricePortions: [], + totalPrice: { + centAmount: 1500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + variant: { + assets: [ + { + id: 'e629929b-9c3f-4141-8e6e-77608a84d138', + key: 'img-1', + name: { + en: 'img-1', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-1.png', + }, + ], + tags: [], + }, + { + id: '18260724-dc23-46c7-8af1-2749b474a7ef', + key: 'img-2', + name: { + en: 'img-2', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-2.png', + }, + ], + tags: [], + }, + { + id: '0487e650-c792-45d8-aff7-e1dff5e23254', + key: 'img-3', + name: { + en: 'img-3', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-3.png', + }, + ], + tags: [], + }, + { + id: 'da50912b-eaed-4738-a432-65dfadd964b3', + key: 'img-4', + name: { + en: 'img-4', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-4.png', + }, + ], + tags: [], + }, + ], + attributes: [ + { + name: 'new_arrival', + value: [false], + }, + { + name: 'size', + value: [ + { + key: 'S', + label: 'S', + }, + ], + }, + { + name: 'full_description', + value: { + en: 'Zamiokulkas needs plenty of sunlight, suitable temperature conditions are in the range of 18 to 25 degrees Celsius, in winter 16-18. Water abundantly, but not often, about once a week. Daily spraying will be useful. In spring and summer, feed once a month, using fertilizers for cacti and succulents.', + ru: 'Замиокулькас нуждается в большом количестве солнечного света, подходящие температурные условия находятся в диапазоне от 18 до 25 градусов тепла, зимой 16-18. Поливают обильно, но не часто, примерно раз в неделю. Будут полезны ежедневные опрыскивания. Весной и летом подкармливают раз в месяц, используя удобрения для кактусов и суккулентов.', + }, + }, + { + name: 'level', + value: [ + { + key: '1', + label: '1', + }, + ], + }, + ], + id: 1, + images: [ + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-1', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-1.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-2', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-2.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-3', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-3.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-4', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-4.png', + }, + ], + key: '79357868957074_1', + prices: [ + { + id: '48eacec5-a0bc-4302-92da-654f37b9d0d3', + key: '79357868957074_1', + value: { + centAmount: 1500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + sku: '79357868957074_1', + }, + }, + ], + origin: 'Customer', + refusedGifts: [], + shipping: [], + shippingMode: 'Single', + taxCalculationMode: 'LineItemLevel', + taxMode: 'Platform', + taxRoundingMode: 'HalfEven', + totalLineItemQuantity: 1, + totalPrice: { + centAmount: 1500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + type: 'Cart', + version: 120, + versionModifiedAt: '2024-06-16T08:44:18.913Z', +}; + +const cartList = { + count: 1, + limit: 20, + offset: 0, + results: [carts], + total: 1, +}; + +const handlers = [ + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/carts`, () => + HttpResponse.json(cartList), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/carts`, () => + HttpResponse.json(cartList), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/carts/*`, () => + HttpResponse.json(carts), + ), + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/carts/*`, () => + HttpResponse.json(carts), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/carts/*`, () => + HttpResponse.json(carts), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/category.ts b/src/test/mocks/handlers/category.ts new file mode 100644 index 00000000..72031c81 --- /dev/null +++ b/src/test/mocks/handlers/category.ts @@ -0,0 +1,64 @@ +import { HttpResponse, http } from 'msw'; + +const categoryList = { + count: 1, + limit: 20, + offset: 0, + results: [ + { + ancestors: [ + { + id: 'cbc4591a-cdd8-4f93-840b-5cb40e47ada8', + typeId: 'category', + }, + ], + assets: [], + createdAt: '2024-04-27T13:33:02.558Z', + createdBy: { + isPlatformClient: true, + }, + description: { + en: 'Seeds', + ru: 'Семена', + }, + externalId: 'seeds', + id: '8e12968e-dc4e-4e19-a8cd-994fe1acf3e6', + key: 'seeds', + lastMessageSequenceNumber: 3, + lastModifiedAt: '2024-06-08T08:07:45.907Z', + lastModifiedBy: { + isPlatformClient: true, + }, + metaDescription: { + en: 'Seeds', + ru: 'Семена', + }, + metaTitle: { + en: 'seeds', + }, + name: { + en: 'Seeds', + ru: 'Семена', + }, + orderHint: '0.31', + parent: { + id: 'cbc4591a-cdd8-4f93-840b-5cb40e47ada8', + typeId: 'category', + }, + slug: { + en: 'seeds', + ru: 'seeds', + }, + version: 15, + versionModifiedAt: '2024-06-08T08:07:45.907Z', + }, + ], + total: 1, +}; +const handlers = [ + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/categories`, () => + HttpResponse.json(categoryList), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/customer.ts b/src/test/mocks/handlers/customer.ts new file mode 100644 index 00000000..555df968 --- /dev/null +++ b/src/test/mocks/handlers/customer.ts @@ -0,0 +1,120 @@ +import { HttpResponse, http } from 'msw'; + +const me = { + addresses: [ + { + city: 'Anytown', + country: 'DK', + email: 'test-test@example.com', + firstName: 'Jane', + id: 'sbSHz6YF', + lastName: 'Smith', + postalCode: '1234', + state: '', + streetName: 'Main Street 123', + streetNumber: '', + }, + ], + authenticationMode: 'Password', + billingAddressIds: ['sbSHz6YF'], + createdAt: '2024-06-16T08:57:27.551Z', + createdBy: { + anonymousId: '533d1e77-4d6f-4567-a6b0-6a332a11892f', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + dateOfBirth: '1990-01-01', + defaultBillingAddressId: 'sbSHz6YF', + defaultShippingAddressId: 'sbSHz6YF', + email: 'test-test@example.com', + firstName: 'Jane', + id: '9b59ebfc-76b3-4904-af39-e0ee2f2a7d7a', + isEmailVerified: false, + lastMessageSequenceNumber: 1, + lastModifiedAt: '2024-06-16T08:57:27.551Z', + lastModifiedBy: { + anonymousId: '533d1e77-4d6f-4567-a6b0-6a332a11892f', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + lastName: 'Smith', + locale: 'en', + password: '****HOM=', + shippingAddressIds: ['sbSHz6YF'], + stores: [], + version: 1, + versionModifiedAt: '2024-06-16T08:57:27.551Z', +}; +const customer = { + cart: { + anonymousId: '533d1e77-4d6f-4567-a6b0-6a332a11892f', + cartState: 'Active', + createdAt: '2024-06-16T08:54:22.342Z', + createdBy: { + anonymousId: '533d1e77-4d6f-4567-a6b0-6a332a11892f', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + customLineItems: [], + customerId: '9b59ebfc-76b3-4904-af39-e0ee2f2a7d7a', + deleteDaysAfterLastModification: 2, + directDiscounts: [], + discountCodes: [], + id: 'a97100b4-e2b8-4e6d-96b0-efa8973df56f', + inventoryMode: 'None', + itemShippingAddresses: [], + lastMessageSequenceNumber: 1, + lastModifiedAt: '2024-06-16T08:54:22.342Z', + lastModifiedBy: { + anonymousId: '533d1e77-4d6f-4567-a6b0-6a332a11892f', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + lineItems: [], + origin: 'Customer', + refusedGifts: [], + shipping: [], + shippingMode: 'Single', + taxCalculationMode: 'LineItemLevel', + taxMode: 'Platform', + taxRoundingMode: 'HalfEven', + totalPrice: { + centAmount: 0, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + type: 'Cart', + version: 2, + versionModifiedAt: '2024-06-16T08:57:27.571Z', + }, + customer: me, +}; + +const meList = { + count: 1, + limit: 20, + offset: 0, + results: [me], + total: 1, +}; + +const handlers = [ + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/signup`, () => + HttpResponse.json(customer), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/login`, () => + HttpResponse.json(customer), + ), + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/customers`, () => + HttpResponse.json(meList), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me`, () => + HttpResponse.json(me), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/password`, () => + HttpResponse.json(me), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/discount.ts b/src/test/mocks/handlers/discount.ts new file mode 100644 index 00000000..c7fa61f8 --- /dev/null +++ b/src/test/mocks/handlers/discount.ts @@ -0,0 +1,57 @@ +import { HttpResponse, http } from 'msw'; + +const discountList = { + count: 4, + limit: 20, + offset: 0, + results: [ + { + cartDiscounts: [ + { + id: 'ff195f35-c6f6-4959-a69b-2cd626e6ce60', + typeId: 'cart-discount', + }, + ], + code: 'first-key-1', + createdAt: '2024-04-29T07:55:04.369Z', + createdBy: { + isPlatformClient: true, + user: { + id: '9cd5771e-eb9b-4e70-8f16-a91c52216948', + typeId: 'user', + }, + }, + description: { + en: 'first', + }, + groups: [], + id: 'fcff07da-a136-4cbd-bedb-a089c0818089', + isActive: true, + lastMessageSequenceNumber: 1, + lastModifiedAt: '2024-04-29T07:55:13.613Z', + lastModifiedBy: { + isPlatformClient: true, + user: { + id: '9cd5771e-eb9b-4e70-8f16-a91c52216948', + typeId: 'user', + }, + }, + maxApplicationsPerCustomer: 1, + name: { + en: 'first', + }, + references: [], + version: 2, + versionModifiedAt: '2024-04-29T07:55:13.613Z', + }, + ], + total: 4, +}; + +const handlers = [ + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/discount-codes`, () => + HttpResponse.json(discountList), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/product.ts b/src/test/mocks/handlers/product.ts new file mode 100644 index 00000000..c8163eb4 --- /dev/null +++ b/src/test/mocks/handlers/product.ts @@ -0,0 +1,315 @@ +import { HttpResponse, http } from 'msw'; + +const productList = { + count: 1, + limit: 9, + offset: 0, + results: [ + { + categories: [ + { + id: '4de8975a-b68f-49e6-a8ea-c1e97c51c2a8', + typeId: 'category', + }, + ], + categoryOrderHints: {}, + createdAt: '2024-05-18T05:58:06.974Z', + description: { + en: 'Zamioculcas (Latin: Zamioculcas) is a houseplant of the Aroid family, distributed in Africa. This genus is represented by a single species - Zamioculcas zamielistny, in nature it can be found in tropical forests. The name of the plant was given because of the similarity to the leaves of zamia. Zamiokulkas is a herbaceous plant with a tuberous rhizome. The thin stem has evenly spaced dark green glossy leaves, which are oval in shape with a pointed tip.', + ru: 'Замиокулькас (лат. Zamioculcas) - это домашнее растение семейства Ароидные, распространенное на территории Африки. Данный род представлен единственным видом - Замиокулькас замиелистный, в природе его можно встретить в тропических лесах. Название растению дали из-за сходства с листьями замии. Замиокулькас представляет собой травянистое растение с клубнеобразным корневищем. На тонком стебле равномерно располагаются тёмно-зеленые глянцевые листья, они имеют овальную форму с заострением на конце.', + }, + hasStagedChanges: false, + id: '8247d798-ca84-4b90-ae1c-d7e5490f58b7', + key: '79357868957074', + lastModifiedAt: '2024-05-26T13:44:24.877Z', + masterVariant: { + assets: [ + { + id: 'e629929b-9c3f-4141-8e6e-77608a84d138', + key: 'img-1', + name: { + en: 'img-1', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-1.png', + }, + ], + tags: [], + }, + { + id: '18260724-dc23-46c7-8af1-2749b474a7ef', + key: 'img-2', + name: { + en: 'img-2', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-2.png', + }, + ], + tags: [], + }, + { + id: '0487e650-c792-45d8-aff7-e1dff5e23254', + key: 'img-3', + name: { + en: 'img-3', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-3.png', + }, + ], + tags: [], + }, + { + id: 'da50912b-eaed-4738-a432-65dfadd964b3', + key: 'img-4', + name: { + en: 'img-4', + }, + sources: [ + { + uri: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-4.png', + }, + ], + tags: [], + }, + ], + attributes: [ + { + name: 'new_arrival', + value: [false], + }, + { + name: 'size', + value: [ + { + key: 'S', + label: 'S', + }, + ], + }, + { + name: 'full_description', + value: { + en: 'Zamiokulkas needs plenty of sunlight, suitable temperature conditions are in the range of 18 to 25 degrees Celsius, in winter 16-18. Water abundantly, but not often, about once a week. Daily spraying will be useful. In spring and summer, feed once a month, using fertilizers for cacti and succulents.', + ru: 'Замиокулькас нуждается в большом количестве солнечного света, подходящие температурные условия находятся в диапазоне от 18 до 25 градусов тепла, зимой 16-18. Поливают обильно, но не часто, примерно раз в неделю. Будут полезны ежедневные опрыскивания. Весной и летом подкармливают раз в месяц, используя удобрения для кактусов и суккулентов.', + }, + }, + { + name: 'level', + value: [ + { + key: '1', + label: '1', + }, + ], + }, + ], + id: 1, + images: [ + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-1', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-1.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-2', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-2.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-3', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-3.png', + }, + { + dimensions: { + h: 500, + w: 500, + }, + label: 'img-4', + url: 'https://raw.githubusercontent.com/stardustmeg/greenshop-db/main/79357868957074/img-4.png', + }, + ], + isMatchingVariant: true, + key: '79357868957074_1', + prices: [ + { + id: '48eacec5-a0bc-4302-92da-654f37b9d0d3', + key: '79357868957074_1', + value: { + centAmount: 1500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + sku: '79357868957074_1', + }, + metaDescription: { + en: 'house plants', + }, + metaTitle: { + en: 'house plants', + }, + name: { + en: 'Zamioculcas', + ru: 'Замиокулькас', + }, + priceMode: 'Embedded', + productType: { + id: '986435e1-45b9-4f14-bcee-9ae3921e5d3c', + typeId: 'product-type', + }, + published: true, + searchKeywords: { + en: [ + { + text: 'zamioculcas', + }, + ], + ru: [ + { + text: 'замиокулькас', + }, + ], + }, + slug: { + en: 'zamioculcas', + ru: 'zamioculcas', + }, + taxCategory: { + id: '47d25f8a-b0df-4a76-8929-7115bf2bcbc1', + typeId: 'tax-category', + }, + variants: [ + { + assets: [], + attributes: [ + { + name: 'new_arrival', + value: [false], + }, + { + name: 'size', + value: [ + { + key: 'M', + label: 'M', + }, + ], + }, + { + name: 'full_description', + value: { + en: 'Zamiokulkas needs plenty of sunlight, suitable temperature conditions are in the range of 18 to 25 degrees Celsius, in winter 16-18. Water abundantly, but not often, about once a week. Daily spraying will be useful. In spring and summer, feed once a month, using fertilizers for cacti and succulents.', + ru: 'Замиокулькас нуждается в большом количестве солнечного света, подходящие температурные условия находятся в диапазоне от 18 до 25 градусов тепла, зимой 16-18. Поливают обильно, но не часто, примерно раз в неделю. Будут полезны ежедневные опрыскивания. Весной и летом подкармливают раз в месяц, используя удобрения для кактусов и суккулентов.', + }, + }, + { + name: 'level', + value: [ + { + key: '1', + label: '1', + }, + ], + }, + ], + id: 2, + images: [], + isMatchingVariant: true, + key: '79357868957074_2', + prices: [ + { + id: 'baf88e8a-23f6-4a52-ae18-45e5bfb93b12', + key: '79357868957074_2', + value: { + centAmount: 2500, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + sku: '79357868957074_2', + }, + { + assets: [], + attributes: [ + { + name: 'new_arrival', + value: [false], + }, + { + name: 'size', + value: [ + { + key: 'XL', + label: 'XL', + }, + ], + }, + { + name: 'full_description', + value: { + en: 'Zamiokulkas needs plenty of sunlight, suitable temperature conditions are in the range of 18 to 25 degrees Celsius, in winter 16-18. Water abundantly, but not often, about once a week. Daily spraying will be useful. In spring and summer, feed once a month, using fertilizers for cacti and succulents.', + ru: 'Замиокулькас нуждается в большом количестве солнечного света, подходящие температурные условия находятся в диапазоне от 18 до 25 градусов тепла, зимой 16-18. Поливают обильно, но не часто, примерно раз в неделю. Будут полезны ежедневные опрыскивания. Весной и летом подкармливают раз в месяц, используя удобрения для кактусов и суккулентов.', + }, + }, + { + name: 'level', + value: [ + { + key: '1', + label: '1', + }, + ], + }, + ], + id: 3, + images: [], + isMatchingVariant: true, + key: '79357868957074_4', + prices: [ + { + id: '5592000f-86c5-4e42-916b-9b62580e7904', + key: '79357868957074_4', + value: { + centAmount: 5600, + currencyCode: 'USD', + fractionDigits: 2, + type: 'centPrecision', + }, + }, + ], + sku: '79357868957074_4', + }, + ], + version: 25, + }, + ], + total: 1, +}; + +const handlers = [ + http.get( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/product-projections/search`, + () => HttpResponse.json(productList), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/project.ts b/src/test/mocks/handlers/project.ts new file mode 100644 index 00000000..09813b75 --- /dev/null +++ b/src/test/mocks/handlers/project.ts @@ -0,0 +1,63 @@ +import { HttpResponse, http } from 'msw'; + +const project = { + carts: { + allowAddingUnpublishedProducts: false, + cartDiscountCache: { + enabled: false, + }, + countryTaxRateFallbackEnabled: false, + deleteDaysAfterLastModification: 90, + loadParsedDiscountsEnabled: false, + }, + countries: ['DE', 'AT', 'BE', 'AZ', 'BG', 'BY', 'CH', 'CZ', 'EE', 'ES', 'EU', 'EZ', 'RU', 'US', 'AE', 'AU'], + createdAt: '2024-04-26T19:16:17.394Z', + createdBy: { + isPlatformClient: true, + user: { + id: '9cd5771e-eb9b-4e70-8f16-a91c52216948', + typeId: 'user', + }, + }, + currencies: ['USD', 'RUB'], + key: 'green-shop', + languages: ['en', 'ru'], + lastModifiedAt: '2024-05-16T07:13:50.923Z', + lastModifiedBy: { + isPlatformClient: true, + }, + messages: { + deleteDaysAfterCreation: 15, + enabled: false, + }, + name: 'Green Shop', + searchIndexing: { + customers: { + lastModifiedAt: '2024-05-16T07:13:50.915Z', + lastModifiedBy: { + isPlatformClient: true, + }, + status: 'Activated', + }, + products: { + lastModifiedAt: '2024-05-05T18:13:15.638Z', + lastModifiedBy: { + isPlatformClient: true, + }, + status: 'Activated', + }, + }, + shoppingLists: { + deleteDaysAfterLastModification: 360, + }, + trialUntil: '2024-06', + version: 14, +}; + +const handlers = [ + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}`, () => + HttpResponse.json(project), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/shopping-list.ts b/src/test/mocks/handlers/shopping-list.ts new file mode 100644 index 00000000..4bc73995 --- /dev/null +++ b/src/test/mocks/handlers/shopping-list.ts @@ -0,0 +1,72 @@ +import { HttpResponse, http } from 'msw'; + +const shopList = { + anonymousId: '599456f9-bff8-46e7-8c9b-5f6e60dabb27', + createdAt: '2024-06-12T12:52:38.321Z', + createdBy: { + anonymousId: '599456f9-bff8-46e7-8c9b-5f6e60dabb27', + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + isPlatformClient: false, + }, + customer: { + id: 'a9a1b24b-a467-4850-9a1c-c4030dff9949', + typeId: 'customer', + }, + deleteDaysAfterLastModification: 2, + id: 'e70b4b40-2cd8-4109-87ce-8f35086c2852', + lastModifiedAt: '2024-06-16T08:44:19.700Z', + lastModifiedBy: { + clientId: 'BPJMQ0wyC-4dA_1sd04SlJQx', + customer: { + id: 'a9a1b24b-a467-4850-9a1c-c4030dff9949', + typeId: 'customer', + }, + isPlatformClient: false, + }, + lineItems: [], + name: { + en: 'Favorite', + ru: 'Favorite', + }, + textLineItems: [], + version: 3, + versionModifiedAt: '2024-06-16T08:44:19.700Z', +}; + +const shopListList = { + count: 1, + limit: 9, + offset: 0, + results: [shopList], + total: 1, +}; +const handlers = [ + http.get( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/shopping-lists`, + () => HttpResponse.json(shopListList), + ), + http.get(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/shopping-lists/*`, () => + HttpResponse.json(shopListList), + ), + http.post( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/shopping-lists`, + () => HttpResponse.json(shopList), + ), + http.post(`${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/shopping-lists`, () => + HttpResponse.json(shopList), + ), + http.post( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/shopping-lists/*`, + () => HttpResponse.json(shopList), + ), + http.post( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/shopping-lists/*`, + () => HttpResponse.json(shopList), + ), + http.delete( + `${import.meta.env.VITE_APP_CTP_API_URL}/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/me/shopping-lists/*`, + () => HttpResponse.json(shopList), + ), +]; + +export default handlers; diff --git a/src/test/mocks/handlers/token.ts b/src/test/mocks/handlers/token.ts new file mode 100644 index 00000000..2ff1421e --- /dev/null +++ b/src/test/mocks/handlers/token.ts @@ -0,0 +1,22 @@ +import { HttpResponse, http } from 'msw'; + +const token = { + access_token: '3pQ1oMrAIqDGIfiJ1Vj5txcKq7H7EtbC', + expires_in: 10800, + refresh_token: 'green-shop:IY1ZJDEe82gz7SsV2awdlo1CXYxA3UGpwJlZQmrc_XY', + scope: import.meta.env.VITE_APP_CTP_SCOPES.split(' '), + token_type: 'Bearer', +}; + +const handlers = [ + http.post( + `${import.meta.env.VITE_APP_CTP_AUTH_URL}/oauth/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/anonymous/token`, + () => HttpResponse.json(token), + ), + http.post( + `${import.meta.env.VITE_APP_CTP_AUTH_URL}/oauth/${import.meta.env.VITE_APP_CTP_PROJECT_KEY}/customers/token`, + () => HttpResponse.json(token), + ), +]; + +export default handlers; diff --git a/src/test/mocks/node.ts b/src/test/mocks/node.ts new file mode 100644 index 00000000..68afb227 --- /dev/null +++ b/src/test/mocks/node.ts @@ -0,0 +1,7 @@ +import { setupServer } from 'msw/node'; + +import handlers from './handler.ts'; + +const server = setupServer(...handlers); + +export default server; diff --git a/src/pages/Blog/PostWidget/model/PostWidgetModel.ts b/src/widgets/Blog/model/BlogWidgetModel.ts similarity index 73% rename from src/pages/Blog/PostWidget/model/PostWidgetModel.ts rename to src/widgets/Blog/model/BlogWidgetModel.ts index 529720d8..6d69db7d 100644 --- a/src/pages/Blog/PostWidget/model/PostWidgetModel.ts +++ b/src/widgets/Blog/model/BlogWidgetModel.ts @@ -1,26 +1,26 @@ -import PostView from '@/pages/Blog/Post/view/PostView.ts'; +import PostView from '@/entities/Post/view/PostView.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; -import postsData from '../../data/posts.ts'; -import PostWidgetView from '../view/PostWidgetView.ts'; +import postsData from '../../../shared/Posts/posts.ts'; +import BlogWidgetView from '../view/BlogWidgetView.ts'; const CART_COUNT = 3; const HALF_RANDOM = 0.5; -export default class PostWidgetModel { +export default class BlogWidgetModel { private postClickHandler = (post: PostView): void => { this.view.openPost(post); }; private posts: PostView[]; - private view: PostWidgetView; + private view: BlogWidgetView; constructor(parent: HTMLDivElement) { const shuffledPosts = [...postsData].sort(() => HALF_RANDOM - Math.random()); const newPost = shuffledPosts.slice(0, CART_COUNT); this.posts = newPost.map((post) => new PostView(post, this.postClickHandler)); - this.view = new PostWidgetView(parent, this.posts); + this.view = new BlogWidgetView(parent, this.posts); this.init(); } diff --git a/src/widgets/Blog/test/blog.spec.ts b/src/widgets/Blog/test/blog.spec.ts new file mode 100644 index 00000000..5cc89f1b --- /dev/null +++ b/src/widgets/Blog/test/blog.spec.ts @@ -0,0 +1,22 @@ +import createBaseElement from '@/shared/utils/createBaseElement.ts'; + +import BlogWidgetModel from '../model/BlogWidgetModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const parent = createBaseElement({ + tag: 'div', +}); +const blog = new BlogWidgetModel(parent); + +describe('Checking blog widget', () => { + it('should check if post is defined', () => { + expect(blog).toBeDefined(); + }); + + it('should check if cart is an instance of CartPageModel', () => { + expect(blog).toBeInstanceOf(BlogWidgetModel); + }); +}); diff --git a/src/pages/Blog/PostWidget/view/PostWidgetView.ts b/src/widgets/Blog/view/BlogWidgetView.ts similarity index 77% rename from src/pages/Blog/PostWidget/view/PostWidgetView.ts rename to src/widgets/Blog/view/BlogWidgetView.ts index 73a78337..e40e9a2f 100644 --- a/src/pages/Blog/PostWidget/view/PostWidgetView.ts +++ b/src/widgets/Blog/view/BlogWidgetView.ts @@ -1,12 +1,12 @@ -import type BlogPostView from '@/pages/Blog/Post/view/PostView'; +import type BlogPostView from '@/entities/Post/view/PostView'; -import getStore from '@/shared/Store/Store.ts'; import { BLOG_DESCRIPTION } from '@/shared/constants/pages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; -import styles from './postWidgetView.module.scss'; +import styles from './blogWidgetView.module.scss'; -export default class PostWidgetView { +export default class BlogWidgetView { private description: HTMLParagraphElement; private page: HTMLDivElement; @@ -50,7 +50,7 @@ export default class PostWidgetView { private createPageDescription(): HTMLParagraphElement { this.description = createBaseElement({ cssClasses: [styles.pageDescription], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_DESCRIPTIONS, + innerContent: BLOG_DESCRIPTION[getCurrentLanguage()].WIDGET_DESCRIPTIONS, tag: 'p', }); return this.description; @@ -59,7 +59,7 @@ export default class PostWidgetView { private createPageTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: [styles.pageTitle], - innerContent: BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITLE, + innerContent: BLOG_DESCRIPTION[getCurrentLanguage()].WIDGET_TITLE, tag: 'h3', }); return this.title; @@ -85,8 +85,9 @@ export default class PostWidgetView { } public updateLanguage(): boolean { - this.title.innerText = BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_TITLE; - this.description.innerText = BLOG_DESCRIPTION[getStore().getState().currentLanguage].WIDGET_DESCRIPTIONS; + const currentLanguage = getCurrentLanguage(); + this.title.innerText = BLOG_DESCRIPTION[currentLanguage].WIDGET_TITLE; + this.description.innerText = BLOG_DESCRIPTION[currentLanguage].WIDGET_DESCRIPTIONS; return true; } } diff --git a/src/pages/Blog/PostWidget/view/postWidgetView.module.scss b/src/widgets/Blog/view/blogWidgetView.module.scss similarity index 85% rename from src/pages/Blog/PostWidget/view/postWidgetView.module.scss rename to src/widgets/Blog/view/blogWidgetView.module.scss index 0d2f7031..208364fc 100644 --- a/src/pages/Blog/PostWidget/view/postWidgetView.module.scss +++ b/src/widgets/Blog/view/blogWidgetView.module.scss @@ -17,9 +17,20 @@ margin: 0 auto; gap: var(--extra-small-offset); + div { + overflow: hidden; + width: 100%; + height: calc(var(--extra-large-offset) * 2); + object-fit: cover; + } + @media (max-width: 768px) { display: flex; flex-direction: column; + + div { + height: auto; + } } } diff --git a/src/widgets/Catalog/model/CatalogModel.ts b/src/widgets/Catalog/model/CatalogModel.ts index c5008bc8..1edaa7f5 100644 --- a/src/widgets/Catalog/model/CatalogModel.ts +++ b/src/widgets/Catalog/model/CatalogModel.ts @@ -1,8 +1,10 @@ import type { OptionsRequest, SortOptions } from '@/shared/API/types/type.ts'; +import type { Category } from '@/shared/types/product.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 { set } from '@/app/Router/helpers/helpers.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'; @@ -12,24 +14,30 @@ import ProductSortsModel from '@/features/ProductSorts/model/ProductSortsModel.t import getCartModel from '@/shared/API/cart/model/CartModel.ts'; import getProductModel from '@/shared/API/product/model/ProductModel.ts'; import FilterProduct from '@/shared/API/product/utils/filter.ts'; -import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; import { FilterFields, SortDirection, SortFields } from '@/shared/API/types/type.ts'; import EventMediatorModel from '@/shared/EventMediator/model/EventMediatorModel.ts'; import LoaderModel from '@/shared/Loader/model/LoaderModel.ts'; -import getStore from '@/shared/Store/Store.ts'; +import modal from '@/shared/Modal/model/ModalModel.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; -import { META_FILTERS } from '@/shared/constants/filters.ts'; +import { META_FILTER } from '@/shared/constants/filters.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'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; import CatalogView from '../view/CatalogView.ts'; +const PRODUCT_COUNT_FOR_HIDDEN_PAGINATION = 7; + class CatalogModel { private currentSize: null | string = null; - private pagination: PaginationModel | null = null; + private paginationBottom: PaginationModel | null = null; + + private paginationTop: PaginationModel | null = null; + + private productCards: ProductCardModel[] = []; private productFilters: ProductFiltersModel | null = null; @@ -45,10 +53,10 @@ class CatalogModel { private addCurrentMetaFilter(filter: FilterProduct, metaFilter: string): FilterProduct { switch (metaFilter) { - case META_FILTERS.en.NEW_ARRIVALS: + case META_FILTER.en.NEW_ARRIVALS: filter.addFilter(FilterFields.NEW_ARRIVAL); return filter; - case META_FILTERS.en.SALE: + case META_FILTER.en.SALE: filter.addFilter(FilterFields.SALE); return filter; default: @@ -56,17 +64,23 @@ class CatalogModel { } } - private decodeSearchParams(): { + private async decodeSearchParams(): Promise<{ page: string; searchValue: null | string; selectedFilters: SelectedFilters; - selectedSorting?: SelectedSorting; - } { + selectedSorting?: SelectedSorting | undefined; + } | null> { + if (RouterModel.getPageID()) { + return null; + } 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 categorySetWithKey = new Set(searchCategory); + const categories = await getProductModel().getCategories(); + const categorySetWithID: Set = this.replaceCategoryKeyWithID(categories, categorySetWithKey); + + const metaFilter = searchParams.get(SEARCH_PARAMS_FIELD.META) ?? META_FILTER.en.ALL_PRODUCTS; const size = searchParams.get(SEARCH_PARAMS_FIELD.SIZE) ?? null; const price = { max: parseFloat(searchParams.get(SEARCH_PARAMS_FIELD.MAX_PRICE) ?? '0'), @@ -78,7 +92,7 @@ class CatalogModel { const searchValue = searchParams.get(SEARCH_PARAMS_FIELD.SEARCH) ?? null; const page = searchParams.get(SEARCH_PARAMS_FIELD.PAGE) ?? DEFAULT_PAGE.toString(); const selectedFilters = { - category, + category: categorySetWithID, metaFilter, price, size, @@ -93,66 +107,83 @@ class CatalogModel { } private async drawProducts(): Promise { + this.productCards = []; const productList = this.view.getItemsList(); productList.innerHTML = ''; - const options = this.getOptions(); + const options = await this.getOptions(); const productsInfo = await this.getProductsInfo(options); - this.pagination?.getHTML().remove(); + this.paginationTop?.getHTML().remove(); + this.paginationBottom?.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); + const product = new ProductCardModel(productData, this.currentSize, cart); productList.append(product.getHTML()); + this.productCards.push(product); }); this.view.switchEmptyList(!productsInfo?.products?.length); - this.pagination = new PaginationModel( + this.paginationTop = 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.paginationBottom = new PaginationModel( + { productTotalCount: productsInfo?.totalProductCount, productsPerPageCount: PRODUCT_LIMIT }, + (page) => { + this.setCurrentPage(page); + scrollTo(0, 0); + }, + ); + this.paginationTop.getView().setSelectedButton(options.page ?? DEFAULT_PAGE); + this.paginationBottom.getView().setSelectedButton(options.page ?? DEFAULT_PAGE); + this.view.getRightTopWrapper().append(this.paginationTop.getHTML()); + if (productsInfo.products.length >= PRODUCT_COUNT_FOR_HIDDEN_PAGINATION) { + this.view.getRightBottomWrapper().append(this.paginationBottom.getHTML()); + } } this.productFilters?.getView().updateParams(productsInfo); this.view.switchEmptyList(!productsInfo?.products?.length); } - private getOptions(): OptionsRequest { + private async getOptions(): Promise { let result = {}; - const { page, searchValue, selectedFilters, selectedSorting } = this.decodeSearchParams(); + const params = await this.decodeSearchParams(); + if (!params) { + return {}; + } this.productFilters?.getView().setInitialActiveFilters({ - categoryLinks: Array.from(selectedFilters.category), - metaLinks: selectedFilters.metaFilter ? [selectedFilters.metaFilter] : [], - sizeLinks: selectedFilters.size ? [selectedFilters.size] : [], + categoryLinks: Array.from(params.selectedFilters.category), + metaLinks: params.selectedFilters.metaFilter ? [params.selectedFilters.metaFilter] : [], + sizeLinks: params.selectedFilters.size ? [params.selectedFilters.size] : [], }); - const { currentLanguage } = getStore().getState(); + const currentLanguage = getCurrentLanguage(); const filter = new FilterProduct(); - selectedFilters.category.forEach((categoryID) => filter.addFilter(FilterFields.CATEGORY, categoryID)); + params.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 (params.selectedFilters.price && params.selectedFilters.price.max > params.selectedFilters.price.min) { + filter.addFilter(FilterFields.PRICE, params.selectedFilters.price); } - if (selectedFilters.size) { - this.currentSize = selectedFilters.size; - filter.addFilter(FilterFields.SIZE, selectedFilters.size); + if (params.selectedFilters.size) { + this.currentSize = params.selectedFilters.size; + filter.addFilter(FilterFields.SIZE, params.selectedFilters.size); } - this.addCurrentMetaFilter(filter, selectedFilters.metaFilter ?? META_FILTERS.en.ALL_PRODUCTS); + this.addCurrentMetaFilter(filter, params.selectedFilters.metaFilter ?? META_FILTER.en.ALL_PRODUCTS); - const currentSort = this.getSelectedSorting(selectedSorting ?? null); + const currentSort = this.getSelectedSorting(params.selectedSorting ?? null); if (currentSort) { result = { filter, - page: Number(page), - search: { locale: currentLanguage, value: searchValue }, + + page: Number(params.page), + search: params.searchValue ? { locale: currentLanguage, value: params.searchValue } : null, sort: currentSort ?? null, }; } else { result = { filter, - page: Number(page), - search: { locale: currentLanguage, value: searchValue }, + page: Number(params.page), + search: params.searchValue ? { locale: currentLanguage, value: params.searchValue } : null, }; } @@ -196,15 +227,18 @@ class CatalogModel { if (field === SORTING_ID.DEFAULT) { return null; } - return { direction: currentDirection, field: currentField, locale: getStore().getState().currentLanguage }; + return { direction: currentDirection, field: currentField, locale: getCurrentLanguage() }; } private init(): void { + modal.hide(); EventMediatorModel.getInstance().subscribe(MEDIATOR_EVENT.REDRAW_PRODUCTS, this.drawProducts.bind(this)); this.getProductsInfo({}) .then((productsInfo) => { this.initSettingComponents(productsInfo); - this.drawProducts().catch(showErrorMessage); + this.drawProducts() + .then(() => this.openProductInfo()) + .catch(showErrorMessage); }) .catch(showErrorMessage); } @@ -224,8 +258,29 @@ class CatalogModel { ); } + private openProductInfo(): void { + if (RouterModel.getPageID()) { + this.productCards.find((productCard) => productCard.getKey() === RouterModel.getPageID())?.openProductInfoModal(); + } + } + + private replaceCategoryKeyWithID(categories: Category[], categorySet: Set): Set { + const categoriesWithID: Set = new Set(); + categories.forEach((category) => { + categorySet.forEach((item) => { + if (category.key === item) { + categoriesWithID.add(category.id); + } else if (category.parent?.key === item) { + categoriesWithID.add(category.parent.id); + } + }); + }); + + return categoriesWithID; + } + private setCurrentPage(page: string): void { - RouterModel.setSearchParams(SEARCH_PARAMS_FIELD.PAGE, page); + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.PAGE, page)); this.drawProducts().catch(showErrorMessage); } diff --git a/src/widgets/Catalog/view/CatalogView.ts b/src/widgets/Catalog/view/CatalogView.ts index a4fdeb64..e7a1ded8 100644 --- a/src/widgets/Catalog/view/CatalogView.ts +++ b/src/widgets/Catalog/view/CatalogView.ts @@ -1,7 +1,7 @@ -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import { EMPTY_PRODUCT } from '@/shared/constants/product.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './catalogView.module.scss'; @@ -43,7 +43,7 @@ class CatalogView { }); observeStore(selectCurrentLanguage, () => { if (this.itemsList.classList.contains(styles.emptyList)) { - this.itemsList.textContent = EMPTY_PRODUCT[getStore().getState().currentLanguage].EMPTY; + this.itemsList.textContent = EMPTY_PRODUCT[getCurrentLanguage()].EMPTY; } }); return this.itemsList; @@ -116,7 +116,7 @@ class CatalogView { public switchEmptyList(isEmpty: boolean): void { this.itemsList.classList.toggle(styles.emptyList, isEmpty); if (isEmpty) { - this.itemsList.textContent = EMPTY_PRODUCT[getStore().getState().currentLanguage].EMPTY; + this.itemsList.textContent = EMPTY_PRODUCT[getCurrentLanguage()].EMPTY; } } } diff --git a/src/widgets/Catalog/view/catalogView.module.scss b/src/widgets/Catalog/view/catalogView.module.scss index 7007e5b8..50446512 100644 --- a/src/widgets/Catalog/view/catalogView.module.scss +++ b/src/widgets/Catalog/view/catalogView.module.scss @@ -46,8 +46,10 @@ justify-content: center; order: 2; grid-template-columns: repeat(3, auto); + grid-template-rows: repeat(3, max-content); + margin-bottom: var(--tiny-offset); height: max-content; - min-height: calc(var(--extra-large-offset) * 5); // 500px + min-height: calc(var(--extra-large-offset) * 13); font-size: var(--regular-font); letter-spacing: var(--one); text-align: center; @@ -75,8 +77,9 @@ align-items: center; justify-content: center; height: 100%; + min-height: calc(var(--extra-large-offset) * 7); background: url('../../../shared/img/png/notFound.png'); - background-position: center 0; + background-position: center 3rem; background-size: calc(var(--extra-large-offset) * 3); background-repeat: no-repeat; } diff --git a/src/widgets/Footer/model/FooterModel.ts b/src/widgets/Footer/model/FooterModel.ts index 73680a20..962a94f3 100644 --- a/src/widgets/Footer/model/FooterModel.ts +++ b/src/widgets/Footer/model/FooterModel.ts @@ -4,8 +4,8 @@ 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 * as buildPath from '@/shared/utils/buildPathname.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; import FooterView from '../view/FooterView.ts'; @@ -43,8 +43,9 @@ const GENERAL_LINKS: Link[] = [ }, }, { + href: PAGE_ID.COOPERATION_PAGE, name: { - en: 'Specials', + en: 'Cooperation', ru: 'Сотрудничество', }, }, @@ -91,7 +92,7 @@ function generateRandomCategoryLink(categoriesArr: Category[]): Link[] { const category = subCategory[randomIndex]; subCategory.splice(randomIndex, 1); result.push({ - href: buildPathName(PAGE_ID.CATALOG_PAGE, null, { subcategory: [category.id] }), + href: buildPath.catalogPathWithQuery({ subcategory: [category.key] }), name: { en: category.name[0].value, ru: category.name[1].value, diff --git a/src/widgets/Footer/view/FooterView.ts b/src/widgets/Footer/view/FooterView.ts index a9fd45b5..d77eb01e 100644 --- a/src/widgets/Footer/view/FooterView.ts +++ b/src/widgets/Footer/view/FooterView.ts @@ -7,13 +7,13 @@ 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 { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showSuccessMessage } from '@/shared/utils/userMessage.ts'; import type { Link } from '../model/FooterModel'; @@ -35,6 +35,7 @@ type Contact = { href?: string; src: string; tag: keyof HTMLElementTagNameMap; + target?: string; }; type Img = { @@ -53,7 +54,7 @@ const GOALS: Goal[] = [ alt: 'Garden Care', description: { en: 'Provide expert tips and tools for maintaining a healthy and beautiful garden', - ru: 'Для Вас советы экспертов и инструменты для поддержания здорового и красивого сада', + ru: 'Для вас советы экспертов и инструменты для поддержания здорового и красивого сада', }, id: 'goal_1', imgH: 93, @@ -95,12 +96,15 @@ const GOALS: Goal[] = [ }, }, ]; + const CONTACTS: Contact[] = [ { alt: 'location greenshop', description: '70 West Buckingham Ave. Farmingdale, NY 11735', + href: 'https://www.google.com/maps/place/70+West+Dr,+Massapequa,+NY+11758,+%D0%A1%D0%A8%D0%90/@40.7095332,-73.4562642,17z/data=!3m1!4b1!4m6!3m5!1s0x89e9d5501f64213b:0xb7d1b7ebb0725ac6!8m2!3d40.7095332!4d-73.4536893!16s%2Fg%2F11c5fp8grj?entry=ttu', src: '/img/png/location.png', - tag: 'address', + tag: 'a', + target: '_blank', }, { alt: 'email greenshop', @@ -164,6 +168,7 @@ type textElementsType = { element: HTMLAnchorElement | HTMLButtonElement | HTMLInputElement | HTMLParagraphElement | HTMLUListElement; textItem: languageVariants; }; + const FOOTER_PAGE = { NAV_CATEGORY: { en: 'Categories', @@ -219,7 +224,7 @@ class FooterView { private wrapper: HTMLDivElement; constructor() { - this.language = getStore().getState().currentLanguage; + this.language = getCurrentLanguage(); this.wrapper = this.createWrapper(); this.footer = this.createHTML(); const blockGoals = this.createGoalsHTML(); @@ -250,7 +255,7 @@ class FooterView { const wrapContactItems = createBaseElement({ cssClasses: [styles.contactItemsWrap], - tag: 'div', + tag: 'address', }); CONTACTS.forEach((contact) => wrapContactItems.append(this.createContactItemHTML(contact))); wrap.append(logoImg, wrapContactItems); @@ -264,6 +269,10 @@ class FooterView { attributes.href = contact.href; } + if (contact.target) { + attributes.target = contact.target; + } + const wrap = createBaseElement({ attributes, cssClasses: [styles.contactItem], @@ -496,7 +505,7 @@ class FooterView { cssClasses: [styles.subForm], tag: 'form', }); - const email = new InputFieldModel(FORM_FIELDS.EMAIL_NOT_LABEL_TEXT, FORM_VALIDATION.FOOTER_EMAIL_VALIDATE); + const email = new InputFieldModel(FORM_FIELDS.EMAIL_NOT_LABEL_TEXT, FORM_VALIDATION.EMAIL_VALIDATE); const inputFieldElement = email.getView().getHTML(); const inputHTML = email.getView().getInput().getHTML(); if (inputFieldElement instanceof HTMLLabelElement) { @@ -521,7 +530,7 @@ class FooterView { submit.getHTML().addEventListener('click', () => { email.getView().getInput().clear(); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.SUCCESSFUL_SUBSCRIBE, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.SUCCESSFUL_SUBSCRIBE); submit.setDisabled(); }); @@ -599,7 +608,7 @@ class FooterView { } public updateLanguage(): void { - this.language = getStore().getState().currentLanguage; + this.language = getCurrentLanguage(); this.goals.forEach((goalEl) => { const title = goalEl.goalTitle; const description = goalEl.goalDescription; diff --git a/src/widgets/Footer/view/footerView.module.scss b/src/widgets/Footer/view/footerView.module.scss index 8469b27f..6222b58e 100644 --- a/src/widgets/Footer/view/footerView.module.scss +++ b/src/widgets/Footer/view/footerView.module.scss @@ -20,11 +20,14 @@ .navigateWrap, .goalsSubWrap { display: flex; - flex-wrap: wrap; justify-content: center; padding: var(--extra-small-offset); width: 100%; background-color: var(--white-tr); + + @media (max-width: 587px) { + flex-direction: column; + } } .navigateWrap { @@ -62,6 +65,7 @@ .goalsWrap { display: flex; flex: 1 1 auto; + flex-shrink: 1; flex-basis: 70%; justify-content: space-around; gap: var(--tiny-offset); @@ -75,6 +79,7 @@ .goalTextWrap { display: flex; flex: 1 1 auto; + flex-shrink: 1; flex-direction: column; } @@ -136,6 +141,7 @@ .goalTitle { padding: var(--tiny-offset) 0; font: var(--bold-font); + letter-spacing: var(--one); color: var(--black); } @@ -144,6 +150,7 @@ .subDescription { padding: var(--tiny-offset) 0; font: var(--regular-font); + letter-spacing: var(--one); word-wrap: break-word; color: var(--noble-gray-700); } @@ -165,6 +172,7 @@ .subTitle { padding: var(--tiny-offset) 0 var(--extra-small-offset); font: var(--medium-bold-font); + letter-spacing: var(--one); color: var(--black); } @@ -181,7 +189,9 @@ width: 78%; span { + max-width: 17rem; font-size: 0.71rem; + letter-spacing: var(--one); word-break: break-all; } } @@ -193,6 +203,7 @@ width: 100%; box-shadow: var(--mellow-shadow-050); font: var(--regular-font); + letter-spacing: var(--one); color: var(--noble-gray-800); } @@ -203,6 +214,7 @@ padding: calc(var(--extra-small-offset) / 1.6) var(--extra-small-offset); height: max-content; font: var(--medium-bold-font); + letter-spacing: var(--one); &:active { transform: scale(1); @@ -247,7 +259,16 @@ display: flex; flex-basis: 25%; align-items: center; + letter-spacing: var(--one); gap: var(--tiny-offset); + + @media (hover: hover) { + &:hover { + .contactText { + color: var(--steam-green-1200); + } + } + } } .contactIcon { @@ -258,6 +279,7 @@ .contactText { font: var(--regular-font); color: var(--noble-gray-800); + transition: color 0.2s; } .linkWrap { diff --git a/src/widgets/Header/model/HeaderModel.ts b/src/widgets/Header/model/HeaderModel.ts index 86e3684d..e41c88a5 100644 --- a/src/widgets/Header/model/HeaderModel.ts +++ b/src/widgets/Header/model/HeaderModel.ts @@ -1,25 +1,24 @@ import type { Cart } from '@/shared/types/cart.ts'; +import type { ShoppingList } from '@/shared/types/shopping-list.ts'; import RouterModel from '@/app/Router/model/RouterModel.ts'; +import CountBadgeModel from '@/entities/CountBadge/model/CountBadgeModel.ts'; import NavigationModel from '@/entities/Navigation/model/NavigationModel.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; import getCustomerModel, { CustomerModel } from '@/shared/API/customer/model/CustomerModel.ts'; -import serverMessageModel from '@/shared/ServerMessage/model/ServerMessageModel.ts'; +import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { setAuthToken, setCurrentLanguage, switchIsUserLoggedIn } from '@/shared/Store/actions.ts'; import observeStore, { selectIsUserLoggedIn } from '@/shared/Store/observer.ts'; import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import HeaderView from '../view/HeaderView.ts'; class HeaderModel { - private cartChangeHandler = (cart: Cart): boolean => { - this.view.updateCartCount(cart.products.length); - return true; - }; + private cartCountBadge = new CountBadgeModel(); private navigation: NavigationModel; @@ -27,35 +26,46 @@ class HeaderModel { private view = new HeaderView(); + private wishListCountBadge = new CountBadgeModel(); + constructor(parent: HTMLDivElement) { this.parent = parent; this.navigation = new NavigationModel(); this.init(); } + private cartChangeHandler(cart: Cart): boolean { + this.cartCountBadge.updateBadgeCount(cart.products.reduce((acc, item) => acc + item.quantity, 0)); + return true; + } + private checkCurrentUser(): void { const { isUserLoggedIn } = getStore().getState(); const logoutButton = this.view.getLogoutButton(); if (isUserLoggedIn) { this.view.getToProfileLink().setEnabled(); + this.view.getToAddressesLink().setEnabled(); logoutButton.setEnabled(); } else { logoutButton.setDisabled(); this.view.getToProfileLink().setDisabled(); + this.view.getToAddressesLink().setDisabled(); } } private init(): void { this.view.getWrapper().append(this.navigation.getHTML()); + this.view.getToCartLink().getHTML().append(this.cartCountBadge.getHTML()); + this.view.getToWishlistLink().getHTML().append(this.wishListCountBadge.getHTML()); this.parent.insertAdjacentElement('beforebegin', this.view.getNavigationWrapper()); this.checkCurrentUser(); this.setLogoHandler(); this.observeCurrentUser(); this.setLogoutButtonHandler(); - this.setCartLinkHandler(); - this.observeCartChange(); - this.setCartCount().catch(showErrorMessage); + this.setLinksHandler(); this.setChangeLanguageCheckboxHandler(); + this.observeCartChange(); + this.observeShoppingListChange(); } private async logoutHandler(): Promise { @@ -70,7 +80,7 @@ class HeaderModel { } private observeCartChange(): boolean { - return getCartModel().observeCartChange(this.cartChangeHandler); + return getCartModel().observeCartChange(this.cartChangeHandler.bind(this)); } private observeCurrentUser(): void { @@ -79,18 +89,8 @@ class HeaderModel { }); } - private async setCartCount(): Promise { - const cart = await getCartModel().getCart(); - this.view.updateCartCount(cart.products.length); - return true; - } - - private setCartLinkHandler(): void { - const logo = this.view.getToCartLink().getHTML(); - logo.addEventListener('click', (event) => { - event.preventDefault(); - RouterModel.getInstance().navigateTo(PAGE_ID.CART_PAGE); - }); + private observeShoppingListChange(): void { + getShoppingListModel().subscribe(this.shoppingListChangeHandler.bind(this)); } private setChangeLanguageCheckboxHandler(): void { @@ -105,18 +105,35 @@ class HeaderModel { if (user) { await getCustomerModel().editCustomer([CustomerModel.actionSetLocale(newLanguage)], user); getStore().dispatch(setCurrentLanguage(newLanguage)); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.LANGUAGE_CHANGED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.LANGUAGE_CHANGED); } } catch (error) { showErrorMessage(error); } } else { getStore().dispatch(setCurrentLanguage(newLanguage)); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.LANGUAGE_CHANGED, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.LANGUAGE_CHANGED); } }); } + private setLinksHandler(): void { + this.view + .getToCartLink() + .getHTML() + .addEventListener('click', (event) => { + event.preventDefault(); + RouterModel.getInstance().navigateTo(PAGE_ID.CART_PAGE); + }); + this.view + .getToWishlistLink() + .getHTML() + .addEventListener('click', (event) => { + event.preventDefault(); + RouterModel.getInstance().navigateTo(PAGE_ID.WISHLIST_PAGE); + }); + } + private setLogoHandler(): void { const logo = this.view.getLinkLogo().getHTML(); logo.addEventListener('click', (event) => { @@ -133,6 +150,10 @@ class HeaderModel { }); } + private shoppingListChangeHandler(shoppingList: ShoppingList): void { + this.wishListCountBadge.updateBadgeCount(shoppingList.products.length); + } + public getHTML(): HTMLElement { return this.view.getHTML(); } diff --git a/src/widgets/Header/view/HeaderView.ts b/src/widgets/Header/view/HeaderView.ts index d440a9e5..4b4d4293 100644 --- a/src/widgets/Header/view/HeaderView.ts +++ b/src/widgets/Header/view/HeaderView.ts @@ -1,19 +1,22 @@ import type { LanguageChoiceType } from '@/shared/constants/common.ts'; +import RouterModel from '@/app/Router/model/RouterModel.ts'; +import CountBadgeModel from '@/entities/CountBadge/model/CountBadgeModel.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import InputModel from '@/shared/Input/model/InputModel.ts'; import LinkModel from '@/shared/Link/model/LinkModel.ts'; import getStore from '@/shared/Store/Store.ts'; import { switchAppTheme } from '@/shared/Store/actions.ts'; import observeStore, { selectCurrentPage, selectIsUserLoggedIn } from '@/shared/Store/observer.ts'; -import { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; -import { AUTOCOMPLETE_OPTION, LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEY } from '@/shared/constants/buttons.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; import { INPUT_TYPE } from '@/shared/constants/forms.ts'; import { PAGE_ID } from '@/shared/constants/pages.ts'; import APP_THEME from '@/shared/constants/styles.ts'; import SVG_DETAILS from '@/shared/constants/svg.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './headerView.module.scss'; @@ -21,10 +24,6 @@ import styles from './headerView.module.scss'; class HeaderView { private burgerButton: ButtonModel; - private cartBadge: HTMLSpanElement; - - private cartBadgeWrap: HTMLDivElement; - private header: HTMLElement; private linkLogo: LinkModel; @@ -37,20 +36,23 @@ class HeaderView { private switchThemeCheckbox: InputModel; + private toAddressesLink: LinkModel; + private toCartLink: LinkModel; private toProfileLink: LinkModel; + private toWishlistLink: LinkModel; + private wrapper: HTMLDivElement; constructor() { this.logoutButton = this.createLogoutButton(); this.linkLogo = this.createLinkLogo(); this.toCartLink = this.createToCartLink(); - this.cartBadgeWrap = this.createBadgeWrap(); - this.cartBadge = this.createBadge(); - + this.toWishlistLink = this.createToWishlistLink(); this.toProfileLink = this.createToProfileLink(); + this.toAddressesLink = this.createToAddressesLink(); this.switchThemeCheckbox = this.createSwitchThemeCheckbox(); this.switchLanguageCheckbox = this.createSwitchLanguageCheckbox(); this.navigationWrapper = this.createNavigationWrapper(); @@ -73,27 +75,6 @@ class HeaderView { }); } - private createBadge(): HTMLSpanElement { - this.cartBadge = createBaseElement({ - cssClasses: [styles.badge], - tag: 'span', - }); - this.cartBadgeWrap.append(this.cartBadge); - - return this.cartBadge; - } - - private createBadgeWrap(): HTMLDivElement { - this.cartBadgeWrap = createBaseElement({ - cssClasses: [styles.badgeWrap], - tag: 'div', - }); - - this.toCartLink.getHTML().append(this.cartBadgeWrap); - - return this.cartBadgeWrap; - } - private createBurgerButton(): ButtonModel { this.burgerButton = new ButtonModel({ classes: [styles.burgerButton], @@ -156,10 +137,10 @@ class HeaderView { private createLogoutButton(): ButtonModel { this.logoutButton = new ButtonModel({ classes: [styles.logoutButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].LOG_OUT, + text: BUTTON_TEXT[getCurrentLanguage()].LOG_OUT, }); - observeCurrentLanguage(this.logoutButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.LOG_OUT); + observeCurrentLanguage(this.logoutButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.LOG_OUT); return this.logoutButton; } @@ -170,11 +151,11 @@ class HeaderView { tag: 'div', }); this.navigationWrapper.append( - this.createSwitchLanguageLabel(), - this.logoutButton.getHTML(), - this.toCartLink.getHTML(), this.toProfileLink.getHTML(), + this.toAddressesLink.getHTML(), this.createSwitchThemeLabel(), + this.createSwitchLanguageLabel(), + this.logoutButton.getHTML(), ); return this.navigationWrapper; @@ -182,13 +163,11 @@ class HeaderView { private createSwitchLanguageCheckbox(): InputModel { this.switchLanguageCheckbox = new InputModel({ - autocomplete: AUTOCOMPLETE_OPTION.OFF, id: styles.switchLanguageLabel, - placeholder: '', type: INPUT_TYPE.CHECK_BOX, }); this.switchLanguageCheckbox.getHTML().classList.add(styles.switchLanguageCheckbox); - this.switchLanguageCheckbox.getHTML().checked = getStore().getState().currentLanguage === LANGUAGE_CHOICE.EN; + this.switchLanguageCheckbox.getHTML().checked = getCurrentLanguage() === LANGUAGE_CHOICE.EN; return this.switchLanguageCheckbox; } @@ -214,9 +193,7 @@ class HeaderView { private createSwitchThemeCheckbox(): InputModel { this.switchThemeCheckbox = new InputModel({ - autocomplete: AUTOCOMPLETE_OPTION.OFF, id: styles.switchThemeLabel, - placeholder: '', type: INPUT_TYPE.CHECK_BOX, }); this.switchThemeCheckbox.getHTML().classList.add(styles.switchThemeCheckbox); @@ -255,6 +232,39 @@ class HeaderView { return label; } + private createToAddressesLink(): LinkModel { + this.toAddressesLink = new LinkModel({ + attrs: { + href: PAGE_ID.USER_ADDRESSES_PAGE, + }, + classes: [styles.addressesLink], + }); + + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.HOUSE)); + this.toAddressesLink.getHTML().append(svg); + + if (!getStore().getState().isUserLoggedIn) { + this.toAddressesLink.getHTML().classList.add(styles.hidden); + } + + observeStore(selectIsUserLoggedIn, () => + this.toAddressesLink.getHTML().classList.toggle(styles.hidden, !getStore().getState().isUserLoggedIn), + ); + + this.toAddressesLink + .getHTML() + .classList.toggle(styles.addressesLinkActive, RouterModel.getCurrentPage() === PAGE_ID.USER_ADDRESSES_PAGE); + + observeStore(selectCurrentPage, () => + this.toAddressesLink + .getHTML() + .classList.toggle(styles.addressesLinkActive, RouterModel.getCurrentPage() === PAGE_ID.USER_ADDRESSES_PAGE), + ); + + return this.toAddressesLink; + } + private createToCartLink(): LinkModel { this.toCartLink = new LinkModel({ attrs: { @@ -270,12 +280,12 @@ class HeaderView { this.toCartLink .getHTML() - .classList.toggle(styles.cartLinkActive, getStore().getState().currentPage === PAGE_ID.CART_PAGE); + .classList.toggle(styles.cartLinkActive, RouterModel.getCurrentPage() === PAGE_ID.CART_PAGE); observeStore(selectCurrentPage, () => this.toCartLink .getHTML() - .classList.toggle(styles.cartLinkActive, getStore().getState().currentPage === PAGE_ID.CART_PAGE), + .classList.toggle(styles.cartLinkActive, RouterModel.getCurrentPage() === PAGE_ID.CART_PAGE), ); return this.toCartLink; @@ -303,24 +313,58 @@ class HeaderView { this.toProfileLink .getHTML() - .classList.toggle(styles.profileLinkActive, getStore().getState().currentPage === PAGE_ID.USER_PROFILE_PAGE); + .classList.toggle(styles.profileLinkActive, RouterModel.getCurrentPage() === PAGE_ID.USER_PROFILE_PAGE); observeStore(selectCurrentPage, () => this.toProfileLink .getHTML() - .classList.toggle(styles.profileLinkActive, getStore().getState().currentPage === PAGE_ID.USER_PROFILE_PAGE), + .classList.toggle(styles.profileLinkActive, RouterModel.getCurrentPage() === PAGE_ID.USER_PROFILE_PAGE), ); return this.toProfileLink; } + private createToWishlistLink(): LinkModel { + this.toWishlistLink = new LinkModel({ + attrs: { + href: PAGE_ID.CART_PAGE, + }, + classes: [styles.wishListLink], + }); + + const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAILS.HEART)); + + this.toWishlistLink.getHTML().append(svg); + + this.toWishlistLink + .getHTML() + .classList.toggle(styles.wishListLinkActive, RouterModel.getCurrentPage() === PAGE_ID.WISHLIST_PAGE); + + observeStore(selectCurrentPage, () => + this.toWishlistLink + .getHTML() + .classList.toggle(styles.wishListLinkActive, RouterModel.getCurrentPage() === PAGE_ID.WISHLIST_PAGE), + ); + + const wishlistBadge = new CountBadgeModel(); + this.toWishlistLink.getHTML().append(wishlistBadge.getHTML()); + + return this.toWishlistLink; + } + private createWrapper(): HTMLDivElement { this.wrapper = createBaseElement({ cssClasses: [styles.wrapper], tag: 'div', }); - this.wrapper.append(this.linkLogo.getHTML(), this.burgerButton.getHTML()); + this.wrapper.append( + this.linkLogo.getHTML(), + this.toCartLink.getHTML(), + this.burgerButton.getHTML(), + this.toWishlistLink.getHTML(), + ); return this.wrapper; } @@ -348,6 +392,10 @@ class HeaderView { return this.switchLanguageCheckbox; } + public getToAddressesLink(): LinkModel { + return this.toAddressesLink; + } + public getToCartLink(): LinkModel { return this.toCartLink; } @@ -356,6 +404,10 @@ class HeaderView { return this.toProfileLink; } + public getToWishlistLink(): LinkModel { + return this.toWishlistLink; + } + public getWrapper(): HTMLDivElement { return this.wrapper; } @@ -367,16 +419,6 @@ class HeaderView { public showNavigationWrapper(): void { this.navigationWrapper.classList.remove(styles.hidden); } - - public updateCartCount(count?: number): void { - if (!count) { - this.cartBadgeWrap.classList.add(styles.hide); - } else { - this.cartBadgeWrap.classList.remove(styles.hide); - } - - this.cartBadge.textContent = count ? count.toString() : ''; - } } export default HeaderView; diff --git a/src/widgets/Header/view/headerView.module.scss b/src/widgets/Header/view/headerView.module.scss index 94ceae3c..e252484b 100644 --- a/src/widgets/Header/view/headerView.module.scss +++ b/src/widgets/Header/view/headerView.module.scss @@ -1,7 +1,7 @@ @import 'src/app/styles/mixins'; .header { - position: sticky; + position: fixed; left: 0; right: 0; top: 0; @@ -21,6 +21,7 @@ @media (max-width: 768px) { display: grid; padding: 0; + padding-block-start: 3rem; } } @@ -43,7 +44,7 @@ gap: var(--medium-offset); @media (max-width: 768px) { - top: calc(var(--extra-small-offset) * 6.1); + top: calc(var(--extra-small-offset) * 6); grid-row: 2; width: 45%; } @@ -58,6 +59,7 @@ } .logo { + position: absolute; order: 1; width: var(--small-offset); height: var(--small-offset); @@ -78,6 +80,7 @@ } @media (max-width: 768px) { + top: 0; grid-row: 1; margin-top: calc(var(--tiny-offset) * 1.5); margin-left: calc(var(--extra-small-offset) * 1.5); @@ -87,11 +90,13 @@ .logoutButton { @include green-btn; - order: 4; + order: 5; } .cartLink, -.profileLink { +.wishListLink, +.profileLink, +.addressesLink { position: relative; display: flex; align-items: center; @@ -114,7 +119,46 @@ } .cartLink { - order: 1; + position: absolute; + right: calc(var(--extra-small-offset) * 6); + + svg { + width: calc(var(--small-offset) / 1.5); + height: calc(var(--small-offset) / 1.5); + } + + @media (max-width: 768px) { + top: calc(var(--extra-small-offset) * 1.2); + } +} + +.wishListLink { + position: absolute; + right: calc(var(--extra-small-offset) * 3.65); + + svg { + width: calc(var(--small-offset) / 1.5); + height: calc(var(--small-offset) / 1.5); + fill: none; + stroke: var(--noble-gray-1100); + stroke-width: 4px; + transition: + stroke 0.2s, + fill 0.2s; + } + + @media (max-width: 768px) { + top: calc(var(--extra-small-offset) * 1.2); + } + + @media (hover: hover) { + &:hover { + svg { + fill: none; + stroke: var(--steam-green-800); + } + } + } } .cartLinkActive { @@ -125,8 +169,17 @@ } } +.wishListLinkActive { + pointer-events: none; + + svg { + fill: none; + stroke: var(--steam-green-800); + } +} + .profileLink { - order: 2; + order: 1; svg { stroke: var(--noble-gray-1100); @@ -142,10 +195,16 @@ } } -.profileLinkActive { +.addressesLink { + order: 2; +} + +.profileLinkActive, +.addressesLinkActive { pointer-events: none; svg { + fill: var(--steam-green-800); stroke: var(--steam-green-800); } } @@ -171,7 +230,7 @@ .burgerLine { position: absolute; border-radius: var(--two); - background-color: var(--steam-green-800); + background-color: var(--noble-gray-1100); transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); pointer-events: none; } @@ -179,7 +238,7 @@ .burgerButton:hover .burgerLine:nth-child(1), .burgerButton:hover .burgerLine:nth-child(2), .burgerButton:hover .burgerLine:nth-child(3) { - filter: brightness(1.3); + background-color: var(--steam-green-800); } .burgerButton:not(.open):hover .burgerLine:nth-child(1), @@ -238,15 +297,15 @@ } .burgerButton.open .burgerLine:nth-child(1) { - left: calc(var(--five) + 0.04rem); + left: calc(var(--one) * 1.5); // 1.5px top: calc(var(--tiny-offset) * 1.1); // 11px - width: var(--extra-small-offset); // 20px + width: calc(var(--extra-small-offset) * 1.1); // 20px transform: rotate(90deg); transition-delay: 150ms; } .burgerButton.open .burgerLine:nth-child(2) { - left: calc(var(--one) * 3); // 3px + left: 0; top: calc(var(--tiny-offset) * 1.7); // 17px width: calc(var(--tiny-offset) * 1.5); // 15px transform: rotate(45deg); @@ -254,7 +313,7 @@ } .burgerButton.open .burgerLine:nth-child(3) { - left: calc(var(--tiny-offset) * 1.3); // 13px + left: calc(var(--tiny-offset)); // 10px top: calc(var(--tiny-offset) * 1.7); // 17px width: calc(var(--tiny-offset) * 1.5); // 15px transform: rotate(-45deg); @@ -266,54 +325,24 @@ } .switchThemeLabel { - position: relative; - display: inline-block; - width: var(--large-offset); - height: calc(calc(var(--small-offset) / 1.5) + var(--extra-small-offset)); - cursor: pointer; + @include switchLabel; + + order: 3; } .switchThemeCheckbox { - width: 0; - height: 0; - opacity: 1; + @include switchCheckbox; &:checked { - + .switchThemeLabelSpan { - background-color: var(--noble-gray-tr-900); - } - + .switchThemeLabelSpan::before { background-color: var(--steam-green-1100); - transform: translate(calc(var(--small-offset) + var(--two)), -50%); + transform: translate(calc(var(--small-offset) + calc(var(--one) * 2)), -50%); } } } .switchThemeLabelSpan { - position: absolute; - border-radius: calc(var(--large-br) * 2); - 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; - inset: 0; - - &::before { - content: ''; - position: absolute; - left: 5px; - top: 50%; - bottom: 0; - z-index: 2; - border-radius: var(--large-br); - width: calc(var(--small-offset) / 1.5); - height: calc(var(--small-offset) / 1.5); - box-shadow: var(--mellow-shadow-600); - background-color: var(--steam-green-800); - transform: translateY(-50%); - transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); - } + @include switchLabelSpan; } .darkSVG, @@ -338,12 +367,9 @@ } .switchLanguageLabel { - position: relative; - display: inline-block; - order: 3; - width: var(--large-offset); - height: calc(calc(var(--small-offset) / 1.5) + var(--extra-small-offset)); - cursor: pointer; + @include switchLabel; + + order: 4; &:disabled { background-color: var(--noble-gray-300); @@ -352,46 +378,17 @@ } .switchLanguageCheckbox { - width: 0; - height: 0; - opacity: 1; + @include switchCheckbox; &:checked { - + .switchLanguageLabelSpan { - background-color: var(--noble-gray-tr-900); - } - + .switchLanguageLabelSpan::before { - background-color: var(--noble-gray-tr-900); - transform: translate(calc(var(--small-offset) + calc(var(--one) * 4)), -50%); + transform: translate(calc(var(--small-offset) + calc(var(--one) * 3)), -50%); } } } .switchLanguageLabelSpan { - position: absolute; - border-radius: calc(var(--large-br) * 2); - 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; - inset: 0; - - &::before { - content: ''; - position: absolute; - left: calc(var(--one) * 5); - top: 50%; - bottom: 0; - z-index: 2; - border-radius: var(--large-br); - width: calc(var(--small-offset) / 1.5); - height: calc(var(--small-offset) / 1.5); - box-shadow: 0 1px 5px #353535; - background-color: var(--noble-gray-tr-900); - transform: translateY(-50%); - transition: 0.3s cubic-bezier(0.8, 0.5, 0.2, 1.4); - } + @include switchLabelSpan(var(--noble-gray-tr-800), var(--noble-gray-tr-900)); } .enSVG, @@ -424,27 +421,3 @@ opacity: 1; } } - -.badgeWrap { - position: absolute; - right: -10%; - top: 1%; - display: flex; - align-items: center; - justify-content: center; - border: var(--one) solid var(--noble-gray-1000); - border-radius: 100%; - width: calc(var(--tiny-offset) * 2); - height: calc(var(--tiny-offset) * 2); - font: var(--regular-font); - background-color: var(--steam-green-800); -} - -.badge { - display: block; - color: var(--noble-gray-200); -} - -.hide { - display: none; -} diff --git a/src/widgets/LoginForm/model/LoginFormModel.ts b/src/widgets/LoginForm/model/LoginFormModel.ts index d871f922..000e300f 100644 --- a/src/widgets/LoginForm/model/LoginFormModel.ts +++ b/src/widgets/LoginForm/model/LoginFormModel.ts @@ -4,14 +4,13 @@ import type { UserCredentials } from '@/shared/types/user.ts'; import CredentialsModel from '@/entities/Credentials/model/CredentialsModel.ts'; import getCustomerModel from '@/shared/API/customer/model/CustomerModel.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 { setCurrentLanguage, switchIsUserLoggedIn } from '@/shared/Store/actions.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import isLanguageChoiceType from '@/shared/types/validation/language.ts'; import { createGreetingMessage } from '@/shared/utils/messageTemplates.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import LoginFormView from '../view/LoginFormView.ts'; import styles from '../view/loginForm.module.scss'; @@ -43,7 +42,7 @@ class LoginFormModel { if (response) { this.loginUserHandler(userLoginData); } else { - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.INVALID_EMAIL, MESSAGE_STATUS.ERROR); + showErrorMessage(SERVER_MESSAGE_KEY.INVALID_EMAIL); } }) .catch((error) => showErrorMessage(error)) @@ -61,15 +60,11 @@ class LoginFormModel { if (isLanguageChoiceType(data.locale)) { getStore().dispatch(setCurrentLanguage(data.locale)); } - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.GREETING, - MESSAGE_STATUS.SUCCESS, - createGreetingMessage(), - ); + showSuccessMessage(createGreetingMessage(data.firstName)); } }) .catch(() => { - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.INCORRECT_PASSWORD, MESSAGE_STATUS.ERROR); + showErrorMessage(SERVER_MESSAGE_KEY.INCORRECT_PASSWORD); }) .finally(() => loader.remove()); } diff --git a/src/widgets/LoginForm/view/LoginFormView.ts b/src/widgets/LoginForm/view/LoginFormView.ts index dda3bcc8..181091ba 100644 --- a/src/widgets/LoginForm/view/LoginFormView.ts +++ b/src/widgets/LoginForm/view/LoginFormView.ts @@ -1,7 +1,7 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { BUTTON_TEXT, BUTTON_TEXT_KEYS, BUTTON_TYPE } from '@/shared/constants/buttons.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEY, BUTTON_TYPE } from '@/shared/constants/buttons.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './loginForm.module.scss'; @@ -32,10 +32,10 @@ class LoginFormView { type: BUTTON_TYPE.SUBMIT, }, classes: [styles.submitFormButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].LOGIN, + text: BUTTON_TEXT[getCurrentLanguage()].LOGIN, }); - observeCurrentLanguage(this.submitFormButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.LOGIN); + observeCurrentLanguage(this.submitFormButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.LOGIN); this.submitFormButton.setDisabled(); diff --git a/src/widgets/ProductInfo/model/ProductInfoModel.ts b/src/widgets/ProductInfo/model/ProductInfoModel.ts index 9ccce257..a3b2a0ca 100644 --- a/src/widgets/ProductInfo/model/ProductInfoModel.ts +++ b/src/widgets/ProductInfo/model/ProductInfoModel.ts @@ -1,27 +1,28 @@ 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 { set } from '@/app/Router/helpers/helpers.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 WishlistButtonModel from '@/features/WishlistButton/model/WishlistButtonModel.ts'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; -import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.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 { LANGUAGE_CHOICE } from '@/shared/constants/common.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 { SEARCH_PARAMS_FIELD } from '@/shared/constants/product.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import { buildPathName } from '@/shared/utils/buildPathname.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import * as buildPath from '@/shared/utils/buildPathname.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { productAddedToCartMessage, productRemovedFromCartMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import Swiper from 'swiper'; import 'swiper/css'; -import 'swiper/css/autoplay'; import 'swiper/css/bundle'; -import { Autoplay, Thumbs } from 'swiper/modules'; +import { Autoplay, Keyboard, Navigation } from 'swiper/modules'; import ProductInfoView from '../view/ProductInfoView.ts'; @@ -29,37 +30,46 @@ const SLIDER_DELAY = 5000; const SLIDER_PER_VIEW = 1; class ProductInfoModel { - private bigSlider: Swiper | null = null; - private currentVariant: Variant; private params: ProductInfoParams; private price: ProductPriceModel; - private smallSlider: Swiper | null = null; + private savedPath?: string; + + private slider: Swiper | null = null; private view: ProductInfoView; - constructor(params: ProductInfoParams) { + private wishlistButton: WishlistButtonModel; + + constructor(params: ProductInfoParams, savedPath?: string) { this.params = params; + this.savedPath = savedPath; this.view = new ProductInfoView(this.params); this.currentVariant = this.params.variant.find(({ size }) => size === this.params.currentSize) ?? this.params.variant[0]; - this.price = new ProductPriceModel(this.currentVariant); + this.price = new ProductPriceModel({ new: this.currentVariant.discount, old: this.currentVariant.price }); + this.wishlistButton = new WishlistButtonModel(this.params); this.init(); } private addProductToCart(): void { const loader = new LoaderModel(LOADER_SIZE.EXTRA_SMALL).getHTML(); this.view.getSwitchToCartButton().getHTML().append(loader); + const currentLanguage = getCurrentLanguage(); getCartModel() - .addProductToCart({ productId: this.params.id, quantity: 1, variantId: this.currentVariant.id }) + .addProductToCart({ + name: this.params.name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value, + productId: this.params.id, + quantity: 1, + variantId: this.currentVariant.id, + }) .then(() => { this.view.switchToCartButtonText(true); - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_ADD_PRODUCT_TO_CART, - MESSAGE_STATUS.SUCCESS, + showSuccessMessage( + productAddedToCartMessage(this.params.name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value), ); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, ''); }) @@ -67,18 +77,18 @@ class ProductInfoModel { .finally(() => loader.remove()); } - private addProductToWishListHandler(): void { - getShoppingListModel() - .addProduct(this.params.id) - .then(() => { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_ADD_PRODUCT_TO_WISHLIST, - MESSAGE_STATUS.SUCCESS, - ); - this.view.switchStateWishListButton(true); - EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, ''); - }) - .catch(showErrorMessage); + private checkPath(savedPath: string): string { + let result = savedPath; + + if (RouterModel.getCurrentPage() === PAGE_ID.CATALOG_PAGE) { + result = savedPath; + } else if (RouterModel.getCurrentPage() === PAGE_ID.PRODUCT_PAGE) { + result = buildPath.productPathWithIDAndQuery(this.params.key, { + size: [this.currentVariant.size], + slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)], + }); + } + return result; } private deleteProductFromCart(cart: Cart): void { @@ -90,9 +100,8 @@ class ProductInfoModel { .deleteProductFromCart(currentProduct) .then(() => { this.view.switchToCartButtonText(false); - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_DELETE_PRODUCT_FROM_CART, - MESSAGE_STATUS.SUCCESS, + showSuccessMessage( + productRemovedFromCartMessage(this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value), ); EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, ''); }) @@ -101,74 +110,95 @@ class ProductInfoModel { } } - private deleteProductToWishListHandler(productInWishList: ShoppingListProduct): void { - getShoppingListModel() - .deleteProduct(productInWishList) - .then(() => { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_DELETE_PRODUCT_FROM_WISHLIST, - MESSAGE_STATUS.SUCCESS, - ); - EventMediatorModel.getInstance().notify(MEDIATOR_EVENT.REDRAW_PRODUCTS, ''); - this.view.switchStateWishListButton(false); - }) - .catch(showErrorMessage); - } - private init(): void { - this.smallSlider = new Swiper(this.view.getSmallSlider(), { - direction: 'vertical', - slidesPerView: this.params.images.length, - }); - this.bigSlider = new Swiper(this.view.getBigSlider(), { + this.slider = new Swiper(this.view.getSlider(), { autoplay: { delay: SLIDER_DELAY, }, - direction: 'vertical', + keyboard: { + enabled: true, + }, loop: true, - modules: [Autoplay, Thumbs], - slidesPerView: SLIDER_PER_VIEW, - thumbs: { - swiper: this.smallSlider, + modules: [Autoplay, Keyboard, Navigation], + navigation: { + nextEl: this.view.getNextSlideButton().getHTML(), + prevEl: this.view.getPrevSlideButton().getHTML(), }, + slidesPerView: SLIDER_PER_VIEW, }); - this.bigSlider.autoplay.start(); - - this.view.getBigSliderSlides().forEach((slide, index) => { - slide.addEventListener('click', () => { - const modalSlider = new ProductModalSliderModel(this.params); - modal.show(); - modalSlider.getModalSlider()?.slideTo(index); - modal.setContent(modalSlider.getHTML()); - }); - }); + this.slider.enable(); + this.slider.slideTo(Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)) - 1 || 0, 0, false); + this.sliderHandler(); + this.nextSlideButtonHandler(); + this.prevSlideButtonHandler(); this.view.getRightWrapper().append(this.price.getHTML()); + this.view.getButtonsWrapper().append(this.wishlistButton.getHTML().getHTML()); this.switchToCartButtonHandler(); - this.switchToWishListButtonHandler(); this.setSizeButtonHandler(); } + private nextSlideButtonHandler(): void { + const nextSlideButton = this.view.getNextSlideButton(); + nextSlideButton.getHTML().addEventListener('click', () => { + const slideInSearch = Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)); + + if (slideInSearch < this.view.getSliderSlides().length) { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(slideInSearch + 1))); + } else { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(1))); + } + }); + } + + private prevSlideButtonHandler(): void { + const prevSlideButton = this.view.getPrevSlideButton(); + prevSlideButton.getHTML().addEventListener('click', () => { + const slideInSearch = Number(RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)); + + if (slideInSearch > 1) { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(slideInSearch - 1))); + } else { + RouterModel.changeSearchParams((url) => + set(url, SEARCH_PARAMS_FIELD.SLIDE, String(this.view.getSliderSlides().length)), + ); + } + }); + } + 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], - })}`; + const path = `${buildPath.productPathWithIDAndQuery(this.params.key, { size: [currentVariant?.size ?? this.params.variant[0].size], slide: [RouterModel.getSearchParams().get(SEARCH_PARAMS_FIELD.SLIDE)] })}`; 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.price = new ProductPriceModel({ new: this.currentVariant.discount, old: this.currentVariant.price }); this.view.getRightWrapper().append(this.price.getHTML()); }); }); } + private sliderHandler(): void { + this.view.getSliderSlides().forEach((slide, index) => { + slide.addEventListener('click', () => { + const { slider } = this; + if (slider) { + RouterModel.changeSearchParams((url) => set(url, SEARCH_PARAMS_FIELD.SLIDE, String(slider.activeIndex + 1))); + } + const router = RouterModel.getInstance(); + const modalSlider = new ProductModalSliderModel(this.params); + modal.show(() => router.navigateTo(this.checkPath(this.savedPath ?? ''))); + modalSlider.getModalSlider()?.slideTo(index); + modal.setContent(modalSlider.getHTML()); + }); + }); + } + private switchToCartButtonHandler(): void { const switchToCartButton = this.view.getSwitchToCartButton(); @@ -188,19 +218,6 @@ class ProductInfoModel { }); } - private switchToWishListButtonHandler(): void { - const switchToWishListButton = this.view.getSwitchToWishListButton(); - switchToWishListButton.getHTML().addEventListener('click', async () => { - const shoppingList = await getShoppingListModel().getShoppingList(); - const productInWishList = shoppingList.products.find((product) => product.productId === this.params.id); - if (productInWishList) { - this.deleteProductToWishListHandler(productInWishList); - } else { - this.addProductToWishListHandler(); - } - }); - } - public getHTML(): HTMLDivElement { return this.view.getHTML(); } diff --git a/src/widgets/ProductInfo/view/ProductInfoView.ts b/src/widgets/ProductInfo/view/ProductInfoView.ts index 4968af4c..e14743d5 100644 --- a/src/widgets/ProductInfo/view/ProductInfoView.ts +++ b/src/widgets/ProductInfo/view/ProductInfoView.ts @@ -1,55 +1,51 @@ import type { ProductInfoParams } from '@/shared/types/product'; import getCartModel from '@/shared/API/cart/model/CartModel.ts'; -import getShoppingListModel from '@/shared/API/shopping-list/model/ShoppingListModel.ts'; import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; import InputModel from '@/shared/Input/model/InputModel.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 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 { INPUT_TYPE } from '@/shared/constants/forms.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; -import { PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS } from '@/shared/constants/product.ts'; +import { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; +import { PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEY } from '@/shared/constants/product.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; +import { DIFFICULTY } from '@/shared/types/product.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { SKUCopiedMessage } from '@/shared/utils/messageTemplates.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import './productInfoView.scss'; -const SLIDER_WIDTH = 8; -const SLIDER_WIDTH_PART = 2; const DELIMITER = '>'; class ProductInfoView { private SKUSpan: HTMLSpanElement; - private bigSlider: HTMLDivElement; - - private bigSliderSlides: HTMLDivElement[] = []; - private buttonsWrapper: HTMLDivElement; private categoriesSpan: HTMLSpanElement; + private nextSlideButton: ButtonModel; + private params: ProductInfoParams; + private prevSlideButton: ButtonModel; + private rightWrapper: HTMLDivElement; private shortDescription: HTMLParagraphElement; private sizeButtons: ButtonModel[] = []; - private smallSlider: HTMLDivElement; + private slider: HTMLDivElement; - private switchToCartButton: ButtonModel; + private sliderSlides: HTMLDivElement[] = []; - private switchToWishListButton: ButtonModel; + private switchToCartButton: ButtonModel; private title: HTMLHeadingElement; @@ -59,94 +55,40 @@ class ProductInfoView { this.params = params; this.title = this.createProductTitle(); this.shortDescription = this.createShortDescription(); - this.smallSlider = this.createSmallSlider(); - this.bigSlider = this.createBigSlider(); + this.nextSlideButton = this.createNextSlideButton(); + this.prevSlideButton = this.createPrevSlideButton(); + this.slider = this.createSlider(); this.SKUSpan = this.createSKUSpan(); this.categoriesSpan = this.createCategoriesSpan(); this.switchToCartButton = this.createSwitchToCartButton(); - this.switchToWishListButton = this.createSwitchToWishListButton(); this.buttonsWrapper = this.createButtonsWrapper(); this.rightWrapper = this.createRightWrapper(); this.view = this.createHTML(); } - private createBigSlider(): HTMLDivElement { - const slider = createBaseElement({ - cssClasses: ['swiper', 'bigSlider'], - tag: 'div', - }); - - const width = this.params.images.length * SLIDER_WIDTH + SLIDER_WIDTH_PART; - slider.style.width = `${width}rem`; - slider.append(this.createBigSliderWrapper()); - return slider; - } - - private createBigSliderSlideContent(src: string, alt: string): HTMLImageElement { - const slide = createBaseElement({ - attributes: { - alt, - src, - }, - cssClasses: ['bigSliderImage'], - tag: 'img', - }); - - return slide; - } - - private createBigSliderWrapper(): HTMLDivElement { - const sliderWrapper = createBaseElement({ - cssClasses: ['swiper-wrapper', 'bigSliderWrapper'], - tag: 'div', - }); - - this.params.images.forEach((image) => { - const slideWrapper = createBaseElement({ - cssClasses: ['swiper-slide', 'bigSliderSlide'], - tag: 'div', - }); - const slide = this.createBigSliderSlideContent(image, this.params.name[0].value); - const loader = new LoaderModel(LOADER_SIZE.MEDIUM); - loader.setAbsolutePosition(); - slideWrapper.append(slide, loader.getHTML()); - slide.classList.add('hidden'); - slide.addEventListener('load', () => { - slide.classList.remove('hidden'); - loader.getHTML().remove(); - }); - this.bigSliderSlides.push(slide); - slideWrapper.append(slide); - sliderWrapper.append(slideWrapper); - }); - - return sliderWrapper; - } - private createButtonsWrapper(): HTMLDivElement { this.buttonsWrapper = createBaseElement({ cssClasses: ['buttonsWrapper'], tag: 'div', }); - this.buttonsWrapper.append(this.switchToCartButton.getHTML(), this.switchToWishListButton.getHTML()); + this.buttonsWrapper.append(this.switchToCartButton.getHTML()); return this.buttonsWrapper; } private createCategoriesSpan(): HTMLSpanElement { + const currentLanguage = getCurrentLanguage(); this.categoriesSpan = createBaseElement({ cssClasses: ['categoriesSpan'], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].CATEGORY, + innerContent: PRODUCT_INFO_TEXT[currentLanguage].CATEGORY, tag: 'span', }); - observeCurrentLanguage(this.categoriesSpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS.CATEGORY); + observeCurrentLanguage(this.categoriesSpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEY.CATEGORY); - const category = - this.params.category[0].parent?.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; - const subcategory = - this.params.category[0].name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const category = this.params.category[0].parent?.name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value; + const subcategory = this.params.category[0].name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value; const currentCategoriesText = `${category ? `${category} ${DELIMITER} ` : ''}${subcategory}`; const currentCategories = createBaseElement({ @@ -157,11 +99,9 @@ class ProductInfoView { this.categoriesSpan.append(currentCategories); observeStore(selectCurrentLanguage, () => { - const category = - this.params.category[0].parent?.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)] - .value; - const subcategory = - this.params.category[0].name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const currentLanguage = getCurrentLanguage(); + const category = this.params.category[0].parent?.name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value; + const subcategory = this.params.category[0].name[Number(currentLanguage === LANGUAGE_CHOICE.RU)].value; const currentCategoriesText = `${category ? `${category} ${DELIMITER} ` : ''}${subcategory}`; currentCategories.textContent = currentCategoriesText; @@ -177,8 +117,8 @@ class ProductInfoView { tag: 'span', }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.LEAVES)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.LEAVES)); difficultyPoint.append(svg); difficultyPoints.push(difficultyPoint); @@ -197,21 +137,56 @@ class ProductInfoView { tag: 'div', }); - leftWrapper.append(this.smallSlider, this.bigSlider); + if (this.params.images.length > 1) { + const navigationWrapper = this.createNavigationWrapper(); + navigationWrapper.append(this.prevSlideButton.getHTML(), this.nextSlideButton.getHTML()); + leftWrapper.append(navigationWrapper); + } + + leftWrapper.append(this.slider); this.view.append(leftWrapper, this.rightWrapper); return this.view; } + private createNavigationWrapper(): HTMLDivElement { + const navigationWrapper = createBaseElement({ + cssClasses: ['navigationWrapper'], + tag: 'div', + }); + return navigationWrapper; + } + + private createNextSlideButton(): ButtonModel { + this.nextSlideButton = new ButtonModel({ + classes: ['nextSlideButton'], + }); + + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.ARROW_UP)); + this.nextSlideButton.getHTML().append(svg); + return this.nextSlideButton; + } + + private createPrevSlideButton(): ButtonModel { + this.prevSlideButton = new ButtonModel({ + classes: ['prevSlideButton'], + }); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.ARROW_UP)); + this.prevSlideButton.getHTML().append(svg); + return this.prevSlideButton; + } + private createProductTitle(): HTMLHeadingElement { this.title = createBaseElement({ cssClasses: ['productTitle'], - innerContent: this.params.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, + innerContent: this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, tag: 'h3', }); observeStore(selectCurrentLanguage, () => { - const textContent = this.params.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const textContent = this.params.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; this.title.textContent = textContent; }); @@ -219,19 +194,21 @@ class ProductInfoView { } private createRightWrapper(): HTMLDivElement { + const currentLanguage = getCurrentLanguage(); + this.rightWrapper = createBaseElement({ - cssClasses: ['rightWrapper', 'productDetailsPriceWrapper'], + cssClasses: ['rightWrapper', 'productDetailsPriceWrapper', 'modalContent'], tag: 'div', }); const shortDescriptionWrapper = createBaseElement({ cssClasses: ['shortDescriptionWrapper'], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].SHORT_DESCRIPTION, + innerContent: PRODUCT_INFO_TEXT[currentLanguage].SHORT_DESCRIPTION, tag: 'div', }); observeStore(selectCurrentLanguage, () => { - const text = PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].SHORT_DESCRIPTION; + const text = PRODUCT_INFO_TEXT[getCurrentLanguage()].SHORT_DESCRIPTION; const textNode = [...shortDescriptionWrapper.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); if (textNode) { textNode.textContent = text; @@ -241,11 +218,15 @@ class ProductInfoView { if (this.params.level) { const difficultySpan = createBaseElement({ cssClasses: ['difficultySpan'], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].DIFFICULTY, + innerContent: PRODUCT_INFO_TEXT[currentLanguage].DIFFICULTY, tag: 'span', + title: DIFFICULTY[getCurrentLanguage()][this.params.level], }); - observeCurrentLanguage(difficultySpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEYS.DIFFICULTY); + observeStore(selectCurrentLanguage, () => { + difficultySpan.title = this.params.level ? DIFFICULTY[getCurrentLanguage()][this.params.level] : ''; + }); + observeCurrentLanguage(difficultySpan, PRODUCT_INFO_TEXT, PRODUCT_INFO_TEXT_KEY.DIFFICULTY); difficultySpan.append(...this.createDifficultyPoints()); this.rightWrapper.append(difficultySpan); @@ -269,29 +250,18 @@ class ProductInfoView { tag: 'span', }); - const currentSKU = new InputModel({ - autocomplete: AUTOCOMPLETE_OPTION.ON, - id: '', - placeholder: '', - type: INPUT_TYPE.TEXT, - value: this.params.key, - }); + const currentSKU = new InputModel({ value: this.params.key }); currentSKU.getHTML().classList.add('currentSKU'); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.COPY)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.COPY)); svg.addEventListener('click', () => { window.navigator.clipboard .writeText(currentSKU.getValue()) - .then(() => - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_COPY_TO_CLIPBOARD, - MESSAGE_STATUS.SUCCESS, - ), - ) - .catch(() => serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.BAD_REQUEST, MESSAGE_STATUS.ERROR)); + .then(() => showSuccessMessage(SKUCopiedMessage(currentSKU.getValue()))) + .catch(showErrorMessage); }); this.SKUSpan.append(currentSKU.getHTML(), svg); @@ -301,13 +271,12 @@ class ProductInfoView { private createShortDescription(): HTMLParagraphElement { this.shortDescription = createBaseElement({ cssClasses: ['shortDescription'], - innerContent: this.params.description[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value, + innerContent: this.params.description[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, tag: 'p', }); observeStore(selectCurrentLanguage, () => { - const textContent = - this.params.description[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + const textContent = this.params.description[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; this.shortDescription.textContent = textContent; }); return this.shortDescription; @@ -339,7 +308,7 @@ class ProductInfoView { private createSizesWrapper(): HTMLDivElement { const sizesWrapper = createBaseElement({ cssClasses: ['sizesWrapper'], - innerContent: PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].SIZE, + innerContent: PRODUCT_INFO_TEXT[getCurrentLanguage()].SIZE, tag: 'div', }); @@ -350,7 +319,7 @@ class ProductInfoView { }); observeStore(selectCurrentLanguage, () => { - const text = PRODUCT_INFO_TEXT[getStore().getState().currentLanguage].SIZE; + const text = PRODUCT_INFO_TEXT[getCurrentLanguage()].SIZE; const textNode = [...sizesWrapper.childNodes].find((child) => child.nodeType === Node.TEXT_NODE); if (textNode) { textNode.textContent = text; @@ -360,42 +329,42 @@ class ProductInfoView { return sizesWrapper; } - private createSmallSlider(): HTMLDivElement { + private createSlider(): HTMLDivElement { const slider = createBaseElement({ - cssClasses: ['swiper', 'smallSlider'], + cssClasses: ['swiper', 'slider'], tag: 'div', }); - slider.append(this.createSmallSliderWrapper()); + slider.append(this.createSliderWrapper()); return slider; } - private createSmallSliderSlideContent(src: string, alt: string): HTMLImageElement { + private createSliderSlideContent(src: string, alt: string): HTMLImageElement { const slide = createBaseElement({ attributes: { alt, src, }, - cssClasses: ['smallSliderImage'], + cssClasses: ['sliderImage'], tag: 'img', }); + return slide; } - private createSmallSliderWrapper(): HTMLDivElement { + private createSliderWrapper(): HTMLDivElement { const sliderWrapper = createBaseElement({ - cssClasses: ['swiper-wrapper', 'smallSliderWrapper'], + cssClasses: ['swiper-wrapper', 'sliderWrapper'], tag: 'div', }); this.params.images.forEach((image) => { const slideWrapper = createBaseElement({ - cssClasses: ['swiper-slide', 'smallSliderSlide'], + cssClasses: ['swiper-slide', 'sliderSlide'], tag: 'div', }); - - const slide = this.createSmallSliderSlideContent(image, this.params.name[0].value); - const loader = new LoaderModel(LOADER_SIZE.SMALL); + const slide = this.createSliderSlideContent(image, this.params.name[0].value); + const loader = new LoaderModel(LOADER_SIZE.MEDIUM); loader.setAbsolutePosition(); slideWrapper.append(slide, loader.getHTML()); slide.classList.add('hidden'); @@ -403,6 +372,7 @@ class ProductInfoView { slide.classList.remove('hidden'); loader.getHTML().remove(); }); + this.sliderSlides.push(slide); slideWrapper.append(slide); sliderWrapper.append(slideWrapper); }); @@ -421,57 +391,36 @@ class ProductInfoView { return this.switchToCartButton; } - private createSwitchToWishListButton(): ButtonModel { - this.switchToWishListButton = new ButtonModel({ - classes: ['switchToWishListButton'], - }); - - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.FILL_HEART)); - this.switchToWishListButton.getHTML().append(svg); - - this.hasProductInWishList(); - - return this.switchToWishListButton; - } - private hasProductInToCart(): void { getCartModel() .getCart() .then((cart) => { + const currentLanguage = getCurrentLanguage(); if ( cart.products.find((product) => product.key === this.params.key && product.size === this.params.currentSize) ) { - this.switchToCartButton.getHTML().textContent = - BUTTON_TEXT[getStore().getState().currentLanguage].DELETE_PRODUCT; + this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[currentLanguage].DELETE_PRODUCT; } else { - this.switchToCartButton.getHTML().textContent = - BUTTON_TEXT[getStore().getState().currentLanguage].ADD_PRODUCT; + this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[currentLanguage].ADD_PRODUCT; } }) .catch(showErrorMessage); } - private hasProductInWishList(): void { - getShoppingListModel() - .getShoppingList() - .then((shoppingList) => { - const result = shoppingList.products.find((product) => product.productId === this.params.id); - this.switchStateWishListButton(Boolean(result)); - }) - .catch(showErrorMessage); + public getButtonsWrapper(): HTMLDivElement { + return this.buttonsWrapper; } - public getBigSlider(): HTMLDivElement { - return this.bigSlider; + public getHTML(): HTMLDivElement { + return this.view; } - public getBigSliderSlides(): HTMLDivElement[] { - return this.bigSliderSlides; + public getNextSlideButton(): ButtonModel { + return this.nextSlideButton; } - public getHTML(): HTMLDivElement { - return this.view; + public getPrevSlideButton(): ButtonModel { + return this.prevSlideButton; } public getRightWrapper(): HTMLDivElement { @@ -482,27 +431,24 @@ class ProductInfoView { return this.sizeButtons; } - public getSmallSlider(): HTMLDivElement { - return this.smallSlider; - } - - public getSwitchToCartButton(): ButtonModel { - return this.switchToCartButton; + public getSlider(): HTMLDivElement { + return this.slider; } - public getSwitchToWishListButton(): ButtonModel { - return this.switchToWishListButton; + public getSliderSlides(): HTMLDivElement[] { + return this.sliderSlides; } - public switchStateWishListButton(hasProductInWishList: boolean): void { - this.switchToWishListButton.getHTML().classList.toggle('inWishList', hasProductInWishList); + public getSwitchToCartButton(): ButtonModel { + return this.switchToCartButton; } public switchToCartButtonText(hasCart: boolean): void { + const currentLanguage = getCurrentLanguage(); if (hasCart) { - this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[getStore().getState().currentLanguage].DELETE_PRODUCT; + this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[currentLanguage].DELETE_PRODUCT; } else { - this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[getStore().getState().currentLanguage].ADD_PRODUCT; + this.switchToCartButton.getHTML().textContent = BUTTON_TEXT[currentLanguage].ADD_PRODUCT; } } diff --git a/src/widgets/ProductInfo/view/productInfoView.scss b/src/widgets/ProductInfo/view/productInfoView.scss index d8af7842..92af27d5 100644 --- a/src/widgets/ProductInfo/view/productInfoView.scss +++ b/src/widgets/ProductInfo/view/productInfoView.scss @@ -3,7 +3,6 @@ .wrapper { display: flex; align-items: center; - justify-content: center; margin-bottom: var(--small-offset); gap: var(--small-offset); @@ -13,107 +12,149 @@ } .leftWrapper { + position: relative; display: flex; - max-width: 50%; + margin: 0 var(--small-offset); + width: 30%; + height: max-content; @media (max-width: 768px) { - max-width: 100%; + margin: 0 auto; + width: 100%; } } -.rightWrapper { +.navigationWrapper { + position: absolute; + left: -3.5rem; + right: -3.5rem; + top: 50%; + z-index: 1; display: flex; - flex-direction: column; - width: 40%; - gap: var(--extra-small-offset); + justify-content: space-between; + transform: translateY(-50%); @media (max-width: 768px) { - width: 100%; - gap: var(--tiny-offset); + display: none; } } -.productTitle { - order: 1; - font: var(--medium-font); - letter-spacing: var(--one); - color: var(--steam-green-500); +.nextSlideButton, +.prevSlideButton { + display: flex; + align-items: center; + justify-content: center; + border: var(--one) solid var(--noble-gray-200); + border-radius: 50%; + width: 3rem; + height: 3rem; + font: var(--extra-medium-font); + color: var(--steam-green-800); + background-color: var(--steam-green-1000); + transition: + color 0.2s, + border-color 0.2s, + transform 0.2s; - @media (max-width: 768px) { - text-align: center; + svg { + width: 2.5rem; + height: 2.5rem; + fill: transparent; + stroke: var(--steam-green-800); + transition: stroke 0.2s; } -} -.smallSlider { - margin: 0; - margin-right: var(--tiny-offset); -} - -.smallSliderImage { - width: 100%; - max-height: 8rem; -} + @media (hover: hover) { + &:hover { + border-color: var(--steam-green-800); + color: var(--steam-green-800); -.smallSliderSlide { - /* stylelint-disable-next-line declaration-no-important */ - height: auto !important; - max-height: 8rem; - transition: opacity 0.2s; - cursor: pointer; + svg { + stroke: var(--steam-green-800); + } + } + } - &:not(:last-child) { - margin-bottom: var(--tiny-offset); + /* stylelint-disable-next-line order/order */ + &:active { + transform: scale(0.9); } +} - @media (hover: hover) { - &:hover { - opacity: 0.7; - } +.nextSlideButton { + svg { + transform: rotate(90deg); } } -.bigSlider { - margin: 0; - width: 70%; +.prevSlideButton { + svg { + transform: rotate(-90deg); + } } -.bigSliderWrapper { - height: 8rem; +.hidden { + display: none; } -.bigSliderImage { - height: 100%; +.slider { + width: clamp(25rem, 95dvw, 30rem); } -.bigSliderSlide { +.sliderImage { + width: 100%; + height: auto; cursor: zoom-in; } +.rightWrapper { + display: flex; + flex-direction: column; + width: 50%; + gap: var(--extra-small-offset); + + @media (max-width: 768px) { + display: grid; + margin: 0 auto; + width: 95%; + gap: var(--tiny-offset); + } +} + +.productTitle { + order: 1; + font: var(--medium-font); + letter-spacing: var(--one); + color: var(--steam-green-500); + + @media (max-width: 768px) { + grid-row: 1; + } +} + .shortDescriptionWrapper { order: 3; font: var(--regular-font); + line-height: 170%; letter-spacing: var(--one); color: var(--steam-green-400); @media (max-width: 768px) { - text-align: center; + grid-column: 2 span; + grid-row: 2; } } -.shortDescription, -.fullDescription { +.shortDescription { font: var(--regular-font); + line-height: 170%; color: var(--noble-gray-800); - - @media (max-width: 768px) { - text-align: center; - } } .sizesWrapper { - display: grid; + display: flex; + align-items: center; order: 4; - grid-template-columns: max-content; width: max-content; font: var(--regular-font); letter-spacing: var(--one); @@ -123,8 +164,9 @@ @media (max-width: 768px) { display: flex; align-items: center; - align-self: center; justify-content: center; + order: 2; + grid-row: 4; } } @@ -132,8 +174,6 @@ display: flex; align-items: center; justify-content: center; - justify-self: center; - grid-row: 2; border: var(--one) solid var(--noble-gray-800); border-radius: 50%; width: 2rem; @@ -153,10 +193,6 @@ color: var(--steam-green-400); } } - - @media (max-width: 768px) { - grid-row: none; - } } .selected { @@ -197,10 +233,20 @@ } } } + + @media (max-width: 768px) { + order: 2; + grid-row: 5; + } } .categoriesSpan { order: 7; + + @media (max-width: 768px) { + order: 1; + grid-row: 6; + } } .currentSKU, @@ -221,7 +267,7 @@ padding: calc(var(--small-offset) / 3) calc(var(--small-offset) / 2); width: 100%; - max-width: 12rem; + max-width: 14rem; } .buttonsWrapper { @@ -232,74 +278,27 @@ @media (max-width: 768px) { align-self: center; - } -} - -.switchToWishListButton { - 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(--white-tr); - transition: - transform 0.2s, - outline 0.2s; - backdrop-filter: blur(10px); - - svg { - width: var(--extra-small-offset); - height: var(--extra-small-offset); - fill: var(--noble-gray-800); - transition: fill 0.2s; - } - - &:active { - transform: scale(0.9); - } - - @media (hover: hover) { - &:hover { - outline: calc(var(--one) * 1.5) solid var(--red-power-600); - - svg { - fill: var(--red-power-600); - } - } - } - - &.inWishList { - @media (hover: hover) { - &:hover { - outline: calc(var(--one) * 1.5) solid var(--noble-gray-700); - - svg { - fill: var(--noble-gray-700); - } - } - } - } -} - -.inWishList { - outline: calc(var(--one) * 1.5) solid var(--red-power-600); - - svg { - fill: var(--red-power-600); + order: 1; + grid-row: 7; } } .modalProductInfoWrapper { .wrapper { margin-bottom: 0; - border-radius: var(--medium-br); + border-top-left-radius: var(--medium-br); + border-bottom-left-radius: var(--medium-br); padding: var(--extra-small-offset); background-color: var(--white-tr); } -} -.hidden { - display: none; + .leftWrapper { + width: 50%; + + @media (max-width: 768px) { + width: 100%; + } + } } .difficultySpan { @@ -312,7 +311,7 @@ gap: var(--one); @media (max-width: 768px) { - align-self: center; + grid-row: 3; } } diff --git a/src/widgets/ProductOrder/model/ProductOrderModel.ts b/src/widgets/ProductOrder/model/ProductOrderModel.ts index 89817414..76948fe3 100644 --- a/src/widgets/ProductOrder/model/ProductOrderModel.ts +++ b/src/widgets/ProductOrder/model/ProductOrderModel.ts @@ -1,13 +1,15 @@ import type { Cart, CartProduct, EditCartItem } from '@/shared/types/cart.ts'; +import ProductPriceModel from '@/entities/ProductPrice/model/ProductPriceModel.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 { LANGUAGE_CHOICE } from '@/shared/constants/common.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; import { CartActive } from '@/shared/types/cart.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { productRemovedFromCartMessage } from '@/shared/utils/messageTemplates.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import ProductOrderView from '../view/ProductOrderView.ts'; @@ -16,14 +18,26 @@ type Callback = (cart: Cart) => void; class ProductOrderModel { private callback: Callback; + private price: ProductPriceModel; + private productItem: CartProduct; + private total: ProductPriceModel; + private view: ProductOrderView; constructor(productItem: CartProduct, callback: Callback) { this.callback = callback; this.productItem = productItem; - this.view = new ProductOrderView(this.productItem, this.updateProductHandler.bind(this)); + this.price = new ProductPriceModel({ new: this.productItem.priceCouponDiscount, old: this.productItem.price }); + this.total = new ProductPriceModel({ + new: + this.productItem.totalPrice === this.productItem.totalPriceCouponDiscount + ? 0 + : this.productItem.totalPriceCouponDiscount, + old: this.productItem.totalPrice, + }); + this.view = new ProductOrderView(this.productItem, this.price, this.total, this.updateProductHandler.bind(this)); this.init(); } @@ -34,9 +48,10 @@ class ProductOrderModel { .deleteProductFromCart(this.productItem) .then((cart) => { if (cart) { - serverMessageModel.showServerMessage( - SERVER_MESSAGE_KEYS.SUCCESSFUL_DELETE_PRODUCT_FROM_CART, - MESSAGE_STATUS.SUCCESS, + showSuccessMessage( + productRemovedFromCartMessage( + this.productItem.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, + ), ); const updateItem = cart.products.find((item) => item.lineItemId === this.productItem.lineItemId); this.updateView(updateItem); @@ -92,6 +107,11 @@ class ProductOrderModel { return this.productItem; } + public setProduct(product: CartProduct): CartProduct { + this.productItem = product; + return this.productItem; + } + public async updateProductHandler(active: CartActive): Promise { switch (active) { case CartActive.DELETE: { @@ -106,6 +126,10 @@ class ProductOrderModel { await this.activePlus(); break; } + case CartActive.UPDATE: { + this.updateView(this.productItem); + break; + } default: break; } diff --git a/src/widgets/ProductOrder/test/productOrder.spec.ts b/src/widgets/ProductOrder/test/productOrder.spec.ts new file mode 100644 index 00000000..62933c6a --- /dev/null +++ b/src/widgets/ProductOrder/test/productOrder.spec.ts @@ -0,0 +1,40 @@ +import type { CartProduct } from '@/shared/types/cart.ts'; + +import sinon from 'sinon'; + +import ProductOrderModel from '../model/ProductOrderModel.ts'; + +/** + * @vitest-environment jsdom + */ + +const productItem: CartProduct = { + images: '', + key: '', + lineItemId: '', + name: [ + { + language: '', + value: '', + }, + ], + price: 0, + priceCouponDiscount: 0, + productId: '', + quantity: 0, + size: null, + totalPrice: 0, + totalPriceCouponDiscount: 0, +}; +const deleteCallback = sinon.fake(); +const productCart = new ProductOrderModel(productItem, deleteCallback); + +describe('Checking productCart', () => { + it('should check if productCart is defined', () => { + expect(productCart).toBeDefined(); + }); + + it('should check if productCart is an instance of ProductOrderModel', () => { + expect(productCart).toBeInstanceOf(ProductOrderModel); + }); +}); diff --git a/src/widgets/ProductOrder/view/ProductOrderView.ts b/src/widgets/ProductOrder/view/ProductOrderView.ts index 898867a2..e8be8a20 100644 --- a/src/widgets/ProductOrder/view/ProductOrderView.ts +++ b/src/widgets/ProductOrder/view/ProductOrderView.ts @@ -1,16 +1,16 @@ +import type ProductPriceModel from '@/entities/ProductPrice/model/ProductPriceModel'; 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, TABLET_WIDTH } from '@/shared/constants/common.ts'; -import { PAGE_ID } from '@/shared/constants/pages.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import { CartActive } from '@/shared/types/cart.ts'; -import { buildPathName } from '@/shared/utils/buildPathname.ts'; +import * as buildPath from '@/shared/utils/buildPathname.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import Hammer from 'hammerjs'; import styles from './productOrderView.module.scss'; @@ -41,7 +41,7 @@ class ProductOrderView { private language: LanguageChoiceType; - private price: HTMLTableCellElement; + private priceElement: ProductPriceModel; private productItem: CartProduct; @@ -49,29 +49,28 @@ class ProductOrderView { private textElement: textElementsType[] = []; - private total: HTMLTableCellElement; + private totalElement: ProductPriceModel; private view: HTMLTableRowElement; - constructor(productItem: CartProduct, callback: CallbackActive) { + constructor( + productItem: CartProduct, + priceElement: ProductPriceModel, + totalElement: ProductPriceModel, + callback: CallbackActive, + ) { this.productItem = productItem; - this.language = getStore().getState().currentLanguage; + this.priceElement = priceElement; + this.totalElement = totalElement; + this.totalElement.getHTML().classList.add(styles.priceElement); + this.priceElement.getHTML().classList.add(styles.priceElement); + this.language = getCurrentLanguage(); this.callback = callback; this.quantity = createBaseElement({ cssClasses: [styles.quantityCell, styles.quantityText], innerContent: this.productItem.quantity.toString(), tag: 'p', }); - 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(); } @@ -80,8 +79,8 @@ class ProductOrderView { 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)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.DELETE)); this.deleteButton.append(svg); return tdDelete; } @@ -89,21 +88,15 @@ class ProductOrderView { private createHTML(): HTMLTableRowElement { this.view = createBaseElement({ cssClasses: [styles.tr, styles.trProduct], tag: 'tr' }); const imgCell = this.createImgCell(); - const tdProduct = createBaseElement({ - cssClasses: [styles.td, styles.nameCell, styles.mainText], - 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: this.productItem.size ? `${TITLE.SIZE[this.language]}: ${this.productItem.size}` : '', - tag: 'td', - }); + const tdProduct = this.createTdProduct(); + const tdSize = this.createTdSize(); + const tdPrice = this.createTdPrice(); this.textElement.push({ element: tdSize, textItem: TITLE.SIZE }); this.textElement.push({ element: tdProduct, textItem: TITLE.NAME }); + const tdTotal = this.createTdTotal(); const quantityCell = this.createQuantityCell(); const deleteCell = this.createDeleCell(); - this.view.append(imgCell, tdProduct, tdSize, this.price, quantityCell, this.total, deleteCell); + this.view.append(imgCell, tdProduct, tdSize, tdPrice, quantityCell, tdTotal, deleteCell); const animation = new Hammer(this.view); animation.on('swipeleft', () => { if (window.innerWidth <= TABLET_WIDTH) { @@ -119,14 +112,19 @@ class ProductOrderView { deleteCell.classList.add(styles.hide); } }); + window.addEventListener('resize', () => { + 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(): 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 href = `${buildPath.productPathWithIDAndQuery(this.productItem.key, { size: [this.productItem.size] })}`; const link = new LinkModel({ attrs: { href, @@ -135,7 +133,7 @@ class ProductOrderView { }); const img = createBaseElement({ cssClasses: [styles.img], tag: 'img' }); img.src = this.productItem.images; - img.alt = this.productItem.name[Number(getStore().getState().currentLanguage === LANGUAGE_CHOICE.RU)].value; + img.alt = this.productItem.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value; link.getHTML().append(img); tdImage.append(link.getHTML()); return tdImage; @@ -162,6 +160,49 @@ class ProductOrderView { return tdQuantity; } + private createTdPrice(): HTMLTableCellElement { + const td = createBaseElement({ + cssClasses: [styles.td, styles.priceCell, styles.priceText], + tag: 'td', + }); + td.append(this.priceElement.getHTML()); + return td; + } + + private createTdProduct(): HTMLTableCellElement { + const td = createBaseElement({ + cssClasses: [styles.td, styles.nameCell, styles.mainText], + tag: 'td', + }); + const href = `${buildPath.productPathWithIDAndQuery(this.productItem.key, { size: [this.productItem.size] })}`; + const link = new LinkModel({ + attrs: { + href, + }, + classes: [styles.nameLink], + text: this.productItem.name[Number(getCurrentLanguage() === LANGUAGE_CHOICE.RU)].value, + }); + td.append(link.getHTML()); + return td; + } + + private createTdSize(): HTMLTableCellElement { + return createBaseElement({ + cssClasses: [styles.td, styles.sizeCell, styles.sizeText], + innerContent: this.productItem.size ? `${TITLE.SIZE[this.language]}: ${this.productItem.size}` : '', + tag: 'td', + }); + } + + private createTdTotal(): HTMLTableCellElement { + const td = createBaseElement({ + cssClasses: [styles.td, styles.totalCell, styles.totalText], + tag: 'td', + }); + td.append(this.totalElement.getHTML()); + return td; + } + public getDeleteButton(): HTMLButtonElement { return this.deleteButton; } @@ -173,12 +214,19 @@ class ProductOrderView { 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)}`; + + this.priceElement.updatePrice({ new: this.productItem.priceCouponDiscount, old: this.productItem.price }); + this.totalElement.updatePrice({ + new: + this.productItem.totalPrice === this.productItem.totalPriceCouponDiscount + ? 0 + : this.productItem.totalPriceCouponDiscount, + old: this.productItem.totalPrice, + }); } public updateLanguage(): void { - this.language = getStore().getState().currentLanguage; + this.language = getCurrentLanguage(); this.textElement.forEach((textEl) => { const elHTML = textEl.element; if (textEl.textItem === TITLE.SIZE) { diff --git a/src/widgets/ProductOrder/view/productOrderView.module.scss b/src/widgets/ProductOrder/view/productOrderView.module.scss index 02523d78..0555b58e 100644 --- a/src/widgets/ProductOrder/view/productOrderView.module.scss +++ b/src/widgets/ProductOrder/view/productOrderView.module.scss @@ -18,11 +18,11 @@ } .swipeRow { - transform: translateX(calc(var(--extra-large-offset) * -1)); + transform: translateX(-25%); } .swipeDelete { - transform: translateX(calc(var(--extra-large-offset) * 1.5)); + transform: translateX(85%); } .deleteCell { @@ -45,6 +45,14 @@ } .deleteButton { + display: flex; + align-items: center; + justify-content: center; + + div { + position: absolute; + } + svg { width: var(--extra-small-offset); height: var(--extra-small-offset); @@ -102,6 +110,14 @@ } } +$color: var(--steam-green-800); + +.nameLink { + @include link(0 0, $color); + + text-transform: none; +} + .nameCell { justify-content: flex-start; grid-area: 2 / 2 / 3 / 3; @@ -133,6 +149,10 @@ } .priceCell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; grid-area: 2 / 3 / 4 / 4; @media (max-width: 768px) { @@ -160,8 +180,9 @@ .quantityText { padding: var(--tiny-offset); - font: var(--regular-font); - color: var(--noble-gray-800); + font: var(--medium-font); + letter-spacing: var(--one); + color: var(--noble-gray-700); } .totalText { @@ -170,12 +191,21 @@ color: var(--steam-green-800); } +.priceDiscountText, .priceText { padding: var(--tiny-offset) 0; font: var(--regular-font); color: var(--noble-gray-700); } +.priceDiscountText { + color: var(--steam-green-700); +} + +.priceDiscountText:empty { + display: none; +} + .sizeText { padding: var(--tiny-offset); font: var(--regular-font); @@ -192,9 +222,11 @@ border-radius: 50%; padding: 0; + padding-bottom: var(--one); width: calc(var(--tiny-offset) * 2.5); height: calc(var(--tiny-offset) * 2.5); - font: var(--regular-font); + font: var(--medium-font); + letter-spacing: 0; } .mobileHide { @@ -202,3 +234,21 @@ display: none; } } + +.discount { + text-decoration: line-through; +} + +.priceElement { + flex-direction: column; + margin: 0; + + span:empty { + display: none; + } + + @media (max-width: 768px) { + flex-direction: row; + justify-content: start; + } +} diff --git a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts index 322d2e3c..6a95c729 100644 --- a/src/widgets/RegistrationForm/model/RegistrationFormModel.ts +++ b/src/widgets/RegistrationForm/model/RegistrationFormModel.ts @@ -7,29 +7,30 @@ import CredentialsModel from '@/entities/Credentials/model/CredentialsModel.ts'; import PersonalInfoModel from '@/entities/PersonalInfo/model/PersonalInfoModel.ts'; import getCustomerModel from '@/shared/API/customer/model/CustomerModel.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 { setBillingCountry, switchIsUserLoggedIn } from '@/shared/Store/actions.ts'; -import { MESSAGE_STATUS, SERVER_MESSAGE_KEYS } from '@/shared/constants/messages.ts'; +import { SERVER_MESSAGE_KEY } from '@/shared/constants/messages.ts'; import { LOADER_SIZE } from '@/shared/constants/sizes.ts'; -import { ADDRESS_TYPE } from '@/shared/types/address.ts'; +import { ADDRESS } from '@/shared/types/address.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; +import { showErrorMessage, showSuccessMessage } from '@/shared/utils/userMessage.ts'; import RegistrationFormView from '../view/RegistrationFormView.ts'; class RegisterFormModel { - private addressWrappers: Record, AddressModel> = { - [ADDRESS_TYPE.BILLING]: new AddressModel( + private addressWrappers: Record, AddressModel> = { + [ADDRESS.BILLING]: new AddressModel( { setDefault: true, }, - ADDRESS_TYPE.BILLING, + ADDRESS.BILLING, ), - [ADDRESS_TYPE.SHIPPING]: new AddressModel( + [ADDRESS.SHIPPING]: new AddressModel( { setAsBilling: true, setDefault: true, }, - ADDRESS_TYPE.SHIPPING, + ADDRESS.SHIPPING, ), }; @@ -58,7 +59,7 @@ class RegisterFormModel { firstName, id: '', lastName, - locale: getStore().getState().currentLanguage, + locale: getCurrentLanguage(), password, shippingAddress: [], version: 0, @@ -83,8 +84,8 @@ class RegisterFormModel { this.getHTML().append(this.personalInfoWrapper.getHTML()); this.inputFields.push( - ...this.personalInfoWrapper.getView().getInputFields(), ...this.credentialsWrapper.getView().getInputFields(), + ...this.personalInfoWrapper.getView().getInputFields(), ); Object.values(this.addressWrappers) @@ -96,7 +97,7 @@ class RegisterFormModel { this.inputFields.forEach((inputField) => this.setInputFieldHandlers(inputField)); this.setPreventDefaultToForm(); this.setSubmitFormHandler(); - const checkboxSingleAddress = this.addressWrappers[ADDRESS_TYPE.SHIPPING] + const checkboxSingleAddress = this.addressWrappers[ADDRESS.SHIPPING] .getView() .getAddressAsBillingCheckBox() ?.getHTML(); @@ -120,11 +121,11 @@ class RegisterFormModel { if (newUserData) { getStore().dispatch(switchIsUserLoggedIn(false)); getStore().dispatch(switchIsUserLoggedIn(true)); - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.SUCCESSFUL_REGISTRATION, MESSAGE_STATUS.SUCCESS); + showSuccessMessage(SERVER_MESSAGE_KEY.SUCCESSFUL_REGISTRATION); } }) .catch(() => { - serverMessageModel.showServerMessage(SERVER_MESSAGE_KEYS.USER_EXISTS, MESSAGE_STATUS.ERROR); + showErrorMessage(SERVER_MESSAGE_KEY.USER_EXISTS); }) .finally(() => loader.remove()); } @@ -157,8 +158,8 @@ class RegisterFormModel { }); } - const billingAddressView = this.addressWrappers[ADDRESS_TYPE.BILLING].getView(); - const shippingAddress = this.addressWrappers[ADDRESS_TYPE.SHIPPING]; + const billingAddressView = this.addressWrappers[ADDRESS.BILLING].getView(); + const shippingAddress = this.addressWrappers[ADDRESS.SHIPPING]; shippingAddress .getView() .getInputFields() diff --git a/src/widgets/RegistrationForm/view/RegistrationFormView.ts b/src/widgets/RegistrationForm/view/RegistrationFormView.ts index cf0754e7..a0da8e43 100644 --- a/src/widgets/RegistrationForm/view/RegistrationFormView.ts +++ b/src/widgets/RegistrationForm/view/RegistrationFormView.ts @@ -1,7 +1,7 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; -import getStore from '@/shared/Store/Store.ts'; -import { BUTTON_TEXT, BUTTON_TEXT_KEYS, BUTTON_TYPE } from '@/shared/constants/buttons.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEY, BUTTON_TYPE } from '@/shared/constants/buttons.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; import styles from './registrationForm.module.scss'; @@ -32,9 +32,9 @@ class RegistrationFormView { type: BUTTON_TYPE.SUBMIT, }, classes: [styles.submitFormButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].REGISTRATION, + text: BUTTON_TEXT[getCurrentLanguage()].REGISTRATION, }); - observeCurrentLanguage(this.submitFormButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.REGISTRATION); + observeCurrentLanguage(this.submitFormButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.REGISTRATION); this.submitFormButton.setDisabled(); return this.submitFormButton; diff --git a/src/widgets/UserAddresses/model/UserAddressesModel.ts b/src/widgets/UserAddresses/model/UserAddressesModel.ts index db8641ea..ba24f2d7 100644 --- a/src/widgets/UserAddresses/model/UserAddressesModel.ts +++ b/src/widgets/UserAddresses/model/UserAddressesModel.ts @@ -1,4 +1,4 @@ -import type { AddressTypeType } from '@/shared/constants/forms.ts'; +import type { AddressType } from '@/shared/constants/forms.ts'; import type { Address, User } from '@/shared/types/user.ts'; import UserAddressModel from '@/entities/UserAddress/model/UserAddressModel.ts'; @@ -7,11 +7,11 @@ 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 MEDIATOR_EVENT from '@/shared/constants/events.ts'; -import { ADDRESS_TYPE, DEFAULT_ADDRESS } from '@/shared/constants/forms.ts'; +import { ADDRESS, 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 { showErrorMessage } from '@/shared/utils/userMessage.ts'; import UserAddressesView from '../view/UserAddressesView.ts'; @@ -36,15 +36,8 @@ class UserAddressesModel { 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 createAddress = (activeTypes: AddressType[], inactiveTypes?: AddressType[]): UserAddressModel => + new UserAddressModel(address, activeTypes, inactiveTypes); const newAddress = determineNewAddress(addressesContainsID, defaultContainsID, user, createAddress); @@ -74,8 +67,6 @@ class UserAddressesModel { } } catch (error) { showErrorMessage(error); - } finally { - this.view.toggleState(false); } } @@ -84,7 +75,7 @@ class UserAddressesModel { .getCreateBillingAddressButton() .getHTML() .addEventListener('click', () => { - const newAddressForm = new AddressAddModel(ADDRESS_TYPE.BILLING, DEFAULT_ADDRESS).getHTML(); + const newAddressForm = new AddressAddModel(ADDRESS.BILLING, DEFAULT_ADDRESS).getHTML(); modal.show(); modal.setContent(newAddressForm); }); @@ -95,7 +86,7 @@ class UserAddressesModel { .getCreateShippingAddressButton() .getHTML() .addEventListener('click', () => { - const newAddressForm = new AddressAddModel(ADDRESS_TYPE.SHIPPING, DEFAULT_ADDRESS).getHTML(); + const newAddressForm = new AddressAddModel(ADDRESS.SHIPPING, DEFAULT_ADDRESS).getHTML(); modal.show(); modal.setContent(newAddressForm); }); @@ -104,14 +95,6 @@ class UserAddressesModel { public getHTML(): HTMLElement { return this.view.getHTML(); } - - public hide(): void { - this.view.hide(); - } - - public show(): void { - this.view.show(); - } } export default UserAddressesModel; diff --git a/src/widgets/UserAddresses/view/UserAddressesView.ts b/src/widgets/UserAddresses/view/UserAddressesView.ts index c199509a..5e581420 100644 --- a/src/widgets/UserAddresses/view/UserAddressesView.ts +++ b/src/widgets/UserAddresses/view/UserAddressesView.ts @@ -1,10 +1,10 @@ import ButtonModel from '@/shared/Button/model/ButtonModel.ts'; -import getStore from '@/shared/Store/Store.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import styles from './userAddressesView.module.scss'; @@ -28,61 +28,48 @@ class UserAddressView { this.createShippingAddressButton = this.createCreateShippingAddressButton(); this.addressesListWrapper = this.createAddressesListWrapper(); this.addressesWrapper = this.createHTML(); + + this.observeStoreChanges(); } private createAddressesListWrapper(): HTMLUListElement { - this.addressesListWrapper = createBaseElement({ + return 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)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.BILL)); this.billingLogo.append(svg); return this.billingLogo; } private createCreateBillingAddressButton(): ButtonModel { - this.createBillingAddressButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.createAddressButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_BILLING_ADDRESS, - }); - - this.createBillingAddressButton.getHTML().append(this.billingLogo); - - observeStore(selectCurrentLanguage, () => { - this.createBillingAddressButton.getHTML().title = - TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_BILLING_ADDRESS; + title: TOOLTIP_TEXT[getCurrentLanguage()].ADD_BILLING_ADDRESS, }); - - return this.createBillingAddressButton; } private createCreateShippingAddressButton(): ButtonModel { - this.createShippingAddressButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.createAddressButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_SHIPPING_ADDRESS, + title: TOOLTIP_TEXT[getCurrentLanguage()].ADD_SHIPPING_ADDRESS, }); - - this.createShippingAddressButton.getHTML().append(this.shippingLogo); - - observeStore(selectCurrentLanguage, () => { - this.createShippingAddressButton.getHTML().title = - TOOLTIP_TEXT[getStore().getState().currentLanguage].ADD_SHIPPING_ADDRESS; - }); - - return this.createShippingAddressButton; } private createHTML(): HTMLDivElement { this.addressesWrapper = createBaseElement({ - cssClasses: [styles.addressesWrapper, styles.hidden], + cssClasses: [styles.addressesWrapper], tag: 'div', }); + + this.createBillingAddressButton.getHTML().append(this.billingLogo); + this.createShippingAddressButton.getHTML().append(this.shippingLogo); + this.addressesWrapper.append( this.createBillingAddressButton.getHTML(), this.createShippingAddressButton.getHTML(), @@ -93,12 +80,19 @@ class UserAddressView { 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)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.DELIVERY)); this.shippingLogo.append(svg); return this.shippingLogo; } + private observeStoreChanges(): void { + observeStore(selectCurrentLanguage, () => { + this.createBillingAddressButton.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].ADD_BILLING_ADDRESS; + this.createShippingAddressButton.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].ADD_SHIPPING_ADDRESS; + }); + } + public getAddressesListWrapper(): HTMLUListElement { return this.addressesListWrapper; } @@ -114,18 +108,6 @@ class UserAddressView { public getHTML(): HTMLDivElement { return this.addressesWrapper; } - - public hide(): void { - this.addressesWrapper.classList.add(styles.hidden); - } - - 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 94cf7114..4aed2ed5 100644 --- a/src/widgets/UserAddresses/view/userAddressesView.module.scss +++ b/src/widgets/UserAddresses/view/userAddressesView.module.scss @@ -8,6 +8,7 @@ padding: var(--small-offset); width: 100%; height: max-content; + max-width: 100%; font: var(--extra-regular-font); letter-spacing: 1px; color: var(--noble-gray-900); diff --git a/src/widgets/UserInfo/model/UserInfoModel.ts b/src/widgets/UserInfo/model/UserInfoModel.ts index 4e5d53a2..a335d3b5 100644 --- a/src/widgets/UserInfo/model/UserInfoModel.ts +++ b/src/widgets/UserInfo/model/UserInfoModel.ts @@ -8,7 +8,7 @@ import modal from '@/shared/Modal/model/ModalModel.ts'; import observeStore, { selectCurrentLanguage } from '@/shared/Store/observer.ts'; import MEDIATOR_EVENT from '@/shared/constants/events.ts'; import { userInfoDateOfBirth, userInfoEmail, userInfoLastName, userInfoName } from '@/shared/utils/messageTemplates.ts'; -import showErrorMessage from '@/shared/utils/userMessage.ts'; +import { showErrorMessage } from '@/shared/utils/userMessage.ts'; import UserInfoView from '../view/UserInfoView.ts'; diff --git a/src/widgets/UserInfo/view/UserInfoView.ts b/src/widgets/UserInfo/view/UserInfoView.ts index 8eb20330..ff641999 100644 --- a/src/widgets/UserInfo/view/UserInfoView.ts +++ b/src/widgets/UserInfo/view/UserInfoView.ts @@ -1,13 +1,13 @@ import type { User } 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 { BUTTON_TEXT, BUTTON_TEXT_KEYS } from '@/shared/constants/buttons.ts'; -import SVG_DETAILS from '@/shared/constants/svg.ts'; +import { BUTTON_TEXT, BUTTON_TEXT_KEY } from '@/shared/constants/buttons.ts'; +import SVG_DETAIL from '@/shared/constants/svg.ts'; import TOOLTIP_TEXT from '@/shared/constants/tooltip.ts'; import createBaseElement from '@/shared/utils/createBaseElement.ts'; import createSVGUse from '@/shared/utils/createSVGUse.ts'; +import getCurrentLanguage from '@/shared/utils/getCurrentLanguage.ts'; import { userInfoDateOfBirth, userInfoEmail, userInfoLastName, userInfoName } from '@/shared/utils/messageTemplates.ts'; import observeCurrentLanguage from '@/shared/utils/observeCurrentLanguage.ts'; @@ -42,90 +42,60 @@ class UserInfoView { this.editInfoButton = this.createEditInfoButton(); this.editPasswordButton = this.createEditPasswordButton(); this.userInfoWrapper = this.createUserInfoWrapper(); + + this.observeStoreChanges(); } private createBirthDate(): HTMLSpanElement { - this.birthDate = createBaseElement({ + return createBaseElement({ cssClasses: [styles.info], innerContent: userInfoDateOfBirth(this.currentUser.birthDate), tag: 'span', }); - - observeStore(selectCurrentLanguage, () => { - this.birthDate.textContent = userInfoDateOfBirth(this.currentUser.birthDate); - }); - return this.birthDate; } private createEditInfoButton(): ButtonModel { - this.editInfoButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.editInfoButton], - text: BUTTON_TEXT[getStore().getState().currentLanguage].EDIT_INFO, + text: BUTTON_TEXT[getCurrentLanguage()].EDIT_INFO, }); - - observeCurrentLanguage(this.editInfoButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEYS.EDIT_INFO); - - return this.editInfoButton; } private createEditPasswordButton(): ButtonModel { - this.editPasswordButton = new ButtonModel({ + return new ButtonModel({ classes: [styles.editPasswordButton], - title: TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_PASSWORD, + title: TOOLTIP_TEXT[getCurrentLanguage()].EDIT_PASSWORD, }); - - this.editPasswordButton.getHTML().append(this.logo); - - observeStore(selectCurrentLanguage, () => { - this.editPasswordButton.getHTML().title = TOOLTIP_TEXT[getStore().getState().currentLanguage].EDIT_PASSWORD; - }); - - return this.editPasswordButton; } private createEmail(): HTMLSpanElement { - this.email = createBaseElement({ + return createBaseElement({ cssClasses: [styles.info], innerContent: userInfoEmail(this.currentUser.email), tag: 'span', }); - - observeStore(selectCurrentLanguage, () => { - this.email.textContent = userInfoEmail(this.currentUser.email); - }); - return this.email; } private createFirstName(): HTMLSpanElement { - this.firstName = createBaseElement({ + return createBaseElement({ cssClasses: [styles.info], innerContent: userInfoName(this.currentUser.firstName), tag: 'span', }); - - observeStore(selectCurrentLanguage, () => { - this.firstName.textContent = userInfoName(this.currentUser.firstName); - }); - return this.firstName; } private createLastName(): HTMLSpanElement { - this.lastName = createBaseElement({ + return createBaseElement({ cssClasses: [styles.info], innerContent: userInfoLastName(this.currentUser.lastName), tag: 'span', }); - - observeStore(selectCurrentLanguage, () => { - this.lastName.textContent = userInfoLastName(this.currentUser.lastName); - }); - return this.lastName; } private createLogo(): HTMLDivElement { this.logo = createBaseElement({ cssClasses: [styles.keyLogo], tag: 'div' }); - const svg = document.createElementNS(SVG_DETAILS.SVG_URL, 'svg'); - svg.append(createSVGUse(SVG_DETAILS.KEY)); + const svg = document.createElementNS(SVG_DETAIL.SVG_URL, 'svg'); + svg.append(createSVGUse(SVG_DETAIL.KEY)); this.logo.append(svg); return this.logo; } @@ -135,6 +105,9 @@ class UserInfoView { cssClasses: [styles.userInfoWrapper, styles.hidden], tag: 'div', }); + + this.editPasswordButton.getHTML().append(this.logo); + this.userInfoWrapper.append( this.firstName, this.lastName, @@ -146,6 +119,17 @@ class UserInfoView { return this.userInfoWrapper; } + private observeStoreChanges(): void { + observeStore(selectCurrentLanguage, () => { + this.birthDate.textContent = userInfoDateOfBirth(this.currentUser.birthDate); + this.email.textContent = userInfoEmail(this.currentUser.email); + this.firstName.textContent = userInfoName(this.currentUser.firstName); + this.lastName.textContent = userInfoLastName(this.currentUser.lastName); + this.editPasswordButton.getHTML().title = TOOLTIP_TEXT[getCurrentLanguage()].EDIT_PASSWORD; + }); + observeCurrentLanguage(this.editInfoButton.getHTML(), BUTTON_TEXT, BUTTON_TEXT_KEY.EDIT_INFO); + } + public getBirthDate(): HTMLSpanElement { return this.birthDate; } diff --git a/src/widgets/UserInfo/view/userInfoView.module.scss b/src/widgets/UserInfo/view/userInfoView.module.scss index f9567b4f..50fcc747 100644 --- a/src/widgets/UserInfo/view/userInfoView.module.scss +++ b/src/widgets/UserInfo/view/userInfoView.module.scss @@ -4,20 +4,20 @@ position: relative; display: grid; align-items: center; - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(6, auto); - margin: 0 var(--small-offset); + grid-template-rows: 5.5rem auto auto auto auto 5.5rem; + margin: 0 auto; padding: var(--small-offset); - width: 60%; - font: var(--extra-regular-font); + width: 65%; + font: var(--basic-regular-font); line-break: break-all; letter-spacing: var(--one); color: var(--noble-gray-900); background-color: var(--steam-green-900); - gap: var(--small-offset); + gap: var(--extra-small-offset); @media (max-width: 768px) { - margin-left: 0; + margin: 0; + padding: var(--extra-small-offset); width: 100%; } } @@ -29,27 +29,22 @@ .editPasswordButton { @include round-btn; - grid-column: 2; grid-row: 1; margin-right: 0; margin-left: auto; } .info { - grid-column: 2 span; + border-radius: var(--medium-br); + padding: var(--extra-small-offset); max-width: 100%; word-break: break-all; + color: var(--steam-green-400); + background: var(--white-tr); } .editInfoButton { @include green-btn; - grid-column: 2 span; grid-row: 6; } - -.hidden { - display: none; - opacity: 0; - visibility: hidden; -} diff --git a/tsconfig.json b/tsconfig.json index e6d0350c..7e618340 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "ESNext", "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, + "esModuleInterop": true, /* Bundler mode */ "moduleResolution": "bundler", @@ -30,5 +31,5 @@ /* Testing */ "types": ["vitest/globals"] }, - "include": ["src"] + "include": ["src", "vitest.setup.ts", "vitest.config.ts"] } diff --git a/vite.config.js b/vite.config.js index 3b3ab0c2..9afc5dde 100644 --- a/vite.config.js +++ b/vite.config.js @@ -47,6 +47,7 @@ export default { ], resolve: { alias: { + '@': path.resolve(__dirname, './src'), 'node-fetch': 'isomorphic-fetch', }, }, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..b1a80e30 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + globals: true, + include: ['src/**/*.spec.ts'], + root: __dirname, + setupFiles: ['vitest.setup.ts'], + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 00000000..6318a8b8 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,20 @@ +import server from '@/test/mocks/node.ts'; + +beforeAll(() => { + server.listen({ + onUnhandledRequest: 'warn', + }); + + // Need it for debugging api requests + // server.events.on('request:start', ({ request }) => { + // console.log('Outgoing:', request.method, request.url); + // }); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +});