diff --git a/client/package.json b/client/package.json index 0fdd0e4ce..e88487f62 100644 --- a/client/package.json +++ b/client/package.json @@ -64,6 +64,7 @@ "postcss-preset-env": "6.7.0", "postcss-safe-parser": "4.0.1", "react": "^16.12.0", + "react-albus": "^2.0.0", "react-app-polyfill": "^1.0.6", "react-body-classname": "^1.3.1", "react-dev-utils": "^10.2.0", @@ -83,6 +84,7 @@ "react-split-pane": "^0.1.91", "react-table": "^7.0.0", "react-table-sticky": "^1.1.2", + "react-transition-group": "^4.4.1", "react-use": "^13.26.1", "react-window": "^1.8.5", "redux": "^4.0.5", diff --git a/client/src/common/classes.js b/client/src/common/classes.js new file mode 100644 index 000000000..eeb71cf89 --- /dev/null +++ b/client/src/common/classes.js @@ -0,0 +1,8 @@ + +const CLASSES = { + DATATABLE_EDITOR: 'DATATABLE_EDITOR' +}; + +export { + CLASSES, +} \ No newline at end of file diff --git a/client/src/common/errors.js b/client/src/common/errors.js new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/common/registerWizard.js b/client/src/common/registerWizard.js new file mode 100644 index 000000000..634f2271a --- /dev/null +++ b/client/src/common/registerWizard.js @@ -0,0 +1,16 @@ + + +export const registerWizardSteps = [ + { + label: 'payment_or_trial', + }, + { + label: 'initializing', + }, + { + label: 'getting_started', + }, + { + label: 'Congratulations', + }, +]; \ No newline at end of file diff --git a/client/src/common/subscriptionModels.js b/client/src/common/subscriptionModels.js new file mode 100644 index 000000000..d6f2b530f --- /dev/null +++ b/client/src/common/subscriptionModels.js @@ -0,0 +1,41 @@ +export const plans = [ + { + name: 'basic', + description: [ + 'Sales/purchases module.', + 'Expense module.', + 'Inventory module.', + 'Unlimited status pages.', + 'Unlimited status pages.', + ], + price: '1200', + slug: 'free', + currency: 'LYD', + }, + { + name: 'pro', + description: [ + 'Sales/purchases module.', + 'Expense module.', + 'Inventory module.', + 'Unlimited status pages.', + 'Unlimited status pages.', + ], + price: '1200', + slug: 'free', + currency: 'LYD', + }, +]; + +export const paymentmethod = [ + { + period: 'monthly', + price: '1200', + currency: 'LYD', + }, + { + period: 'yearly', + price: '1200', + currency: 'LYD', + }, +]; diff --git a/client/src/components/App.js b/client/src/components/App.js index d4e0add38..6f5f0899a 100644 --- a/client/src/components/App.js +++ b/client/src/components/App.js @@ -5,9 +5,9 @@ import { createBrowserHistory } from 'history'; import { ReactQueryConfigProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query-devtools'; -import PrivateRoute from 'components/PrivateRoute'; +import PrivateRoute from 'components/Guards/PrivateRoute'; import Authentication from 'components/Authentication'; -import Dashboard from 'components/Dashboard/Dashboard'; +import DashboardPrivatePages from 'components/Dashboard/PrivatePages'; import GlobalErrors from 'containers/GlobalErrors/GlobalErrors'; import messages from 'lang/en'; @@ -19,7 +19,7 @@ function App({ locale }) { const queryConfig = { queries: { refetchOnWindowFocus: false, - } + }, }; return ( @@ -32,7 +32,7 @@ function App({ locale }) { - + diff --git a/client/src/components/Authentication.js b/client/src/components/Authentication.js index 8b6e39cfa..e029770bd 100644 --- a/client/src/components/Authentication.js +++ b/client/src/components/Authentication.js @@ -1,13 +1,23 @@ import React from 'react'; -import { Redirect, Route, Switch, Link } from 'react-router-dom'; +import { Redirect, Route, Switch, Link, useLocation } from 'react-router-dom'; import BodyClassName from 'react-body-classname'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; import authenticationRoutes from 'routes/authentication'; import { FormattedMessage as T } from 'react-intl'; import withAuthentication from 'containers/Authentication/withAuthentication'; import { compose } from 'utils'; +import Icon from 'components/Icon'; + +function PageFade(props) { + return ( + + ); +} function AuthenticationWrapper({ isAuthorized = false, ...rest }) { const to = { pathname: '/homepage' }; + const location = useLocation(); + const locationKey = location.pathname; return ( <> @@ -15,27 +25,37 @@ function AuthenticationWrapper({ isAuthorized = false, ...rest }) { ) : ( - -
- - - +
+ + + -
- {authenticationRoutes.map((route, index) => ( - - ))} +
+
+
+ +
+ + + + + {authenticationRoutes.map((route, index) => ( + + ))} + + +
- +
)} diff --git a/client/src/components/Dashboard/Dashboard.js b/client/src/components/Dashboard/Dashboard.js index 7be031c16..047374735 100644 --- a/client/src/components/Dashboard/Dashboard.js +++ b/client/src/components/Dashboard/Dashboard.js @@ -1,6 +1,8 @@ import React from 'react'; import { Switch, Route } from 'react-router'; -import classNames from 'classnames'; +import { useQuery } from 'react-query'; + +import DashboardLoadingIndicator from './DashboardLoadingIndicator'; import Sidebar from 'components/Sidebar/Sidebar'; import DashboardContent from 'components/Dashboard/DashboardContent'; @@ -10,28 +12,47 @@ import PreferencesSidebar from 'components/Preferences/PreferencesSidebar'; import Search from 'containers/GeneralSearch/Search'; import DashboardSplitPane from 'components/Dashboard/DashboardSplitePane'; -export default function Dashboard() { + +import withSettingsActions from 'containers/Settings/withSettingsActions'; + +import { compose } from 'utils'; + + +function Dashboard({ + // #withSettings + requestFetchOptions, +}) { + const fetchOptions = useQuery( + ['options'], () => requestFetchOptions(), + ); + return ( -
- - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + + - - -
+ + + + ); } + +export default compose( + withSettingsActions, +)(Dashboard); \ No newline at end of file diff --git a/client/src/components/Dashboard/DashboardLoadingIndicator.js b/client/src/components/Dashboard/DashboardLoadingIndicator.js new file mode 100644 index 000000000..8a8009226 --- /dev/null +++ b/client/src/components/Dashboard/DashboardLoadingIndicator.js @@ -0,0 +1,25 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Choose, Icon } from 'components'; + +export default function DashboardLoadingIndicator({ + isLoading = false, + className, + children, +}) { + return ( + + +
+
+ +
+
+
+ + + { children } + +
+ ); +} diff --git a/client/src/components/Dashboard/PrivatePages.js b/client/src/components/Dashboard/PrivatePages.js new file mode 100644 index 000000000..6b00bc8e8 --- /dev/null +++ b/client/src/components/Dashboard/PrivatePages.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { Switch, Route } from 'react-router'; +import { useQuery } from 'react-query'; + +import Dashboard from 'components/Dashboard/Dashboard'; +import SetupWizardPage from 'containers/Setup/WizardSetupPage'; +import DashboardLoadingIndicator from 'components/Dashboard/DashboardLoadingIndicator'; + +import withOrganizationActions from 'containers/Organization/withOrganizationActions'; +import withSubscriptionsActions from 'containers/Subscriptions/withSubscriptionsActions'; + +import EnsureOrganizationIsReady from 'components/Guards/EnsureOrganizationIsReady'; +import EnsureOrganizationIsNotReady from 'components/Guards/EnsureOrganizationIsNotReady'; +import { compose } from 'utils'; + +/** + * Dashboard inner private pages. + */ +function DashboardPrivatePages({ + + // #withOrganizationActions + requestAllOrganizations, + + // #withSubscriptionsActions + requestFetchSubscriptions, +}) { + // Fetch all user's organizatins. + const fetchOrganizations = useQuery( + ['organizations'], () => requestAllOrganizations(), + ); + + // Fetchs organization subscriptions. + const fetchSuscriptions = useQuery( + ['susbcriptions'], () => requestFetchSubscriptions(), + { enabled: fetchOrganizations.data }, + ) + + return ( + + + + + + + + + + + + + + + + ); +} + +export default compose( + withOrganizationActions, + withSubscriptionsActions, +)(DashboardPrivatePages); \ No newline at end of file diff --git a/client/src/components/Guards/EnsureOrganizationIsNotReady.js b/client/src/components/Guards/EnsureOrganizationIsNotReady.js new file mode 100644 index 000000000..33bfa6b53 --- /dev/null +++ b/client/src/components/Guards/EnsureOrganizationIsNotReady.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Redirect } from 'react-router-dom'; +import { compose } from 'utils'; +import withAuthentication from 'containers/Authentication/withAuthentication'; +import withOrganization from 'containers/Organization/withOrganization'; + +function EnsureOrganizationIsNotReady({ + children, + + // #withOrganization + isOrganizationReady, + isOrganizationSetupCompleted +}) { + return (isOrganizationReady && !isOrganizationSetupCompleted) ? ( + + ) : children; +} + +export default compose( + withAuthentication(({ currentOrganizationId }) => ({ + currentOrganizationId, + })), + connect((state, props) => ({ + organizationId: props.currentOrganizationId, + })), + withOrganization(({ + isOrganizationReady, + isOrganizationSetupCompleted + }) => ({ + isOrganizationReady, + isOrganizationSetupCompleted + })), +)(EnsureOrganizationIsNotReady); \ No newline at end of file diff --git a/client/src/components/Guards/EnsureOrganizationIsReady.js b/client/src/components/Guards/EnsureOrganizationIsReady.js new file mode 100644 index 000000000..8870a4fed --- /dev/null +++ b/client/src/components/Guards/EnsureOrganizationIsReady.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Redirect } from 'react-router-dom'; +import { compose } from 'utils'; + +import withAuthentication from 'containers/Authentication/withAuthentication'; +import withOrganization from 'containers/Organization/withOrganization'; + + +function EnsureOrganizationIsReady({ + // #ownProps + children, + redirectTo = '/setup', + + // #withOrganizationByOrgId + isOrganizationInitialized, +}) { + return (isOrganizationInitialized) ? children : ( + + ); +} + +export default compose( + withAuthentication(), + connect((state, props) => ({ + organizationId: props.currentOrganizationId, + })), + withOrganization(({ isOrganizationInitialized }) => ({ isOrganizationInitialized })), +)(EnsureOrganizationIsReady); \ No newline at end of file diff --git a/client/src/components/PrivateRoute.js b/client/src/components/Guards/PrivateRoute.js similarity index 100% rename from client/src/components/PrivateRoute.js rename to client/src/components/Guards/PrivateRoute.js diff --git a/client/src/containers/Authentication/AuthInsider.js b/client/src/containers/Authentication/AuthInsider.js index 7484693c5..5626f58a1 100644 --- a/client/src/containers/Authentication/AuthInsider.js +++ b/client/src/containers/Authentication/AuthInsider.js @@ -7,17 +7,10 @@ export default function AuthInsider({ copyright = true, children, }) { - return ( -
-
- -
+
-
+
{ children }
@@ -25,5 +18,5 @@ export default function AuthInsider({
- ) -} \ No newline at end of file + ); +} diff --git a/client/src/containers/Authentication/Login.js b/client/src/containers/Authentication/Login.js index ccdcda0ad..6dabb1606 100644 --- a/client/src/containers/Authentication/Login.js +++ b/client/src/containers/Authentication/Login.js @@ -22,7 +22,6 @@ import withAuthenticationActions from './withAuthenticationActions'; import { compose } from 'utils'; - const ERRORS_TYPES = { INVALID_DETAILS: 'INVALID_DETAILS', USER_INACTIVE: 'USER_INACTIVE', diff --git a/client/src/containers/Authentication/Register.js b/client/src/containers/Authentication/Register.js index b35eb1bc0..d88ec2a6e 100644 --- a/client/src/containers/Authentication/Register.js +++ b/client/src/containers/Authentication/Register.js @@ -13,16 +13,16 @@ import { import { FormattedMessage as T, useIntl } from 'react-intl'; import AppToaster from 'components/AppToaster'; +import AuthInsider from 'containers/Authentication/AuthInsider'; import ErrorMessage from 'components/ErrorMessage'; import Icon from 'components/Icon'; import { If } from 'components'; -import AuthInsider from 'containers/Authentication/AuthInsider'; -import withAuthenticationActions from './withAuthenticationActions'; +import withAuthenticationActions from 'containers/Authentication/withAuthenticationActions'; import { compose } from 'utils'; -function Register({ requestRegister }) { +function RegisterUserForm({ requestRegister, requestLogin }) { const { formatMessage } = useIntl(); const history = useHistory(); const [shown, setShown] = useState(false); @@ -31,9 +31,6 @@ function Register({ requestRegister }) { }, [shown]); const ValidationSchema = Yup.object().shape({ - organization_name: Yup.string() - .required() - .label(formatMessage({ id: 'organization_name_' })), first_name: Yup.string() .required() .label(formatMessage({ id: 'first_name_' })), @@ -56,7 +53,6 @@ function Register({ requestRegister }) { const initialValues = useMemo( () => ({ - organization_name: '', first_name: '', last_name: '', email: '', @@ -82,14 +78,20 @@ function Register({ requestRegister }) { onSubmit: (values, { setSubmitting, setErrors }) => { requestRegister(values) .then((response) => { - AppToaster.show({ - message: formatMessage({ - id: 'welcome_organization_account_has_been_created', - }), - intent: Intent.SUCCESS, - }); - setSubmitting(false); - history.push('/auth/login'); + requestLogin({ + crediential: values.email, + password: values.password, + }) + .then(() => { + history.push('/register/subscription'); + setSubmitting(false); + }) + .catch((errors) => { + AppToaster.show({ + message: formatMessage({ id: 'something_wentwrong' }), + intent: Intent.SUCCESS, + }); + }); }) .catch((errors) => { if (errors.some((e) => e.type === 'PHONE_NUMBER_EXISTS')) { @@ -150,31 +152,7 @@ function Register({ requestRegister }) {
- } - className={'form-group--name'} - intent={ - errors.organization_name && - touched.organization_name && - Intent.DANGER - } - helperText={ - - } - > - - - + {' '} - +
} + label={} intent={errors.password && touched.password && Intent.DANGER} helperText={ @@ -144,10 +143,10 @@ function ResetPassword({ requestResetPassword }) { fill={true} className={'btn-new'} intent={Intent.PRIMARY} - type='submit' + type="submit" loading={isSubmitting} > - +
diff --git a/client/src/containers/Authentication/SendResetPassword.js b/client/src/containers/Authentication/SendResetPassword.js index 12195b289..637304e29 100644 --- a/client/src/containers/Authentication/SendResetPassword.js +++ b/client/src/containers/Authentication/SendResetPassword.js @@ -69,13 +69,13 @@ function SendResetPassword({ requestSendResetPassword }) { return ( -
+

- +

- +

@@ -104,7 +104,7 @@ function SendResetPassword({ requestSendResetPassword }) { fill={true} loading={isSubmitting} > - +
diff --git a/client/src/containers/Authentication/withAuthentication.js b/client/src/containers/Authentication/withAuthentication.js index 9ad30b97a..94d846f28 100644 --- a/client/src/containers/Authentication/withAuthentication.js +++ b/client/src/containers/Authentication/withAuthentication.js @@ -6,6 +6,7 @@ export default (mapState) => { const mapped = { isAuthorized: isAuthenticated(state), user: state.authentication.user, + currentOrganizationId: state.authentication?.organizationId, }; return mapState ? mapState(mapped, state, props) : mapped; }; diff --git a/client/src/containers/Organization/withOrganization.js b/client/src/containers/Organization/withOrganization.js new file mode 100644 index 000000000..8c4b94769 --- /dev/null +++ b/client/src/containers/Organization/withOrganization.js @@ -0,0 +1,40 @@ +import { connect } from 'react-redux'; +import { + getOrganizationByIdFactory, + isOrganizationReadyFactory, + isOrganizationSeededFactory, + isOrganizationBuiltFactory, + isOrganizationSeedingFactory, + isOrganizationInitializingFactory, + isOrganizationSubscribedFactory, + isOrganizationCongratsFactory, +} from 'store/organizations/organizations.selectors'; + +export default (mapState) => { + const getOrganizationById = getOrganizationByIdFactory(); + const isOrganizationReady = isOrganizationReadyFactory(); + + const isOrganizationSeeded = isOrganizationSeededFactory(); + const isOrganizationBuilt = isOrganizationBuiltFactory(); + + const isOrganizationInitializing = isOrganizationInitializingFactory(); + const isOrganizationSeeding = isOrganizationSeedingFactory(); + + const isOrganizationSubscribed = isOrganizationSubscribedFactory(); + const isOrganizationCongrats = isOrganizationCongratsFactory(); + + const mapStateToProps = (state, props) => { + const mapped = { + organization: getOrganizationById(state, props), + isOrganizationReady: isOrganizationReady(state, props), + isOrganizationSeeded: isOrganizationSeeded(state, props), + isOrganizationInitialized: isOrganizationBuilt(state, props), + isOrganizationSeeding: isOrganizationInitializing(state, props), + isOrganizationInitializing: isOrganizationSeeding(state, props), + isOrganizationSubscribed: isOrganizationSubscribed(state, props), + isOrganizationSetupCompleted: isOrganizationCongrats(state, props), + }; + return (mapState) ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +}; \ No newline at end of file diff --git a/client/src/containers/Organization/withOrganizationActions.js b/client/src/containers/Organization/withOrganizationActions.js new file mode 100644 index 000000000..7d7f4c075 --- /dev/null +++ b/client/src/containers/Organization/withOrganizationActions.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { + fetchOrganizations, + buildTenant, + seedTenant, + setOrganizationSetupCompleted, +} from 'store/organizations/organizations.actions'; + +const mapDispatchToProps = (dispatch) => ({ + requestOrganizationBuild: () => dispatch(buildTenant()), + requestOrganizationSeed: () => dispatch(seedTenant()), + requestAllOrganizations: () => dispatch(fetchOrganizations()), + setOrganizationSetupCompleted: (congrats) => dispatch(setOrganizationSetupCompleted(congrats)), +}); + +export default connect(null, mapDispatchToProps); \ No newline at end of file diff --git a/client/src/containers/Preferences/General/General.js b/client/src/containers/Preferences/General/General.js index e9aea064c..228fd90fe 100644 --- a/client/src/containers/Preferences/General/General.js +++ b/client/src/containers/Preferences/General/General.js @@ -40,7 +40,6 @@ function GeneralPreferences({ }) { const { formatMessage } = useIntl(); const [selectedItems, setSelectedItems] = useState({}); - const [timeZone, setTimeZone] = useState(''); const fetchHook = useQuery( ['settings'], @@ -65,67 +64,89 @@ function GeneralPreferences({ { id: 1, name: 'Libyan Dinar ', value: 'LYD' }, ]; - // @todo @mohamed - Translate the months. - // eg. > `${formatMessage({ id: 'january' })} - ${formatMessage({ id: 'december' })}` const fiscalYear = [ { id: 0, - name: `${formatMessage({ id: 'january' })} - ${formatMessage({ id: 'december' })}`, + name: `${formatMessage({ id: 'january' })} - ${formatMessage({ + id: 'december', + })}`, value: 'january', }, { id: 1, - name: `${formatMessage({ id: 'february' })} - ${formatMessage({ id: 'january' })}`, + name: `${formatMessage({ id: 'february' })} - ${formatMessage({ + id: 'january', + })}`, value: 'february', }, { id: 2, - name: `${formatMessage({ id: 'march' })} - ${formatMessage({ id: 'february' })}`, + name: `${formatMessage({ id: 'march' })} - ${formatMessage({ + id: 'february', + })}`, value: 'March', }, { id: 3, - name: `${formatMessage({ id: 'april' })} - ${formatMessage({ id: 'march' })}`, + name: `${formatMessage({ id: 'april' })} - ${formatMessage({ + id: 'march', + })}`, value: 'april', }, { id: 4, - name: `${formatMessage({ id: 'may' })} - ${formatMessage({ id: 'april' })}`, + name: `${formatMessage({ id: 'may' })} - ${formatMessage({ + id: 'april', + })}`, value: 'may', }, { id: 5, - name: `${formatMessage({ id: 'june' })} - ${formatMessage({ id: 'may' })}`, + name: `${formatMessage({ id: 'june' })} - ${formatMessage({ + id: 'may', + })}`, value: 'june', }, { id: 6, - name: `${formatMessage({ id: 'july' })} - ${formatMessage({ id: 'june' })}`, + name: `${formatMessage({ id: 'july' })} - ${formatMessage({ + id: 'june', + })}`, value: 'july', }, { id: 7, - name: `${formatMessage({ id: 'august' })} - ${formatMessage({ id: 'july' })}`, + name: `${formatMessage({ id: 'august' })} - ${formatMessage({ + id: 'july', + })}`, value: 'August', }, { id: 8, - name: `${formatMessage({ id: 'september' })} - ${formatMessage({ id: 'august' })}`, + name: `${formatMessage({ id: 'september' })} - ${formatMessage({ + id: 'august', + })}`, value: 'september', }, { id: 9, - name: `${formatMessage({ id: 'october' })} - ${formatMessage({ id: 'november' })}`, + name: `${formatMessage({ id: 'october' })} - ${formatMessage({ + id: 'november', + })}`, value: 'october', }, { id: 10, - name: `${formatMessage({ id: 'november' })} - ${formatMessage({ id: 'october' })}`, + name: `${formatMessage({ id: 'november' })} - ${formatMessage({ + id: 'october', + })}`, value: 'november', }, { id: 11, - name: `${formatMessage({ id: 'december' })} - ${formatMessage({ id: 'november' })}`, + name: `${formatMessage({ id: 'december' })} - ${formatMessage({ + id: 'november', + })}`, value: 'december', }, ]; @@ -185,9 +206,9 @@ function GeneralPreferences({ language: Yup.string() .required() .label(formatMessage({ id: 'language' })), - // time_zone: Yup.string() - // .required() - // .label(formatMessage({ id: 'time_zone' })), + time_zone: Yup.string() + .required() + .label(formatMessage({ id: 'time_zone_' })), date_format: Yup.string() .required() .label(formatMessage({ id: 'date_format_' })), @@ -230,15 +251,8 @@ function GeneralPreferences({ }, }); - // @todo @mohamed remove duplicate functions. - - const onItemRenderer = (item, { handleClick }) => ( - + ); const currencyItem = (item, { handleClick }) => ( @@ -280,8 +294,12 @@ function GeneralPreferences({ } }; - const handleTimezoneChange = (timezone) => setTimeZone(timezone); - + const handleTimezoneChange = useCallback( + (timezone) => { + setFieldValue('time_zone', timezone); + }, + [setFieldValue], + ); return (
} diff --git a/client/src/containers/Sales/Estimate/EntriesItemsTable.js b/client/src/containers/Sales/Estimate/EntriesItemsTable.js index 44192fcad..aeee8505e 100644 --- a/client/src/containers/Sales/Estimate/EntriesItemsTable.js +++ b/client/src/containers/Sales/Estimate/EntriesItemsTable.js @@ -3,7 +3,7 @@ import { omit } from 'lodash'; import { Button, Intent, Position, Tooltip } from '@blueprintjs/core'; import { FormattedMessage as T, useIntl } from 'react-intl'; -import CLASSES from 'components/classes'; +import { CLASSES } from 'common/classes'; import DataTable from 'components/DataTable'; import Icon from 'components/Icon'; import { diff --git a/client/src/containers/Setup/SetupCongratsPage.js b/client/src/containers/Setup/SetupCongratsPage.js new file mode 100644 index 000000000..51343733b --- /dev/null +++ b/client/src/containers/Setup/SetupCongratsPage.js @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react'; +import { Button, Intent } from '@blueprintjs/core'; +import { FormattedMessage as T } from 'react-intl'; +import { useHistory } from "react-router-dom"; +import WorkflowIcon from './WorkflowIcon'; +import withOrganizationActions from 'containers/Organization/withOrganizationActions'; + +import { compose } from 'utils'; + +function SetupCongratsPage({ + setOrganizationSetupCompleted, +}) { + const history = useHistory(); + + const handleBtnClick = useCallback(() => { + setOrganizationSetupCompleted(false); + history.push('/'); + }, [ + setOrganizationSetupCompleted, + history, + ]); + + return ( +
+
+ +
+ +
+

Congrats! You are ready to go

+ +

+ It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. +

+ + +
+
+ ); +} + +export default compose( + withOrganizationActions, +)(SetupCongratsPage); \ No newline at end of file diff --git a/client/src/containers/Setup/SetupInitializingForm.js b/client/src/containers/Setup/SetupInitializingForm.js new file mode 100644 index 000000000..37dc4b6c9 --- /dev/null +++ b/client/src/containers/Setup/SetupInitializingForm.js @@ -0,0 +1,51 @@ +import React, { useEffect } from 'react'; +import { useQuery } from 'react-query'; +import { withWizard } from 'react-albus' +import { ProgressBar, Intent } from '@blueprintjs/core'; + +import withOrganizationActions from 'containers/Organization/withOrganizationActions'; +import withOrganization from 'containers/Organization/withOrganization' + +import { compose } from 'utils'; + +/** + * Setup initializing step form. + */ +function SetupInitializingForm({ + + // #withOrganizationActions + requestOrganizationBuild, + + wizard: { next }, +}) { + const { isSuccess } = useQuery( + ['build-tenant'], () => requestOrganizationBuild(), + ); + + useEffect(() => { + if (isSuccess) { + next(); + } + }, [isSuccess, next]); + + return ( +
+ +
+

+ {/* You organization is initializin... */} + It's time to make your accounting really simple! +

+

+ while we set up your account, please remember to verify your account by + clicking on the link we sent to yout registered email address +

+
+
+ ); +} + +export default compose( + withOrganizationActions, + withWizard, +)(SetupInitializingForm); \ No newline at end of file diff --git a/client/src/containers/Setup/SetupLeftSection.js b/client/src/containers/Setup/SetupLeftSection.js new file mode 100644 index 000000000..41581dec3 --- /dev/null +++ b/client/src/containers/Setup/SetupLeftSection.js @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import { Icon, For } from 'components'; +import { FormattedMessage as T } from 'react-intl'; +import withAuthenticationActions from 'containers/Authentication/withAuthenticationActions'; +import withAuthentication from 'containers/Authentication/withAuthentication'; +import footerLinks from 'config/footerLinks'; +import { compose } from 'utils'; + + +function FooterLinkItem({ title, link }) { + return ( +
+ { title } +
+ ); +} + +/** + * Wizard setup left section. + */ +function SetupLeftSection({ + // #withAuthenticationActions + requestLogout, + + // #withAuthentication + currentOrganizationId +}) { + const onClickLogout = useCallback(() => { + requestLogout(); + }, [requestLogout]); + + return ( +
+
+
+ +
+ +

+ +

+ +

+ +

+ + +
+ + Oragnization ID: { currentOrganizationId }, + +
+ + + +
+ +
+
+

{'+21892-791-8381'}

+
+ +
+ +
+
+
+
+ ) +} + +export default compose( + withAuthenticationActions, + withAuthentication(({ currentOrganizationId }) => ({ currentOrganizationId })), +)(SetupLeftSection); diff --git a/client/src/containers/Setup/SetupOrganizationForm.js b/client/src/containers/Setup/SetupOrganizationForm.js new file mode 100644 index 000000000..a78ff2651 --- /dev/null +++ b/client/src/containers/Setup/SetupOrganizationForm.js @@ -0,0 +1,436 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import * as Yup from 'yup'; +import { useFormik } from 'formik'; +import { Row, Col } from 'react-grid-system'; +import { + Button, + Intent, + FormGroup, + InputGroup, + MenuItem, + Classes, + Position, +} from '@blueprintjs/core'; +import moment from 'moment'; +import classNames from 'classnames'; +import { TimezonePicker } from '@blueprintjs/timezone'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import { DateInput } from '@blueprintjs/datetime'; +import { withWizard } from 'react-albus'; +import { momentFormatter, tansformDateValue } from 'utils'; +import { ListSelect, ErrorMessage, FieldRequiredHint } from 'components'; + +import withSettingsActions from 'containers/Settings/withSettingsActions'; +import withOrganizationActions from 'containers/Organization/withOrganizationActions'; + +import { compose, optionsMapToArray } from 'utils'; + +function SetupOrganizationForm({ + requestSubmitOptions, + requestOrganizationSeed, + wizard, + setOrganizationSetupCompleted +}) { + const { formatMessage } = useIntl(); + const [selected, setSelected] = useState(); + + const baseCurrency = [ + { id: 0, name: 'LYD - Libyan Dinar', value: 'LYD' }, + { id: 1, name: 'USD - American Dollar', value: 'USD' }, + ]; + + const languages = [ + { id: 0, name: 'English', value: 'en' }, + { id: 1, name: 'Arabic', value: 'ar' }, + ]; + + const fiscalYear = [ + { + id: 0, + name: `${formatMessage({ id: 'january' })} - ${formatMessage({ + id: 'december', + })}`, + value: 'january', + }, + { + id: 1, + name: `${formatMessage({ id: 'february' })} - ${formatMessage({ + id: 'january', + })}`, + value: 'february', + }, + { + id: 2, + name: `${formatMessage({ id: 'march' })} - ${formatMessage({ + id: 'february', + })}`, + value: 'March', + }, + { + id: 3, + name: `${formatMessage({ id: 'april' })} - ${formatMessage({ + id: 'march', + })}`, + value: 'april', + }, + { + id: 4, + name: `${formatMessage({ id: 'may' })} - ${formatMessage({ + id: 'april', + })}`, + value: 'may', + }, + { + id: 5, + name: `${formatMessage({ id: 'june' })} - ${formatMessage({ + id: 'may', + })}`, + value: 'june', + }, + { + id: 6, + name: `${formatMessage({ id: 'july' })} - ${formatMessage({ + id: 'june', + })}`, + value: 'july', + }, + { + id: 7, + name: `${formatMessage({ id: 'august' })} - ${formatMessage({ + id: 'july', + })}`, + value: 'August', + }, + { + id: 8, + name: `${formatMessage({ id: 'september' })} - ${formatMessage({ + id: 'august', + })}`, + value: 'september', + }, + { + id: 9, + name: `${formatMessage({ id: 'october' })} - ${formatMessage({ + id: 'november', + })}`, + value: 'october', + }, + { + id: 10, + name: `${formatMessage({ id: 'november' })} - ${formatMessage({ + id: 'october', + })}`, + value: 'november', + }, + { + id: 11, + name: `${formatMessage({ id: 'december' })} - ${formatMessage({ + id: 'november', + })}`, + value: 'december', + }, + ]; + + const ValidationSchema = Yup.object().shape({ + name: Yup.string() + .required() + .label(formatMessage({ id: 'organization_name_' })), + financial_date_start: Yup.date() + .required() + .label(formatMessage({ id: 'date_start_' })), + base_currency: Yup.string() + .required() + .label(formatMessage({ id: 'base_currency_' })), + language: Yup.string() + .required() + .label(formatMessage({ id: 'language' })), + fiscal_year: Yup.string() + .required() + .label(formatMessage({ id: 'fiscal_year_' })), + time_zone: Yup.string() + .required() + .label(formatMessage({ id: 'time_zone_' })), + }); + + const initialValues = useMemo( + () => ({ + name: '', + financial_date_start: moment(new Date()).format('YYYY-MM-DD'), + base_currency: '', + language: '', + fiscal_year: '', + time_zone: '', + }), + [], + ); + + const { + values, + errors, + touched, + handleSubmit, + setFieldValue, + getFieldProps, + isSubmitting, + } = useFormik({ + enableReinitialize: true, + validationSchema: ValidationSchema, + initialValues: { + ...initialValues, + }, + onSubmit: (values, { setSubmitting, setErrors }) => { + const options = optionsMapToArray(values).map((option) => { + return { key: option.key, ...option, group: 'organization' }; + }); + requestSubmitOptions({ options }) + .then(() => { + return requestOrganizationSeed(); + }) + .then(() => { + return setOrganizationSetupCompleted(true); + }) + .then((response) => { + setSubmitting(false); + wizard.next(); + }) + .catch((erros) => { + setSubmitting(false); + }); + }, + }); + + const onItemsSelect = (filedName) => { + return (filed) => { + setSelected({ + ...selected, + [filedName]: filed, + }); + setFieldValue(filedName, filed.value); + }; + }; + + const filterItems = (query, item, _index, exactMatch) => { + const normalizedTitle = item.name.toLowerCase(); + const normalizedQuery = query.toLowerCase(); + + if (exactMatch) { + return normalizedTitle === normalizedQuery; + } else { + return normalizedTitle.indexOf(normalizedQuery) >= 0; + } + }; + + const onItemRenderer = (item, { handleClick }) => ( + + ); + + const handleTimeZoneChange = useCallback( + (time_zone) => { + setFieldValue('time_zone', time_zone); + }, + [setFieldValue], + ); + + const handleDateChange = useCallback( + (date) => { + const formatted = moment(date).format('YYYY-MM-DD'); + setFieldValue('financial_date_start', formatted); + }, + [setFieldValue], + ); + + return ( +
+
+

+ +

+

+ +

+
+ +
+

+ +

+ + } + labelInfo={} + className={'form-group--name'} + intent={errors.name && touched.name && Intent.DANGER} + helperText={} + > + + + + {/* financial starting date */} + } + labelInfo={} + intent={ + errors.financial_date_start && + touched.financial_date_start && + Intent.DANGER + } + helperText={ + + } + className={classNames('form-group--select-list', Classes.FILL)} + > + + + + {/* base currency */} + + } + labelInfo={} + className={classNames( + 'form-group--base-currency', + 'form-group--select-list', + Classes.LOADING, + Classes.FILL, + )} + intent={ + errors.base_currency && touched.base_currency && Intent.DANGER + } + helperText={ + + } + > + } + itemRenderer={onItemRenderer} + popoverProps={{ minimal: true }} + onItemSelect={onItemsSelect('base_currency')} + itemPredicate={filterItems} + selectedItem={values.base_currency} + selectedItemProp={'value'} + defaultText={} + labelProp={'name'} + /> + + + + {/* language */} + + } + labelInfo={} + className={classNames( + 'form-group--language', + 'form-group--select-list', + Classes.FILL, + )} + intent={errors.language && touched.language && Intent.DANGER} + helperText={ + + } + > + } + itemRenderer={onItemRenderer} + popoverProps={{ minimal: true }} + onItemSelect={onItemsSelect('language')} + itemPredicate={filterItems} + selectedItem={values.language} + selectedItemProp={'value'} + defaultText={} + labelProp={'name'} + /> + + + + {/* fiscal Year */} + } + labelInfo={} + className={classNames( + 'form-group--fiscal_year', + 'form-group--select-list', + Classes.FILL, + )} + intent={errors.fiscal_year && touched.fiscal_year && Intent.DANGER} + helperText={ + + } + > + } + itemRenderer={onItemRenderer} + popoverProps={{ minimal: true }} + onItemSelect={onItemsSelect('fiscal_year')} + itemPredicate={filterItems} + selectedItem={values.fiscal_year} + selectedItemProp={'value'} + defaultText={} + labelProp={'name'} + /> + + + {/* Time zone */} + } + labelInfo={} + className={classNames( + 'form-group--time-zone', + 'form-group--select-list', + Classes.FILL, + )} + intent={errors.time_zone && touched.time_zone && Intent.DANGER} + helperText={ + + } + > + } + /> + + +

+ +

+
+ +
+
+
+ ); +} + +export default compose( + withSettingsActions, + withOrganizationActions, + withWizard, +)(SetupOrganizationForm); diff --git a/client/src/containers/Setup/SetupRightSection.js b/client/src/containers/Setup/SetupRightSection.js new file mode 100644 index 000000000..3913fbaba --- /dev/null +++ b/client/src/containers/Setup/SetupRightSection.js @@ -0,0 +1,115 @@ +import React, { useCallback } from 'react'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import { Wizard, Steps, Step } from 'react-albus'; +import { useHistory } from "react-router-dom"; +import { connect } from 'react-redux'; + +import WizardSetupSteps from './WizardSetupSteps'; +import withSubscriptions from 'containers/Subscriptions/withSubscriptions'; + +import SetupSubscriptionForm from './SetupSubscriptionForm'; +import SetupOrganizationForm from './SetupOrganizationForm'; +import SetupInitializingForm from './SetupInitializingForm'; +import SetupCongratsPage from './SetupCongratsPage'; + +import withAuthentication from 'containers/Authentication/withAuthentication'; +import withOrganization from 'containers/Organization/withOrganization' +import { compose } from 'utils'; + +/** + * Wizard setup right section. + */ +function SetupRightSection ({ + // #withAuthentication + currentOrganizationId, + + // #withOrganization + isOrganizationInitialized, + isOrganizationSeeded, + isOrganizationSetupCompleted, + + // #withSubscriptions + isSubscriptionActive +}) { + const history = useHistory(); + + const handleSkip = useCallback(({ step, push }) => { + const scenarios = [ + { condition: isOrganizationSetupCompleted, redirectTo: 'congrats' }, + { condition: !isSubscriptionActive, redirectTo: 'subscription' }, + { condition: isSubscriptionActive && !isOrganizationInitialized, redirectTo: 'initializing' }, + { condition: isSubscriptionActive && !isOrganizationSeeded, redirectTo: 'organization' }, + ]; + const scenario = scenarios.find((scenario) => scenario.condition); + + if (scenario) { + push(scenario.redirectTo); + } + }, [ + isSubscriptionActive, + isOrganizationInitialized, + isOrganizationSeeded, + isOrganizationSetupCompleted + ]); + + return ( +
+ ( +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+
+
+
+ )} /> +
+ ) +} + +export default compose( + withAuthentication(({ currentOrganizationId }) => ({ currentOrganizationId })), + connect((state, props) => ({ + organizationId: props.currentOrganizationId, + })), + withOrganization(({ + organization, + isOrganizationInitialized, + isOrganizationSeeded, + isOrganizationSetupCompleted + }) => ({ + organization, + isOrganizationInitialized, + isOrganizationSeeded, + isOrganizationSetupCompleted + })), + withSubscriptions(({ + isSubscriptionActive, + }) => ({ + isSubscriptionActive + }), 'main'), +)(SetupRightSection); \ No newline at end of file diff --git a/client/src/containers/Setup/SetupSubscriptionForm.js b/client/src/containers/Setup/SetupSubscriptionForm.js new file mode 100644 index 000000000..ba3c2be35 --- /dev/null +++ b/client/src/containers/Setup/SetupSubscriptionForm.js @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react'; +import * as Yup from 'yup'; +import { useFormik } from 'formik'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import { Button, Intent } from '@blueprintjs/core'; +import { withWizard } from 'react-albus'; +import withSubscriptionsActions from 'containers/Subscriptions/withSubscriptionsActions'; +import BillingPlans from 'containers/Subscriptions/billingPlans'; +import BillingPeriods from 'containers/Subscriptions/billingPeriods'; +import { BillingPaymentmethod } from 'containers/Subscriptions/billingPaymentmethod'; +import withBillingActions from 'containers/Subscriptions/withBillingActions'; +import { compose } from 'utils'; + +/** + * Subscription step of wizard setup. + */ +function SetupSubscriptionForm({ + // #withBillingActions + requestSubmitBilling, + + // #withWizard + wizard, + + // #withSubscriptionsActions + requestFetchSubscriptions +}) { + const { formatMessage } = useIntl(); + const validationSchema = Yup.object().shape({ + plan_slug: Yup.string() + .required() + .label(formatMessage({ id: 'plan_slug' })), + license_code: Yup.string() + .min(10) + .max(10) + .required() + .label(formatMessage({ id: 'license_code_' })) + .trim(), + }); + + const initialValues = useMemo( + () => ({ + plan_slug: '', + license_code: '', + }), + [], + ); + + const formik = useFormik({ + enableReinitialize: true, + validationSchema: validationSchema, + initialValues: { + ...initialValues, + }, + onSubmit: (values, { setSubmitting, setErrors }) => { + requestSubmitBilling(values) + .then((response) => { + return requestFetchSubscriptions(); + }) + .then(() => { + wizard.next(); + setSubmitting(false); + }) + .catch((errors) => { + setSubmitting(false); + }); + }, + }); + return ( +
+
+ + + + +
+ +
+ +
+ ); +} + +export default compose( + withBillingActions, + withWizard, + withSubscriptionsActions, +)(SetupSubscriptionForm); diff --git a/client/src/containers/Setup/WizardSetupPage.js b/client/src/containers/Setup/WizardSetupPage.js new file mode 100644 index 000000000..0a22bffd3 --- /dev/null +++ b/client/src/containers/Setup/WizardSetupPage.js @@ -0,0 +1,13 @@ +import React from 'react'; +import SetupRightSection from './SetupRightSection'; +import SetupLeftSection from './SetupLeftSection'; + + +export default function WizardSetupPage() { + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/client/src/containers/Setup/WizardSetupSteps.js b/client/src/containers/Setup/WizardSetupSteps.js new file mode 100644 index 000000000..d29cb4abe --- /dev/null +++ b/client/src/containers/Setup/WizardSetupSteps.js @@ -0,0 +1,36 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FormattedMessage as T } from 'react-intl'; +import { registerWizardSteps } from 'common/registerWizard' + +function WizardSetupStep({ + label, + isActive = false +}) { + return ( +
  • +

    +
  • + ); +} + +function WizardSetupSteps({ + currentStep = 1, +}) { + return ( +
    +
    +
      + {registerWizardSteps.map((step, index) => ( + + ))} +
    +
    +
    + ); +} + +export default WizardSetupSteps; diff --git a/client/src/containers/Setup/WorkflowIcon.js b/client/src/containers/Setup/WorkflowIcon.js new file mode 100644 index 000000000..88eb709aa --- /dev/null +++ b/client/src/containers/Setup/WorkflowIcon.js @@ -0,0 +1,120 @@ +import React from 'react'; + +export default function WorkflowIcon({ + width = '309.566', + height = '356.982', +}) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/client/src/containers/Subscriptions/BillingForm.js b/client/src/containers/Subscriptions/BillingForm.js index 33b1fafb8..828d84a42 100644 --- a/client/src/containers/Subscriptions/BillingForm.js +++ b/client/src/containers/Subscriptions/BillingForm.js @@ -3,12 +3,6 @@ import * as Yup from 'yup'; import { useFormik } from 'formik'; import { Button, Intent } from '@blueprintjs/core'; import { FormattedMessage as T, useIntl } from 'react-intl'; -import { useHistory } from 'react-router-dom'; -import { pick } from 'lodash'; - -import AppToaster from 'components/AppToaster'; -import ErrorMessage from 'components/ErrorMessage'; - import withDashboardActions from 'containers/Dashboard/withDashboardActions'; import { MeteredBillingTabs, PaymentMethodTabs } from './SubscriptionTabs'; import withBillingActions from './withBillingActions'; @@ -21,13 +15,6 @@ function BillingForm({ //#withBillingActions requestSubmitBilling, }) { - // const defaultPlan = useMemo(() => ({ - // plan_slug: [ - // { id: 0, name: 'Basic', value: 'basic' }, - // { id: 0, name: 'Pro', value: 'pro' }, - // ], - // })); - const { formatMessage } = useIntl(); useEffect(() => { @@ -43,7 +30,7 @@ function BillingForm({ const initialValues = useMemo( () => ({ - plan_slug: 'basic', + plan_slug: 'free', license_code: '', }), [], @@ -55,16 +42,9 @@ function BillingForm({ initialValues: { ...initialValues, }, - onSubmit: (values, { setSubmitting, resetForm, setErrors }) => { requestSubmitBilling(values) .then((response) => { - AppToaster.show({ - message: formatMessage({ - id: 'the_biling_has_been_successfully_created', - }), - intent: Intent.SUCCESS, - }); setSubmitting(false); }) .catch((errors) => { @@ -72,7 +52,7 @@ function BillingForm({ }); }, }); - console.log(formik.values, 'formik'); + return (
    diff --git a/client/src/containers/Subscriptions/BillingTab.js b/client/src/containers/Subscriptions/BillingTab.js index df46a8977..7b8db9bd5 100644 --- a/client/src/containers/Subscriptions/BillingTab.js +++ b/client/src/containers/Subscriptions/BillingTab.js @@ -1,162 +1,14 @@ -import React, { - useState, - useMemo, - useCallback, - useEffect, - useRef, -} from 'react'; -import { FormattedMessage as T, useIntl } from 'react-intl'; -import { PaymentMethodTabs } from './SubscriptionTabs'; +import React from 'react'; +import BillingPlans from 'containers/Subscriptions/billingPlans'; +import BillingPeriods from 'containers/Subscriptions/billingPeriods'; +import { BillingPaymentmethod } from 'containers/Subscriptions/billingPaymentmethod'; function BillingTab({ formik }) { - const [plan, setPlan] = useState(); - const planRef = useRef(null); - const billingRef = useRef(null); - - const handlePlan = () => { - const plans = planRef.current.querySelectorAll('a'); - const planSelected = planRef.current.querySelector('.plan-selected'); - - plans.forEach((el) => { - el.addEventListener('click', () => { - planSelected.classList.remove('plan-selected'); - el.classList.add('plan-selected'); - }); - }); - }; - - const handleBilling = () => { - const billingPriod = billingRef.current.querySelectorAll('a'); - const billingSelected = billingRef.current.querySelector( - '.billing-selected', - ); - billingPriod.forEach((el) => { - el.addEventListener('click', () => { - billingSelected.classList.remove('billing-selected'); - el.classList.add('billing-selected'); - }); - }); - }; - - useEffect(() => { - handlePlan(); - handleBilling(); - }); - return ( ); } diff --git a/client/src/containers/Subscriptions/LicenseTab.js b/client/src/containers/Subscriptions/LicenseTab.js index e485db707..6df07c5ec 100644 --- a/client/src/containers/Subscriptions/LicenseTab.js +++ b/client/src/containers/Subscriptions/LicenseTab.js @@ -11,7 +11,7 @@ function LicenseTab({

    -

    +

    diff --git a/client/src/containers/Subscriptions/billingPaymentmethod.js b/client/src/containers/Subscriptions/billingPaymentmethod.js new file mode 100644 index 000000000..031aaf9ea --- /dev/null +++ b/client/src/containers/Subscriptions/billingPaymentmethod.js @@ -0,0 +1,17 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import { PaymentMethodTabs } from './SubscriptionTabs'; + +export const BillingPaymentmethod = ({ formik, title }) => { + return ( +
    +

    + +

    +

    + +

    + +
    + ); +}; diff --git a/client/src/containers/Subscriptions/billingPeriods.js b/client/src/containers/Subscriptions/billingPeriods.js new file mode 100644 index 000000000..9454bdaff --- /dev/null +++ b/client/src/containers/Subscriptions/billingPeriods.js @@ -0,0 +1,69 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import classNames from 'classnames'; +import { paymentmethod } from 'common/subscriptionModels'; + +function BillingPeriod({ price, period, currency, onSelected, selected }) { + return ( + + + + + +
    + + {price} {currency} + + + + +
    +
    + ); +} +function BillingPeriods({ formik, title, selected = 1 }) { + const billingRef = useRef(null); + + useEffect(() => { + const billingPriod = billingRef.current.querySelectorAll('a'); + const billingSelected = billingRef.current.querySelector( + '.billing-selected', + ); + billingPriod.forEach((el) => { + el.addEventListener('click', () => { + billingSelected.classList.remove('billing-selected'); + el.classList.add('billing-selected'); + }); + }); + }); + + return ( +
    +

    + +

    +

    + +

    +
    + {paymentmethod.map((pay, index) => ( + formik.setFieldValue('period', pay.period)} + selected={selected == index + 1} + /> + ))} +
    +
    + ); +} + +export default BillingPeriods; diff --git a/client/src/containers/Subscriptions/billingPlans.js b/client/src/containers/Subscriptions/billingPlans.js new file mode 100644 index 000000000..f94e14c1e --- /dev/null +++ b/client/src/containers/Subscriptions/billingPlans.js @@ -0,0 +1,89 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { FormattedMessage as T, useIntl } from 'react-intl'; +import classNames from 'classnames'; +import { plans } from 'common/subscriptionModels'; + +function BillingPlan({ + name, + description, + price, + slug, + currency, + onSelected, + selected, +}) { + return ( + onSelected(slug)} + > +
    +
    + +
    +
    +
    +
      + {description.map((desc, index) => ( +
    • {desc}
    • + ))} +
    +
    +
    + + {' '} + {price} {currency} + + + + +
    +
    + ); +} + +function BillingPlans({ formik, title, selected = 1 }) { + const planRef = useRef(null); + + useEffect(() => { + const plans = planRef.current.querySelectorAll('a'); + const planSelected = planRef.current.querySelector('.plan-selected'); + + plans.forEach((el) => { + el.addEventListener('click', () => { + planSelected.classList.remove('plan-selected'); + el.classList.add('plan-selected'); + }); + }); + }); + + return ( +
    +

    + +

    +

    + +

    +
    + {plans.map((plan, index) => ( + formik.setFieldValue('plan_slug', plan.slug)} + selected={selected == index + 1} + /> + ))} +
    +
    + ); +} + +export default BillingPlans; + diff --git a/client/src/containers/Subscriptions/withSubscriptions.js b/client/src/containers/Subscriptions/withSubscriptions.js new file mode 100644 index 000000000..441dc0fb6 --- /dev/null +++ b/client/src/containers/Subscriptions/withSubscriptions.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { + isSubscriptionOnTrialFactory, + isSubscriptionInactiveFactory, + isSubscriptionActiveFactory, +} from 'store/subscription/subscription.selectors'; + +export default (mapState, slug) => { + const isSubscriptionOnTrial = isSubscriptionOnTrialFactory(slug); + const isSubscriptionInactive = isSubscriptionInactiveFactory(slug); + const isSubscriptionActive = isSubscriptionActiveFactory(slug); + + const mapStateToProps = (state, props) => { + const mapped = { + isSubscriptionOnTrial: isSubscriptionOnTrial(state, props), + isSubscriptionInactive: isSubscriptionInactive(state, props), + isSubscriptionActive: isSubscriptionActive(state, props), + }; + return (mapState) ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +}; \ No newline at end of file diff --git a/client/src/containers/Subscriptions/withSubscriptionsActions.js b/client/src/containers/Subscriptions/withSubscriptionsActions.js new file mode 100644 index 000000000..2b8ed6694 --- /dev/null +++ b/client/src/containers/Subscriptions/withSubscriptionsActions.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import { + fetchSubscriptions, +} from 'store/subscription/subscription.actions' + +const mapDispatchToProps = (dispatch) => ({ + requestFetchSubscriptions: () => dispatch(fetchSubscriptions()), +}); + +export default connect(null, mapDispatchToProps); \ No newline at end of file diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index 9098e57b0..2fc8eb4c4 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -53,10 +53,10 @@ export default { you_remembered_your_password: 'You remembered your password ?', new_password: 'New Password', submit_new_password: 'Submit new password', - reset_your_password: 'Reset Your Password', - we_ll_send_you_a_link_to_reset_your_password: - 'Enter your email address and we’ll send you a link to reset your password.', - send_password_reset_link: 'Send password reset link', + you_can_t_login: 'You can’t login?', + we_ll_send_a_recovery_link_to_your_email: + 'We’ll send a recovery link to your email.', + send_reset_password_mail: 'Send Reset Password Mail', return_to_log_in: 'Return to log in', sub_account: 'Sub account?', account_type: 'Account Type', @@ -398,7 +398,7 @@ export default { cost_account_: 'Cost account', inventory_account_: 'Inventory account', view_name_: 'View name', - time_zone: 'Time zone', + time_zone_: 'Time zone', location: 'Location', the_items_has_been_successfully_deleted: 'The items have been successfully deleted.', @@ -704,12 +704,12 @@ export default { pro: 'PRO', monthly: 'Monthly', yearly: 'Yearly', - license_code: 'License code', + license_code: 'License Code', year: 'Year', please_enter_your_preferred_payment_method: 'Please enter your preferred payment method below. You can use a credit / debit card or prepay through PayPal. ', cards_will_be_charged: - 'Cards will be charged either at the end of the month or whenever your balance exceeds the usage threshold.All major credit / debit cards accepted.', + 'Cards will be charged either at the end of the month or whenever your balance exceeds the usage threshold. All major credit / debit cards accepted.', license_number: 'License number', subscribe: 'Subscribe', year_per: 'year', @@ -740,4 +740,30 @@ export default { sell_account: 'Sell Account', cost_account: 'Cost Account', inventory_account: 'Inventory Account', + + register_a_new_organization_now: 'Register a New Organization now!.', + you_have_a_bigcapital_account: 'You have a Bigcapital account ', + contact_us_technical_support: 'Contact us - Technical Support', + let_s_get_started: 'Let’s Get Started', + tell_the_system_a_little_bit_about_your_organization: + 'Tell the system a little bit about your organization.', + organization_details: 'Organization details', + financial_starting_date: 'Financial starting date ', + base_currency: 'Base Currency', + note_you_can_change_your_preferences_later_in_dashboard_if_needed: + 'Note: You can change your preferences later in dashboard, if needed.', + save_continue: 'Save & Continue', + organization_register: 'Organization Register', + getting_started: 'Getting started', + payment_or_trial: 'Payment or trial', + initializing: 'Initializing', + fiscal_year_: 'Fiscal year', + welcome: 'Welcome ', + sign_out: 'Sign out', + we_re_here_to_help: 'We’re Here to Help!', + date_start_: 'Date start', + something_wentwrong: 'Something went wrong.', + new_password: 'New password', + license_code_: 'License code', + legal_organization_name: 'Legal Organization Name' }; diff --git a/client/src/routes/authentication.js b/client/src/routes/authentication.js index 0940781cc..f3fb7da68 100644 --- a/client/src/routes/authentication.js +++ b/client/src/routes/authentication.js @@ -9,12 +9,6 @@ export default [ loader: () => import('containers/Authentication/Login'), }), }, - { - path: `${BASE_URL}/register`, - component: LazyLoader({ - loader: () => import('containers/Authentication/Register'), - }), - }, { path: `${BASE_URL}/send_reset_password`, component: LazyLoader({ @@ -33,4 +27,10 @@ export default [ loader: () => import('containers/Authentication/InviteAccept'), }), }, + { + path: `${BASE_URL}/register`, + component: LazyLoader({ + loader: () => import('containers/Authentication/Register'), + }), + } ]; diff --git a/client/src/routes/register.js b/client/src/routes/register.js new file mode 100644 index 000000000..44277cd21 --- /dev/null +++ b/client/src/routes/register.js @@ -0,0 +1,23 @@ +import LazyLoader from 'components/LazyLoader'; + +export default [ + + { + path: '/register/subscription', + component: LazyLoader({ + loader: () => import('containers/Authentication/Register/RegisterSubscriptionForm'), + }), + }, + { + path: '/register/organization', + component: LazyLoader({ + loader: () => import('containers/Authentication/Register/RegisterOrganizationForm'), + }), + }, + { + path: `/`, + component: LazyLoader({ + loader: () => import('containers/Authentication/Register/RegisterUserForm'), + }), + }, +]; diff --git a/client/src/store/authentication/authentication.reducer.js b/client/src/store/authentication/authentication.reducer.js index 15a4d281f..12db81283 100644 --- a/client/src/store/authentication/authentication.reducer.js +++ b/client/src/store/authentication/authentication.reducer.js @@ -4,7 +4,9 @@ import t from 'store/types'; const initialState = { token: '', organization: '', + organizationId: null, user: '', + tenant: {}, locale: '', errors: [], }; @@ -15,6 +17,8 @@ export default createReducer(initialState, { state.token = token; state.user = user; state.organization = tenant.organization_id; + state.organizationId = tenant.id; + state.tenant = tenant; }, [t.LOGIN_FAILURE]: (state, action) => { @@ -36,3 +40,9 @@ export const isAuthenticated = (state) => !!state.authentication.token; export const hasErrorType = (state, errorType) => { return state.authentication.errors.find((e) => e.type === errorType); }; + +export const isTenantSeeded = (state) => !!state.tenant.seeded_at; +export const isTenantBuilt = (state) => !!state.tenant.initialized_at; + +export const isTenantHasSubscription = () => false; +export const isTenantSubscriptionExpired = () => false; \ No newline at end of file diff --git a/client/src/store/billing/Billing.action.js b/client/src/store/billing/Billing.action.js index e1ca8945f..bfef16e6d 100644 --- a/client/src/store/billing/Billing.action.js +++ b/client/src/store/billing/Billing.action.js @@ -4,7 +4,7 @@ import t from 'store/types'; export const submitBilling = ({ form }) => { return (dispatch) => new Promise((resolve, reject) => { - ApiService.post('payment', form) + ApiService.post('subscription/license/payment', form) .then((response) => { resolve(response); }) diff --git a/client/src/store/organizations/organizations.actions.js b/client/src/store/organizations/organizations.actions.js new file mode 100644 index 000000000..d3db2ba7f --- /dev/null +++ b/client/src/store/organizations/organizations.actions.js @@ -0,0 +1,64 @@ +import ApiService from 'services/ApiService'; +import t from 'store/types'; + +export const fetchOrganizations = () => (dispatch) => new Promise((resolve, reject) => { + ApiService.get('organization/all').then((response) => { + dispatch({ + type: t.ORGANIZATIONS_LIST_SET, + payload: { + organizations: response.data.organizations, + }, + }); + resolve(response) + }).catch(error => { reject(error); }); +}); + +export const buildTenant = () => (dispatch, getState) => new Promise((resolve, reject) => { + const organizationId = getState().authentication.organizationId; + + dispatch({ + type: t.SET_ORGANIZATION_INITIALIZING, + payload: { organizationId } + }); + ApiService.post(`organization/build`).then((response) => { + resolve(response); + dispatch({ + type: t.SET_ORGANIZATION_INITIALIZED, + payload: { organizationId } + }); + }) + .catch((error) => { + reject(error.response.data.errors || []); + }); +}); + +export const seedTenant = () => (dispatch, getState) => new Promise((resolve, reject) => { + const organizationId = getState().authentication.organizationId; + + dispatch({ + type: t.SET_ORGANIZATION_SEEDING, + payload: { organizationId } + }); + ApiService.post(`organization/seed/`).then((response) => { + resolve(response); + dispatch({ + type: t.SET_ORGANIZATION_SEEDED, + payload: { organizationId } + }); + }) + .catch((error) => { + reject(error.response.data.errors || []); + }); +}); + +export const setOrganizationSetupCompleted = (congrats) => (dispatch, getState) => { + const organizationId = getState().authentication.organizationId; + + dispatch({ + type: t.SET_ORGANIZATION_CONGRATS, + payload: { + organizationId, + congrats + }, + }); +}; \ No newline at end of file diff --git a/client/src/store/organizations/organizations.reducers.js b/client/src/store/organizations/organizations.reducers.js new file mode 100644 index 000000000..6fa8a44e0 --- /dev/null +++ b/client/src/store/organizations/organizations.reducers.js @@ -0,0 +1,73 @@ +import { createReducer } from '@reduxjs/toolkit'; +import t from 'store/types'; + +const initialState = { + data: {}, + byOrganizationId: {}, +}; + +const reducer = createReducer(initialState, { + + [t.ORGANIZATIONS_LIST_SET]: (state, action) => { + const { organizations } = action.payload; + const _data = {}; + const _dataByOrganizationId = {}; + + organizations.forEach((organization) => { + _data[organization.id] = organization; + _dataByOrganizationId[organization.organization_id] = organization.id; + }); + state.data = _data; + state.byOrganizationId = _dataByOrganizationId; + }, + + [t.SET_ORGANIZATION_SEEDING]: (state, action) => { + const { organizationId } = action.payload; + + state.data[organizationId] = { + ...(state.data[organizationId] || {}), + is_seeding: true, + }; + }, + + [t.SET_ORGANIZATION_SEEDED]: (state, action) => { + const { organizationId } = action.payload; + + state.data[organizationId] = { + ...(state.data[organizationId] || {}), + is_seeding: false, + seeded_at: new Date().toISOString(), + is_ready: true, + }; + }, + + [t.SET_ORGANIZATION_INITIALIZING]: (state, action) => { + const { organizationId } = action.payload; + + state.data[organizationId] = { + ...(state.data[organizationId] || {}), + is_initializing: true, + }; + }, + + [t.SET_ORGANIZATION_INITIALIZED]: (state, action) => { + const { organizationId } = action.payload; + + state.data[organizationId] = { + ...(state.data[organizationId] || {}), + is_initializing: false, + initialized_at: new Date().toISOString(), + }; + }, + + [t.SET_ORGANIZATION_CONGRATS]: (state, action) => { + const { organizationId, congrats } = action.payload; + + state.data[organizationId] = { + ...(state.data[organizationId] || {}), + is_congrats: !!congrats, + }; + } +}) + +export default reducer; \ No newline at end of file diff --git a/client/src/store/organizations/organizations.selectors.js b/client/src/store/organizations/organizations.selectors.js new file mode 100644 index 000000000..197ca0254 --- /dev/null +++ b/client/src/store/organizations/organizations.selectors.js @@ -0,0 +1,57 @@ +import { createSelector } from '@reduxjs/toolkit'; + +const organizationSelector = (state, props) => state.organizations.data[props.organizationId]; + +export const getOrganizationByIdFactory = () => createSelector( + organizationSelector, + (organization) => organization +); + +export const isOrganizationSeededFactory = () => createSelector( + organizationSelector, + (organization) => { + return !!organization?.seeded_at; + }, +); + +export const isOrganizationBuiltFactory = () => createSelector( + organizationSelector, + (organization) => { + return !!organization?.initialized_at; + }, +); + +export const isOrganizationInitializingFactory = () => createSelector( + organizationSelector, + (organization) => { + return organization?.is_initializing; + }, +); + +export const isOrganizationSeedingFactory = () => createSelector( + organizationSelector, + (organization) => { + return organization?.is_seeding; + }, +); + +export const isOrganizationReadyFactory = () => createSelector( + organizationSelector, + (organization) => { + return organization?.is_ready; + }, +); + +export const isOrganizationSubscribedFactory = () => createSelector( + organizationSelector, + (organization) => { + return organization?.subscriptions?.length > 0; + } +); + +export const isOrganizationCongratsFactory = () => createSelector( + organizationSelector, + (organization) => { + return !!organization?.is_congrats; + } +) \ No newline at end of file diff --git a/client/src/store/organizations/organizations.types.js b/client/src/store/organizations/organizations.types.js new file mode 100644 index 000000000..25f43f0de --- /dev/null +++ b/client/src/store/organizations/organizations.types.js @@ -0,0 +1,12 @@ + +export default { + ORGANIZATIONS_LIST_SET: 'ORGANIZATIONS_LIST_SET', + + SET_ORGANIZATION_SEEDING: 'SET_ORGANIZATION_SEEDING', + SET_ORGANIZATION_SEEDED: 'SET_ORGANIZATION_SEEDED', + + SET_ORGANIZATION_INITIALIZED: 'SET_ORGANIZATION_INITIALIZED', + SET_ORGANIZATION_INITIALIZING: 'SET_ORGANIZATION_INITIALIZING', + + SET_ORGANIZATION_CONGRATS: 'SET_ORGANIZATION_CONGRATS' +}; \ No newline at end of file diff --git a/client/src/store/reducers.js b/client/src/store/reducers.js index 2e6e5f46e..62e4188ce 100644 --- a/client/src/store/reducers.js +++ b/client/src/store/reducers.js @@ -25,9 +25,13 @@ import bills from './Bills/bills.reducer'; import vendors from './vendors/vendors.reducer'; import paymentReceives from './PaymentReceive/paymentReceive.reducer'; import paymentMades from './PaymentMades/paymentMade.reducer'; +import organizations from './organizations/organizations.reducers'; +import subscriptions from './subscription/subscription.reducer'; export default combineReducers({ authentication, + organizations, + subscriptions, dashboard, users, accounts, @@ -45,12 +49,11 @@ export default combineReducers({ exchangeRates, globalErrors, customers, - salesEstimates, salesInvoices, salesReceipts, bills, vendors, paymentReceives, - paymentMades + paymentMades, }); diff --git a/client/src/store/subscription/subscription.actions.js b/client/src/store/subscription/subscription.actions.js new file mode 100644 index 000000000..28ef9d8f2 --- /dev/null +++ b/client/src/store/subscription/subscription.actions.js @@ -0,0 +1,14 @@ +import ApiService from 'services/ApiService'; +import t from 'store/types'; + +export const fetchSubscriptions = () => (dispatch) => new Promise((resolve, reject) => { + ApiService.get('subscription').then((response) => { + dispatch({ + type: t.SET_PLAN_SUBSCRIPTIONS_LIST, + payload: { + subscriptions: response.data.subscriptions, + }, + }); + resolve(response); + }).catch((error) => { reject(error); }) +}); \ No newline at end of file diff --git a/client/src/store/subscription/subscription.reducer.js b/client/src/store/subscription/subscription.reducer.js new file mode 100644 index 000000000..ee9e20f9b --- /dev/null +++ b/client/src/store/subscription/subscription.reducer.js @@ -0,0 +1,19 @@ +import { createReducer } from '@reduxjs/toolkit'; +import t from 'store/types'; + +const initialState = { + data: {}, +}; + +export default createReducer(initialState, { + + [t.SET_PLAN_SUBSCRIPTIONS_LIST]: (state, action) => { + const { subscriptions } = action.payload; + const _data = {}; + + subscriptions.forEach((subscription) => { + _data[subscription.id] = subscription; + }); + state.data = _data; + }, +}); diff --git a/client/src/store/subscription/subscription.selectors.js b/client/src/store/subscription/subscription.selectors.js new file mode 100644 index 000000000..eb59631db --- /dev/null +++ b/client/src/store/subscription/subscription.selectors.js @@ -0,0 +1,23 @@ +import { createSelector } from '@reduxjs/toolkit'; + +const subscriptionSelector = (slug) => (state, props) => { + const subscriptions = Object.values(state.subscriptions.data); + return subscriptions.find((subscription) => subscription.slug === slug); +}; + +export const isSubscriptionOnTrialFactory = (slug) => createSelector( + subscriptionSelector(slug), + (subscription) => !!subscription?.on_trial, +); + +export const isSubscriptionActiveFactory = (slug) => createSelector( + subscriptionSelector(slug), + (subscription) => { + return !!subscription?.active; + } +); + +export const isSubscriptionInactiveFactory = (slug) => createSelector( + subscriptionSelector(slug), + (subscription) => !!subscription?.inactive, +); \ No newline at end of file diff --git a/client/src/store/subscription/subscription.types.js b/client/src/store/subscription/subscription.types.js new file mode 100644 index 000000000..6a9692684 --- /dev/null +++ b/client/src/store/subscription/subscription.types.js @@ -0,0 +1,4 @@ + +export default { + SET_PLAN_SUBSCRIPTIONS_LIST: 'SET_PLAN_SUBSCRIPTIONS_LIST', +}; \ No newline at end of file diff --git a/client/src/store/types.js b/client/src/store/types.js index b0adaa6cb..3e4a32ed8 100644 --- a/client/src/store/types.js +++ b/client/src/store/types.js @@ -24,6 +24,8 @@ import bills from './Bills/bills.type'; import vendors from './vendors/vendors.types'; import paymentReceives from './PaymentReceive/paymentReceive.type'; import paymentMades from './PaymentMades/paymentMade.type'; +import organizations from './organizations/organizations.types'; +import subscription from './subscription/subscription.types'; export default { ...authentication, @@ -52,4 +54,6 @@ export default { ...bills, ...paymentReceives, ...paymentMades, + ...organizations, + ...subscription, }; diff --git a/client/src/style/App.scss b/client/src/style/App.scss index 0c6cdc201..86c111565 100644 --- a/client/src/style/App.scss +++ b/client/src/style/App.scss @@ -63,12 +63,14 @@ $pt-font-family: Noto Sans, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, @import 'pages/exchange-rate'; @import 'pages/customer'; @import 'pages/billing'; +@import 'pages/register-wizard-page'; +@import 'pages/register-organizaton'; // Views @import 'views/filter-dropdown'; @import 'views/sidebar'; @import 'pages/estimate'; - + .App { min-width: 960px; } @@ -173,3 +175,24 @@ body.authentication { .bp3-popover.bp3-tooltip { max-width: 300px; } + + +.bigcapital-loading{ + height: 100%; + width: 100%; + position: absolute; + display: flex; + + .center{ + width: auto; + margin: auto; + display: flex; + flex-direction: column; + } + + .text{ + margin-top: 12px; + opacity: 0.85; + display: none; + } +} \ No newline at end of file diff --git a/client/src/style/objects/typography.scss b/client/src/style/objects/typography.scss index 5662bec67..ecf59becd 100644 --- a/client/src/style/objects/typography.scss +++ b/client/src/style/objects/typography.scss @@ -11,4 +11,9 @@ body{ .divider{ border-top: 1px solid #e8e8e8; height: 1px; +} + +.paragraph{ + line-height: 1.5; + font-size: 14px; } \ No newline at end of file diff --git a/client/src/style/pages/authentication.scss b/client/src/style/pages/authentication.scss index 0d7cd6369..226474cf0 100644 --- a/client/src/style/pages/authentication.scss +++ b/client/src/style/pages/authentication.scss @@ -1,30 +1,32 @@ -.authentication-insider{ - margin-top: 80px; +.authentication-insider { + width: 384px; + margin: 0 auto; margin-bottom: 40px; + padding-top: 80px; - &__logo-section{ + &__logo-section { text-align: center; margin-bottom: 60px; } - &__content{ + &__content { position: relative; } - &__footer{ - .auth-copyright{ + &__footer { + .auth-copyright { text-align: center; font-size: 12px; color: #666; - .bp3-icon-bigcapital{ + .bp3-icon-bigcapital { margin-top: 9px; - svg{ - path{ - fill: #A3A3A3; + svg { + path { + fill: #a3a3a3; } } } @@ -32,55 +34,79 @@ } } -.authentication-page { - &__goto-bigcapital{ +.authTransition{ + + &-enter { + opacity: 0; + } + + &-enter-active { + opacity: 1; + transition: opacity 250ms ease-in-out; + } + + &-enter-done { + opacity: 1; + } + + &-exit { + opacity: 1; + } + + &-exit-active { + opacity: 0.5; + transition: opacity 250ms ease-in-out; + } + &-exit-active { + opacity: 0; + display: none; + } + +} + + +.authentication-page { + &__goto-bigcapital { position: fixed; margin-top: 30px; margin-left: 30px; color: #777; } - + .bp3-input { min-height: 40px; - border: 2px solid #E3E3E3; + border: 1px solid #ced4da; } - .bp3-form-group{ + .bp3-form-group { margin-bottom: 25px; - - &.bp3-intent-danger{ - .bp3-input{ - border-color: #eea9a9; - } - } } - .bp3-form-group.has-password-revealer{ - - .bp3-label{ + .bp3-form-group.has-password-revealer { + .bp3-label { display: flex; justify-content: space-between; } - .password-revealer{ - .text{ + .password-revealer { + .text { font-size: 12px; } } } - .bp3-button.bp3-fill.bp3-intent-primary{ + .bp3-button.bp3-fill.bp3-intent-primary { font-size: 16px; } - &__label-section{ - margin-bottom: 34px; + &__label-section { + margin-bottom: 30px; color: #555; - - h3{ + + h3 { font-weight: 500; - font-size: 28px; - color: #444; + font-size: 22px; + color: #2d2b43; margin: 0 0 12px; } @@ -92,24 +118,22 @@ &__form-wrapper { width: 100%; - max-width: 415px; - padding: 15px; margin: 0 auto; } - &__footer-links{ + &__footer-links { padding: 9px; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; text-align: center; margin-bottom: 1.2rem; - a{ + a { color: #0052cc; } } - &__loading-overlay{ + &__loading-overlay { position: absolute; top: 0; left: 0; @@ -132,21 +156,25 @@ // Login Form // ------------------------------ .login-form { + // width: 690px; + // margin: 0px auto; + // padding: 85px 50px; - .checkbox{ - &--remember-me{ - margin: -4px 0 28px 0px; + .checkbox { + &--remember-me { + margin: -6px 0 26px 0px; font-size: 14px; } } } - // Register Form + // Register form + // ---------------------------- .register-form { &__agreement-section { margin-top: -10px; - + p { font-size: 13px; margin-top: -10px; @@ -165,10 +193,21 @@ } } + // Send reset password + // ---------------------------- + .send-reset-password { + .form-group--crediential { + margin-bottom: 36px; + } + } + + // Invite form. + // ---------------- .invite-form { &__statement-section { margin-top: -10px; + p { font-size: 13px; margin-bottom: 20px; @@ -176,8 +215,8 @@ } } - .authentication-page__loading-overlay{ + .authentication-page__loading-overlay { background: rgba(252, 253, 255, 0.9); } } -} \ No newline at end of file +} diff --git a/client/src/style/pages/billing.scss b/client/src/style/pages/billing.scss index 4ca989d44..5783dd714 100644 --- a/client/src/style/pages/billing.scss +++ b/client/src/style/pages/billing.scss @@ -1,18 +1,19 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - .billing-form { padding: 25px 45px; width: 800px; margin: 0 auto; + .billing-section{ + margin-bottom: 30px; + } + + .paragraph + .billing-form__plan-container{ + margin-top: 20px; + } + &__plan-container { display: flex; flex-flow: row wrap; - margin-bottom: 30px; .plan-wrapper { display: flex; @@ -34,20 +35,20 @@ .plan-header { display: flex; justify-content: flex-start; - margin-bottom: 10px; } .plan-name { background: #3657ff; border-radius: 3px; - padding: 1px 8px 1px 8px; + padding: 2px 8px 2px 8px; font-size: 13px; color: #fff; - margin-bottom: 15px; + margin-bottom: 16px; + height: 21px; } .plan-description { font-size: 14px; font-weight: 400; - line-height: 2em; + line-height: 1.8rem; &.plan-description ul { list-style: none; @@ -76,8 +77,14 @@ background-color: #fcfdff; } } + + .paragraph + .payment-method-continer{ + margin-top: 20px; + } + .payment-method-continer { margin-bottom: 30px; + .period-container { display: inline-flex; background-color: #fcfdff; @@ -110,22 +117,12 @@ } } - .bg-title { - font-size: 22px; - font-weight: 400; - line-height: normal; - } - .bg-message { - margin-bottom: 15px; - font-size: 14px; - } .license-container { - .bp3-form-group { - margin-bottom: 20px; - .bp3-label { - margin-bottom: 15px; - } + + .form-group-license_code{ + margin-top: 20px; } + .bp3-form-content { .bp3-input-group { display: block; @@ -139,9 +136,11 @@ } h4 { font-size: 18px; + font-weight: 400; color: #444444; } p { + margin-top: 15px; font-size: 14px; } } diff --git a/client/src/style/pages/register-organizaton.scss b/client/src/style/pages/register-organizaton.scss new file mode 100644 index 000000000..cf30a8958 --- /dev/null +++ b/client/src/style/pages/register-organizaton.scss @@ -0,0 +1,83 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.register-organizaton-form { + width: 690px; + margin: 0px auto; + padding: 80px 50px; + + .register-org-title { + margin-bottom: 30px; + + h2 { + font-size: 22px; + font-weight: 400; + color: #555555; + line-height: 2em; + } + p { + font-size: 14px; + } + } + h3 { + font-size: 18px; + font-weight: 400; + color: #888888; + margin-bottom: 20px; + } + + .bp3-form-group { + .bp3-input-group { + .bp3-input { + position: relative; + width: 619px; + height: 38px; + } + } + } + + .form-group--base-currency, + .form-group--language { + .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) { + min-width: 300px; + min-height: 38px; + } + } + .form-group--language { + margin-left: 18px; + } + .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) { + min-width: 619px; + min-height: 38px; + } + + .form-group--time-zone { + .bp3-text-muted { + color: #000000; + } + .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) { + background: #fff; + box-shadow: 0 0 0 transparent; + border: 1px solid #ced4da; + padding: 8px; + } + } + + .register-org-note { + width: 618px; + font-size: 14px; + line-height: 2.7rem; + margin-bottom: 20px; + border-bottom: 3px solid #f5f5f5; + } + .register-org-button { + .bp3-button { + background-color: #0052cc; + width: 174px; + min-height: 40px; + } + } +} diff --git a/client/src/style/pages/register-wizard-page.scss b/client/src/style/pages/register-wizard-page.scss new file mode 100644 index 000000000..1af53e8ea --- /dev/null +++ b/client/src/style/pages/register-wizard-page.scss @@ -0,0 +1,319 @@ +.setup-page { + max-width: 1400px; + + &__right-section { + display: flex; + flex-direction: row; + width: 100%; + + &:before { + content: ''; + display: block; + width: 30%; + height: 1px; + min-width: 300px; + max-width: 350px; + } + + h1 { + font-size: 22px; + } + h1, + h3{ + font-weight: 500; + color: #6b7382; + } + } + + &__content { + width: 70%; + padding-bottom: 40px; + } + + &__left-section { + position: fixed; + background-color: #01115e; + overflow: auto; + z-index: 1; + height: 100%; + width: 30%; + left: 0; + top: 0; + max-width: 350px; + min-width: 300px; + + .content { + display: flex; + flex-direction: column; + color: #ffffff; + padding: 25px; + margin: 0px auto; + border: none; + height: 100%; + + &__logo { + opacity: 0.65; + margin-bottom: 60px; + padding-left: 10px; + } + + &__title { + font-size: 26px; + font-weight: 700; + line-height: normal; + margin-bottom: 20px; + margin-top: 14px; + color: rgba(255, 255, 255, 0.75); + } + + &__text { + font-size: 16px; + opacity: 0.75; + margin-bottom: 10px; + } + + &__organization { + font-size: 16px; + opacity: 0.75; + + span > a { + text-decoration: underline; + color: #ffffff; + margin-top: 6px; + display: inline-block; + } + } + + &__divider { + height: 3px; + width: 100px; + background: rgba(255, 255, 255, 0.15); + margin: 10px 0; + } + + &__footer { + margin-top: auto; + } + + &__contact-info { + font-size: 16px; + margin-bottom: 20px; + opacity: 0.75; + padding-bottom: 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + } + + &__links { + text-align: left; + opacity: 0.55; + + > div { + font-size: 13px; + margin-right: 15px; + display: inline; + + a { + color: #fff; + + &:hover { + text-decoration: underline; + } + } + } + } + } + } +} + +.setup-page-steps { + &-container { + width: 80%; + margin: 0 auto; + padding: 50px 0 0; + } + + ul { + display: flex; + } + + li { + position: relative; + list-style-type: none; + width: 25%; + text-align: center; + color: #333; + font-size: 16px; + + &::before { + width: 11px; + height: 11px; + content: ''; + line-height: 30px; + display: block; + text-align: center; + margin: 0 auto 10px auto; + border-radius: 50%; + background-color: #75859c; + } + + &::after { + width: 100%; + height: 2px; + content: ''; + position: absolute; + background-color: #75859c; + top: 5px; + left: -50%; + z-index: -1; + } + + &:first-child::after { + display: none; + } + + &.is-active { + &::before { + background-color: #75859c; + } + + ~ li { + &:before, + &:after { + background: #ebebeb; + } + } + + p.wizard-info { + color: #004dd0; + } + } + } +} + +.setup-organization { + width: 580px; + margin: 0 auto; + padding: 45px 0 20px; + + &__title-wrap{ + margin-bottom: 32px; + + h1 { + margin-top: 0; + margin-bottom: 10px; + color: #565e6c; + } + .paragraph{ + opacity: 0.75; + } + } + + &__form{ + h3 { + margin-bottom: 1rem; + } + } + + .bp3-form-group { + .bp3-input-group { + .bp3-input { + height: 38px; + } + } + } + + .bp3-button:not([class*='bp3-intent-']):not(.bp3-minimal) { + width: 100%; + height: 38px; + } + + .register-org-note{ + font-size: 13px; + padding-bottom: 10px; + border-bottom: 1px solid #e1e1e1; + margin-bottom: 1.75rem; + color: #666; + } + + .register-org-button { + .bp3-button { + background-color: #0052cc; + min-width: 175px; + height: 40px; + font-size: 15px; + } + } +} + +// Setup initializing form +.setup-initializing-form { + width: 95%; + margin: 0 auto; + padding: 16% 0 0; + + .bp3-progress-bar { + background: rgba(92, 112, 128, 0.2); + border-radius: 40px; + display: block; + height: 6px; + overflow: hidden; + position: relative; + width: 80%; + margin: 0 auto; + + .bp3-progress-meter { + background-color: #809cb3; + } + } + + &__title { + text-align: center; + margin-top: 35px; + + h1 { + font-size: 22px; + font-weight: 600; + color: #454c59; + margin-top: 0; + margin-bottom: 14px; + } + + .paragraph { + width: 70%; + margin: 0 auto; + color: #2e4266; + } + } +} + +.setup-congrats{ + width: 600px; + margin: 0 auto; + text-align: center; + padding-top: 80px; + + &__page{ + + } + + &__text{ + margin-top: 30px; + + h1{ + color: #2D2B43; + margin-bottom: 12px; + } + .paragraph{ + font-size: 15px; + opacity: 0.85; + margin-bottom: 14px; + } + .bp3-button{ + height: 38px; + padding-left: 25px; + padding-right: 25px; + font-size: 15px; + margin-top: 12px; + } + } +} \ No newline at end of file diff --git a/server/src/api/controllers/Organization.ts b/server/src/api/controllers/Organization.ts index 9d2351e05..5c298d0c1 100644 --- a/server/src/api/controllers/Organization.ts +++ b/server/src/api/controllers/Organization.ts @@ -1,5 +1,5 @@ import { Inject, Service } from 'typedi'; -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import asyncMiddleware from "api/middleware/asyncMiddleware"; import JWTAuth from 'api/middleware/jwtAuth'; import TenancyMiddleware from 'api/middleware/TenancyMiddleware'; @@ -27,12 +27,14 @@ export default class OrganizationController extends BaseController{ router.use(JWTAuth); router.use(AttachCurrentTenantUser); router.use(TenancyMiddleware); - router.use(SubscriptionMiddleware('main')); - + // Should to seed organization tenant be configured. + router.use('/seed', SubscriptionMiddleware('main')); router.use('/seed', SettingsMiddleware); router.use('/seed', EnsureConfiguredMiddleware); + router.use('/build', SubscriptionMiddleware('main')); + router.post( '/build', asyncMiddleware(this.build.bind(this)) @@ -41,6 +43,10 @@ export default class OrganizationController extends BaseController{ '/seed', asyncMiddleware(this.seed.bind(this)), ); + router.get( + '/all', + asyncMiddleware(this.allOrganizations.bind(this)), + ); return router; } @@ -116,4 +122,21 @@ export default class OrganizationController extends BaseController{ next(error); } } + + /** + * Listing all organizations that assocaited to the authorized user. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async allOrganizations(req: Request, res: Response, next: NextFunction) { + const { user } = req; + + try { + const organizations = await this.organizationService.listOrganizations(user); + return res.status(200).send({ organizations }); + } catch (error) { + next(error); + } + } } \ No newline at end of file diff --git a/server/src/api/controllers/Subscription/index.ts b/server/src/api/controllers/Subscription/index.ts index 1154de6a7..1485e7249 100644 --- a/server/src/api/controllers/Subscription/index.ts +++ b/server/src/api/controllers/Subscription/index.ts @@ -1,12 +1,17 @@ -import { Router } from 'express' -import { Container, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express' +import { Container, Service, Inject } from 'typedi'; import JWTAuth from 'api/middleware/jwtAuth'; import TenancyMiddleware from 'api/middleware/TenancyMiddleware'; import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser'; import PaymentViaLicenseController from 'api/controllers/Subscription/PaymentViaLicense'; +import SubscriptionService from 'services/Subscription/SubscriptionService'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; @Service() -export default class SubscriptionController { +export default class SubscriptionController { + @Inject() + subscriptionService: SubscriptionService; + /** * Router constructor. */ @@ -19,6 +24,26 @@ export default class SubscriptionController { router.use('/license', Container.get(PaymentViaLicenseController).router()); + router.get('/', + asyncMiddleware(this.getSubscriptions.bind(this)) + ); return router; } + + /** + * Retrieve all subscriptions of the authenticated user's tenant. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getSubscriptions(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const subscriptions = await this.subscriptionService.getSubscriptions(tenantId); + return res.status(200).send({ subscriptions }); + } catch (error) { + next(error); + } + } } diff --git a/server/src/data/options.js b/server/src/data/options.js index e10fee599..727bce956 100644 --- a/server/src/data/options.js +++ b/server/src/data/options.js @@ -25,6 +25,10 @@ export default { type: 'string', // config: true, }, + { + key: 'financial_date_start', + type: 'string', + }, { key: 'language', type: 'string', diff --git a/server/src/database/seeds/core/20200810121807_seed_views.js b/server/src/database/seeds/core/20200810121807_seed_views.js index 5e945978c..19da37d7b 100644 --- a/server/src/database/seeds/core/20200810121807_seed_views.js +++ b/server/src/database/seeds/core/20200810121807_seed_views.js @@ -1,3 +1,5 @@ +import Container from 'typedi'; +import TenancyService from 'services/Tenancy/TenancyService' exports.up = (knex) => { const tenancyService = Container.get(TenancyService); diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts index 12ccab4ec..5a97bc254 100644 --- a/server/src/services/Authentication/index.ts +++ b/server/src/services/Authentication/index.ts @@ -87,9 +87,6 @@ export default class AuthenticationService implements IAuthenticationService { // Remove password property from user object. Reflect.deleteProperty(user, 'password'); - // Remove id property from tenant object. - Reflect.deleteProperty(tenant, 'id'); - return { user, token, tenant }; } diff --git a/server/src/services/Organization/index.ts b/server/src/services/Organization/index.ts index 88ef7515a..a50d67930 100644 --- a/server/src/services/Organization/index.ts +++ b/server/src/services/Organization/index.ts @@ -1,6 +1,6 @@ import { Service, Inject } from 'typedi'; import { ServiceError } from 'exceptions'; -import { ITenant } from 'interfaces'; +import { ISystemService, ISystemUser, ITenant } from 'interfaces'; import { EventDispatcher, EventDispatcherInterface, @@ -38,7 +38,7 @@ export default class OrganizationService { * @param {srting} organizationId * @return {Promise} */ - async build(organizationId: string): Promise { + public async build(organizationId: string): Promise { const tenant = await this.getTenantByOrgIdOrThrowError(organizationId); this.throwIfTenantInitizalized(tenant); @@ -69,7 +69,7 @@ export default class OrganizationService { * @param {number} organizationId * @return {Promise} */ - async seed(organizationId: string): Promise { + public async seed(organizationId: string): Promise { const tenant = await this.getTenantByOrgIdOrThrowError(organizationId); this.throwIfTenantSeeded(tenant); @@ -91,6 +91,20 @@ export default class OrganizationService { } } + /** + * Listing all associated organizations to the given user. + * @param {ISystemUser} user - + * @return {Promise} + */ + public async listOrganizations(user: ISystemUser): Promise { + this.logger.info('[organization] trying to list all organizations.', { user }); + + const { tenantRepository } = this.sysRepositories; + const tenant = await tenantRepository.getById(user.tenantId); + + return [tenant]; + } + /** * Throws error in case the given tenant is undefined. * @param {ITenant} tenant diff --git a/server/src/services/Subscription/SubscriptionService.ts b/server/src/services/Subscription/SubscriptionService.ts index 695a0687c..8508b5746 100644 --- a/server/src/services/Subscription/SubscriptionService.ts +++ b/server/src/services/Subscription/SubscriptionService.ts @@ -1,5 +1,5 @@ import { Service, Inject } from 'typedi'; -import { Plan, Tenant } from 'system/models'; +import { Plan, PlanSubscription } from 'system/models'; import Subscription from 'services/Subscription/Subscription'; import LicensePaymentMethod from 'services/Payment/LicensePaymentMethod'; import PaymentContext from 'services/Payment'; @@ -29,7 +29,7 @@ export default class SubscriptionService { * @param {string} licenseCode * @return {Promise} */ - async subscriptionViaLicense( + public async subscriptionViaLicense( tenantId: number, planSlug: string, paymentModel?: ILicensePaymentModel, @@ -53,4 +53,15 @@ export default class SubscriptionService { tenantId, paymentModel }); } + + /** + * Retrieve all subscription of the given tenant. + * @param {number} tenantId + */ + public async getSubscriptions(tenantId: number) { + this.logger.info('[subscription] trying to get tenant subscriptions.', { tenantId }); + const subscriptions = await PlanSubscription.query().where('tenant_id', tenantId); + + return subscriptions; + } } \ No newline at end of file diff --git a/server/src/system/models/Tenant.js b/server/src/system/models/Tenant.js index 701597109..8c9b1688d 100644 --- a/server/src/system/models/Tenant.js +++ b/server/src/system/models/Tenant.js @@ -17,6 +17,20 @@ export default class Tenant extends BaseModel { return ['createdAt', 'updatedAt']; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['isReady']; + } + + /** + * Tenant is ready. + */ + get isReady() { + return !!(this.initializedAt && this.seededAt); + } + /** * Query modifiers. */ diff --git a/server/src/system/repositories/TenantRepository.ts b/server/src/system/repositories/TenantRepository.ts index 23de76a6b..9af7a37e4 100644 --- a/server/src/system/repositories/TenantRepository.ts +++ b/server/src/system/repositories/TenantRepository.ts @@ -63,11 +63,21 @@ export default class TenantRepository extends SystemRepository { /** * Retrieve tenant details by the given tenant id. - * @param {string} tenantId + * @param {string} tenantId - Tenant id. */ getById(tenantId: number) { return this.cache.get(`tenant.id.${tenantId}`, () => { return Tenant.query().findById(tenantId); }); } + + /** + * Retrieve tenant details with associated subscriptions + * and plans by the given tenant id. + * @param {number} tenantId - Tenant id. + */ + getByIdWithSubscriptions(tenantId: number) { + return Tenant.query().findById(tenantId) + .withGraphFetched('subscriptions.plan'); + } } \ No newline at end of file