diff --git a/package.json b/package.json
index ba5e928..3f8fe02 100644
--- a/package.json
+++ b/package.json
@@ -23,10 +23,10 @@
"cross-fetch": "^3.0.4",
"dotenv": "^8.2.0",
"effector": "^21.8.12",
- "effector-inspector": "^0.4.1",
+ "effector-inspector": "^0.5.0",
"effector-react": "^21.3.3",
"effector-reflect": "^0.3.2",
- "effector-root": "^1.1.0",
+ "effector-root": "^1.3.0",
"express": "^4.17.1",
"history": "^4.10.1",
"http-proxy-middleware": "^1.0.3",
diff --git a/src/features/navigation/index.ts b/src/features/navigation/index.ts
index c224789..84704a3 100644
--- a/src/features/navigation/index.ts
+++ b/src/features/navigation/index.ts
@@ -43,3 +43,5 @@ if (process.env.BUILD_TARGET === 'client') {
fn: ({ pathname, params }) => `${pathname}${queryToString(params)}`,
});
}
+
+export { EffectorSsrRedirect } from './lib';
diff --git a/src/features/navigation/lib/effector-ssr-redirect.ts b/src/features/navigation/lib/effector-ssr-redirect.ts
new file mode 100644
index 0000000..52c185b
--- /dev/null
+++ b/src/features/navigation/lib/effector-ssr-redirect.ts
@@ -0,0 +1,16 @@
+import { useEffect } from 'react';
+import { useEvent } from 'effector-react/ssr';
+import { historyPush } from '../index';
+
+/**
+ * A component similar to 'Redirect' from 'react-router'
+ * need to use this instead of original component,
+ * because the one from 'react-router' does not work with ssr
+ * */
+export const EffectorSsrRedirect = ({ href }: { href: string }) => {
+ const push = useEvent(historyPush);
+ useEffect(() => {
+ push(href);
+ }, [push, href]);
+ return null;
+};
diff --git a/src/features/navigation/lib/index.ts b/src/features/navigation/lib/index.ts
new file mode 100644
index 0000000..de88292
--- /dev/null
+++ b/src/features/navigation/lib/index.ts
@@ -0,0 +1 @@
+export { EffectorSsrRedirect } from './effector-ssr-redirect';
diff --git a/src/globals.ts b/src/globals.ts
index b0a1943..61ff1c3 100644
--- a/src/globals.ts
+++ b/src/globals.ts
@@ -22,7 +22,7 @@ export const Globals = createGlobalStyle`
--block-shadow: 0 3px 12px -3px var(--border-color);
/* titles */
- --title-color: var(--black)
+ --title-color: var(--black);
--title-height: 1.2rem;
--h1-font-size: 4.2rem;
diff --git a/src/lib/sleep.ts b/src/lib/sleep.ts
new file mode 100644
index 0000000..0d7f188
--- /dev/null
+++ b/src/lib/sleep.ts
@@ -0,0 +1,3 @@
+export function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/pages/access-recovery/confirm/page.tsx b/src/pages/access-recovery/confirm/page.tsx
index 34f9de6..3772429 100644
--- a/src/pages/access-recovery/confirm/page.tsx
+++ b/src/pages/access-recovery/confirm/page.tsx
@@ -44,7 +44,7 @@ export const AccessRecoveryConfirmPage = withStart(pageStart, () => (
-
+
diff --git a/src/pages/home/page.tsx b/src/pages/home/page.tsx
index 4602bc1..ed65f91 100644
--- a/src/pages/home/page.tsx
+++ b/src/pages/home/page.tsx
@@ -1,10 +1,10 @@
import React from 'react';
+import { createEvent } from 'effector-root';
+import { useStore } from 'effector-react/ssr';
+import { reflect } from 'effector-reflect/ssr';
import styled from 'styled-components';
import { Button } from 'woly';
import { withStart, createStart } from 'lib/page-routing';
-import { useStore } from 'effector-react/ssr';
-import { createEvent } from 'effector-root';
-import { reflect } from 'effector-reflect/ssr';
import { $fullName, $showError } from './model';
export const pageStarted = createStart();
diff --git a/src/pages/login/page.tsx b/src/pages/login/page.tsx
index e6a2740..3126c7d 100644
--- a/src/pages/login/page.tsx
+++ b/src/pages/login/page.tsx
@@ -7,7 +7,7 @@ import { createEvent, createStore } from 'effector-root';
import { reflect } from 'effector-reflect/ssr';
import { withStart, createStart } from 'lib/page-routing';
import Logo from 'logo.svg';
-import { CenterCardTemplate } from '@auth/ui';
+import { CenterCardTemplate } from 'ui';
import { path } from 'pages/paths';
import { Failure } from './types';
diff --git a/src/pages/paths.ts b/src/pages/paths.ts
index 0cd3c08..b0fbd36 100644
--- a/src/pages/paths.ts
+++ b/src/pages/paths.ts
@@ -6,4 +6,9 @@ export const path = {
accessRecovery: () => '/access-recovery',
accessRecoveryConfirm: (code: string) => `/access-recovery/confirm-${code}`,
oauthAuthorize: () => '/oauth/authorize',
+ settings: {
+ base: () => '/settings',
+ profile: () => `${path.settings.base()}/profile`,
+ emails: () => `${path.settings.base()}/emails`,
+ },
};
diff --git a/src/pages/routes.ts b/src/pages/routes.ts
index 234e77a..dc6f953 100644
--- a/src/pages/routes.ts
+++ b/src/pages/routes.ts
@@ -8,6 +8,7 @@ import { RegisterConfirmPage } from './register/confirm';
import { RegisterPage } from './register';
import { AccessRecoveryPage } from './access-recovery';
import { AccessRecoveryConfirmPage } from './access-recovery/confirm';
+import { SettingsPage } from './settings';
export const routes = [
{
@@ -45,6 +46,11 @@ export const routes = [
exact: true,
component: AccessRecoveryConfirmPage,
},
+ {
+ path: path.settings.base(),
+ exact: false,
+ component: SettingsPage,
+ },
{
path: '*',
component: Error404Page,
diff --git a/src/pages/settings/emails/index.ts b/src/pages/settings/emails/index.ts
new file mode 100644
index 0000000..55e4008
--- /dev/null
+++ b/src/pages/settings/emails/index.ts
@@ -0,0 +1,22 @@
+import { sample } from 'effector-root';
+import { contract } from 'lib/contract';
+import * as model from './model';
+import * as page from './page';
+
+export { EmailsProfilePage } from './page';
+
+sample({
+ clock: page.newEmailChanged,
+ fn: (e) => e.target.value,
+ target: model.changeEmail,
+});
+contract({
+ page,
+ model: {
+ ...model,
+ formSubmitted: model.submitForm.prepend(() => undefined),
+ $userNewEmail: model.$newEmail,
+ passwordChanged: model.changePassword.prepend((e) => e.target.value),
+ newEmailChanged: model.changeEmail.prepend((e) => e.target.value),
+ },
+});
diff --git a/src/pages/settings/emails/model.ts b/src/pages/settings/emails/model.ts
new file mode 100644
index 0000000..7ef4051
--- /dev/null
+++ b/src/pages/settings/emails/model.ts
@@ -0,0 +1,81 @@
+import {
+ combine,
+ createStore,
+ createEffect,
+ createEvent,
+ restore,
+ guard,
+} from 'effector-root';
+import { sleep } from 'lib/sleep';
+import { validateEmail } from 'lib/email';
+import { EmailErrorType, RequestFailure } from './types';
+
+const requestFx = createEffect(
+ async (req: { email: string; password: string }) => {
+ await sleep(2000);
+ const isSuccess = Math.random() > 0.5;
+ if (isSuccess) return req;
+
+ throw {
+ type: RequestFailure.required,
+ };
+ },
+);
+
+const changeEmail = createEvent();
+const $newEmail = restore(changeEmail, '');
+const changePassword = createEvent();
+const $password = restore(changePassword, '');
+
+const $emailError = $newEmail.map((email) => {
+ if (!email) return EmailErrorType.required;
+ if (!validateEmail(email)) return EmailErrorType.invalid;
+ return null;
+});
+const $isEmailValid = $emailError.map((error) => !error);
+const $isPasswordValid = $password.map(Boolean);
+
+const $isFormValid = combine(
+ $isEmailValid,
+ $isPasswordValid,
+ (isEmail, isPw) => isEmail && isPw,
+);
+
+const $isFormPending = requestFx.pending;
+
+const $isSubmitDisabled = combine(
+ $isFormValid,
+ $isFormPending,
+ (isValid, isPending) => !isValid || isPending,
+);
+
+const $isFormDisabled = $isFormPending.map((is) => is);
+
+const $errorType = createStore(null);
+
+const submitForm = createEvent();
+
+requestFx.finally.watch((res) => {
+ if (res.status === 'fail') console.log(res.error);
+ if (res.status === 'done') console.log(res.result);
+});
+
+guard({
+ clock: submitForm,
+ filter: $isSubmitDisabled.map((is) => !is),
+ source: { email: $newEmail, password: $password },
+ target: requestFx,
+});
+
+export {
+ submitForm,
+ $newEmail,
+ changeEmail,
+ $password,
+ changePassword,
+ $emailError,
+ $isFormDisabled,
+ $isSubmitDisabled,
+ $isFormPending,
+ $errorType,
+};
diff --git a/src/pages/settings/emails/page.tsx b/src/pages/settings/emails/page.tsx
new file mode 100644
index 0000000..98a03db
--- /dev/null
+++ b/src/pages/settings/emails/page.tsx
@@ -0,0 +1,128 @@
+import React, { ChangeEvent, FormEvent } from 'react';
+import styled from 'styled-components';
+import { reflect } from 'effector-reflect';
+import { Button, Form, Input, Title } from 'woly';
+import { createEvent, createStore, StoreValue } from 'effector-root';
+import { EmailErrorType, RequestFailure } from './types';
+
+export const formSubmitted = createEvent();
+
+export const $userNewEmail = createStore('');
+export const newEmailChanged = createEvent>();
+export const $password = createStore('');
+export const passwordChanged = createEvent>();
+
+export const $emailError = createStore(null);
+const $emailErrorMessage = $emailError.map((error) => {
+ if (error === EmailErrorType.required) return 'Email is required';
+ if (error === EmailErrorType.invalid) return 'Email is invalid';
+ return null;
+});
+const $passwordErrorMessage = $password.map((pw) => {
+ if (!pw) return 'Password is required';
+ return null;
+});
+
+export const $isFormDisabled = createStore(false);
+export const $isSubmitDisabled = createStore(false);
+
+export const $isFormPending = createStore(false);
+const $submitText = $isFormPending.map((is) => (is ? 'Sending...' : 'Change'));
+
+export const $errorType = createStore(null);
+const $errorText = $errorType.map((errorType) => {
+ if (!errorType) return null;
+ return 'AAAA';
+});
+
+export const EmailsProfilePage = () => {
+ return (
+
+
+ Change email form
+ Email
+
+
+ Password
+
+
+
+
+
+
+ );
+};
+
+const Email = reflect({
+ view: Input,
+ bind: {
+ value: $userNewEmail,
+ onChange: newEmailChanged,
+ disabled: $isFormDisabled,
+ },
+});
+const Password = reflect({
+ view: Input,
+ bind: {
+ value: $password,
+ onChange: passwordChanged,
+ disabled: $isFormDisabled,
+ },
+});
+const EmailError = reflect({
+ view: ({ error }: { error: StoreValue }) => {
+ if (error) return {error};
+ return null;
+ },
+ bind: {
+ error: $emailErrorMessage,
+ },
+});
+const PasswordError = reflect({
+ view: ({ error }: { error: StoreValue }) => {
+ if (error) return {error};
+ return null;
+ },
+ bind: {
+ error: $passwordErrorMessage,
+ },
+});
+
+const ErrorBlock = reflect({
+ view: ({ failure }: { failure: string | null }) => {
+ if (failure) {
+ return {failure};
+ }
+ return null;
+ },
+ bind: {
+ failure: $errorText,
+ },
+});
+
+const Submit = reflect({
+ view: Button,
+ bind: {
+ disable: $isSubmitDisabled,
+ text: $submitText,
+ },
+});
+
+const EmailForm = reflect({
+ view: Form,
+ bind: {
+ onSubmit: formSubmitted,
+ },
+});
+
+const Fail = styled.div`
+ font-size: 1.3rem;
+ margin-bottom: 1rem;
+`;
+const ErrorTitle = styled.span`
+ color: red;
+ display: block;
+`;
+const FormWrapper = styled.div`
+ padding: 3rem;
+`;
diff --git a/src/pages/settings/emails/types.ts b/src/pages/settings/emails/types.ts
new file mode 100644
index 0000000..43dda95
--- /dev/null
+++ b/src/pages/settings/emails/types.ts
@@ -0,0 +1,7 @@
+export enum EmailErrorType {
+ required = 'required',
+ invalid = 'invalid',
+}
+export enum RequestFailure {
+ required = 'required',
+}
diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts
new file mode 100644
index 0000000..129072d
--- /dev/null
+++ b/src/pages/settings/index.ts
@@ -0,0 +1,7 @@
+import { contract } from 'lib/contract';
+import * as page from './page';
+import * as model from './model';
+
+export { SettingsPage } from './page';
+
+contract({ page, model });
diff --git a/src/pages/settings/model.ts b/src/pages/settings/model.ts
new file mode 100644
index 0000000..f681e10
--- /dev/null
+++ b/src/pages/settings/model.ts
@@ -0,0 +1,5 @@
+import { createStart } from 'lib/page-routing';
+import { checkAuthenticated } from '../../features/session';
+
+export const pageStarted = createStart();
+checkAuthenticated({ when: pageStarted });
diff --git a/src/pages/settings/page.tsx b/src/pages/settings/page.tsx
new file mode 100644
index 0000000..14d36fd
--- /dev/null
+++ b/src/pages/settings/page.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Route, Switch } from 'react-router-dom';
+import { EffectorSsrRedirect } from 'features/navigation';
+import { createStart, withStart } from 'lib/page-routing';
+import { path } from '../paths';
+import { SettingsProfilePage } from './profile';
+import { EmailsProfilePage } from './emails';
+
+export const pageStarted = createStart();
+
+export const SettingsPage = withStart(pageStarted, () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/src/pages/settings/profile/index.ts b/src/pages/settings/profile/index.ts
new file mode 100644
index 0000000..30d3242
--- /dev/null
+++ b/src/pages/settings/profile/index.ts
@@ -0,0 +1,15 @@
+import { contract } from 'lib/contract';
+import * as page from './page';
+import * as model from './model';
+
+export { SettingsProfilePage } from './page';
+
+contract({
+ page,
+ model: {
+ ...model,
+ firstNameChanged: model.changeFirstName,
+ lastNameChanged: model.changeLastName,
+ formSubmitted: model.submitForm,
+ },
+});
diff --git a/src/pages/settings/profile/model.ts b/src/pages/settings/profile/model.ts
new file mode 100644
index 0000000..2d57df1
--- /dev/null
+++ b/src/pages/settings/profile/model.ts
@@ -0,0 +1,105 @@
+import { ChangeEvent, FormEvent } from 'react';
+import { createStart } from 'lib/page-routing';
+import {
+ createStore,
+ createEvent,
+ createEffect,
+ combine,
+ guard,
+} from 'effector-root';
+import { $session } from 'features/session';
+import { sleep } from 'lib/sleep';
+import { RequestFailure, FieldError } from './types';
+
+export const pageStarted = createStart();
+
+const requestFx = createEffect(
+ async (req: { firstName: string; lastName: string }) => {
+ await sleep(2000);
+ const isSuccess = Math.random() > 0.5;
+ if (isSuccess) {
+ return req;
+ }
+ const errorTypes = [
+ RequestFailure.one,
+ RequestFailure.two,
+ RequestFailure.unexpected,
+ ];
+ const errorIndex = Math.floor(Math.random() * errorTypes.length);
+ throw {
+ type: errorTypes[errorIndex],
+ };
+ },
+);
+
+const $userFirstName = createStore('');
+const $userLastName = createStore('');
+const changeFirstName = createEvent>();
+const changeLastName = createEvent>();
+
+$userFirstName
+ .on(changeFirstName, (_, e) => e.target.value)
+ .on($session, (_, session) => session?.firstName);
+$userLastName
+ .on(changeLastName, (_, e) => e.target.value)
+ .on($session, (_, session) => session?.lastName);
+
+function validateField(field: string) {
+ if (!field) return FieldError.required;
+ if (field.length > 32) return FieldError.maxLength;
+ return null;
+}
+const $firstNameError = $userFirstName.map(validateField);
+const $lastNameError = $userLastName.map(validateField);
+
+const $isFirstNameValid = $firstNameError.map((error) => !error);
+const $isLastNameValid = $lastNameError.map((error) => !error);
+
+const $isFormValid = combine(
+ $isFirstNameValid,
+ $isLastNameValid,
+ (isFirst, isLast) => isFirst && isLast,
+);
+const $isFormPending = requestFx.pending;
+
+const $isSubmitDisabled = combine(
+ $isFormValid,
+ $isFormPending,
+ (isValid, isPending) => !isValid || isPending,
+);
+const $isFormDisabled = $isFormPending.map((is) => is);
+
+const $errorType = createStore(null);
+
+const submitForm = createEvent();
+
+guard({
+ clock: submitForm,
+ filter: $isSubmitDisabled.map((is) => !is),
+ source: { firstName: $userFirstName, lastName: $userLastName },
+ target: requestFx,
+});
+
+// todo: replace with mocks
+requestFx.finally.watch((res) => {
+ if (res.status === 'fail') {
+ console.log(res.error);
+ }
+ if (res.status === 'done') {
+ console.log(res.result);
+ }
+});
+
+export {
+ $userFirstName,
+ $userLastName,
+ changeFirstName,
+ changeLastName,
+ submitForm,
+ $firstNameError,
+ $lastNameError,
+ $isFormDisabled,
+ $isSubmitDisabled,
+ $isFormPending,
+ $errorType,
+};
diff --git a/src/pages/settings/profile/page.tsx b/src/pages/settings/profile/page.tsx
new file mode 100644
index 0000000..0a43aed
--- /dev/null
+++ b/src/pages/settings/profile/page.tsx
@@ -0,0 +1,155 @@
+import React, { ChangeEvent, FormEvent } from 'react';
+import { Button, Form, Input, Title } from 'woly';
+import { combine, createEvent, createStore, StoreValue } from 'effector-root';
+import { reflect } from 'effector-reflect/ssr';
+import styled from 'styled-components';
+import { FieldError, RequestFailure } from './types';
+
+export const $userFirstName = createStore('');
+export const $userLastName = createStore('');
+export const firstNameChanged = createEvent>();
+export const lastNameChanged = createEvent>();
+export const formSubmitted = createEvent();
+
+export const $firstNameError = createStore(null);
+export const $lastNameError = createStore(null);
+const $firstNameErrorMessage = combine(
+ $firstNameError,
+ $userFirstName,
+ (error, firstName) => {
+ if (error === FieldError.required) return 'Field is required';
+ if (error === FieldError.maxLength)
+ return `Field is too long (${firstName.length}/32)`;
+ return null;
+ },
+);
+const $lastNameErrorMessage = combine(
+ $lastNameError,
+ $userLastName,
+ (error, lastName) => {
+ if (error === FieldError.required) return 'Field is required';
+ if (error === FieldError.maxLength)
+ return `Field is too long (${lastName.length}/32)`;
+ return null;
+ },
+);
+
+export const $isFormDisabled = createStore(false);
+export const $isSubmitDisabled = createStore(false);
+
+export const $isFormPending = createStore(false);
+const $submitText = $isFormPending.map((isPending) =>
+ isPending ? 'Sending...' : 'Change',
+);
+
+export const $errorType = createStore(null);
+const $errorText = $errorType.map((errorType) => {
+ if (!errorType) return null;
+ return 'AAAA';
+});
+
+export const SettingsProfilePage = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+const FirstNameField = () => {
+ return (
+
+ First name
+
+
+
+ );
+};
+const LastNameField = () => {
+ return (
+
+ Last name
+
+
+
+ );
+};
+
+const FirstName = reflect({
+ view: Input,
+ bind: {
+ value: $userFirstName,
+ onChange: firstNameChanged,
+ disabled: $isFormDisabled,
+ },
+});
+const LastName = reflect({
+ view: Input,
+ bind: {
+ value: $userLastName,
+ onChange: lastNameChanged,
+ disabled: $isFormDisabled,
+ },
+});
+const FirstNameError = reflect({
+ bind: {
+ error: $firstNameErrorMessage,
+ },
+ view: ({ error }: { error: StoreValue }) => {
+ if (error) return {error};
+ return null;
+ },
+});
+const LastNameError = reflect({
+ bind: {
+ error: $lastNameErrorMessage,
+ },
+ view: ({ error }: { error: StoreValue }) => {
+ if (error) return {error};
+ return null;
+ },
+});
+const ErrorBlock = reflect({
+ bind: {
+ failure: $errorText,
+ },
+ view: ({ failure }: { failure: string | null }) => {
+ if (failure) {
+ return {failure};
+ }
+ return null;
+ },
+});
+const Submit = reflect({
+ view: Button,
+ bind: {
+ disabled: $isSubmitDisabled,
+ text: $submitText,
+ },
+});
+const UserForm = reflect({
+ view: Form,
+ bind: {
+ onSubmit: formSubmitted,
+ },
+});
+
+const FormWrapper = styled.div`
+ padding: 3rem;
+`;
+const Fail = styled.div`
+ font-size: 1.3rem;
+ margin-bottom: 1rem;
+`;
+const ErrorTitle = styled.span`
+ color: red;
+`;
+const FieldWrapper = styled.div`
+ padding: 2rem;
+ border: 1px solid;
+`;
diff --git a/src/pages/settings/profile/types.ts b/src/pages/settings/profile/types.ts
new file mode 100644
index 0000000..ec81ce4
--- /dev/null
+++ b/src/pages/settings/profile/types.ts
@@ -0,0 +1,10 @@
+export enum RequestFailure {
+ one = 'one',
+ two = 'two',
+ unexpected = 'unexpected',
+}
+
+export enum FieldError {
+ required = 'required',
+ maxLength = 'max-len',
+}