diff --git a/src/Components/ApplicantProfileForm/ApplicantProfileFormController.js b/src/Components/ApplicantProfileForm/ApplicantProfileFormController.js index 7fb5598..967219a 100644 --- a/src/Components/ApplicantProfileForm/ApplicantProfileFormController.js +++ b/src/Components/ApplicantProfileForm/ApplicantProfileFormController.js @@ -1,6 +1,7 @@ import { ComponentController } from '../../modules/Components/Component.js'; import eventBus from '../../modules/Events/EventBus.js'; -import { USER_UPDATED } from '../../modules/Events/Events.js'; +import { NOTIFICATION_OK, USER_UPDATED } from '../../modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; export class ApplicantProfileFormController extends ComponentController { constructor(model, view, controller) { @@ -40,6 +41,10 @@ export class ApplicantProfileFormController extends ComponentController { return false; } eventBus.emit(USER_UPDATED); + eventBus.emit(NOTIFICATION_OK, { + message: 'Успешно сохранено', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); return true; } diff --git a/src/Components/ApplicantProfileForm/ApplicantProfileFormModel.js b/src/Components/ApplicantProfileForm/ApplicantProfileFormModel.js index e787aeb..e00096b 100644 --- a/src/Components/ApplicantProfileForm/ApplicantProfileFormModel.js +++ b/src/Components/ApplicantProfileForm/ApplicantProfileFormModel.js @@ -1,6 +1,7 @@ import { ComponentModel } from '../../modules/Components/Component.js'; import { Api } from '../../modules/Api/Api.js'; import { Applicant } from '../../modules/models/Applicant.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; export class ApplicantProfileFormModel extends ComponentModel { #lastValidData; @@ -23,9 +24,14 @@ export class ApplicantProfileFormModel extends ComponentModel { async submit(formData) { formData.birthDate = new Date(formData.birthDate); formData.id = this.#userId; - if (await Api.updateApplicantProfile(formData)) { - this.#lastValidData = new Applicant(formData); + try { + await Api.updateApplicantProfile(formData); + const app = new Applicant(formData); + app.birthDate = app.birthDate.toISOString().split('T')[0]; + this.#lastValidData = app; return true; + } catch (err) { + catchStandardResponseError(err); } return false; } diff --git a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormController.js b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormController.js index 6f31206..fdc73d8 100644 --- a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormController.js +++ b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormController.js @@ -2,6 +2,9 @@ import { ComponentController } from '../../modules/Components/Component.js'; import { REGISTER_APPLICANT } from '../../modules/Events/Events.js'; import router from '/src/modules/Router/Router.js'; import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { NOTIFICATION_OK } from '../../modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; export class ApplicantRegistrationFormController extends ComponentController { constructor(model, view, controller) { @@ -20,14 +23,19 @@ export class ApplicantRegistrationFormController extends ComponentController { } this._model .register(formData) - .then(() => router.navigate(new URL(resolveUrl('vacancies')), true, true)) + .then(() => { + eventBus.emit(NOTIFICATION_OK, { + message: 'Успешно сохранено', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + router.navigate(new URL(resolveUrl('vacancies')), true, true); + }) .catch((errorMsg) => { this._view.declineValidation(errorMsg); }); } _validate(formData) { - this._view.hideError(); const formValidationError = this._model.validate(formData); if (formValidationError) { this._view.declineValidation(formValidationError); diff --git a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormModel.js b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormModel.js index b26ad0d..08c262c 100644 --- a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormModel.js +++ b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormModel.js @@ -1,6 +1,8 @@ import state from '../../modules/AppState/AppState.js'; import { ComponentModel } from '../../modules/Components/Component.js'; import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { USER_ALREADY_EXISTS_ERROR } from '../../modules/Api/Errors.js'; +import { TransportError, ResponseError } from '../../modules/Api/Api.js'; export class ApplicantRegistrationFormModel extends ComponentModel { validate(formData) { @@ -18,6 +20,15 @@ export class ApplicantRegistrationFormModel extends ComponentModel { } async register(formData) { - return state.userSession.register(USER_TYPE.APPLICANT, formData); + return state.userSession.register(USER_TYPE.APPLICANT, formData).catch((err) => { + if (err.toString() === USER_ALREADY_EXISTS_ERROR) { + return Promise.reject('Соискатель с таким адресом электронной почты уже зарегистрирован'); + } + if (err instanceof TransportError) { + return Promise.reject('Произошла сетевая ошибка, повторите позднее'); + } + if (err instanceof ResponseError) + return Promise.reject('Произошла непредвиденная ошибка, повторите позднее'); + }); } } diff --git a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormView.js b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormView.js index a09928d..83f7e9b 100644 --- a/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormView.js +++ b/src/Components/ApplicantRegistrationForm/ApplicantRegistrationFormView.js @@ -1,8 +1,10 @@ import { ComponentView } from '../../modules/Components/Component.js'; +import { NOTIFICATION_ERROR } from '../../modules/Events/Events.js'; import { addEventListeners } from '../../modules/Events/EventUtils.js'; import { getFormData } from '../../modules/FormUtils/FormUtils.js'; import eventBus from '/src/modules/Events/EventBus.js'; import { REGISTER_APPLICANT } from '/src/modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; export class ApplicantRegistrationFormView extends ComponentView { constructor({ elementClass }, existingElement) { @@ -28,19 +30,16 @@ export class ApplicantRegistrationFormView extends ComponentView { this.repeatPasswordField = this._html.querySelector( '.applicant-registration-form__repeat-password', ); - this.error = this._html.querySelector('.applicant-registration-form__error'); } getData() { return getFormData(this._html); } - hideError() { - this.error.hidden = true; - } - declineValidation(errorMessage) { - this.error.innerText = errorMessage; - this.error.hidden = false; + eventBus.emit(NOTIFICATION_ERROR, { + message: errorMessage, + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); } } diff --git a/src/Components/ApplicantRegistrationForm/applicant-registration-form.hbs b/src/Components/ApplicantRegistrationForm/applicant-registration-form.hbs index 34ea4b1..fcb996b 100644 --- a/src/Components/ApplicantRegistrationForm/applicant-registration-form.hbs +++ b/src/Components/ApplicantRegistrationForm/applicant-registration-form.hbs @@ -1,5 +1,4 @@
- {{> validated-input formName="applicant-registration-form" elementName="first-name" inputName="firstName" inputCaption="Имя" inputType="text"}} {{> validated-input formName="applicant-registration-form" elementName="second-name" inputName="secondName" inputCaption="Фамилия" inputType="text"}} {{> validated-input formName="applicant-registration-form" elementName="birthdate" inputName="birthDate" inputCaption="Дата Рождения" inputType="date"}} diff --git a/src/Components/AppliersList/AppliersList.js b/src/Components/AppliersList/AppliersList.js new file mode 100644 index 0000000..d8d24a1 --- /dev/null +++ b/src/Components/AppliersList/AppliersList.js @@ -0,0 +1,21 @@ +import { Component } from '../../modules/Components/Component.js'; +import { AppliersListModel } from './AppliersListModel.js'; +import { AppliersListController } from './AppliersListController.js'; +import { AppliersListView } from './AppliersListView.js'; +import { ListMixin } from '../Lists/List/ListMixin.js'; + +export class AppliersList extends Component { + constructor({ vacancyId, elementClass, existingElement }) { + super({ + modelClass: AppliersListModel, + controllerClass: AppliersListController, + viewClass: AppliersListView, + viewParams: { elementClass }, + modelParams: { vacancyId }, + existingElement, + }); + this._controller.fillList(); + } +} + +Object.assign(AppliersList.prototype, ListMixin); diff --git a/src/Components/AppliersList/AppliersListController.js b/src/Components/AppliersList/AppliersListController.js new file mode 100644 index 0000000..54f970a --- /dev/null +++ b/src/Components/AppliersList/AppliersListController.js @@ -0,0 +1,12 @@ +import { ComponentController } from '../../modules/Components/Component.js'; + +export class AppliersListController extends ComponentController { + constructor(model, view, component) { + super(model, view, component); + } + + async fillList() { + const appliers = await this._model.getItems(); + appliers.forEach((applier) => this._view.addListItem(applier)); + } +} diff --git a/src/Components/AppliersList/AppliersListModel.js b/src/Components/AppliersList/AppliersListModel.js new file mode 100644 index 0000000..f6c5bb9 --- /dev/null +++ b/src/Components/AppliersList/AppliersListModel.js @@ -0,0 +1,31 @@ +import { ComponentModel } from '../../modules/Components/Component.js'; +import { Api } from '../../modules/Api/Api.js'; +import { Applicant } from '../../modules/models/Applicant.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class AppliersListModel extends ComponentModel { + #vacancyId; + constructor({ vacancyId }) { + super(); + this.#vacancyId = vacancyId; + } + + async getItems() { + try { + const peopleJson = (await Api.getAppliersByVacancyId({ id: this.#vacancyId })).subscribers; + const applicantObjects = peopleJson.reduce((applicantObjects, applicantJsonItem) => { + try { + const applicant = new Applicant(applicantJsonItem); + applicant.name = `${applicant.firstName} ${applicant.secondName}`; + applicantObjects.push(applicant); + return applicantObjects; + } catch { + return applicantObjects; + } + }, []); + return applicantObjects; + } catch (err) { + catchStandardResponseError(err); + } + } +} diff --git a/src/Components/AppliersList/AppliersListView.js b/src/Components/AppliersList/AppliersListView.js new file mode 100644 index 0000000..e176f53 --- /dev/null +++ b/src/Components/AppliersList/AppliersListView.js @@ -0,0 +1,34 @@ +import { ComponentView } from '../../modules/Components/Component.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; + +export class AppliersListView extends ComponentView { + #listItems; + #isEmpty; + constructor(renderParams, existingElement) { + super({ + renderParams, + existingElement, + templateName: 'appliers-list.hbs', + }); + this.list = this._html.querySelector('.appliers-list__list'); + this.#listItems = []; + this.#isEmpty = true; + this.list.innerText = 'Пока никто не откликнулся'; + } + + addListItem({ id, name }) { + if (this.#isEmpty) { + this.#isEmpty = false; + this.list.innerHTML = ''; + } + const listItem = document.createElement('li'); + const anchorElement = document.createElement('a'); + anchorElement.href = `${resolveUrl('profile')}?id=${id}&userType=${USER_TYPE.APPLICANT}`; + anchorElement.innerText = name; + anchorElement.classList.add('appliers-list__list-item'); + listItem.appendChild(anchorElement); + this.list.appendChild(listItem); + this.#listItems.push(listItem); + } +} diff --git a/src/Components/AppliersList/appliers-list.hbs b/src/Components/AppliersList/appliers-list.hbs new file mode 100644 index 0000000..55911bb --- /dev/null +++ b/src/Components/AppliersList/appliers-list.hbs @@ -0,0 +1,6 @@ +
+

Откликнулись

+
+
    +
+
\ No newline at end of file diff --git a/src/Components/CrudFormBox/CrudFormBoxController.js b/src/Components/CrudFormBox/CrudFormBoxController.js index 275e4e7..a4584d5 100644 --- a/src/Components/CrudFormBox/CrudFormBoxController.js +++ b/src/Components/CrudFormBox/CrudFormBoxController.js @@ -39,11 +39,12 @@ export class CrudFormBoxController extends ComponentController { this._component.disableForm(); } - submitForm({ caller }) { + async submitForm({ caller }) { if (!Object.is(this._view.formView, caller)) { return; } - if (!this._component.form.submit()) { + const submitResult = await this._component.form.submit(); + if (!submitResult) { return; } this._view.readState(); diff --git a/src/Components/CvArticle/ButtonContainer/ButtonContainer.js b/src/Components/CvArticle/ButtonContainer/ButtonContainer.js new file mode 100644 index 0000000..4239d80 --- /dev/null +++ b/src/Components/CvArticle/ButtonContainer/ButtonContainer.js @@ -0,0 +1,18 @@ +import { + Component, + ComponentController, + ComponentModel, +} from '../../../modules/Components/Component.js'; +import { ButtonContainerView } from './ButtonContainerView.js'; + +export class ButtonContainer extends Component { + constructor({ isOwner, isEmployer, ownerId, cvId, existingElement }) { + super({ + modelClass: ComponentModel, + viewClass: ButtonContainerView, + controllerClass: ComponentController, + viewParams: { isOwner, isEmployer, ownerId, cvId }, + existingElement, + }); + } +} diff --git a/src/Components/CvArticle/ButtonContainer/ButtonContainerView.js b/src/Components/CvArticle/ButtonContainer/ButtonContainerView.js new file mode 100644 index 0000000..9e9263c --- /dev/null +++ b/src/Components/CvArticle/ButtonContainer/ButtonContainerView.js @@ -0,0 +1,41 @@ +import { CV_DELETE, CV_EDIT } from '../../../modules/Events/Events.js'; +import { addEventListeners } from '../../../modules/Events/EventUtils.js'; +import { ComponentView } from '/src/modules/Components/Component.js'; +import eventBus from '/src/modules/Events/EventBus.js'; + +export class ButtonContainerView extends ComponentView { + #editButton; + #deleteButton; + #cvId; + constructor({ isOwner, isEmployer, ownerId, cvId }, existingElement) { + super({ + renderParams: { isOwner, isEmployer, ownerId }, + existingElement, + templateName: 'cv-article__button-container.hbs', + }); + this.#cvId = cvId; + if (isOwner) { + this.#editButton = this._html.querySelector('.cv-article__edit-button'); + this.#deleteButton = this._html.querySelector('.cv-article__delete-button'); + this._eventListeners.push( + { + event: 'click', + object: this.#editButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(CV_EDIT, { vacancyId: this.#cvId }); + }.bind(this), + }, + { + event: 'click', + object: this.#deleteButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(CV_DELETE, { vacancyId: this.#cvId }); + }.bind(this), + }, + ); + } + addEventListeners(this._eventListeners); + } +} diff --git a/src/Components/CvArticle/ButtonContainer/cv-article__button-container.hbs b/src/Components/CvArticle/ButtonContainer/cv-article__button-container.hbs new file mode 100644 index 0000000..5d675a1 --- /dev/null +++ b/src/Components/CvArticle/ButtonContainer/cv-article__button-container.hbs @@ -0,0 +1,12 @@ +
+{{#if isEmployer}} + Все резюме +{{else}} + {{#if isOwner}} + + + {{/if}} +{{/if}} +
+
\ No newline at end of file diff --git a/src/Components/CvArticle/CvArticle.js b/src/Components/CvArticle/CvArticle.js new file mode 100644 index 0000000..a8d6b18 --- /dev/null +++ b/src/Components/CvArticle/CvArticle.js @@ -0,0 +1,38 @@ +import { Component } from '../../modules/Components/Component.js'; +import { CvArticleController } from './CvArticleController.js'; +import { CvArticleModel } from './CvArticleModel.js'; +import { CvArticleView } from './CvArticleView.js'; +import { ButtonContainer } from './ButtonContainer/ButtonContainer.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; + +export class CvArticle extends Component { + constructor({ elementClass, cvId, userId, userType }) { + super({ + modelClass: CvArticleModel, + modelParams: { cvId }, + viewClass: CvArticleView, + controllerClass: CvArticleController, + viewParams: { elementClass }, + }); + this._userId = userId; + this._userType = userType; + this._cvId = cvId; + } + + async makeButtons() { + const modelData = await this._controller.fetchData(); + this._buttonContainer = new ButtonContainer({ + isOwner: modelData.applicantId === this._userId && this._userType === USER_TYPE.APPLICANT, + isEmployer: this._userType === USER_TYPE.EMPLOYER, + ownerId: modelData.applicantId, + cvId: this._cvId, + }); + this._children.push(this._buttonContainer); + this._controller.addButtonContainer(this._buttonContainer); + this._controller.renderData(); + } + + async getApplicantId() { + return this._model.getApplicantId(); + } +} diff --git a/src/Components/CvArticle/CvArticleController.js b/src/Components/CvArticle/CvArticleController.js new file mode 100644 index 0000000..3a92979 --- /dev/null +++ b/src/Components/CvArticle/CvArticleController.js @@ -0,0 +1,48 @@ +import { ComponentController } from '../../modules/Components/Component.js'; +import { GO_TO, REDIRECT_TO, CV_DELETE, CV_EDIT } from '../../modules/Events/Events.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import { CvPage } from '../../Pages/CvPage/CvPage.js'; +import eventBus from '../../modules/Events/EventBus.js'; + +export class CvArticleController extends ComponentController { + constructor(model, view, component) { + super(model, view, component); + this.setHandlers([ + { + event: CV_DELETE, + handler: this.cvDelete.bind(this), + }, + { + event: CV_EDIT, + handler: this.cvEdit.bind(this), + }, + ]); + } + + async fetchData() { + return this._model.getCvData(); + } + + async renderData() { + return this._view.renderData(await this._model.getCvData()); + } + + addButtonContainer(container) { + this._view.addButtonContainer(container.render()); + } + + async cvDelete() { + const deleted = await this._model.cvDelete(); + if (deleted) { + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('myProfile') }); + } + } + + async cvEdit() { + const query = {}; + const cv = await this._model.getCvData(); + query[CvPage.CV_ID_PARAM] = cv.id; + eventBus.emit(GO_TO, { redirectUrl: resolveUrl('editCv', query) }); + throw Error('Not implemented'); + } +} diff --git a/src/Components/CvArticle/CvArticleModel.js b/src/Components/CvArticle/CvArticleModel.js new file mode 100644 index 0000000..63863a9 --- /dev/null +++ b/src/Components/CvArticle/CvArticleModel.js @@ -0,0 +1,43 @@ +import { ComponentModel } from '../../modules/Components/Component.js'; +import { Api } from '../../modules/Api/Api.js'; +import { NotFoundError } from '../../modules/Router/Router.js'; +import { Cv } from '../../modules/models/Cv.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class CvArticleModel extends ComponentModel { + #cvData; + #cvId; + + constructor({ cvId }) { + super(); + this.#cvId = cvId; + this.#cvData = Api.getCvById({ id: this.#cvId }).then( + (data) => new Cv(data), + () => { + throw new NotFoundError('cv not found'); + }, + ); + } + + async getCvData() { + return this.#cvData; + } + + async getApplicantId() { + const vacancyData = await this.#cvData; + return vacancyData.applicantId; + } + + async cvDelete() { + if (!this.#cvId) { + return false; + } + try { + await Api.deleteCvById({ id: this.#cvId }); + return true; + } catch (err) { + catchStandardResponseError(err); + return false; + } + } +} diff --git a/src/Components/CvArticle/CvArticleView.js b/src/Components/CvArticle/CvArticleView.js new file mode 100644 index 0000000..2d0cf4e --- /dev/null +++ b/src/Components/CvArticle/CvArticleView.js @@ -0,0 +1,45 @@ +import { ComponentView } from '../../modules/Components/Component.js'; + +export class CvArticleView extends ComponentView { + constructor({ elementClass, isOwner, isEmployer }, existingElement) { + super({ + renderParams: { + elementClass, + isOwner, + isEmployer, + }, + templateName: 'cv-article.hbs', + existingElement, + }); + this._avatar = this._html.querySelector('.cv-article__cv-picture'); + this._position = this._html.querySelector('.cv-article__header-text'); + this._jobSearchStatus = this._html.querySelector('.cv-article__job-search-status'); + this._workingExperience = this._html.querySelector('.cv-article__working-experience'); + this._description = this._html.querySelector('.cv-article__description'); + } + + renderData({ + positionRu, + positionEn, + description, + jobSearchStatus, + workingExperience, + updatedAt, + }) { + this._position.innerText = positionEn ? `${positionRu} / ${positionEn}` : positionRu; + this._jobSearchStatus.innerText = jobSearchStatus; + this._description.innerText = description || 'Не указано'; + this._workingExperience.innerText = workingExperience || 'Не указан'; + this._updatedAt.innerText = `последнее обновление: ${updatedAt.toLocaleDateString('ru-RU', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + year: 'numeric', + })}`; + } + + addButtonContainer(containerRender) { + this._html.appendChild(containerRender); + this._updatedAt = this._html.querySelector('.cv-article__created-at'); + } +} diff --git a/src/Components/CvArticle/cv-article.hbs b/src/Components/CvArticle/cv-article.hbs new file mode 100644 index 0000000..ae78c15 --- /dev/null +++ b/src/Components/CvArticle/cv-article.hbs @@ -0,0 +1,14 @@ +
+
+
+

+
+
+
+
+

Описание и достижения

+
+
+

Опыт работы

+
+
\ No newline at end of file diff --git a/src/Components/CvForm/CvForm.js b/src/Components/CvForm/CvForm.js new file mode 100644 index 0000000..ae7053c --- /dev/null +++ b/src/Components/CvForm/CvForm.js @@ -0,0 +1,59 @@ +import { Component } from '../../modules/Components/Component.js'; +import { LiteralInput } from '/src/Components/FormInputs/LiteralInput/LiteralInput.js'; +import { ValidatedTextArea } from '../FormInputs/ValidatedTextArea/ValidatedTextArea.js'; +import { CvFormModel } from './CvFormModel.js'; +import { CvFormView } from './CvFormView.js'; +import { CvFormController } from './CvFormController.js'; + +export class CvForm extends Component { + #isNew; + constructor({ cvId = null, elementClass, existingElement }) { + super({ + modelClass: CvFormModel, + viewClass: CvFormView, + controllerClass: CvFormController, + modelParams: { cvId }, + existingElement, + viewParams: { elementClass, isNew: !cvId }, + }); + this.#isNew = !cvId; + this._positionRuField = new LiteralInput({ + existingElement: this._view.positionRuField, + selfValidate: true, + }); + this._positionEnField = new LiteralInput({ + existingElement: this._view.positionEnField, + selfValidate: true, + }); + this._jobSearchStatusField = new LiteralInput({ + existingElement: this._view.jobSearchStatusField, + selfValidate: true, + }); + this._descriptionField = new ValidatedTextArea({ + existingElement: this._view.descriptionField, + selfValidate: true, + }); + this._workingExperienceField = new ValidatedTextArea({ + existingElement: this._view.workingExperienceField, + selfValidate: true, + }); + this._children.push( + this._positionEnField, + this._positionRuField, + this._jobSearchStatusField, + this._descriptionField, + this._workingExperienceField, + ); + if (!this.#isNew) { + this.reset(); + } + } + + get view() { + return this._view; + } + + reset() { + return this._controller.reset(); + } +} diff --git a/src/Components/CvForm/CvFormController.js b/src/Components/CvForm/CvFormController.js new file mode 100644 index 0000000..36ab204 --- /dev/null +++ b/src/Components/CvForm/CvFormController.js @@ -0,0 +1,84 @@ +import { ComponentController } from '../../modules/Components/Component.js'; +import { + NOTIFICATION_ERROR, + NOTIFICATION_OK, + REDIRECT_TO, + SUBMIT_FORM, +} from '../../modules/Events/Events.js'; +import { CvPage } from '../../Pages/CvPage/CvPage.js'; +import { Cv } from '../../modules/models/Cv.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class CvFormController extends ComponentController { + constructor(model, view, controller) { + super(model, view, controller); + this.setHandlers([ + { + event: SUBMIT_FORM, + handler: this.submit.bind(this), + }, + ]); + } + + _validate() { + const errorMessage = this._model.validate(this._view.getData()); + if (errorMessage) { + eventBus.emit(NOTIFICATION_ERROR, { + message: errorMessage, + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + return false; + } + return [ + this._component._positionRuField.controller.validateInput({ + callerView: this._component._positionRuField._view, + }), + this._component._positionEnField.controller.validateInput({ + callerView: this._component._positionEnField._view, + }), + this._component._jobSearchStatusField.controller.validateInput({ + callerView: this._component._jobSearchStatusField._view, + }), + this._component._workingExperienceField.controller.validateInput({ + callerView: this._component._workingExperienceField._view, + }), + this._component._descriptionField.controller.validateInput({ + callerView: this._component._descriptionField._view, + }), + ].every((val) => val); + } + + async submit({ caller }) { + if (!Object.is(caller, this._view)) { + return; + } + if (!this._validate()) { + return; + } + try { + const cv = await this._model.submit(new Cv(this._view.getData())); + if (!cv) { + return; + } + const query = {}; + query[CvPage.CV_ID_PARAM] = cv.id; + eventBus.emit(NOTIFICATION_OK, { + message: 'Операция проведена успешно', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('cv', query) }); + } catch (err) { + catchStandardResponseError(err); + return; + } + } + + async reset() { + const oldData = await this._model.getLastValidData(); + this._view.renderData(oldData); + return true; + } +} diff --git a/src/Components/CvForm/CvFormModel.js b/src/Components/CvForm/CvFormModel.js new file mode 100644 index 0000000..4e0e6b4 --- /dev/null +++ b/src/Components/CvForm/CvFormModel.js @@ -0,0 +1,58 @@ +import { Api } from '../../modules/Api/Api.js'; +import { ComponentModel } from '../../modules/Components/Component.js'; +import { Cv } from '../../modules/models/Cv.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { REDIRECT_TO } from '../../modules/Events/Events.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class CvFormModel extends ComponentModel { + #lastValidData; + #cvId; + #isNew; + + constructor({ cvId = null }) { + super(); + this.#cvId = cvId; + this.#isNew = !this.#cvId; + this.#lastValidData = this.#cvId + ? Api.getCvById({ id: this.#cvId }).then( + (cv) => new Cv(cv), + () => { + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('createCv') }); + }, + ) + : null; + } + + async getLastValidData() { + return this.#lastValidData; + } + + async submit(formData) { + try { + const cv = this.#isNew + ? await Api.createCv(formData) + : await Api.updateCvById(zip({ id: this.#cvId }, formData)); + if (cv) { + this.#lastValidData = formData; + return cv; + } + } catch (err) { + catchStandardResponseError(err); + } + return null; + } + + validate(formData) { + const hasEmptyRequiredFields = [formData.positionRu, formData.jobSearchStatus].some( + (fieldValue) => { + return !fieldValue.trim(); + }, + ); + if (hasEmptyRequiredFields) { + return 'Заполните обязательные поля'; + } + } +} diff --git a/src/Components/CvForm/CvFormView.js b/src/Components/CvForm/CvFormView.js new file mode 100644 index 0000000..1802fe6 --- /dev/null +++ b/src/Components/CvForm/CvFormView.js @@ -0,0 +1,47 @@ +import { ComponentView } from '../../modules/Components/Component.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { SUBMIT_FORM } from '../../modules/Events/Events.js'; +import { addEventListeners } from '../../modules/Events/EventUtils.js'; +import { getFormData } from '../../modules/FormUtils/FormUtils.js'; + +export class CvFormView extends ComponentView { + constructor({ elementClass, isNew }, existingElement) { + super({ + renderParams: { elementClass, isNew }, + existingElement, + templateName: 'cv-form.hbs', + }); + this.positionRuField = this._html.querySelector('.cv-form__position-ru'); + this.positionEnField = this._html.querySelector('.cv-form__position-en'); + this.jobSearchStatusField = this._html.querySelector('.cv-form__job-search-status'); + this.descriptionField = this._html.querySelector('.cv-form__description'); + this.workingExperienceField = this._html.querySelector('.cv-form__working-experience'); + + this._eventListeners.push({ + event: 'submit', + object: this._html, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(SUBMIT_FORM, { caller: this }); + }.bind(this), + }); + addEventListeners(this._eventListeners); + } + + getData() { + return getFormData(this._html); + } + + getId() { + return 'cv-form'; + } + + renderData({ positionRu, positionEn, jobSearchStatus, description, workingExperience }) { + this.positionRuField.querySelector('.validated-input__input').value = positionRu; + this.positionEnField.querySelector('.validated-input__input').value = positionEn; + this.jobSearchStatusField.querySelector('.validated-input__input').value = jobSearchStatus; + this.descriptionField.querySelector('.validated-textarea__textarea').value = description; + this.workingExperienceField.querySelector('.validated-textarea__textarea').value = + workingExperience; + } +} diff --git a/src/Components/CvForm/cv-form.hbs b/src/Components/CvForm/cv-form.hbs new file mode 100644 index 0000000..f8c1273 --- /dev/null +++ b/src/Components/CvForm/cv-form.hbs @@ -0,0 +1,19 @@ + + {{> validated-input formName="cv-form" elementName="position-ru" inputName="positionRu" inputCaption="Должность" inputType="text"}} + {{> validated-input formName="cv-form" elementName="position-en" inputName="positionEn" inputCaption="Перевод должности на английский" inputType="text"}} + {{> validated-input formName="cv-form" elementName="job-search-status" inputName="jobSearchStatus" inputCaption="Статус поиска работы" inputType="text"}} + {{> validated-textarea formName="cv-form" elementName="description" inputName="description" inputCaption="Описание и достижения"}} + {{> validated-textarea formName="cv-form" elementName="working-experience" inputName="workingExperience" inputCaption="Опыт работы"}} +
+ {{#if isNew}} + + {{else}} + + {{/if}} + В профиль +
+
\ No newline at end of file diff --git a/src/Components/EmployerCompanyForm/EmployerProfileForm.js b/src/Components/EmployerCompanyForm/EmployerProfileForm.js deleted file mode 100644 index 2d8767d..0000000 --- a/src/Components/EmployerCompanyForm/EmployerProfileForm.js +++ /dev/null @@ -1,72 +0,0 @@ -import { Component } from '../../modules/Components/Component.js'; -import { EmployerProfileFormView } from './EmployerProfileFormView.js'; -import { EmployerProfileFormModel } from './EmployerProfileFormModel.js'; -import { EmployerProfileFormController } from './EmployerProfileFormController.js'; -import { LiteralInput } from '/src/Components/FormInputs/LiteralInput/LiteralInput.js'; -import { CityInput } from '/src/Components/FormInputs/CityInput/CityInput.js'; -import { ValidatedTextArea } from '../FormInputs/ValidatedTextArea/ValidatedTextArea.js'; - -export class EmployerProfileForm extends Component { - constructor({ userId, elementClass, existingElement }) { - super({ - modelClass: EmployerProfileFormModel, - viewClass: EmployerProfileFormView, - controllerClass: EmployerProfileFormController, - modelParams: { userId }, - existingElement, - viewParams: { elementClass }, - }); - this._firstNameField = new LiteralInput({ - existingElement: this._view.firstNameField, - selfValidate: true, - }); - this._secondNameField = new LiteralInput({ - existingElement: this._view.secondNameField, - selfValidate: true, - }); - this._cityField = new CityInput({ - existingElement: this._view.cityField, - selfValidate: true, - }); - this._contactsField = new ValidatedTextArea({ - existingElement: this._view.contactsField, - selfValidate: true, - }); - this._children.push( - this._firstNameField, - this._secondNameField, - this._cityField, - this._contactsField, - ); - - this.reset(); - } - - get view() { - return this._view; - } - - enable() { - [this._firstNameField, this._secondNameField, this._cityField, this._contactsField].forEach( - (field) => { - field.controller.enable(); - }, - ); - } - - disable() { - [this._firstNameField, this._secondNameField, this._cityField, this._contactsField].forEach( - (field) => { - field.controller.disable(); - }, - ); - } - - reset() { - return this._controller.reset(); - } - - submit() { - return this._controller.submit(); - } -} diff --git a/src/Components/EmployerCompanyForm/EmployerProfileFormController.js b/src/Components/EmployerCompanyForm/EmployerProfileFormController.js deleted file mode 100644 index 3375312..0000000 --- a/src/Components/EmployerCompanyForm/EmployerProfileFormController.js +++ /dev/null @@ -1,43 +0,0 @@ -import { ComponentController } from '../../modules/Components/Component.js'; -import eventBus from '../../modules/Events/EventBus.js'; -import { USER_UPDATED } from '../../modules/Events/Events.js'; - -export class EmployerProfileFormController extends ComponentController { - constructor(model, view, controller) { - super(model, view, controller); - } - - _validate() { - return [ - this._component._firstNameField.controller.validateInput({ - callerView: this._component._firstNameField._view, - }), - - this._component._secondNameField.controller.validateInput({ - callerView: this._component._secondNameField._view, - }), - - this._component._cityField.controller.validateInput({ - callerView: this._component._cityField._view, - }), - - this._component._contactsField.controller.validateInput({ - callerView: this._component._contactsField._view, - }), - ].every((val) => val); - } - - submit() { - if (!this._validate() || !this._model.submit(this._view.getData())) { - return false; - } - eventBus.emit(USER_UPDATED); - return true; - } - - async reset() { - const oldData = await this._model.lastValidData; - this._view.renderData(oldData); - return true; - } -} diff --git a/src/Components/EmployerCompanyForm/EmployerProfileFormModel.js b/src/Components/EmployerCompanyForm/EmployerProfileFormModel.js deleted file mode 100644 index 09df162..0000000 --- a/src/Components/EmployerCompanyForm/EmployerProfileFormModel.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Api } from '../../modules/Api/Api.js'; -import { ComponentModel } from '../../modules/Components/Component.js'; - -export class EmployerProfileFormModel extends ComponentModel { - #lastValidData; - - constructor({ userId }) { - super(); - this.#lastValidData = Api.getApplicantById({ id: userId }); - } - - get lastValidData() { - return this.#lastValidData; - } - - async submit(formData) { - if (Api.updateEmployerProfile(formData)) { - this.#lastValidData = formData; - return true; - } - return false; - } -} diff --git a/src/Components/EmployerCompanyForm/EmployerProfileFormView.js b/src/Components/EmployerCompanyForm/EmployerProfileFormView.js deleted file mode 100644 index 0193739..0000000 --- a/src/Components/EmployerCompanyForm/EmployerProfileFormView.js +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentView } from '../../modules/Components/Component.js'; -import { getFormData } from '../../modules/FormUtils/FormUtils.js'; - -export class EmployerProfileFormView extends ComponentView { - constructor({ elementClass }, existingElement) { - super({ - renderParams: { elementClass }, - existingElement, - templateName: 'employer-profile-form.hbs', - }); - this.firstNameField = this._html.querySelector('.employer-profile-form__first-name'); - this.secondNameField = this._html.querySelector('.employer-profile-form__second-name'); - this.cityField = this._html.querySelector('.employer-profile-form__city'); - this.contactsField = this._html.querySelector('.employer-profile-form__contacts'); - } - - getData() { - return getFormData(this._html); - } - - getId() { - return 'employer-profile-form'; - } - - renderData({ firstName, secondName, city, contacts }) { - this.firstNameField.querySelector('.validated-input__input').value = firstName; - this.secondNameField.querySelector('.validated-input__input').value = secondName; - this.cityField.querySelector('.validated-input__input').value = city; - this.contactsField.querySelector('.validated-textarea__textarea').value = contacts; - } -} diff --git a/src/Components/EmployerCompanyForm/employer-company-form.hbs b/src/Components/EmployerCompanyForm/employer-company-form.hbs deleted file mode 100644 index 13bffdc..0000000 --- a/src/Components/EmployerCompanyForm/employer-company-form.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
- {{> validated-input formName="employer-company-form" elementName="first-name" inputName="firstName" inputCaption="Имя" inputType="text"}} - {{> validated-input formName="employer-company-form" elementName="city" inputName="city" inputCaption="Город" inputType="text"}} - {{> validated-textarea formName="employer-company-form" elementName="contacts" inputName="contacts" inputCaption="Контакты"}} -
\ No newline at end of file diff --git a/src/Components/EmployerProfileForm/EmployerProfileFormController.js b/src/Components/EmployerProfileForm/EmployerProfileFormController.js index 7c7a1a1..4598626 100644 --- a/src/Components/EmployerProfileForm/EmployerProfileFormController.js +++ b/src/Components/EmployerProfileForm/EmployerProfileFormController.js @@ -1,6 +1,7 @@ import { ComponentController } from '../../modules/Components/Component.js'; import eventBus from '../../modules/Events/EventBus.js'; -import { USER_UPDATED } from '../../modules/Events/Events.js'; +import { NOTIFICATION_OK, USER_UPDATED } from '../../modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; export class EmployerProfileFormController extends ComponentController { constructor(model, view, controller) { @@ -32,6 +33,10 @@ export class EmployerProfileFormController extends ComponentController { return false; } eventBus.emit(USER_UPDATED); + eventBus.emit(NOTIFICATION_OK, { + message: 'Успешно сохранено', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); return true; } diff --git a/src/Components/EmployerProfileForm/EmployerProfileFormModel.js b/src/Components/EmployerProfileForm/EmployerProfileFormModel.js index 0b025e2..fdbb93e 100644 --- a/src/Components/EmployerProfileForm/EmployerProfileFormModel.js +++ b/src/Components/EmployerProfileForm/EmployerProfileFormModel.js @@ -1,4 +1,5 @@ import { Api } from '../../modules/Api/Api.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; import { ComponentModel } from '../../modules/Components/Component.js'; import { Employer } from '../../modules/models/Employer.js'; @@ -19,11 +20,13 @@ export class EmployerProfileFormModel extends ComponentModel { } async submit(formData) { - formData.birthDate = new Date(formData.birthDate); formData.id = this.#userId; - if (await Api.updateEmployerProfile(formData)) { - this.#lastValidData = new Employer(formData); + try { + const response = await Api.updateEmployerProfile(formData); + this.#lastValidData = new Employer(response); return true; + } catch (err) { + catchStandardResponseError(err); } return false; } diff --git a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormController.js b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormController.js index 7987227..29c938a 100644 --- a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormController.js +++ b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormController.js @@ -27,7 +27,6 @@ export class EmployerRegistrationFormController extends ComponentController { } _validate(formData) { - this._view.hideError(); const formValidationError = this._model.validate(formData); if (formValidationError) { this._view.declineValidation(formValidationError); diff --git a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormModel.js b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormModel.js index 535dd60..8ce5810 100644 --- a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormModel.js +++ b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormModel.js @@ -1,6 +1,8 @@ import { ComponentModel } from '../../modules/Components/Component.js'; import state from '../../modules/AppState/AppState.js'; import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { USER_ALREADY_EXISTS_ERROR } from '../../modules/Api/Errors.js'; +import { ResponseError, TransportError } from '../../modules/Api/Api.js'; export class EmployerRegistrationFormModel extends ComponentModel { validate(formData) { @@ -18,6 +20,15 @@ export class EmployerRegistrationFormModel extends ComponentModel { } async register(formData) { - return state.userSession.register(USER_TYPE.EMPLOYER, formData); + return state.userSession.register(USER_TYPE.EMPLOYER, formData).catch((err) => { + if (err.toString() === USER_ALREADY_EXISTS_ERROR) { + return Promise.reject('Работодатель с таким адресом электронной почты уже зарегистрирован'); + } + if (err instanceof TransportError) { + return Promise.reject('Произошла сетевая ошибка, повторите позднее'); + } + if (err instanceof ResponseError) + return Promise.reject('Произошла непредвиденная ошибка, повторите позднее'); + }); } } diff --git a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormView.js b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormView.js index 6a383ad..e3d4962 100644 --- a/src/Components/EmployerRegistrationForm/EmployerRegistrationFormView.js +++ b/src/Components/EmployerRegistrationForm/EmployerRegistrationFormView.js @@ -1,6 +1,8 @@ import { ComponentView } from '../../modules/Components/Component.js'; +import { NOTIFICATION_ERROR } from '../../modules/Events/Events.js'; import { addEventListeners } from '../../modules/Events/EventUtils.js'; import { getFormData } from '../../modules/FormUtils/FormUtils.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; import eventBus from '/src/modules/Events/EventBus.js'; import { REGISTER_EMPLOYER } from '/src/modules/Events/Events.js'; @@ -40,12 +42,10 @@ export class EmployerRegistrationFormView extends ComponentView { return getFormData(this._html); } - hideError() { - this.error.hidden = true; - } - declineValidation(errorMessage) { - this.error.innerText = errorMessage; - this.error.hidden = false; + eventBus.emit(NOTIFICATION_ERROR, { + message: errorMessage, + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); } } diff --git a/src/Components/EmployerRegistrationForm/employer-registration-form.hbs b/src/Components/EmployerRegistrationForm/employer-registration-form.hbs index f04b5c1..c32152f 100644 --- a/src/Components/EmployerRegistrationForm/employer-registration-form.hbs +++ b/src/Components/EmployerRegistrationForm/employer-registration-form.hbs @@ -1,5 +1,4 @@
- {{> validated-input formName="employer-registration-form" elementName="first-name" inputName="firstName" inputCaption="Имя" inputType="text"}} {{> validated-input formName="employer-registration-form" elementName="second-name" inputName="secondName" inputCaption="Фамилия" inputType="text"}} {{> validated-input formName="employer-registration-form" elementName="position" inputName="position" inputCaption="Должность" inputType="text"}} diff --git a/src/Components/FormInputs/CurrencyInput/CurrencyInput.js b/src/Components/FormInputs/CurrencyInput/CurrencyInput.js new file mode 100644 index 0000000..832ae20 --- /dev/null +++ b/src/Components/FormInputs/CurrencyInput/CurrencyInput.js @@ -0,0 +1,16 @@ +import { Component } from '../../../modules/Components/Component.js'; +import { ValidatedInputController } from '../ValidatedInput/ValidatedInputController.js'; +import { ValidatedInputView } from '../ValidatedInput/ValidatedInputView.js'; +import { CurrencyInputModel } from './CurrencyInputModel.js'; + +export class CurrencyInput extends Component { + constructor({ existingElement, selfValidate = false }) { + super({ + modelClass: CurrencyInputModel, + controllerClass: ValidatedInputController, + viewClass: ValidatedInputView, + existingElement, + controllerParams: { selfValidate }, + }); + } +} diff --git a/src/Components/FormInputs/CurrencyInput/CurrencyInputModel.js b/src/Components/FormInputs/CurrencyInput/CurrencyInputModel.js new file mode 100644 index 0000000..c244df4 --- /dev/null +++ b/src/Components/FormInputs/CurrencyInput/CurrencyInputModel.js @@ -0,0 +1,9 @@ +import { ValidatedInputModel } from '/src/Components/FormInputs/ValidatedInput/ValidatedInputModel.js'; + +export class CurrencyInputModel extends ValidatedInputModel { + validate(numberStr) { + numberStr = numberStr.trim(); + const matches = numberStr.match(/^[0-9]+$/); + return matches ? '' : 'Тут нужно ввести целое число'; + } +} diff --git a/src/Components/FormInputs/LiteralInput/LiteralInputModel.js b/src/Components/FormInputs/LiteralInput/LiteralInputModel.js index 5b89a06..b9b68b0 100644 --- a/src/Components/FormInputs/LiteralInput/LiteralInputModel.js +++ b/src/Components/FormInputs/LiteralInput/LiteralInputModel.js @@ -2,7 +2,7 @@ import { ComponentModel } from '../../../modules/Components/Component.js'; export class LiteralInputModel extends ComponentModel { validate(text) { - if (!text.match(/^[a-zA-zа-яА-я ]*$/)) { + if (!text.match(/^[a-zA-zа-яА-я\- ]*$/)) { return 'Здесь нужно вводить только буквы'; } return ''; diff --git a/src/Components/FormInputs/PasswordInput/PasswordInputModel.js b/src/Components/FormInputs/PasswordInput/PasswordInputModel.js index 703eccc..e09b7c4 100644 --- a/src/Components/FormInputs/PasswordInput/PasswordInputModel.js +++ b/src/Components/FormInputs/PasswordInput/PasswordInputModel.js @@ -5,7 +5,7 @@ export class PasswordInputModel extends ValidatedInputModel { validate(password) { if (password.length < this.#MIN_PASSWORD_LEN) { - return 'Придумайте пароль длиной хотя бы 8 символов'; + return 'Введите пароль длиной хотя бы 8 символов'; } return ''; } diff --git a/src/Components/FormInputs/ValidatedInput/validated-input.hbs b/src/Components/FormInputs/ValidatedInput/validated-input.hbs index d28d2fd..5ca3741 100644 --- a/src/Components/FormInputs/ValidatedInput/validated-input.hbs +++ b/src/Components/FormInputs/ValidatedInput/validated-input.hbs @@ -8,6 +8,7 @@ class='validated-input__input text_roboto-regular' id='{{formName}}__{{elementName}}' name='{{inputName}}' + placeholder='{{inputPlaceholder}}' /> \ No newline at end of file diff --git a/src/Components/Header/header.hbs b/src/Components/Header/header.hbs index 3d4662a..a99b857 100644 --- a/src/Components/Header/header.hbs +++ b/src/Components/Header/header.hbs @@ -21,10 +21,10 @@ Мои резюме - + {{!-- Портфолио - + --}} {{else}} diff --git a/src/Components/Lists/ApplicantCvList/ApplicantCvList.js b/src/Components/Lists/ApplicantCvList/ApplicantCvList.js index 6e49b1d..c4dbd0b 100644 --- a/src/Components/Lists/ApplicantCvList/ApplicantCvList.js +++ b/src/Components/Lists/ApplicantCvList/ApplicantCvList.js @@ -3,6 +3,7 @@ import { ApplicantCvListModel } from './ApplicantCvListModel.js'; import { ApplicantCvListController } from './ApplicantCvListController.js'; import { ApplicantCvListView } from './ApplicantCvListView.js'; import { ListMixin } from '../List/ListMixin.js'; +import { resolveUrl } from '../../../modules/UrlUtils/UrlUtils.js'; export class ApplicantCvList extends Component { constructor({ userId, isListOwner, elementClass, existingElement }) { @@ -10,7 +11,7 @@ export class ApplicantCvList extends Component { modelClass: ApplicantCvListModel, controllerClass: ApplicantCvListController, viewClass: ApplicantCvListView, - viewParams: { elementClass }, + viewParams: { elementClass, isOwner: isListOwner, addHref: resolveUrl('createCv') }, modelParams: { userId, isListOwner }, existingElement, }); diff --git a/src/Components/Lists/ApplicantCvList/ApplicantCvListModel.js b/src/Components/Lists/ApplicantCvList/ApplicantCvListModel.js index 03067fe..195fb32 100644 --- a/src/Components/Lists/ApplicantCvList/ApplicantCvListModel.js +++ b/src/Components/Lists/ApplicantCvList/ApplicantCvListModel.js @@ -2,28 +2,36 @@ import { ComponentModel } from '../../../modules/Components/Component.js'; import { Api } from '../../../modules/Api/Api.js'; import { Minicard } from '../../Minicard/Minicard.js'; import { resolveUrl } from '../../../modules/UrlUtils/UrlUtils.js'; +import { Cv } from '../../../modules/models/Cv.js'; +import { CvPage } from '../../../Pages/CvPage/CvPage.js'; export class ApplicantCvListModel extends ComponentModel { #userId; #isOwner; + #items; constructor({ userId, isListOwner }) { super(); this.#userId = userId; this.#isOwner = isListOwner; + this.#items = []; } async getItems() { const cvsJson = await Api.getApplicantCvs({ id: this.#userId }); const cvsObjects = cvsJson.reduce((cvsObjects, cvJsonItem) => { try { - const { id, positionRu } = cvJsonItem; + const cv = new Cv(cvJsonItem); + this.#items.push(cv); + const urlSearchQuery = {}; + urlSearchQuery[`${CvPage.CV_ID_PARAM}`] = cv.id; cvsObjects.push( new Minicard({ renderParams: { elementClass: 'applicant-cv-list__minicard', - title: positionRu, + title: cv.positionRu, isCardOwner: this.#isOwner, - editButtonUrl: resolveUrl(`/cv/edit/${id}`), + goToLink: resolveUrl('cv', urlSearchQuery), + editButtonUrl: resolveUrl('editCv', urlSearchQuery), }, }), ); @@ -34,4 +42,18 @@ export class ApplicantCvListModel extends ComponentModel { }, []); return cvsObjects; } + + async removeChild(cvArrId) { + if (cvArrId >= this.#items.length || cvArrId < 0) { + return false; + } + const cv = this.#items[cvArrId]; + try { + await Api.deleteCvById({ id: cv.id }); + return true; + } catch (err) { + console.log(err); + return false; + } + } } diff --git a/src/Components/Lists/EmployerVacancyList/EmployerVacancyList.js b/src/Components/Lists/EmployerVacancyList/EmployerVacancyList.js index 26099ca..7105b35 100644 --- a/src/Components/Lists/EmployerVacancyList/EmployerVacancyList.js +++ b/src/Components/Lists/EmployerVacancyList/EmployerVacancyList.js @@ -3,6 +3,7 @@ import { EmployerVacancyListModel } from './EmployerVacancyListModel.js'; import { EmployerVacancyListController } from './EmployerVacancyListController.js'; import { EmployerVacancyListView } from './EmployerVacancyListView.js'; import { ListMixin } from '../List/ListMixin.js'; +import { resolveUrl } from '../../../modules/UrlUtils/UrlUtils.js'; export class EmployerVacancyList extends Component { constructor({ userId, isListOwner, elementClass, existingElement }) { @@ -10,7 +11,7 @@ export class EmployerVacancyList extends Component { modelClass: EmployerVacancyListModel, controllerClass: EmployerVacancyListController, viewClass: EmployerVacancyListView, - viewParams: { elementClass }, + viewParams: { elementClass, isOwner: isListOwner, addHref: resolveUrl('createVacancy') }, modelParams: { userId, isListOwner }, existingElement, }); diff --git a/src/Components/Lists/EmployerVacancyList/EmployerVacancyListModel.js b/src/Components/Lists/EmployerVacancyList/EmployerVacancyListModel.js index 46fb9e3..2713692 100644 --- a/src/Components/Lists/EmployerVacancyList/EmployerVacancyListModel.js +++ b/src/Components/Lists/EmployerVacancyList/EmployerVacancyListModel.js @@ -2,28 +2,36 @@ import { ComponentModel } from '../../../modules/Components/Component.js'; import { Api } from '../../../modules/Api/Api.js'; import { Minicard } from '../../Minicard/Minicard.js'; import { resolveUrl } from '../../../modules/UrlUtils/UrlUtils.js'; +import { Vacancy } from '../../../modules/models/Vacancy.js'; +import { VacancyPage } from '../../../Pages/VacancyPage/VacancyPage.js'; export class EmployerVacancyListModel extends ComponentModel { #userId; #isOwner; + #items; constructor({ userId, isListOwner }) { super(); this.#userId = userId; this.#isOwner = isListOwner; + this.#items = []; } async getItems() { const vacanciesJson = await Api.getEmployerVacancies({ id: this.#userId }); const vacanciesObjects = vacanciesJson.reduce((vacanciesObjects, vacancyJson) => { try { - const { id, position } = vacancyJson; + const vacancy = new Vacancy(vacancyJson); + this.#items.push(vacancy); + const urlSearchQuery = {}; + urlSearchQuery[`${VacancyPage.VACANCY_ID_PARAM}`] = vacancy.id; vacanciesObjects.push( new Minicard({ renderParams: { elementClass: 'employer-vacancy-list__minicard', - title: position, + title: vacancy.position, isCardOwner: this.#isOwner, - editButtonUrl: resolveUrl(`/vacancy/edit/${id}`), + goToLink: resolveUrl('vacancy', urlSearchQuery), + editButtonUrl: resolveUrl('editVacancy', urlSearchQuery), }, }), ); @@ -34,4 +42,18 @@ export class EmployerVacancyListModel extends ComponentModel { }, []); return vacanciesObjects; } + + async removeChild(vacancyArrId) { + if (vacancyArrId >= this.#items.length || vacancyArrId < 0) { + return false; + } + const vacancy = this.#items[vacancyArrId]; + try { + await Api.deleteVacancyById({ id: vacancy.id }); + return true; + } catch (err) { + console.log(err); + return false; + } + } } diff --git a/src/Components/Lists/List/ListController.js b/src/Components/Lists/List/ListController.js index 8bcf16f..683b15a 100644 --- a/src/Components/Lists/List/ListController.js +++ b/src/Components/Lists/List/ListController.js @@ -1,5 +1,8 @@ +import { catchStandardResponseError } from '../../../modules/Api/Errors.js'; import { ComponentController } from '../../../modules/Components/Component.js'; -import { MINICARD_DELETE } from '../../../modules/Events/Events.js'; +import eventBus from '../../../modules/Events/EventBus.js'; +import { MINICARD_DELETE, NOTIFICATION_OK } from '../../../modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../../NotificationBox/NotificationBox.js'; export class ListController extends ComponentController { constructor(model, view, component) { @@ -13,18 +16,33 @@ export class ListController extends ComponentController { } async loadList() { - const items = await this._model.getItems(); - items.forEach((item) => { - this._view.addChild(item.render()); - this._component.bindMinicard(item); - }); + try { + const items = await this._model.getItems(); + items.forEach((item) => { + this._view.addChild(item.render()); + this._component.bindMinicard(item); + }); + } catch (err) { + catchStandardResponseError(err); + } } - removeMinicard({ caller }) { + async removeMinicard({ caller }) { const minicardIndex = this._view.findChildIndex(caller.render()); if (minicardIndex >= 0) { - this._component.unbindMinicard(minicardIndex); - this._view.removeChild(minicardIndex); + try { + const removed = await this._model.removeChild(minicardIndex); + if (removed) { + this._component.unbindMinicard(minicardIndex); + this._view.removeChild(minicardIndex); + eventBus.emit(NOTIFICATION_OK, { + message: 'Успешно удалено', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + } + } catch (err) { + catchStandardResponseError(err); + } } } } diff --git a/src/Components/Lists/List/list.hbs b/src/Components/Lists/List/list.hbs index edf07ba..ec7fda9 100644 --- a/src/Components/Lists/List/list.hbs +++ b/src/Components/Lists/List/list.hbs @@ -1,2 +1,6 @@
+ {{#if isOwner}} + {{/if}}
\ No newline at end of file diff --git a/src/Components/LoginForm/LoginFormController.js b/src/Components/LoginForm/LoginFormController.js index e135585..575343a 100644 --- a/src/Components/LoginForm/LoginFormController.js +++ b/src/Components/LoginForm/LoginFormController.js @@ -2,6 +2,8 @@ import { ComponentController } from '../../modules/Components/Component.js'; import { USER_WANTS_LOGIN } from '../../modules/Events/Events.js'; import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; import router from '/src/modules/Router/Router.js'; +import { NOTIFICATION_OK } from '../../modules/Events/Events.js'; +import eventBus from '../../modules/Events/EventBus.js'; export class LoginFormController extends ComponentController { constructor(model, view, component) { @@ -15,7 +17,6 @@ export class LoginFormController extends ComponentController { } _validate(formData) { - this._view.hideError(); const formValidationError = this._model.validate(formData); if (formValidationError) { this._view.declineValidation(formValidationError); @@ -42,7 +43,10 @@ export class LoginFormController extends ComponentController { } this._model .login(formData) - .then(() => router.navigate(new URL(resolveUrl('vacancies')), true, true)) + .then(() => { + eventBus.emit(NOTIFICATION_OK, { message: 'Вы успешно вошли', timeout: 3000 }); + router.navigate(new URL(resolveUrl('vacancies')), true, true); + }) .catch((errorMsg) => { this._view.declineValidation(errorMsg); }); diff --git a/src/Components/LoginForm/LoginFormModel.js b/src/Components/LoginForm/LoginFormModel.js index f9dad6b..f097163 100644 --- a/src/Components/LoginForm/LoginFormModel.js +++ b/src/Components/LoginForm/LoginFormModel.js @@ -1,5 +1,8 @@ import { ComponentModel } from '../../modules/Components/Component.js'; import state from '/src/modules/AppState/AppState.js'; +import { TransportError, ResponseError } from '../../modules/Api/Api.js'; + +const WRONG_AUTH_ERROR = 'wrong login or password'; export class LoginFormModel extends ComponentModel { validate(formData) { @@ -13,20 +16,17 @@ export class LoginFormModel extends ComponentModel { } async login(formData) { - return state.userSession.login(formData); - // await this._state.userSession - // .login({ - // userType: data.get('user-type'), - // login: data.get('email'), - // password: data.get('password'), - // }) - // .catch((status) => { - // if (status === 401) { - // this.error('Неверный email или пароль'); - // return Promise.resolve(); - // } - // return Promise.reject(status); - // }) - // .catch(() => this.error('Произошла непредвиденная ошибка, повторите позднее')); + return state.userSession.login(formData).catch((err) => { + if (err.toString() === WRONG_AUTH_ERROR) { + return Promise.reject('Неверный email или пароль'); + } + if (err instanceof TransportError) { + return Promise.reject('Произошла сетевая ошибка, повторите позднее'); + } + if (err instanceof ResponseError) { + return Promise.reject('Произошла непредвиденная ошибка, повторите позднее'); + } + return Promise.reject(err); + }); } } diff --git a/src/Components/LoginForm/LoginFormView.js b/src/Components/LoginForm/LoginFormView.js index 7a9a58f..49f9d48 100644 --- a/src/Components/LoginForm/LoginFormView.js +++ b/src/Components/LoginForm/LoginFormView.js @@ -1,11 +1,11 @@ import { ComponentView } from '../../modules/Components/Component.js'; -import { USER_WANTS_LOGIN } from '../../modules/Events/Events.js'; +import { NOTIFICATION_ERROR, USER_WANTS_LOGIN } from '../../modules/Events/Events.js'; import { addEventListeners } from '../../modules/Events/EventUtils.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; import eventBus from '/src/modules/Events/EventBus.js'; export class LoginFormView extends ComponentView { #emailField; - #error; #passField; #userTypeRadioGroup; constructor({ elementClass }, existingElement) { @@ -26,7 +26,6 @@ export class LoginFormView extends ComponentView { this.#userTypeRadioGroup = this._html.querySelector('.login-form__user-type-radiogroup'); this.#emailField = this._html.querySelector('.login-form__email'); this.#passField = this._html.querySelector('.login-form__password'); - this.#error = this._html.querySelector('.login-form__error'); } get userTypeRadioGroup() { @@ -41,13 +40,11 @@ export class LoginFormView extends ComponentView { return this.#passField; } - hideError() { - this.#error.hidden = true; - } - declineValidation(errorMessage) { - this.#error.innerText = errorMessage; - this.#error.hidden = false; + eventBus.emit(NOTIFICATION_ERROR, { + message: errorMessage, + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); } getData() { diff --git a/src/Components/LoginForm/login-form.hbs b/src/Components/LoginForm/login-form.hbs index 81b1d5c..e31b5ac 100644 --- a/src/Components/LoginForm/login-form.hbs +++ b/src/Components/LoginForm/login-form.hbs @@ -1,5 +1,4 @@ - {{> user-type-radiogroup elementClass="login-form__user-type-radiogroup"}} {{> validated-input formName="login-form" elementName="email" inputName="email" inputCaption="Электронная почта" inputType="email"}} {{> validated-input formName="login-form" elementName="password" inputName="password" inputCaption="Пароль" inputType="password"}} diff --git a/src/Components/Minicard/MinicardView.js b/src/Components/Minicard/MinicardView.js index c03dbed..bc2f90e 100644 --- a/src/Components/Minicard/MinicardView.js +++ b/src/Components/Minicard/MinicardView.js @@ -5,9 +5,9 @@ import { addEventListeners } from '../../modules/Events/EventUtils.js'; export class MinicardView extends ComponentView { #deleteButton; - constructor({ elementClass, isCardOwner, editButtonUrl, title }, existingElement) { + constructor({ elementClass, isCardOwner, editButtonUrl, title, goToLink }, existingElement) { super({ - renderParams: { elementClass, isCardOwner, editButtonUrl, title }, + renderParams: { elementClass, isCardOwner, editButtonUrl, title, goToLink }, existingElement, templateName: 'minicard.hbs', }); diff --git a/src/Components/Minicard/minicard.hbs b/src/Components/Minicard/minicard.hbs index 7e00ce8..e89a518 100644 --- a/src/Components/Minicard/minicard.hbs +++ b/src/Components/Minicard/minicard.hbs @@ -1,8 +1,8 @@
- {{title}} + {{title}} {{#if isCardOwner}} {{/if}} diff --git a/src/Components/NotificationBox/NotificationBox.js b/src/Components/NotificationBox/NotificationBox.js new file mode 100644 index 0000000..9b14e37 --- /dev/null +++ b/src/Components/NotificationBox/NotificationBox.js @@ -0,0 +1,21 @@ +import { NotificationBoxController } from './NotificationBoxController.js'; +import { NotificationBoxModel } from './NotificationBoxModel.js'; +import { NotificationBoxView } from './NotificationBoxView.js'; +import { Component } from '../../modules/Components/Component.js'; + +export const NOTIFICATION_TIMEOUT = { + LONG: 3000, + MEDIUM: 2000, + SHORT: 1000, +}; + +export class NotificationBox extends Component { + constructor({ existingElement }) { + super({ + modelClass: NotificationBoxModel, + controllerClass: NotificationBoxController, + viewClass: NotificationBoxView, + existingElement, + }); + } +} diff --git a/src/Components/NotificationBox/NotificationBoxController.js b/src/Components/NotificationBox/NotificationBoxController.js new file mode 100644 index 0000000..40abf48 --- /dev/null +++ b/src/Components/NotificationBox/NotificationBoxController.js @@ -0,0 +1,35 @@ +import { ComponentController } from '../../modules/Components/Component.js'; +import { NOTIFICATION_STYLE } from './NotificationBoxView.js'; +import { NOTIFICATION_ERROR, NOTIFICATION_OK } from '../../modules/Events/Events.js'; + +export class NotificationBoxController extends ComponentController { + constructor(model, view, component) { + super(model, view, component); + this.setHandlers([ + { + event: NOTIFICATION_ERROR, + handler: this.errorNotification.bind(this), + }, + { + event: NOTIFICATION_OK, + handler: this.okNotification.bind(this), + }, + ]); + } + + errorNotification({ message, timeout }) { + this._view.addNotification({ + notificationText: message, + timeoutMs: timeout, + style: NOTIFICATION_STYLE.ERROR, + }); + } + + okNotification({ message, timeout }) { + this._view.addNotification({ + notificationText: message, + timeoutMs: timeout, + style: NOTIFICATION_STYLE.OK, + }); + } +} diff --git a/src/Components/NotificationBox/NotificationBoxModel.js b/src/Components/NotificationBox/NotificationBoxModel.js new file mode 100644 index 0000000..892fc87 --- /dev/null +++ b/src/Components/NotificationBox/NotificationBoxModel.js @@ -0,0 +1,3 @@ +import { ComponentModel } from '../../modules/Components/Component.js'; + +export const NotificationBoxModel = ComponentModel; diff --git a/src/Components/NotificationBox/NotificationBoxView.js b/src/Components/NotificationBox/NotificationBoxView.js new file mode 100644 index 0000000..2068901 --- /dev/null +++ b/src/Components/NotificationBox/NotificationBoxView.js @@ -0,0 +1,76 @@ +import { ComponentView } from '../../modules/Components/Component.js'; + +export const NOTIFICATION_STYLE = { + ERROR: 'error', + OK: 'ok', +}; + +const STYLE_TO_CLASS = {}; +STYLE_TO_CLASS[NOTIFICATION_STYLE.ERROR] = 'notification_error'; +STYLE_TO_CLASS[NOTIFICATION_STYLE.OK] = 'notification_ok'; + +export class NotificationBoxView extends ComponentView { + #NOTIFICATION_CAP = 5; + #NOTIFICATION_CHECK_TIMEOUT = 500; + #notificationQueue = []; + #notificationsNum = 0; + #intervalDescriptor; + constructor(renderParams, existingElement) { + super({ renderParams, templateName: null, existingElement }); + this._notifications; + this.notificationCallback = this.processNotifications.bind(this); + } + + addNotification({ notificationText, timeoutMs, style }) { + const newNotification = this.renderNotification({ notificationText, style }); + this.#notificationQueue.push({ notificationHTML: newNotification, timeout: timeoutMs }); + this.processNotifications(); + } + + processNotifications() { + if (this.#NOTIFICATION_CAP > this.#notificationsNum) { + if (this.#intervalDescriptor) { + clearInterval(this.#intervalDescriptor); + this.#intervalDescriptor = null; + } + if (this.#notificationQueue.length === 0) { + return; + } + const { notificationHTML, timeout } = this.#notificationQueue.shift(); + this.#notificationsNum++; + this._html.appendChild(notificationHTML); + setTimeout( + function () { + notificationHTML.outerHTML = ''; + this.#notificationsNum--; + }.bind(this), + timeout, + ); + } else { + if (!this.#intervalDescriptor) { + this.#intervalDescriptor = setInterval( + this.notificationCallback, + this.#NOTIFICATION_CHECK_TIMEOUT, + ); + } + } + } + + renderNotification({ notificationText, style }) { + const newNotification = document.createElement('div'); + newNotification.classList.add('notification'); + newNotification.classList.add('notification-box__notification'); + newNotification.innerText = notificationText; + switch (style) { + case NOTIFICATION_STYLE.OK: { + newNotification.classList.add(STYLE_TO_CLASS[NOTIFICATION_STYLE.OK]); + break; + } + case NOTIFICATION_STYLE.ERROR: { + newNotification.classList.add(STYLE_TO_CLASS[NOTIFICATION_STYLE.ERROR]); + break; + } + } + return newNotification; + } +} diff --git a/src/Components/ProfileMinicard/ProfileMinicardController.js b/src/Components/ProfileMinicard/ProfileMinicardController.js index 3d3c808..be3290e 100644 --- a/src/Components/ProfileMinicard/ProfileMinicardController.js +++ b/src/Components/ProfileMinicard/ProfileMinicardController.js @@ -1,3 +1,4 @@ +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; import { ComponentController } from '../../modules/Components/Component.js'; import { USER_UPDATED } from '../../modules/Events/Events.js'; @@ -13,6 +14,10 @@ export class ProfileMinicardController extends ComponentController { } async updateInfo() { - this._view.renderData(await this._model.getData()); + try { + this._view.renderData(await this._model.getData()); + } catch (err) { + catchStandardResponseError(err); + } } } diff --git a/src/Components/VacancyArticle/ButtonContainer/ButtonContainer.js b/src/Components/VacancyArticle/ButtonContainer/ButtonContainer.js new file mode 100644 index 0000000..670f4f9 --- /dev/null +++ b/src/Components/VacancyArticle/ButtonContainer/ButtonContainer.js @@ -0,0 +1,18 @@ +import { + Component, + ComponentController, + ComponentModel, +} from '../../../modules/Components/Component.js'; +import { ButtonContainerView } from './ButtonContainerView.js'; + +export class ButtonContainer extends Component { + constructor({ isOwner, isApplied, isApplicant, ownerId, vacancyId, existingElement }) { + super({ + modelClass: ComponentModel, + viewClass: ButtonContainerView, + controllerClass: ComponentController, + viewParams: { isOwner, isApplicant, isApplied, ownerId, vacancyId }, + existingElement, + }); + } +} diff --git a/src/Components/VacancyArticle/ButtonContainer/ButtonContainerView.js b/src/Components/VacancyArticle/ButtonContainer/ButtonContainerView.js new file mode 100644 index 0000000..7a1840a --- /dev/null +++ b/src/Components/VacancyArticle/ButtonContainer/ButtonContainerView.js @@ -0,0 +1,83 @@ +import { + VACANCY_APPLY, + VACANCY_DELETE, + VACANCY_EDIT, + VACANCY_RESET_APPLY, +} from '../../../modules/Events/Events.js'; +import { addEventListeners } from '../../../modules/Events/EventUtils.js'; +import { ComponentView } from '/src/modules/Components/Component.js'; +import eventBus from '/src/modules/Events/EventBus.js'; + +export class ButtonContainerView extends ComponentView { + #applyButton; + #editButton; + #deleteButton; + #vacancyId; + #isApplied; + #resetApplyButton; + #activeApplyButton; + constructor({ isOwner, isApplicant, ownerId, vacancyId, isApplied }, existingElement) { + super({ + renderParams: { isOwner, isApplicant, ownerId }, + existingElement, + templateName: 'vacancy-article__button-container.hbs', + }); + this.#isApplied = isApplied; + this.#vacancyId = vacancyId; + if (isApplicant) { + this.#applyButton = this._html.querySelector('.vacancy-article__apply-button'); + this._eventListeners.push({ + event: 'click', + object: this.#applyButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(VACANCY_APPLY, { caller: this, vacancyId: this.#vacancyId }); + }.bind(this), + }); + this.#resetApplyButton = this._html.querySelector('.vacancy-article__reset-apply-button'); + this._eventListeners.push({ + event: 'click', + object: this.#resetApplyButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(VACANCY_RESET_APPLY, { caller: this, vacancyId: this.#vacancyId }); + }.bind(this), + }); + this.#activeApplyButton = this.#isApplied ? this.#resetApplyButton : this.#applyButton; + this.#activeApplyButton.classList.remove('hidden'); + } else if (isOwner) { + this.#editButton = this._html.querySelector('.vacancy-article__edit-button'); + this.#deleteButton = this._html.querySelector('.vacancy-article__delete-button'); + this._eventListeners.push( + { + event: 'click', + object: this.#editButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(VACANCY_EDIT, { vacancyId: this.#vacancyId }); + }.bind(this), + }, + { + event: 'click', + object: this.#deleteButton, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(VACANCY_DELETE, { vacancyId: this.#vacancyId }); + }.bind(this), + }, + ); + } + addEventListeners(this._eventListeners); + } + + toggleApplyButton() { + this.#activeApplyButton.classList.add('hidden'); + if (Object.is(this.#activeApplyButton, this.#applyButton)) { + this.#activeApplyButton = this.#resetApplyButton; + this.#resetApplyButton.classList.remove('hidden'); + } else { + this.#activeApplyButton = this.#applyButton; + this.#applyButton.classList.remove('hidden'); + } + } +} diff --git a/src/Components/VacancyArticle/ButtonContainer/vacancy-article__button-container.hbs b/src/Components/VacancyArticle/ButtonContainer/vacancy-article__button-container.hbs new file mode 100644 index 0000000..8bf8fbd --- /dev/null +++ b/src/Components/VacancyArticle/ButtonContainer/vacancy-article__button-container.hbs @@ -0,0 +1,14 @@ +
+{{#if isApplicant}} + + + Все вакансии +{{else}} + {{#if isOwner}} + + + {{/if}} +{{/if}} +
+
\ No newline at end of file diff --git a/src/Components/VacancyArticle/VacancyArticle.js b/src/Components/VacancyArticle/VacancyArticle.js new file mode 100644 index 0000000..e06c544 --- /dev/null +++ b/src/Components/VacancyArticle/VacancyArticle.js @@ -0,0 +1,49 @@ +import { Component } from '../../modules/Components/Component.js'; +import { VacancyArticleView } from './VacancyArticleView.js'; +import { VacancyArticleModel } from './VacancyArticleModel.js'; +import { VacancyArticleController } from './VacancyArticleController.js'; +import { ButtonContainer } from './ButtonContainer/ButtonContainer.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { Api } from '../../modules/Api/Api.js'; + +export class VacancyArticle extends Component { + constructor({ elementClass, vacancyId, userId, userType }) { + super({ + modelClass: VacancyArticleModel, + modelParams: { vacancyId }, + viewClass: VacancyArticleView, + controllerClass: VacancyArticleController, + viewParams: { elementClass }, + }); + this._userId = userId; + this._userType = userType; + this._vacancyId = vacancyId; + } + + async makeButtons() { + let modelData = {}; + let appliedStatus = {}; + if (this._userType === USER_TYPE.APPLICANT) { + [modelData, appliedStatus] = await Promise.all([ + this._controller.fetchData(), + Api.getVacancyApplyStatusById({ id: this._vacancyId }), + ]); + } else { + modelData = await this._controller.fetchData(); + } + this._buttonContainer = new ButtonContainer({ + isOwner: modelData.employerId === this._userId && this._userType === USER_TYPE.EMPLOYER, + isApplied: appliedStatus.isSubscribed, + isApplicant: this._userType === USER_TYPE.APPLICANT, + ownerId: modelData.employerId, + vacancyId: this._vacancyId, + }); + this._children.push(this._buttonContainer); + this._controller.addButtonContainer(this._buttonContainer); + this._controller.renderData(); + } + + async getEmployerId() { + return this._model.getEmployerId(); + } +} diff --git a/src/Components/VacancyArticle/VacancyArticleController.js b/src/Components/VacancyArticle/VacancyArticleController.js new file mode 100644 index 0000000..b4afba2 --- /dev/null +++ b/src/Components/VacancyArticle/VacancyArticleController.js @@ -0,0 +1,74 @@ +import { ComponentController } from '../../modules/Components/Component.js'; +import { + GO_TO, + REDIRECT_TO, + VACANCY_APPLY, + VACANCY_DELETE, + VACANCY_EDIT, + VACANCY_RESET_APPLY, +} from '../../modules/Events/Events.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { VacancyEditPage } from '../../Pages/VacancyEditPage/VacancyEditPage.js'; + +export class VacancyArticleController extends ComponentController { + constructor(model, view, component) { + super(model, view, component); + this.setHandlers([ + { + event: VACANCY_DELETE, + handler: this.vacancyDelete.bind(this), + }, + { + event: VACANCY_EDIT, + handler: this.vacancyEdit.bind(this), + }, + { + event: VACANCY_APPLY, + handler: this.vacancyApply.bind(this), + }, + { + event: VACANCY_RESET_APPLY, + handler: this.vacancyResetApply.bind(this), + }, + ]); + } + + async fetchData() { + return this._model.getVacancyData(); + } + + async renderData() { + return this._view.renderData(await this._model.getVacancyData()); + } + + addButtonContainer(container) { + this._view.addButtonContainer(container.render()); + } + + async vacancyDelete() { + const deleted = await this._model.vacancyDelete(); + if (deleted) { + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('myProfile') }); + } + } + + async vacancyEdit() { + const query = {}; + const vacancy = await this._model.getVacancyData(); + query[VacancyEditPage.VACANCY_ID_PARAM] = vacancy.id; + eventBus.emit(GO_TO, { redirectUrl: resolveUrl('editVacancy', query) }); + } + + async vacancyApply({ caller }) { + if (this._model.vacancyApply()) { + caller.toggleApplyButton(); + } + } + + async vacancyResetApply({ caller }) { + if (this._model.vacancyResetApply()) { + caller.toggleApplyButton(); + } + } +} diff --git a/src/Components/VacancyArticle/VacancyArticleModel.js b/src/Components/VacancyArticle/VacancyArticleModel.js new file mode 100644 index 0000000..b0b2527 --- /dev/null +++ b/src/Components/VacancyArticle/VacancyArticleModel.js @@ -0,0 +1,73 @@ +import { ComponentModel } from '../../modules/Components/Component.js'; +import { Api } from '../../modules/Api/Api.js'; +import { Vacancy } from '../../modules/models/Vacancy.js'; +import { NotFoundError } from '../../modules/Router/Router.js'; +import state from '../../modules/AppState/AppState.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class VacancyArticleModel extends ComponentModel { + #vacancyData; + #vacancyId; + #userType; + + constructor({ vacancyId }) { + super(); + this.#vacancyId = vacancyId; + this.#vacancyData = Api.getVacancyById({ id: this.#vacancyId }).then( + (data) => new Vacancy(data), + () => { + throw new NotFoundError('vacancy not found'); + }, + ); + this.#userType = state.userSession.userType; + } + + async getVacancyData() { + return this.#vacancyData; + } + + async getEmployerId() { + const vacancyData = await this.#vacancyData; + return vacancyData.employerId; + } + + async vacancyDelete() { + if (!this.#vacancyId) { + return false; + } + try { + await Api.deleteVacancyById({ id: this.#vacancyId }); + return true; + } catch (err) { + catchStandardResponseError(err); + return false; + } + } + + async vacancyApply() { + if (!this.#vacancyId || this.#userType !== USER_TYPE.APPLICANT) { + return false; + } + try { + await Api.vacancyApply({ id: this.#vacancyId }); + return true; + } catch (err) { + catchStandardResponseError(err); + return false; + } + } + + async vacancyResetApply() { + if (!this.#vacancyId || this.#userType !== USER_TYPE.APPLICANT) { + return false; + } + try { + await Api.vacancyResetApply({ id: this.#vacancyId }); + return true; + } catch (err) { + catchStandardResponseError(err); + return false; + } + } +} diff --git a/src/Components/VacancyArticle/VacancyArticleView.js b/src/Components/VacancyArticle/VacancyArticleView.js new file mode 100644 index 0000000..5ea4e87 --- /dev/null +++ b/src/Components/VacancyArticle/VacancyArticleView.js @@ -0,0 +1,50 @@ +import { ComponentView } from '../../modules/Components/Component.js'; + +export class VacancyArticleView extends ComponentView { + constructor({ elementClass, isOwner, isApplicant }, existingElement) { + super({ + renderParams: { + elementClass, + isOwner, + isApplicant, + }, + templateName: 'vacancy-article.hbs', + existingElement, + }); + this._avatar = this._html.querySelector('.vacancy-article__company-picture'); + this._position = this._html.querySelector('.vacancy-summary__header'); + this._companyName = this._html.querySelector('.vacancy-summary__company-name'); + this._salary = this._html.querySelector('.vacancy-summary__salary'); + this._workType = this._html.querySelector('.vacancy-summary__work-type'); + this._description = this._html.querySelector('.vacancy-article__description'); + } + + renderData({ + avatar, + position, + companyName, + salary, + workType, + description, + location, + updatedAt, + }) { + this._avatar.href = avatar; + this._position.innerText = position; + this._companyName.innerText = `${companyName}, ${location}`; + this._salary.innerText = salary ? `${salary} руб.` : 'Не указана'; + this._workType.innerText = workType; + this._description.innerText = description; + this._updatedAt.innerText = `последнее обновление: ${updatedAt.toLocaleDateString('ru-RU', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + year: 'numeric', + })}`; + } + + addButtonContainer(containerRender) { + this._html.appendChild(containerRender); + this._updatedAt = this._html.querySelector('.vacancy-article__created-at'); + } +} diff --git a/src/Components/VacancyArticle/vacancy-article.hbs b/src/Components/VacancyArticle/vacancy-article.hbs new file mode 100644 index 0000000..141f47b --- /dev/null +++ b/src/Components/VacancyArticle/vacancy-article.hbs @@ -0,0 +1,16 @@ +
+
+ +
+

+
+
+ + +
+
+ +
+
+
+
\ No newline at end of file diff --git a/src/Components/VacancyCard/VacancyCard.js b/src/Components/VacancyCard/VacancyCard.js index de51e15..d505af6 100644 --- a/src/Components/VacancyCard/VacancyCard.js +++ b/src/Components/VacancyCard/VacancyCard.js @@ -6,12 +6,12 @@ import { import { VacancyCardView } from './VacancyCardView.js'; export class VacancyCard extends Component { - constructor({ employer, vacancy, existingElement }) { + constructor({ vacancyObj, existingElement }) { super({ modelClass: ComponentModel, controllerClass: ComponentController, viewClass: VacancyCardView, - viewParams: { employer, vacancy }, + viewParams: vacancyObj, existingElement, }); } diff --git a/src/Components/VacancyCard/VacancyCardView.js b/src/Components/VacancyCard/VacancyCardView.js index 7c12eab..8d0dc2f 100644 --- a/src/Components/VacancyCard/VacancyCardView.js +++ b/src/Components/VacancyCard/VacancyCardView.js @@ -1,12 +1,20 @@ import { ComponentView } from '../../modules/Components/Component.js'; export class VacancyCardView extends ComponentView { - constructor({ employer, vacancy }, existingElement) { + constructor( + { avatar, id, position, companyName, location, salary, description, updatedAt }, + existingElement, + ) { + const renderParams = { avatar, id, position, companyName, location, salary, description }; + renderParams.updatedAt = updatedAt.toLocaleDateString('ru-RU', { + weekday: 'short', + day: 'numeric', + month: 'numeric', + year: 'numeric', + }); + super({ - renderParams: { - employer, - vacancy, - }, + renderParams, templateName: 'vacancy-card.hbs', existingElement, }); diff --git a/src/Components/VacancyCard/vacancy-card.hbs b/src/Components/VacancyCard/vacancy-card.hbs index 1ce90c9..a1a938f 100644 --- a/src/Components/VacancyCard/vacancy-card.hbs +++ b/src/Components/VacancyCard/vacancy-card.hbs @@ -1,20 +1,17 @@
- +
-

{{vacancy.position}}

-
{{employer.name}}, {{employer.city}}
-
Зарплата: {{vacancy.salary}}
+

{{position}}

+
{{companyName}}, {{location}}
+
Зарплата: {{salary}}
-
- {{vacancy.description}} + {{description}}
\ No newline at end of file diff --git a/src/Components/VacancyForm/VacancyForm.js b/src/Components/VacancyForm/VacancyForm.js new file mode 100644 index 0000000..1a3a4bb --- /dev/null +++ b/src/Components/VacancyForm/VacancyForm.js @@ -0,0 +1,61 @@ +import { Component } from '../../modules/Components/Component.js'; +import { LiteralInput } from '/src/Components/FormInputs/LiteralInput/LiteralInput.js'; +import { CityInput } from '/src/Components/FormInputs/CityInput/CityInput.js'; +import { CurrencyInput } from '../../Components/FormInputs/CurrencyInput/CurrencyInput.js'; +import { TextInput } from '../FormInputs/TextInput/TextInput.js'; +import { ValidatedTextArea } from '../FormInputs/ValidatedTextArea/ValidatedTextArea.js'; +import { VacancyFormController } from './VacancyFormController.js'; +import { VacancyFormModel } from './VacancyFormModel.js'; +import { VacancyFormView } from './VacancyFormView.js'; + +export class VacancyForm extends Component { + #isNew; + constructor({ vacancyId = null, elementClass, existingElement }) { + super({ + modelClass: VacancyFormModel, + viewClass: VacancyFormView, + controllerClass: VacancyFormController, + modelParams: { vacancyId }, + existingElement, + viewParams: { elementClass, isNew: !vacancyId, vacancyId }, + }); + this.#isNew = !vacancyId; + this._positionField = new TextInput({ + existingElement: this._view.positionField, + selfValidate: true, + }); + this._workTypeField = new LiteralInput({ + existingElement: this._view.workTypeField, + selfValidate: true, + }); + this._salaryField = new CurrencyInput({ + existingElement: this._view.salaryField, + selfValidate: true, + }); + this._locationField = new CityInput({ + existingElement: this._view.locationField, + selfValidate: true, + }); + this._descriptionField = new ValidatedTextArea({ + existingElement: this._view.descriptionField, + selfValidate: true, + }); + this._children.push( + this._positionField, + this._workTypeField, + this._locationField, + this._descriptionField, + ); + if (!this.#isNew) { + this.reset(); + } + } + + get view() { + return this._view; + } + + reset() { + return this._controller.reset(); + } +} diff --git a/src/Components/VacancyForm/VacancyFormController.js b/src/Components/VacancyForm/VacancyFormController.js new file mode 100644 index 0000000..fd73527 --- /dev/null +++ b/src/Components/VacancyForm/VacancyFormController.js @@ -0,0 +1,83 @@ +import { ComponentController } from '../../modules/Components/Component.js'; +import { NOTIFICATION_OK, REDIRECT_TO, SUBMIT_FORM } from '../../modules/Events/Events.js'; +import { Vacancy } from '../../modules/models/Vacancy.js'; +import { VacancyPage } from '../../Pages/VacancyPage/VacancyPage.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { NOTIFICATION_ERROR } from '../../modules/Events/Events.js'; +import { NOTIFICATION_TIMEOUT } from '../NotificationBox/NotificationBox.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; + +export class VacancyFormController extends ComponentController { + constructor(model, view, controller) { + super(model, view, controller); + this.setHandlers([ + { + event: SUBMIT_FORM, + handler: this.submit.bind(this), + }, + ]); + } + + _validate() { + const errorMessage = this._model.validate(this._view.getData()); + if (errorMessage) { + eventBus.emit(NOTIFICATION_ERROR, { + message: errorMessage, + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + return false; + } + return [ + this._component._positionField.controller.validateInput({ + callerView: this._component._positionField._view, + }), + + this._component._salaryField.controller.validateInput({ + callerView: this._component._salaryField._view, + }), + + this._component._workTypeField.controller.validateInput({ + callerView: this._component._workTypeField._view, + }), + + this._component._locationField.controller.validateInput({ + callerView: this._component._locationField._view, + }), + + this._component._descriptionField.controller.validateInput({ + callerView: this._component._descriptionField._view, + }), + ].every((val) => val); + } + + async submit({ caller }) { + if (!Object.is(caller, this._view)) { + return; + } + if (!this._validate()) { + return; + } + try { + const vacancy = await this._model.submit(new Vacancy(this._view.getData())); + if (!vacancy) { + return; + } + const query = {}; + query[VacancyPage.VACANCY_ID_PARAM] = vacancy.id; + eventBus.emit(NOTIFICATION_OK, { + message: 'Операция проведена успешно', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('vacancy', query) }); + } catch (err) { + catchStandardResponseError(err); + } + } + + async reset() { + const oldData = await this._model.getLastValidData(); + this._view.renderData(oldData); + return true; + } +} diff --git a/src/Components/VacancyForm/VacancyFormModel.js b/src/Components/VacancyForm/VacancyFormModel.js new file mode 100644 index 0000000..19c6800 --- /dev/null +++ b/src/Components/VacancyForm/VacancyFormModel.js @@ -0,0 +1,54 @@ +import { Api } from '../../modules/Api/Api.js'; +import { ComponentModel } from '../../modules/Components/Component.js'; +import { Vacancy } from '../../modules/models/Vacancy.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { REDIRECT_TO } from '../../modules/Events/Events.js'; + +export class VacancyFormModel extends ComponentModel { + #lastValidData; + #vacancyId; + #isNew; + + constructor({ vacancyId = null }) { + super(); + this.#vacancyId = vacancyId; + this.#isNew = !this.#vacancyId; + this.#lastValidData = this.#vacancyId + ? Api.getVacancyById({ id: this.#vacancyId }).then( + (vacancy) => new Vacancy(vacancy), + () => { + eventBus.emit(REDIRECT_TO, { redirectUrl: resolveUrl('createVacancy') }); + }, + ) + : null; + } + + async getLastValidData() { + return this.#lastValidData; + } + + async submit(formData) { + const vacancy = this.#isNew + ? await Api.createVacancy(formData) + : await Api.updateVacancyById(zip({ id: this.#vacancyId }, formData)); + if (vacancy) { + this.#lastValidData = formData; + return vacancy; + } + return null; + } + + validate(formData) { + const hasEmptyFields = Object.entries(formData).some(([fieldKey, fieldValue]) => { + if (fieldKey === 'salary') { + return false; + } + return !fieldValue.trim(); + }); + if (hasEmptyFields) { + return 'Заполните пустые поля'; + } + } +} diff --git a/src/Components/VacancyForm/VacancyFormView.js b/src/Components/VacancyForm/VacancyFormView.js new file mode 100644 index 0000000..afa446d --- /dev/null +++ b/src/Components/VacancyForm/VacancyFormView.js @@ -0,0 +1,45 @@ +import { ComponentView } from '../../modules/Components/Component.js'; +import eventBus from '../../modules/Events/EventBus.js'; +import { SUBMIT_FORM } from '../../modules/Events/Events.js'; +import { addEventListeners } from '../../modules/Events/EventUtils.js'; +import { getFormData } from '../../modules/FormUtils/FormUtils.js'; + +export class VacancyFormView extends ComponentView { + constructor({ elementClass, isNew, vacancyId }, existingElement) { + super({ + renderParams: { elementClass, isNew, vacancyId }, + existingElement, + templateName: 'vacancy-form.hbs', + }); + this.positionField = this._html.querySelector('.vacancy-form__position'); + this.workTypeField = this._html.querySelector('.vacancy-form__work-type'); + this.locationField = this._html.querySelector('.vacancy-form__location'); + this.salaryField = this._html.querySelector('.vacancy-form__salary'); + this.descriptionField = this._html.querySelector('.vacancy-form__description'); + this._eventListeners.push({ + event: 'submit', + object: this._html, + listener: function (ev) { + ev.preventDefault(); + eventBus.emit(SUBMIT_FORM, { caller: this }); + }.bind(this), + }); + addEventListeners(this._eventListeners); + } + + getData() { + return getFormData(this._html); + } + + getId() { + return 'vacancy-form'; + } + + renderData({ position, workType, salary, location, description }) { + this.positionField.querySelector('.validated-input__input').value = position; + this.workTypeField.querySelector('.validated-input__input').value = workType; + this.locationField.querySelector('.validated-input__input').value = location; + this.salaryField.querySelector('.validated-input__input').value = salary; + this.descriptionField.querySelector('.validated-textarea__textarea').value = description; + } +} diff --git a/src/Components/VacancyForm/vacancy-form.hbs b/src/Components/VacancyForm/vacancy-form.hbs new file mode 100644 index 0000000..0897dc1 --- /dev/null +++ b/src/Components/VacancyForm/vacancy-form.hbs @@ -0,0 +1,19 @@ + + {{> validated-input formName="vacancy-form" elementName="position" inputName="position" inputCaption="Должность" inputType="text"}} + {{> validated-input formName="vacancy-form" elementName="salary" inputName="salary" inputCaption="Заработная плата (в руб.)" inputType="numeric"}} + {{> validated-input formName="vacancy-form" elementName="work-type" inputName="workType" inputCaption="Вид работы (к примеру разовая или постоянная)" inputType="text"}} + {{> validated-input formName="vacancy-form" elementName="location" inputName="location" inputCaption="Город, где нужно будет работать" inputType="text"}} + {{> validated-textarea formName="vacancy-form" elementName="description" inputName="description" inputCaption="Описание вакансии"}} +
+ {{#if isNew}} + + {{else}} + + {{/if}} + В профиль +
+ \ No newline at end of file diff --git a/src/Pages/CvEditPage/CvEditPage.js b/src/Pages/CvEditPage/CvEditPage.js new file mode 100644 index 0000000..b802f77 --- /dev/null +++ b/src/Pages/CvEditPage/CvEditPage.js @@ -0,0 +1,60 @@ +import { Header } from '../../Components/Header/Header.js'; +import state from '../../modules/AppState/AppState.js'; +import { Page } from '../../modules/Page/Page.js'; +import { ForbiddenPage, NotFoundError } from '../../modules/Router/Router.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import { CvEditPageController } from './CvEditPageController.js'; +import { CvEditPageModel } from './CvEditPageModel.js'; +import { CvEditPageView } from './CvEditPageView.js'; +import { CvForm } from '../../Components/CvForm/CvForm.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; + +export class CvEditPage extends Page { + #cvId; + + static CV_ID_PARAM = 'id'; + + constructor({ url }) { + if (state.userSession.userType !== USER_TYPE.APPLICANT) { + throw new ForbiddenPage(resolveUrl('vacancies')); + } + let cvId; + switch (url.pathname) { + case resolveUrl('editCv').pathname: { + cvId = +url.searchParams.get(CvEditPage.CV_ID_PARAM); + if (!cvId) { + throw new NotFoundError(); + } + break; + } + case resolveUrl('createCv').pathname: { + cvId = null; + break; + } + } + super({ + url, + modelClass: CvEditPageModel, + viewClass: CvEditPageView, + controllerClass: CvEditPageController, + viewParams: zip(Header.getViewParams(), { isNew: !cvId }), + }); + this.#cvId = cvId; + } + + postRenderInit() { + this._header = new Header({ + existingElement: this._view.header, + }); + this._children.push(this._header); + + this._cvForm = new CvForm({ + userId: state.userSession.userId, + cvId: this.#cvId, + elementClass: 'cv-edit-page__cv-form', + }); + this._children.push(this._cvForm); + this._controller.addCvForm(this._cvForm); + } +} diff --git a/src/Pages/CvEditPage/CvEditPageController.js b/src/Pages/CvEditPage/CvEditPageController.js new file mode 100644 index 0000000..e15eb3f --- /dev/null +++ b/src/Pages/CvEditPage/CvEditPageController.js @@ -0,0 +1,11 @@ +import { PageController } from '../../modules/Page/Page.js'; + +export class CvEditPageController extends PageController { + constructor(model, view, component) { + super(model, view, component); + } + + addCvForm(cvForm) { + this._view.addCvForm(cvForm.render()); + } +} diff --git a/src/Pages/CvEditPage/CvEditPageModel.js b/src/Pages/CvEditPage/CvEditPageModel.js new file mode 100644 index 0000000..c994c89 --- /dev/null +++ b/src/Pages/CvEditPage/CvEditPageModel.js @@ -0,0 +1,3 @@ +import { PageModel } from '../../modules/Page/Page.js'; + +export const CvEditPageModel = PageModel; diff --git a/src/Pages/CvEditPage/CvEditPageView.js b/src/Pages/CvEditPage/CvEditPageView.js new file mode 100644 index 0000000..262192f --- /dev/null +++ b/src/Pages/CvEditPage/CvEditPageView.js @@ -0,0 +1,17 @@ +import { PageView } from '../../modules/Page/Page.js'; + +export class CvEditPageView extends PageView { + constructor(renderParams) { + super({ + templateName: 'cv-page-edit.hbs', + renderParams: renderParams, + }); + this.header = this._html.querySelector('.header'); + this.formBox = this._html.querySelector('.cv-page-edit__form-container'); + } + + addCvForm(formRender) { + this.cvForm = formRender; + this.formBox.appendChild(formRender); + } +} diff --git a/src/Pages/CvEditPage/cv-page-edit.hbs b/src/Pages/CvEditPage/cv-page-edit.hbs new file mode 100644 index 0000000..4008baa --- /dev/null +++ b/src/Pages/CvEditPage/cv-page-edit.hbs @@ -0,0 +1,8 @@ +
+ {{> header}} +
+
+

{{#if isNew}}Создание{{else}}Редактирование{{/if}} резюме

+
+
+
\ No newline at end of file diff --git a/src/Pages/CvPage/CvPage.js b/src/Pages/CvPage/CvPage.js new file mode 100644 index 0000000..4d598cf --- /dev/null +++ b/src/Pages/CvPage/CvPage.js @@ -0,0 +1,61 @@ +import { Header } from '../../Components/Header/Header.js'; +import { ProfileMinicard } from '../../Components/ProfileMinicard/ProfileMinicard.js'; +import state from '../../modules/AppState/AppState.js'; +import { Page } from '../../modules/Page/Page.js'; +import { NotFoundError } from '../../modules/Router/Router.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { CvPageController } from './CvPageController.js'; +import { CvPageModel } from './CvPageModel.js'; +import { CvPageView } from './CvPageView.js'; +import { CvArticle } from '../../Components/CvArticle/CvArticle.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; + +export class CvPage extends Page { + #cvId; + #userType; + #userId; + #applicantId; + + static CV_ID_PARAM = 'id'; + + constructor({ url }) { + super({ + url, + modelClass: CvPageModel, + viewClass: CvPageView, + controllerClass: CvPageController, + viewParams: zip(Header.getViewParams(), { isAuthorized: state.userSession.isLoggedIn }), + }); + this.#cvId = +url.searchParams.get(CvPage.CV_ID_PARAM); + if (!this.#cvId) { + throw new NotFoundError(); + } + this.#userType = state.userSession.userType; + this.#userId = state.userSession.userId; + } + + async postRenderInit() { + this._header = new Header({ + existingElement: this._view.header, + }); + this._children.push(this._header); + + this._cvArticle = new CvArticle({ + elementClass: '.vacancy-page__vacancy-article', + userId: this.#userId, + cvId: this.#cvId, + userType: this.#userType, + }); + await this._cvArticle.makeButtons(); + this.#applicantId = await this._cvArticle.getApplicantId(); + this._controller.addCvArticle(this._cvArticle); + this._children.push(this._cvArticle); + + this._profileMinicard = new ProfileMinicard({ + userId: this.#applicantId, + userType: USER_TYPE.APPLICANT, + existingElement: this._view.profileMinicard, + }); + this._children.push(this._profileMinicard); + } +} diff --git a/src/Pages/CvPage/CvPageController.js b/src/Pages/CvPage/CvPageController.js new file mode 100644 index 0000000..f0d96da --- /dev/null +++ b/src/Pages/CvPage/CvPageController.js @@ -0,0 +1,11 @@ +import { PageController } from '../../modules/Page/Page.js'; + +export class CvPageController extends PageController { + constructor(model, view, component) { + super(model, view, component); + } + + addCvArticle(cvArticle) { + this._view.addCvArticle(cvArticle.render()); + } +} diff --git a/src/Pages/CvPage/CvPageModel.js b/src/Pages/CvPage/CvPageModel.js new file mode 100644 index 0000000..48a831f --- /dev/null +++ b/src/Pages/CvPage/CvPageModel.js @@ -0,0 +1,3 @@ +import { PageModel } from '../../modules/Page/Page.js'; + +export const CvPageModel = PageModel; diff --git a/src/Pages/CvPage/CvPageView.js b/src/Pages/CvPage/CvPageView.js new file mode 100644 index 0000000..e784ed0 --- /dev/null +++ b/src/Pages/CvPage/CvPageView.js @@ -0,0 +1,20 @@ +import { PageView } from '../../modules/Page/Page.js'; + +export class CvPageView extends PageView { + constructor(renderParams) { + renderParams.isEmployer = !renderParams.isApplicant && renderParams.isAuthorized; + super({ + templateName: 'cv-page.hbs', + renderParams: renderParams, + }); + this.header = this._html.querySelector('.header'); + this.profileMinicard = this._html.querySelector('.cv-page__profile-minicard'); + this.cvContainer = this._html.querySelector('.cv-page__cv-container'); + this.sideColumn = this._html.querySelector('.cv-page__left-column'); + } + + addCvArticle(render) { + this.cvContainer.innerHTML = ''; + this.cvContainer.appendChild(render); + } +} diff --git a/src/Pages/CvPage/cv-page.hbs b/src/Pages/CvPage/cv-page.hbs new file mode 100644 index 0000000..1452db3 --- /dev/null +++ b/src/Pages/CvPage/cv-page.hbs @@ -0,0 +1,10 @@ +
+ {{> header}} +
+
+ {{> profile-minicard elementClass="cv-page__profile-minicard"}} +
+
+
+
+
\ No newline at end of file diff --git a/src/Pages/ProfilePage/ProfilePage.js b/src/Pages/ProfilePage/ProfilePage.js index a07bb45..b553c2e 100644 --- a/src/Pages/ProfilePage/ProfilePage.js +++ b/src/Pages/ProfilePage/ProfilePage.js @@ -13,7 +13,6 @@ import { NotFoundError } from '../../modules/Router/Router.js'; import USER_TYPE from '../../modules/UserSession/UserType.js'; import { ProfileMinicard } from '../../Components/ProfileMinicard/ProfileMinicard.js'; import { EmployerVacancyList } from '../../Components/Lists/EmployerVacancyList/EmployerVacancyList.js'; -import { ApplicantPortfolioList } from '../../Components/Lists/ApplicantPortfolioList/ApplicantPortfolioList.js'; import { ApplicantCvList } from '../../Components/Lists/ApplicantCvList/ApplicantCvList.js'; export const PROFILE_PAGE_PARAMS = { @@ -103,15 +102,15 @@ export class ProfilePage extends Page { elementClass: 'profile-page__personal-data', }), }, - { - frameName: PROFILE_STARTING_FRAMES.PORTFOLIOS, - frameCaption: 'Портфолио', - frameComponent: new ApplicantPortfolioList({ - userId: this.#userId, - isListOwner: this.#isProfileOwner, - elementClass: 'profile-page__portfolio-list', - }), - }, + // { + // frameName: PROFILE_STARTING_FRAMES.PORTFOLIOS, + // frameCaption: 'Портфолио', + // frameComponent: new ApplicantPortfolioList({ + // userId: this.#userId, + // isListOwner: this.#isProfileOwner, + // elementClass: 'profile-page__portfolio-list', + // }), + // }, { frameName: PROFILE_STARTING_FRAMES.CVS, frameCaption: 'Резюме', diff --git a/src/Pages/VacanciesPage/VacanciesPageController.js b/src/Pages/VacanciesPage/VacanciesPageController.js index 8598523..d5dc413 100644 --- a/src/Pages/VacanciesPage/VacanciesPageController.js +++ b/src/Pages/VacanciesPage/VacanciesPageController.js @@ -17,7 +17,7 @@ export class VacanciesPageController extends PageController { } async fetchVacancies() { - const vacancies = await this._model.getVacancies().catch(() => []); + const vacancies = await this._model.getVacancies(); this._component.bindVacancies(vacancies); vacancies.forEach((vacancy) => { this._view.addVacancy(vacancy.view.render()); diff --git a/src/Pages/VacanciesPage/VacanciesPageModel.js b/src/Pages/VacanciesPage/VacanciesPageModel.js index b78f92b..30a067c 100644 --- a/src/Pages/VacanciesPage/VacanciesPageModel.js +++ b/src/Pages/VacanciesPage/VacanciesPageModel.js @@ -4,7 +4,9 @@ import USER_TYPE from '/src/modules/UserSession/UserType.js'; import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; import { AlertWindow } from '../../Components/AlertWindow/AlertWindow.js'; import { VacancyCard } from '/src/Components/VacancyCard/VacancyCard.js'; +import { Vacancy } from '../../modules/models/Vacancy.js'; import { Api } from '../../modules/Api/Api.js'; +import { catchStandardResponseError } from '../../modules/Api/Errors.js'; export class VacanciesPageModel extends PageModel { #vacanciesLoaded; @@ -24,7 +26,7 @@ export class VacanciesPageModel extends PageModel { viewParams: { elementClass: 'ruler__alert-window', text: 'Попробуйте добавить свою вакансию!', - buttonUrl: '/', + buttonUrl: resolveUrl('createVacancy'), buttonText: 'Добавить вакансию', }, }), @@ -56,25 +58,25 @@ export class VacanciesPageModel extends PageModel { } async getVacancies() { - let vacanciesJson = await Api.vacanciesFeed({ - offset: this.#vacanciesLoaded, - num: this.#VACANCIES_AMOUNT, - }); - const vacanciesObjects = vacanciesJson.reduce((vacanciesObjects, vacancyJson) => { - try { - const { createdAt, description, employer, location, logo, position, salary } = vacancyJson; - vacanciesObjects.push( - new VacancyCard({ - employer: { logo, city: location, name: employer }, - vacancy: { createdAt, description, position, salary }, - }), - ); - this.#vacanciesLoaded++; - return vacanciesObjects; - } catch { - return vacanciesObjects; - } - }, []); - return vacanciesObjects; + try { + let vacanciesJson = await Api.vacanciesFeed({ + offset: this.#vacanciesLoaded, + num: this.#VACANCIES_AMOUNT, + }); + const vacanciesCards = vacanciesJson.reduce((vacanciesCards, vacancyJson) => { + try { + const vacancy = new Vacancy(vacancyJson); + vacanciesCards.push(new VacancyCard({ vacancyObj: vacancy })); + this.#vacanciesLoaded++; + return vacanciesCards; + } catch { + return vacanciesCards; + } + }, []); + return vacanciesCards; + } catch (err) { + catchStandardResponseError(err); + return []; + } } } diff --git a/src/Pages/VacancyEditPage/VacancyEditPage.js b/src/Pages/VacancyEditPage/VacancyEditPage.js new file mode 100644 index 0000000..e526e6e --- /dev/null +++ b/src/Pages/VacancyEditPage/VacancyEditPage.js @@ -0,0 +1,59 @@ +import { Header } from '../../Components/Header/Header.js'; +import state from '../../modules/AppState/AppState.js'; +import { Page } from '../../modules/Page/Page.js'; +import { ForbiddenPage, NotFoundError } from '../../modules/Router/Router.js'; +import { resolveUrl } from '../../modules/UrlUtils/UrlUtils.js'; +import { VacancyEditPageController } from './VacancyEditPageController.js'; +import { VacancyEditPageModel } from './VacancyEditPageModel.js'; +import { VacancyEditPageView } from './VacancyEditPageView.js'; +import { VacancyForm } from '../../Components/VacancyForm/VacancyForm.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; + +export class VacancyEditPage extends Page { + #vacancyId; + + static VACANCY_ID_PARAM = 'id'; + + constructor({ url }) { + if (state.userSession.userType !== USER_TYPE.EMPLOYER) { + throw new ForbiddenPage(resolveUrl('vacancies')); + } + let vacancyId; + switch (url.pathname) { + case resolveUrl('editVacancy').pathname: { + vacancyId = +url.searchParams.get(VacancyEditPage.VACANCY_ID_PARAM); + if (!vacancyId) { + throw new NotFoundError(); + } + break; + } + case resolveUrl('createVacancy').pathname: { + vacancyId = null; + break; + } + } + super({ + url, + modelClass: VacancyEditPageModel, + viewClass: VacancyEditPageView, + controllerClass: VacancyEditPageController, + viewParams: zip(Header.getViewParams(), { isNew: !vacancyId }), + }); + this.#vacancyId = vacancyId; + } + + postRenderInit() { + this._header = new Header({ + existingElement: this._view.header, + }); + this._children.push(this._header); + this._vacancyForm = new VacancyForm({ + userId: state.userSession.userId, + vacancyId: this.#vacancyId, + elementClass: 'vacancy-edit-page__vacancy-form', + }); + this._children.push(this._vacancyForm); + this._controller.addVacancyForm(this._vacancyForm); + } +} diff --git a/src/Pages/VacancyEditPage/VacancyEditPageController.js b/src/Pages/VacancyEditPage/VacancyEditPageController.js new file mode 100644 index 0000000..c0ac327 --- /dev/null +++ b/src/Pages/VacancyEditPage/VacancyEditPageController.js @@ -0,0 +1,11 @@ +import { PageController } from '../../modules/Page/Page.js'; + +export class VacancyEditPageController extends PageController { + constructor(model, view, component) { + super(model, view, component); + } + + addVacancyForm(vacancyForm) { + this._view.addVacancyForm(vacancyForm.render()); + } +} diff --git a/src/Pages/VacancyEditPage/VacancyEditPageModel.js b/src/Pages/VacancyEditPage/VacancyEditPageModel.js new file mode 100644 index 0000000..3b99fc1 --- /dev/null +++ b/src/Pages/VacancyEditPage/VacancyEditPageModel.js @@ -0,0 +1,3 @@ +import { PageModel } from '../../modules/Page/Page.js'; + +export class VacancyEditPageModel extends PageModel {} diff --git a/src/Pages/VacancyEditPage/VacancyEditPageView.js b/src/Pages/VacancyEditPage/VacancyEditPageView.js new file mode 100644 index 0000000..eb4bdde --- /dev/null +++ b/src/Pages/VacancyEditPage/VacancyEditPageView.js @@ -0,0 +1,18 @@ +import { PageView } from '../../modules/Page/Page.js'; + +export class VacancyEditPageView extends PageView { + constructor(renderParams) { + renderParams.isEmployer = !renderParams.isApplicant && renderParams.isAuthorized; + super({ + templateName: 'vacancy-page-edit.hbs', + renderParams: renderParams, + }); + this.header = this._html.querySelector('.header'); + this.formBox = this._html.querySelector('.vacancy-page-edit__form-container'); + } + + addVacancyForm(formRender) { + this.vacancyForm = formRender; + this.formBox.appendChild(formRender); + } +} diff --git a/src/Pages/VacancyEditPage/vacancy-page-edit.hbs b/src/Pages/VacancyEditPage/vacancy-page-edit.hbs new file mode 100644 index 0000000..e984ca9 --- /dev/null +++ b/src/Pages/VacancyEditPage/vacancy-page-edit.hbs @@ -0,0 +1,8 @@ +
+ {{> header}} +
+
+

{{#if isNew}}Создание{{else}}Редактирование{{/if}} вакансии

+
+
+
\ No newline at end of file diff --git a/src/Pages/VacancyPage/VacancyPage.js b/src/Pages/VacancyPage/VacancyPage.js new file mode 100644 index 0000000..26c2b69 --- /dev/null +++ b/src/Pages/VacancyPage/VacancyPage.js @@ -0,0 +1,72 @@ +import { Header } from '../../Components/Header/Header.js'; +import { ProfileMinicard } from '../../Components/ProfileMinicard/ProfileMinicard.js'; +import state from '../../modules/AppState/AppState.js'; +import { Page } from '../../modules/Page/Page.js'; +import { NotFoundError } from '../../modules/Router/Router.js'; +import USER_TYPE from '../../modules/UserSession/UserType.js'; +import { VacancyPageController } from './VacancyPageController.js'; +import { VacancyPageModel } from './VacancyPageModel.js'; +import { VacancyPageView } from './VacancyPageView.js'; +import { VacancyArticle } from '../../Components/VacancyArticle/VacancyArticle.js'; +import { AppliersList } from '../../Components/AppliersList/AppliersList.js'; +import { zip } from '../../modules/ObjectUtils/Zip.js'; + +export class VacancyPage extends Page { + #vacancyId; + #userType; + #userId; + #employerId; + + static VACANCY_ID_PARAM = 'id'; + + constructor({ url }) { + super({ + url, + modelClass: VacancyPageModel, + viewClass: VacancyPageView, + controllerClass: VacancyPageController, + viewParams: zip(Header.getViewParams(), { isAuthorized: state.userSession.isLoggedIn }), + }); + this.#vacancyId = +url.searchParams.get(VacancyPage.VACANCY_ID_PARAM); + if (!this.#vacancyId) { + throw new NotFoundError(); + } + this.#userType = state.userSession.userType; + this.#userId = state.userSession.userId; + } + + async postRenderInit() { + this._header = new Header({ + existingElement: this._view.header, + }); + this._children.push(this._header); + this._vacancyArticle = new VacancyArticle({ + elementClass: '.vacancy-page__vacancy-article', + userId: this.#userId, + vacancyId: this.#vacancyId, + userType: this.#userType, + }); + await this._vacancyArticle.makeButtons(); + this.#employerId = await this._vacancyArticle.getEmployerId(); + this._controller.addVacancyArticle(this._vacancyArticle); + this._children.push(this._vacancyArticle); + + if (this.#userType !== USER_TYPE.EMPLOYER) { + this._profileMinicard = new ProfileMinicard({ + userId: this.#employerId, + userType: USER_TYPE.EMPLOYER, + existingElement: this._view.profileMinicard, + }); + this._children.push(this._profileMinicard); + } + + if (this.#userType === USER_TYPE.EMPLOYER && this.#userId === this.#employerId) { + this._appliersList = new AppliersList({ + elementClass: 'vacancy-page__appliers-list', + vacancyId: this.#vacancyId, + }); + this._children.push(this._appliersList); + this._view.addAppliersList(this._appliersList.render()); + } + } +} diff --git a/src/Pages/VacancyPage/VacancyPageController.js b/src/Pages/VacancyPage/VacancyPageController.js new file mode 100644 index 0000000..1443919 --- /dev/null +++ b/src/Pages/VacancyPage/VacancyPageController.js @@ -0,0 +1,11 @@ +import { PageController } from '../../modules/Page/Page.js'; + +export class VacancyPageController extends PageController { + constructor(model, view, component) { + super(model, view, component); + } + + addVacancyArticle(vacancyArticle) { + this._view.addVacancyArticle(vacancyArticle.render()); + } +} diff --git a/src/Pages/VacancyPage/VacancyPageModel.js b/src/Pages/VacancyPage/VacancyPageModel.js new file mode 100644 index 0000000..a8cbe8a --- /dev/null +++ b/src/Pages/VacancyPage/VacancyPageModel.js @@ -0,0 +1,3 @@ +import { PageModel } from '../../modules/Page/Page.js'; + +export class VacancyPageModel extends PageModel {} diff --git a/src/Pages/VacancyPage/VacancyPageView.js b/src/Pages/VacancyPage/VacancyPageView.js new file mode 100644 index 0000000..341aaad --- /dev/null +++ b/src/Pages/VacancyPage/VacancyPageView.js @@ -0,0 +1,27 @@ +import { PageView } from '../../modules/Page/Page.js'; + +export class VacancyPageView extends PageView { + constructor(renderParams) { + renderParams.isEmployer = !renderParams.isApplicant && renderParams.isAuthorized; + super({ + templateName: 'vacancy-page.hbs', + renderParams: renderParams, + }); + this.header = this._html.querySelector('.header'); + if (!renderParams.isEmployer) { + this.profileMinicard = this._html.querySelector('.vacancy-page__profile-minicard'); + } + this.vacancyContainer = this._html.querySelector('.vacancy-page__vacancy-container'); + this.sideColumn = this._html.querySelector('.vacancy-page__left-column'); + } + + addVacancyArticle(render) { + this.vacancyContainer.innerHTML = ''; + this.vacancyContainer.appendChild(render); + } + + addAppliersList(render) { + this.sideColumn.appendChild(render); + render.classList.remove('hidden'); + } +} diff --git a/src/Pages/VacancyPage/vacancy-page.hbs b/src/Pages/VacancyPage/vacancy-page.hbs new file mode 100644 index 0000000..989d96f --- /dev/null +++ b/src/Pages/VacancyPage/vacancy-page.hbs @@ -0,0 +1,12 @@ +
+ {{> header}} +
+
+ {{#unless isEmployer}} + {{> profile-minicard elementClass="vacancy-page__profile-minicard"}} + {{/unless}} +
+
+
+
+
\ No newline at end of file diff --git a/src/css/cv.css b/src/css/cv.css new file mode 100644 index 0000000..0f280b2 --- /dev/null +++ b/src/css/cv.css @@ -0,0 +1,184 @@ +.cv-page { + display: grid; + align-items: start; + margin: auto; + width: 1280px; + padding-top: 32px; + grid-template-columns: 1fr 3fr; +} + +.cv-page__profile-minicard { + background-color: var(--color-background-900); + border-radius: 25px; + padding: 16px 36px; + color: var(--color-main-100); +} + +.cv-page__cv-container { + margin-left: 32px; + border-radius: 25px; + background-color: var(--color-background-900); +} + +/* Vacancy article */ + +.cv-article { + display: flex; + flex-direction: column; + align-items: start; +} + +.cv-article__header-container { + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; + padding: 32px; +} + +.cv-article__cv-picture { + width: 128px; + height: 128px; +} + +.cv-article__header { + display: flex; + flex-direction: column; + justify-content: start; + color: var(--color-main-100); +} + +.cv-article__description { + padding: 0 32px; + font-size: var(--text-size-5); + line-height: 1.5; + color: var(--color-main-100); + margin-bottom: 32px; +} + +.cv-article__working-experience { + padding: 0px 32px; + font-size: var(--text-size-5); + line-height: 1.5; + color: var(--color-main-100); +} + +.cv-article__another-button { + margin-right: 10px; + box-shadow: none; + padding: 10px 20px; +} + +.cv-article__created-at { + color: var(--color-main-100); + opacity: 0.7; + text-decoration: none; + text-transform: lowercase; +} + +.cv-article_theme-dark { + background-color: var(--color-background-800); + color: var(--color-main-100); +} + +.cv-article__divider { + display: inline-block; + background-color: var(--color-background-100); + opacity: 0.3; + height: 1px; + width: 100%; +} + +.cv-article__button-container { + display: flex; + align-items: baseline; + gap: 8px; + padding: 32px 32px; + justify-content: start; +} + +.cv-article__header-text { + font-size: var(--text-size-10); + text-decoration: none; + margin-top: 0px; + margin-bottom: 8px; +} + +.cv-article__job-search-status { + box-sizing: border-box; +} + +.cv-article__description-header { + font-size: var(--text-size-9); + color: var(--color-main-100); + padding: 0 32px; +} + +.cv-article__working-experience-header { + font-size: var(--text-size-9); + color: var(--color-main-100); + padding: 0 32px; +} + +/* Cv Edit Page */ + +.cv-page-edit { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + width: 100vw; + height: 100vh; +} + +.cv-page-edit__form-container { + margin-top: 32px; + padding: 20px; + border-radius: 14px; + width: 100%; + max-width: 600px; + box-sizing: border-box; + background-color: var(--color-background-900); +} + +.cv-page-edit__header { + font-size: var(--text-size-8); + color: var(--color-main-100); + text-align: center; +} + +/* Vacancy Form */ + +.cv-form__position-ru { + margin-bottom: 10px; +} + +.cv-form__position-en { + margin-bottom: 10px; +} + +.cv-form__job-search-status { + margin-bottom: 10px; +} + +.cv-form__working-experience { + margin-bottom: 20px; +} + +.cv-form__description { + margin-bottom: 10px; +} + +.cv-form__description-textarea { + resize: vertical; +} + +.cv-form__working-experience-textarea { + resize: vertical; +} + +.cv-form__button-container { + display: flex; + align-items: baseline; + gap: 20px; +} diff --git a/src/css/index.css b/src/css/index.css index 282e973..952bc2d 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -6,6 +6,8 @@ @import url(registration.css); @import url(forms.css); @import url(profile.css); +@import url(vacancy.css); +@import url(cv.css); :root { --grey-very-dark: #1b1b1b; @@ -128,7 +130,7 @@ body { } .ruler__alert-window { - border-radius: 10px; + border-radius: 14px; padding: 12px; } @@ -141,28 +143,13 @@ body { .alert-window_theme-dark { background-color: var(--color-background-800); - color: white; + color: var(--color-main-100); } .alert-window__button { margin-top: 10px; } -.notification { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - background-color: #ff4c4c; - color: white; - padding: 10px 20px; - border-radius: 5px; - display: none; - z-index: 1000; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - font-size: 14px; -} - /* Frame series component */ .frame-series__frame-selector { @@ -222,6 +209,7 @@ body { .minicard__title { font-size: var(--text-size-7); color: var(--color-main-100); + text-decoration: none; } .minicard__button-container { @@ -244,6 +232,54 @@ body { gap: 30px; } +.list__add-button-container { + display: flex; + justify-content: end; +} + .hidden { display: none !important; } + +.pill { + text-align: center; +} + +.pill_main { + border-radius: 14px; + padding: 4px 12px; + background-color: var(--color-background-700); +} + +/* Notification-box */ + +.notification-box { + display: flex; + font-family: 'Roboto', sans-serif; + flex-direction: column-reverse; + gap: 20px; + position: absolute; + left: 50%; + bottom: 20px; + width: 400px; + transform: translate(-50%); + z-index: 1000; +} + +.notification_error { + background-color: var(--color-background-700); + color: var(--color-secondary-support-400); + font-size: var(--text-size-5); + border-radius: 14px; + padding: 10px 20px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.notification_ok { + background-color: var(--color-background-700); + color: var(--color-main-500); + font-size: var(--text-size-5); + border-radius: 14px; + padding: 10px 20px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} diff --git a/src/css/registration.css b/src/css/registration.css index 8805cea..e066ff9 100755 --- a/src/css/registration.css +++ b/src/css/registration.css @@ -25,6 +25,7 @@ .registration-container__header { margin-top: 0px; text-align: center; + margin: 10px; color: white; } diff --git a/src/css/vacancies.css b/src/css/vacancies.css index 6196293..9e30200 100644 --- a/src/css/vacancies.css +++ b/src/css/vacancies.css @@ -11,18 +11,18 @@ background-color: var( --color-background-1000 ); /* for some reason variable for body resolved only here */ - color: white; + color: var(--color-main-100); } .vacancies-page__side-column { - border-radius: 10px; + border-radius: 25px; padding: 12px; width: 20%; } .vacancies-page__content { width: 70%; - border-radius: 10px; + border-radius: 25px; padding: 20px; } @@ -44,7 +44,7 @@ .content-body_theme-dark { background-color: var(--color-background-900); - color: white; + color: var(--color-main-100); } /* Vacancy-container component */ @@ -58,7 +58,7 @@ width: 90%; margin-bottom: 30px; padding: 20px; - border-radius: 5px; + border-radius: 14px; } /* vacancy-card component */ @@ -75,8 +75,10 @@ } .vacancy-card__company-picture { - width: 100px; - height: 100px; + width: 96px; + height: 96px; + border-radius: 100%; + border: solid 1px; } .vacancy-card__header { @@ -86,6 +88,11 @@ margin-left: 20px; } +.vacancy-card__header-link { + text-decoration: none; + color: var(--color-main-100); +} + .vacancy-card__bookmark-icon { margin-left: auto; width: 25px; @@ -94,10 +101,20 @@ .vacancy-card__description { margin: 10px 0; + font-size: var(--text-size-5); + text-overflow: ellipsis; + overflow: hidden; + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; } .vacancy-card__footer { - margin-top: 10px; + display: flex; + flex-direction: row; + gap: 20px; + align-items: baseline; } .vacancy-card__apply-button { @@ -115,16 +132,5 @@ .vacancy-card_theme-dark { background-color: var(--color-background-800); - color: white; -} - -/* vacancy-summary component */ - -.vacancy-summary { - display: flex; - flex-direction: column; -} -.vacancy-summary__header { - margin-top: 0px; - margin-bottom: 8px; + color: var(--color-main-100); } diff --git a/src/css/vacancy.css b/src/css/vacancy.css new file mode 100644 index 0000000..c3bf97a --- /dev/null +++ b/src/css/vacancy.css @@ -0,0 +1,217 @@ +.vacancy-page { + display: grid; + align-items: start; + margin: auto; + width: 1280px; + padding-top: 32px; + grid-template-columns: 1fr 3fr; +} + +.vacancy-page__profile-minicard { + background-color: var(--color-background-900); + border-radius: 25px; + padding: 16px 36px; + color: var(--color-main-100); +} + +.vacancy-page__vacancy-container { + margin-left: 32px; + border-radius: 25px; + background-color: var(--color-background-900); +} + +/* Vacancy article */ + +.vacancy-article { + display: flex; + flex-direction: column; + align-items: start; +} + +.vacancy-article__header-container { + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; + padding: 32px; +} + +.vacancy-article__company-picture { + width: 128px; + height: 128px; +} + +.vacancy-article__header { + display: flex; + flex-direction: column; + justify-content: start; + margin-left: 20px; +} + +.vacancy-article__description { + padding: 32px 32px 0px 32px; + font-size: var(--text-size-5); + line-height: 1.5; + color: var(--color-main-100); +} + +.vacancy-article__footer { + margin-top: 10px; +} + +.vacancy-article__apply-button { + margin-right: 10px; + box-shadow: none; + padding: 10px 20px; +} + +.vacancy-article__reset-apply-button { + margin-right: 10px; + box-shadow: none; + padding: 10px 20px; +} + +.vacancy-article__created-at { + color: var(--color-main-100); + opacity: 0.7; + text-decoration: none; + text-transform: lowercase; +} + +.vacancy-article_theme-dark { + background-color: var(--color-background-800); + color: var(--color-main-100); +} + +.vacancy-article__divider { + display: inline-block; + background-color: var(--color-background-100); + opacity: 0.3; + height: 1px; + width: 100%; +} + +.vacancy-article__button-container { + display: flex; + align-items: baseline; + gap: 8px; + padding: 32px 32px; + justify-content: start; +} + +/* vacancy-summary component */ + +.vacancy-summary { + display: flex; + flex-direction: column; + color: var(--color-main-100); +} + +.vacancy-summary__header { + font-size: var(--text-size-10); + text-decoration: none; + margin-top: 0px; + margin-bottom: 8px; +} + +.vacancy-summary__company-name { + font-size: var(--text-size-5); + margin-bottom: 8px; +} + +.vacancy-summary__salary { + font-size: var(--text-size-4); + margin-right: 20px; +} + +.vacancy-page__appliers-list { + border-radius: 25px; + padding: 24px 0px; + background-color: var(--color-background-900); + color: var(--color-main-100); + font-size: var(--text-size-5); +} + +/* Appliers list */ + +.appliers-list__header { + margin: 0; + padding: 0 24px; + font-size: var(--text-size-9); +} + +.appliers-list__divider { + margin: 12px 0px; + width: 100%; + height: 1px; + opacity: 0.3; + background-color: var(--color-main-100); +} + +.appliers-list__list { + margin: 0; + line-height: 1.5; +} + +.appliers-list__list-item { + color: var(--color-main-200); +} + +/* Vacancy Edit Page */ + +.vacancy-page-edit { + display: flex; + flex-direction: column; + justify-content: start; + align-items: center; + width: 100vw; + height: 100vh; +} + +.vacancy-page-edit__form-container { + margin-top: 32px; + padding: 20px; + border-radius: 14px; + width: 100%; + max-width: 600px; + box-sizing: border-box; + background-color: var(--color-background-900); +} + +.vacancy-page-edit__header { + font-size: var(--text-size-8); + color: var(--color-main-100); + text-align: center; +} + +/* Vacancy Form */ + +.vacancy-form__position { + margin-bottom: 10px; +} + +.vacancy-form__salary { + margin-bottom: 10px; +} + +.vacancy-form__work-type { + margin-bottom: 10px; +} + +.vacancy-form__location { + margin-bottom: 10px; +} + +.vacancy-form__description { + margin-bottom: 20px; +} + +.vacancy-form__description-textarea { + resize: vertical; +} + +.vacancy-form__button-container { + display: flex; + align-items: baseline; + gap: 20px; +} diff --git a/src/index.html b/src/index.html index 223edb6..b77f723 100644 --- a/src/index.html +++ b/src/index.html @@ -2,14 +2,15 @@ - μArt - + Art +
- - - +
+ + + diff --git a/src/index.js b/src/index.js index 366777f..83a02ac 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,45 @@ import { handlebarsInit } from '/src/modules/Handlebars/Handlebars.js'; import router from '/src/modules/Router/Router.js'; +import eventBus from './modules/Events/EventBus.js'; import appState from './modules/AppState/AppState.js'; import { LoginPage } from './Pages/LoginPage/LoginPage.js'; import { RegistrationPage } from './Pages/RegistrationPage/RegistrationPage.js'; import { VacanciesPage } from './Pages/VacanciesPage/VacanciesPage.js'; import { ProfilePage } from './Pages/ProfilePage/ProfilePage.js'; +import { VacancyPage } from './Pages/VacancyPage/VacancyPage.js'; +import { VacancyEditPage } from './Pages/VacancyEditPage/VacancyEditPage.js'; +import { resolveUrl } from './modules/UrlUtils/UrlUtils.js'; +import { REDIRECT_TO, GO_TO } from './modules/Events/Events.js'; +import { CvPage } from './Pages/CvPage/CvPage.js'; +import { CvEditPage } from './Pages/CvEditPage/CvEditPage.js'; +import { NotificationBox } from './Components/NotificationBox/NotificationBox.js'; handlebarsInit(); -router.addRoute('/', VacanciesPage); -router.addRoute('/login', LoginPage); -router.addRoute('/registration', RegistrationPage); -router.addRoute('/me', ProfilePage); -router.addRoute('/profile', ProfilePage); +// eslint-disable-next-line no-unused-vars +const notificationBox = new NotificationBox({ + existingElement: document.querySelector('.notification-box'), +}); + +router.addRoute(resolveUrl('vacancies').pathname, VacanciesPage); +router.addRoute(resolveUrl('login').pathname, LoginPage); +router.addRoute(resolveUrl('register').pathname, RegistrationPage); +router.addRoute(resolveUrl('myProfile').pathname, ProfilePage); +router.addRoute(resolveUrl('profile').pathname, ProfilePage); +router.addRoute(resolveUrl('vacancy').pathname, VacancyPage); +router.addRoute(resolveUrl('createVacancy').pathname, VacancyEditPage); +router.addRoute(resolveUrl('editVacancy').pathname, VacancyEditPage); +router.addRoute(resolveUrl('cv').pathname, CvPage); +router.addRoute(resolveUrl('createCv').pathname, CvEditPage); +router.addRoute(resolveUrl('editCv').pathname, CvEditPage); + +eventBus.on(REDIRECT_TO, ({ redirectUrl }) => { + router.navigate(redirectUrl, true, true); +}); + +eventBus.on(GO_TO, ({ redirectUrl }) => { + router.navigate(redirectUrl, false, true); +}); appState.userSession.checkAuthorization().finally(() => { router.start(); diff --git a/src/modules/Api/Api.js b/src/modules/Api/Api.js index 103d6a1..74b230d 100644 --- a/src/modules/Api/Api.js +++ b/src/modules/Api/Api.js @@ -1,4 +1,4 @@ -const backendPrefix = 'http://127.0.0.1:8080/api/v1/'; +const backendPrefix = 'http://192.168.88.82:8080/api/v1/'; const backendApi = new Map( Object.entries({ authenticated: backendPrefix + 'authorized', @@ -12,11 +12,23 @@ const backendApi = new Map( applicantPortfolio: backendPrefix + 'applicant/portfolio/', applicantCv: backendPrefix + 'applicant/cv/', employerVacancies: backendPrefix + 'employer/vacancies/', + vacancy: backendPrefix + 'vacancy/', + vacancySubscribers: backendPrefix + 'vacancy/subscribers/', + cv: backendPrefix + 'cv/', + vacancyApply: backendPrefix + 'vacancy/subscription/', }), ); export class UnmarshallError extends Error {} export class ResponseError extends Error {} +export class TransportError extends Error {} + +export const HTTP_METHOD = { + GET: 'get', + POST: 'post', + PUT: 'put', + DELETE: 'delete', +}; export const HTTP_STATUSCODE = { OK: 200, @@ -40,13 +52,16 @@ const unpackStandardApiCall = async (response) => { } throw new ResponseError(responseBody.error); } - if (!responseBody.body) { + if (!responseBody.body && responseBody.body !== null) { throw new UnmarshallError('Expected body in json, but not found'); } return responseBody.body; }; -const fetchCorsJson = (url, { method = 'GET', credentials = 'same-origin', body = undefined }) => { +const fetchCorsJson = ( + url, + { method = HTTP_METHOD.GET, credentials = 'same-origin', body = undefined }, +) => { return fetch(url, { method, headers: { @@ -56,12 +71,14 @@ const fetchCorsJson = (url, { method = 'GET', credentials = 'same-origin', body mode: 'cors', credentials, body, + }).catch((response) => { + return Promise.reject(new TransportError(response.statusCode)); }); }; export class Api { static isAuthenticated = async () => { const response = await fetchCorsJson(backendApi.get('authenticated'), { - method: 'POST', + method: HTTP_METHOD.POST, credentials: 'include', }); return unpackStandardApiCall(response); @@ -69,7 +86,7 @@ export class Api { static login = async ({ userType, email, password }) => { const response = await fetchCorsJson(backendApi.get('login'), { - method: 'POST', + method: HTTP_METHOD.POST, body: JSON.stringify({ userType, email, @@ -82,7 +99,7 @@ export class Api { static registerApplicant = async ({ firstName, secondName, birthDate, email, password }) => { const response = await fetchCorsJson(backendApi.get('registerApplicant'), { - method: 'POST', + method: HTTP_METHOD.POST, credentials: 'include', body: JSON.stringify({ firstName, @@ -106,7 +123,7 @@ export class Api { password, }) => { const response = await fetchCorsJson(backendApi.get('registerEmployer'), { - method: 'POST', + method: HTTP_METHOD.POST, credentials: 'include', body: JSON.stringify({ firstName: firstName, @@ -124,35 +141,35 @@ export class Api { static getApplicantById = async ({ id }) => { const response = await fetchCorsJson(backendApi.get('applicantProfile') + id, { - method: 'GET', + method: HTTP_METHOD.GET, }); return await unpackStandardApiCall(response); }; static getEmployerById = async ({ id }) => { const response = await fetchCorsJson(backendApi.get('employerProfile') + id, { - method: 'GET', + method: HTTP_METHOD.GET, }); return unpackStandardApiCall(response); }; static async getEmployerVacancies({ id }) { const response = await fetchCorsJson(backendApi.get('employerVacancies') + id, { - method: 'GET', + method: HTTP_METHOD.GET, }); return unpackStandardApiCall(response); } static async getApplicantPortfolios({ id }) { const response = await fetchCorsJson(backendApi.get('applicantPortfolio') + id, { - method: 'GET', + method: HTTP_METHOD.GET, }); return unpackStandardApiCall(response); } static async getApplicantCvs({ id }) { const response = await fetchCorsJson(backendApi.get('applicantCv') + id, { - method: 'GET', + method: HTTP_METHOD.GET, }); return unpackStandardApiCall(response); } @@ -176,7 +193,7 @@ export class Api { }; const response = await fetchCorsJson(backendApi.get('applicantProfile') + id, { credentials: 'include', - method: 'PUT', + method: HTTP_METHOD.PUT, body: JSON.stringify(inputData), }); return response.ok; @@ -186,32 +203,135 @@ export class Api { const inputData = { firstName, lastName: secondName, city, contacts }; const response = await fetchCorsJson(backendApi.get('employerProfile') + id, { credentials: 'include', - method: 'PUT', + method: HTTP_METHOD.PUT, body: JSON.stringify(inputData), }); return response.ok; }; + static getVacancyById = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancy') + id, { + method: HTTP_METHOD.GET, + }); + return unpackStandardApiCall(response); + }; + + static deleteVacancyById = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancy') + id, { + method: HTTP_METHOD.DELETE, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static createVacancy = async ({ salary, position, location, description, workType }) => { + const response = await fetchCorsJson(backendApi.get('vacancy'), { + method: HTTP_METHOD.POST, + body: JSON.stringify({ + salary, + position, + location, + description, + workType, + }), + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static updateVacancyById = async ({ id, salary, position, location, description, workType }) => { + const response = await fetchCorsJson(backendApi.get('vacancy') + id, { + method: HTTP_METHOD.PUT, + body: JSON.stringify({ + salary, + position, + location, + description, + workType, + }), + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static getAppliersByVacancyId = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancySubscribers') + id, { + method: HTTP_METHOD.GET, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + static vacanciesFeed = async ({ offset, num }) => { - return fetchCorsJson( + const response = await fetchCorsJson( backendApi.get('vacancies') + new URLSearchParams({ offset: offset, num: num, }), { - method: 'GET', + method: HTTP_METHOD.GET, }, - ) - .then((response) => { - return response.json(); - }) - .then((body) => { - return body.status == '200' && body.vacancies instanceof Array - ? body.vacancies - : Promise.reject('failed to unpack data'); - }) - .catch(() => 'failed to receive valid data'); + ); + return unpackStandardApiCall(response); + }; + + static getCvById = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('cv') + id, { + method: HTTP_METHOD.GET, + }); + return unpackStandardApiCall(response); + }; + + static deleteCvById = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('cv') + id, { + method: HTTP_METHOD.DELETE, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static createCv = async ({ + positionRu, + positionEn, + workingExperience, + jobSearchStatus, + description, + }) => { + const response = await fetchCorsJson(backendApi.get('cv'), { + method: HTTP_METHOD.POST, + body: JSON.stringify({ + positionRu, + positionEn, + workingExperience, + jobSearchStatus, + description, + }), + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static updateCvById = async ({ + id, + positionRu, + positionEn, + workingExperience, + jobSearchStatus, + description, + }) => { + const response = await fetchCorsJson(backendApi.get('cv') + id, { + method: HTTP_METHOD.PUT, + body: JSON.stringify({ + positionRu, + positionEn, + workingExperience, + jobSearchStatus, + description, + }), + credentials: 'include', + }); + return unpackStandardApiCall(response); }; static logout = async () => { @@ -222,4 +342,30 @@ export class Api { }); return unpackStandardApiCall(response); }; + + static getVacancyApplyStatusById = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancyApply') + id, { + method: HTTP_METHOD.GET, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static vacancyApply = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancyApply') + id, { + method: HTTP_METHOD.POST, + body: {}, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; + + static vacancyResetApply = async ({ id }) => { + const response = await fetchCorsJson(backendApi.get('vacancyApply') + id, { + method: HTTP_METHOD.DELETE, + body: {}, + credentials: 'include', + }); + return unpackStandardApiCall(response); + }; } diff --git a/src/modules/Api/Errors.js b/src/modules/Api/Errors.js new file mode 100644 index 0000000..e7bb6f7 --- /dev/null +++ b/src/modules/Api/Errors.js @@ -0,0 +1,21 @@ +import { NOTIFICATION_TIMEOUT } from '../../Components/NotificationBox/NotificationBox.js'; +import eventBus from '../Events/EventBus.js'; +import { NOTIFICATION_ERROR } from '../Events/Events.js'; +import { ResponseError, TransportError } from './Api.js'; + +export const catchStandardResponseError = (error) => { + if (error instanceof ResponseError) { + eventBus.emit(NOTIFICATION_ERROR, { + message: 'Произошла непредвиденная ошибка, повторите позднее', + timeout: NOTIFICATION_TIMEOUT.MEDIUM, + }); + } + if (error instanceof TransportError) { + eventBus.emit(NOTIFICATION_ERROR, { + message: 'Произошла сетевая ошибка, повторите позднее', + timeout: NOTIFICATION_TIMEOUT.LONG, + }); + } +}; + +export const USER_ALREADY_EXISTS_ERROR = 'user already exists'; diff --git a/src/modules/Events/EventBus.js b/src/modules/Events/EventBus.js index cc4ae39..85f2e7b 100644 --- a/src/modules/Events/EventBus.js +++ b/src/modules/Events/EventBus.js @@ -18,7 +18,6 @@ class EventBus { } emit(eventName, eventData) { - console.log(`got event: ${eventName}`); if (this.#listeners.has(eventName)) { const callbacks = this.#listeners.get(eventName); callbacks.forEach((callback) => { diff --git a/src/modules/Events/Events.js b/src/modules/Events/Events.js index ecd29b7..769bea4 100644 --- a/src/modules/Events/Events.js +++ b/src/modules/Events/Events.js @@ -21,3 +21,17 @@ export const RESET_FORM = 'reset form'; export const USER_UPDATED = 'user updated'; export const MINICARD_DELETE = 'minicard delete'; + +export const VACANCY_APPLY = 'vacancy apply'; +export const VACANCY_RESET_APPLY = 'vacancy reset apply'; +export const VACANCY_EDIT = 'vacancy edit'; +export const VACANCY_DELETE = 'vacancy delete'; + +export const CV_EDIT = 'cv edit'; +export const CV_DELETE = 'cv delete'; + +export const REDIRECT_TO = 'redirect to'; +export const GO_TO = 'go to'; + +export const NOTIFICATION_ERROR = 'notification error'; +export const NOTIFICATION_OK = 'notification ok'; diff --git a/src/modules/Handlebars/Handlebars.js b/src/modules/Handlebars/Handlebars.js index a1cee01..325245a 100644 --- a/src/modules/Handlebars/Handlebars.js +++ b/src/modules/Handlebars/Handlebars.js @@ -31,4 +31,6 @@ export const handlebarsInit = () => { 'employer-profile-form', Handlebars.templates['employer-profile-form.hbs'], ); + Handlebars.registerPartial('vacancy-article', Handlebars.templates['vacancy-article.hbs']); + Handlebars.registerPartial('vacancy-form', Handlebars.templates['vacancy-form.hbs']); }; diff --git a/src/modules/ObjectUtils/Zip.js b/src/modules/ObjectUtils/Zip.js new file mode 100644 index 0000000..235d531 --- /dev/null +++ b/src/modules/ObjectUtils/Zip.js @@ -0,0 +1,31 @@ +/** + * Zip multiple objects into one object containing all their attributes. If + * some of the objects have same attribute, value of the first object with + * such an attribute will be taken. + * Note that subobjects are not deepcopied. + * @param {...Object} objects - The objects to be zipped + * @throws {TypeError} All of the arguments must be objects + */ +export const zip = (...objects) => { + if (!objects.every(isObject)) { + throw new TypeError('All of the arguments must be objects'); + } + + return objects.reduce((zippedObject, currentObject) => { + Object.entries(currentObject).forEach(([key, value]) => { + if (!Object.prototype.hasOwnProperty.call(zippedObject, key)) { + zippedObject[key] = value; + } + }); + return zippedObject; + }, {}); +}; + +/** + * Check if input is object. + * Array is considered to be an object too. + * @param {*} potentialObject - a value to be checked + */ +const isObject = (potentialObject) => { + return Object.prototype.toString.call(potentialObject) === '[object Object]'; +}; diff --git a/src/modules/Page/Page.js b/src/modules/Page/Page.js index 2988341..042d9a0 100644 --- a/src/modules/Page/Page.js +++ b/src/modules/Page/Page.js @@ -4,8 +4,9 @@ import { ComponentModel, ComponentView, } from '../Components/Component.js'; -import { USER_WANTS_LOGOUT } from '../Events/Events.js'; +import { NOTIFICATION_OK, USER_WANTS_LOGOUT } from '../Events/Events.js'; import state from '/src/modules/AppState/AppState.js'; +import eventBus from '../Events/EventBus.js'; /** Base class representing browser page */ export class Page extends Component { @@ -56,5 +57,6 @@ export class PageController extends ComponentController { _userLogout() { state.userSession.logout(); + eventBus.emit(NOTIFICATION_OK, { message: 'Вы успешно вышли', timeout: 2000 }); } } diff --git a/src/modules/Router/Router.js b/src/modules/Router/Router.js index 32b3d8b..5f1c8ea 100644 --- a/src/modules/Router/Router.js +++ b/src/modules/Router/Router.js @@ -73,7 +73,7 @@ export class Router { * @param {boolean} modifyHistory - If true, browser history will be modified * @throws {TypeError} Invalid argument types */ - navigate(url, redirection = false, modifyHistory = true) { + async navigate(url, redirection = false, modifyHistory = true) { if (typeof redirection !== 'boolean') { throw TypeError('redirection must be a boolean'); } @@ -96,7 +96,7 @@ export class Router { const newPage = this.#routes.has(url.pathname) ? this.#routes.get(url.pathname) : NotFoundPage; - this._replacePage(newPage, url); + await this._replacePage(newPage, url); } catch (err) { if (err instanceof ForbiddenPage) { this.navigate(err.redirectUrl, true, true); @@ -110,7 +110,7 @@ export class Router { } } - _replacePage(newPageClass, newPageUrl) { + async _replacePage(newPageClass, newPageUrl) { if (this.#currentPage) { this.#currentPage.cleanup(); } @@ -118,7 +118,7 @@ export class Router { const app = document.getElementById(APP_ID); app.innerHTML = ''; app.appendChild(this.#currentPage.render()); - this.#currentPage.postRenderInit(); + await this.#currentPage.postRenderInit(); } /** diff --git a/src/modules/UrlUtils/UrlUtils.js b/src/modules/UrlUtils/UrlUtils.js index f437767..3375159 100644 --- a/src/modules/UrlUtils/UrlUtils.js +++ b/src/modules/UrlUtils/UrlUtils.js @@ -5,6 +5,13 @@ const urls = { register: '/registration', vacancies: '/', myProfile: '/me', + profile: '/profile', + vacancy: '/vacancy', + createVacancy: '/vacancy/new', + editVacancy: '/vacancy/edit', + cv: '/cv', + createCv: '/cv/new', + editCv: '/cv/edit', }; const knownUrls = new Map(Object.entries(urls)); diff --git a/src/modules/models/Applicant.js b/src/modules/models/Applicant.js index 31d4354..b493ad8 100644 --- a/src/modules/models/Applicant.js +++ b/src/modules/models/Applicant.js @@ -3,7 +3,7 @@ export class Applicant { constructor(backendResponse) { this.id = backendResponse.id; this.firstName = backendResponse.firstName; - this.secondName = backendResponse.lastName; + this.secondName = backendResponse.lastName || backendResponse.secondName; this.city = backendResponse.city; this.birthDate = new Date(backendResponse.birthDate); this.avatar = backendResponse.pathToProfileAvatar || resolveStatic('img/user-icon-80.svg'); diff --git a/src/modules/models/Cv.js b/src/modules/models/Cv.js new file mode 100644 index 0000000..2e1c915 --- /dev/null +++ b/src/modules/models/Cv.js @@ -0,0 +1,16 @@ +import { resolveStatic } from '../UrlUtils/UrlUtils.js'; + +export class Cv { + constructor(backendResponse) { + this.id = backendResponse.id; + this.applicantId = backendResponse.applicant; + this.positionRu = backendResponse.positionRu; + this.positionEn = backendResponse.positionEn; + this.description = backendResponse.description; + this.jobSearchStatus = backendResponse.jobSearchStatus; + this.workingExperience = backendResponse.workingExperience; + this.avatar = backendResponse.avatar || resolveStatic('img/company-icon.svg'); + this.createdAt = new Date(backendResponse.createdAt); + this.updatedAt = new Date(backendResponse.updatedAt); + } +} diff --git a/src/modules/models/Employer.js b/src/modules/models/Employer.js index 1b081ff..d399045 100644 --- a/src/modules/models/Employer.js +++ b/src/modules/models/Employer.js @@ -3,7 +3,7 @@ export class Employer { constructor(backendResponse) { this.id = backendResponse.id; this.firstName = backendResponse.firstName; - this.secondName = backendResponse.lastName; + this.secondName = backendResponse.lastName || backendResponse.secondName; this.city = backendResponse.city; this.birthDate = new Date(backendResponse.birthDate); this.avatar = backendResponse.pathToProfileAvatar || resolveStatic('img/user-icon-80.svg'); diff --git a/src/modules/models/Vacancy.js b/src/modules/models/Vacancy.js new file mode 100644 index 0000000..f0cf750 --- /dev/null +++ b/src/modules/models/Vacancy.js @@ -0,0 +1,17 @@ +import { resolveStatic } from '../UrlUtils/UrlUtils.js'; + +export class Vacancy { + constructor(backendResponse) { + this.id = backendResponse.id; + this.employerId = backendResponse.employer; + this.companyName = backendResponse.companyName; + this.salary = +backendResponse.salary; + this.position = backendResponse.position; + this.location = backendResponse.location; + this.description = backendResponse.description; + this.workType = backendResponse.workType; + this.avatar = backendResponse.avatar || resolveStatic('img/company-icon.svg'); + this.createdAt = new Date(backendResponse.createdAt); + this.updatedAt = new Date(backendResponse.updatedAt); + } +} diff --git a/src/public/img/company-icon.svg b/src/public/img/company-icon.svg new file mode 100644 index 0000000..79e0797 --- /dev/null +++ b/src/public/img/company-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/public/img/favicon.ico b/src/public/img/favicon.ico new file mode 100644 index 0000000..5cf1ed9 Binary files /dev/null and b/src/public/img/favicon.ico differ diff --git a/src/server/server.mjs b/src/server/server.mjs index e35d5d8..56735b6 100644 --- a/src/server/server.mjs +++ b/src/server/server.mjs @@ -1,13 +1,15 @@ import { createServer } from 'node:http'; import { readFile } from 'node:fs'; -const hostname = '127.0.0.1'; +const hostname = '0.0.0.0'; const port = '8000'; const routingTemplates = { '.js$': '.', - '(.css|.html|.png|.svg)$': 'src/', + '(.css|.html|.png|.svg)$': './src/', + 'index.css$': './src', '.ttf$': '.', + 'favicon.ico': './src/public/img', }; const customContentTypes = {