Merge pull request 'RC-10-favorites-and-history-cards' (#6) from RC-10-favorites-and-history-cards into dev

Reviewed-on: #6
This commit is contained in:
2023-08-27 14:02:14 +03:00
36 changed files with 958 additions and 294 deletions

View File

@@ -1,11 +0,0 @@
/**
* Authorization Roles
*/
const authRoles = {
admin: ['admin'],
staff: ['admin', 'staff'],
user: ['admin', 'staff', 'user'],
onlyGuest: [],
};
export default authRoles;

19
src/app/configs/consts.js Normal file
View File

@@ -0,0 +1,19 @@
export const STATISTICS_MODES = {
positive: 'positive',
extra_positive: 'extra_positive',
negative: 'negative',
extra_negative: 'extra_negative',
};
export const PROPERTIES_LAYOUTS = {
list: 'list',
grid: 'grid',
};
// Authorization Roles
export const authRoles = {
admin: ['admin'],
staff: ['admin', 'staff'],
user: ['admin', 'staff', 'user'],
onlyGuest: [],
};

View File

@@ -6,6 +6,63 @@ import { showMessage } from 'app/store/fuse/messageSlice';
import { logoutUser, setUser } from 'app/store/userSlice';
import { authService, firebase } from '../services';
const cards = [
{
id: '123',
image:
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
title: '6 Via delle Crosarolle, Crosarolle, Veneto, Crosarolle, Veneto',
category: 'buy',
status: 'new',
favorite: false,
update: '12.12.2022',
statistics: [
{ subject: 'Monthly Cash Flow', value: '$ 78,000', mode: 'extra_positive' },
{ subject: 'Cash on Cash Return', value: '78%', mode: 'positive' },
{ subject: 'Selling Prise', value: '$500,000', mode: '' },
{ subject: 'Cash Out of Pocket', value: '$125,000', mode: '' },
{ subject: 'Annual Revenue', value: '$5,000', mode: '' },
{ subject: 'Annual Net Profit', value: '+$50,000', mode: 'extra_positive' },
],
},
{
id: '456',
image:
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
title: '6 Via delle Crosarolle, Crosarolle, Veneto',
category: 'buy',
status: 'new',
favorite: false,
update: '12.12.2022',
statistics: [
{ subject: 'Monthly Cash Flow', value: '$ 78,000', mode: 'extra_negative' },
{ subject: 'Cash on Cash Return', value: '78%', mode: 'negative' },
{ subject: 'Selling Prise', value: '$500,000', mode: '' },
{ subject: 'Cash Out of Pocket', value: '$125,000', mode: '' },
{ subject: 'Annual Revenue', value: '$5,000', mode: '' },
{ subject: 'Annual Net Profit', value: '+$50,000', mode: 'extra_negative' },
],
},
{
id: '789',
image:
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
title: '6 Via delle Crosarolle, Crosarolle, Veneto',
category: 'rent',
status: 'new',
favorite: false,
update: '12.12.2022',
statistics: [
{ subject: 'Monthly Cash Flow', value: '$ 78,000', mode: 'positive' },
{ subject: 'Cash on Cash Return', value: '78%', mode: 'positive' },
{ subject: 'Selling Prise', value: '$500,000', mode: '' },
{ subject: 'Cash Out of Pocket', value: '$125,000', mode: '' },
{ subject: 'Annual Revenue', value: '$5,000', mode: '' },
{ subject: 'Annual Net Profit', value: '+$50,000', mode: 'extra_positive' },
],
},
];
const AuthContext = React.createContext();
function AuthProvider({ children }) {
@@ -25,14 +82,19 @@ function AuthProvider({ children }) {
authService.onAuthStateChanged((authUser) => {
dispatch(showMessage({ message: 'Signing...' }));
if (authUser) {
const storageUser = JSON.parse(localStorage.user ?? '{}');
authService
.getUserData(authUser.uid)
.then((user) => {
if (user) {
success(user, 'Signed in');
success({ ...user, ...storageUser }, 'Signed in');
} else {
// First login
const { displayName, photoURL, email } = authUser;
success({ role: 'user', data: { displayName, photoURL, email } }, 'Signed in');
success(
{ role: 'user', data: { displayName, photoURL, email }, ...storageUser },
'Signed in'
);
}
})
.catch((error) => {

View File

@@ -1,20 +1,3 @@
import { useState, useEffect } from 'react';
export function useWindowDimensions() {
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return { width, height };
};
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
useEffect(() => {
const onRecize = () => setWindowDimensions(getWindowDimensions());
window.addEventListener('resize', onRecize);
return () => window.removeEventListener('resize', onRecize);
}, []);
return windowDimensions;
}
export { default as useWindowDimensions } from './useWindowDimensions';
export { default as useOnClickOutside } from './useOnClickOutside';
export { default as usePropertiesHeader } from './usePropertiesHeader';

View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react';
export default function useOnClickOutside(ref, handler) {
useEffect(() => {
const onEvent = (event) => {
if (!ref.current) {
return;
}
if (event instanceof KeyboardEvent && event.key === 'Escape') {
handler(event);
} else if (ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', onEvent);
document.addEventListener('touchstart', onEvent);
document.addEventListener('keydown', onEvent);
return () => {
document.removeEventListener('mousedown', onEvent);
document.removeEventListener('touchstart', onEvent);
document.removeEventListener('keydown', onEvent);
};
}, [ref, handler]);
}

View File

@@ -0,0 +1,105 @@
import { PROPERTIES_LAYOUTS } from 'app/configs/consts';
import { useState, useMemo, useCallback, useEffect } from 'react';
export default function usePropertiesHeader(items) {
const [categories, setCategories] = useState([
{
name: 'all',
amount: 0,
active: true,
},
]);
const [layouts, setLayouts] = useState([
{ name: PROPERTIES_LAYOUTS.list, active: true },
{ name: PROPERTIES_LAYOUTS.grid, active: false },
]);
const activeCategory = useMemo(
() => categories.find(({ active }) => active)?.name ?? categories[0].name,
[categories]
);
const activeLayout = useMemo(
() => layouts.find(({ active }) => active)?.name ?? PROPERTIES_LAYOUTS.list,
[layouts]
);
const onCategory = useCallback(
(value) => {
if (value === activeCategory) {
return;
}
setCategories((prevState) =>
prevState.map((category) => ({ ...category, active: category.name === value }))
);
},
[activeCategory]
);
const onLayout = useCallback(
(value) => {
if (value === activeLayout) {
return;
}
setLayouts((prevState) => prevState.map(({ name }) => ({ name, active: name === value })));
},
[activeLayout]
);
const onItemDelete = (itemCategory) => {
setCategories((prevState) => {
const isItemCategoryLast =
prevState.find((category) => category.name === itemCategory)?.amount === 1;
return prevState
.map((category, idx) => {
if (!idx) {
return {
name: category.name,
amount: category.amount - 1,
active: isItemCategoryLast,
};
}
if (category?.name === itemCategory) {
if (category.amount > 1) {
return { ...category, amount: category.amount - 1 };
}
return null;
}
return category;
})
.filter((category) => category);
});
};
useEffect(() => {
let updatedCategories = [...categories];
items.forEach((item) => {
const hasItemCategory = updatedCategories.find((category) => category.name === item.category);
updatedCategories = updatedCategories.map((category, idx) => {
if (!idx) {
category.amount += 1;
}
return category;
});
if (hasItemCategory) {
updatedCategories = updatedCategories.map((category) => ({
...category,
amount: item.category === category.name ? category.amount + 1 : category.amount,
}));
} else {
updatedCategories.push({ name: item.category, amount: 1, active: false });
}
});
setCategories(updatedCategories);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { categories, activeCategory, onCategory, layouts, activeLayout, onLayout, onItemDelete };
}

View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export default function useWindowDimensions() {
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return { width, height };
};
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions());
useEffect(() => {
const onRecize = () => setWindowDimensions(getWindowDimensions());
window.addEventListener('resize', onRecize);
return () => window.removeEventListener('resize', onRecize);
}, []);
return windowDimensions;
}

View File

@@ -1,7 +1,7 @@
import i18next from 'i18next';
import ForgotPasswordPage from './ForgotPasswordPage';
import authRoles from '../../../configs/authRoles';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'forgotPasswordPage', en);

View File

@@ -1,7 +1,7 @@
import i18next from 'i18next';
import SignInPage from './SignInPage';
import authRoles from '../../../configs/authRoles';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'signInPage', en);

View File

@@ -1,7 +1,7 @@
import i18next from 'i18next';
import SignUpPage from './SignUpPage';
import authRoles from '../../../configs/authRoles';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'signUpPage', en);

View File

@@ -9,7 +9,7 @@ function AboutUs({ t }) {
<div className="flex gap-64 mb-[126px]">
<div className="flex items-center">
<iframe
className="rounded-[20px]"
className="rounded-20"
width="715"
height="402"
src="https://www.youtube.com/embed/rNSIwjmynYQ?controls=0"
@@ -18,7 +18,7 @@ function AboutUs({ t }) {
allowFullScreen
/>
</div>
<aside className="flex flex-col items-center py-40 px-52 bg-primary-light rounded-[20px]">
<aside className="flex flex-col items-center py-40 px-52 bg-primary-light rounded-20">
<h3 className="mb-16 text-lg text-common-layout font-medium">{t('about_us_subject')}</h3>
<p className="mb-16 text-lg text-common-layout font-light">{t('about_us_text_1')}</p>
<p className="mb-16 text-lg text-common-layout font-light">{t('about_us_text_2')}</p>

View File

@@ -4,10 +4,10 @@ import { Link } from 'react-router-dom';
function ArticleCard({ t, id, title, description, image, updated }) {
return (
<article className="flex flex-col justify-between max-w-[460px] w-full h-[526px] bg-primary-light rounded-[20px] shadow-light">
<article className="flex flex-col justify-between max-w-[460px] w-full h-[526px] bg-primary-light rounded-20 shadow-light">
<div>
<img
className="w-full h-[230px] mb-20 rounded-[20px] object-cover"
className="w-full h-[230px] mb-20 rounded-20 object-cover"
src={image}
alt={title}
width="460"

View File

@@ -58,7 +58,7 @@ function FeedbackForm({ t }) {
<form
name="signinForm"
noValidate
className="grid grid-cols-2 gap-x-20 gap-y-32 px-80 py-40 bg-primary-light rounded-[20px]"
className="grid grid-cols-2 gap-x-20 gap-y-32 px-80 py-40 bg-primary-light rounded-20"
onSubmit={handleSubmit(onSubmit)}
>
<legend className="col-span-2 justify-self-center max-w-[860px] mb-8 text-4xl font-medium text-center">

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
function StatisticsCard({ title, text }) {
return (
<article className="flex flex-col justify-start items-center max-w-[460px] w-full min-h-[356px] h-full pt-32 px-40 text-common-primary bg-primary-light rounded-[20px] shadow-light even:bg-secondary-main even:text-primary-light">
<article className="flex flex-col justify-start items-center max-w-[460px] w-full min-h-[356px] h-full pt-32 px-40 text-common-primary bg-primary-light rounded-20 shadow-light even:bg-secondary-main even:text-primary-light">
<h3 className="mb-52 text-[80px] font-semibold">{title}</h3>
<p className="text-xl leading-5 font-light">{text}</p>
</article>

View File

@@ -1,6 +1,6 @@
import i18next from 'i18next';
import authRoles from '../../../configs/authRoles';
import authRoles from '../../../configs/consts';
import Dashboard from './Dashboard';
import en from './i18n/en';

View File

@@ -1,7 +1,15 @@
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import FusePageSimple from '@fuse/core/FusePageSimple';
import DemoContent from '@fuse/core/DemoContent';
import { Paper } from '@mui/material';
import { styled } from '@mui/material/styles';
import { PROPERTIES_LAYOUTS } from 'app/configs/consts';
import { selectUserFavorites, updateUserFavorites } from 'app/store/userSlice';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { usePropertiesHeader } from 'src/app/hooks';
import PropertiesHeader from '../shared-components/PropertiesHeader';
import PropertyGridCard from '../shared-components/PropertyGridCard';
import PropertyListItem from '../shared-components/PropertyListItem';
const Root = styled(FusePageSimple)(({ theme }) => ({
'& .FusePageSimple-header': {
@@ -16,21 +24,73 @@ const Root = styled(FusePageSimple)(({ theme }) => ({
'& .FusePageSimple-sidebarContent': {},
}));
function FavoritesPage(props) {
const { t } = useTranslation('favoritesPage');
function FavoritesPage() {
const dispatch = useDispatch();
const items = useSelector(selectUserFavorites);
const { categories, activeCategory, onCategory, layouts, activeLayout, onLayout, onItemDelete } =
usePropertiesHeader(items);
const onFavorite = useCallback(
(id) => {
const targetItem = items.find((item) => item.id === id);
if (!targetItem) {
return;
}
onItemDelete(targetItem?.category);
dispatch(updateUserFavorites(targetItem)).catch((error) => console.log(error));
},
[items, onItemDelete, dispatch]
);
const renderedItems = useMemo(
() =>
items.map((item, idx) => {
if (activeCategory !== 'all' && item.category !== activeCategory) {
return;
}
// eslint-disable-next-line consistent-return
return activeLayout === PROPERTIES_LAYOUTS.list ? (
<PropertyListItem
{...item}
key={item.title + idx}
onDelete={onFavorite}
onFavorite={onFavorite}
/>
) : (
<PropertyGridCard
{...item}
key={item.title + idx}
onDelete={onFavorite}
onFavorite={onFavorite}
/>
);
}),
[items, activeCategory, activeLayout, onFavorite]
);
return (
<Root
// header={
// <div className="p-24">
// <h4>{t('TITLE')}</h4>
// </div>
// }
content={
<div className="p-24">
<h4>Content</h4>
<br />
<DemoContent />
<div className="w-full p-60">
<Paper className="w-full h-320 mb-[30px] rounded-20 shadow-light" />
<PropertiesHeader
className="mb-40"
categories={categories}
layouts={layouts}
onCategory={onCategory}
onLayout={onLayout}
/>
<div
className={clsx(
'w-full flex flex-wrap justify-center gap-28',
activeLayout === PROPERTIES_LAYOUTS.list && 'flex-col'
)}
>
{renderedItems}
</div>
</div>
}
scroll="content"

View File

@@ -1,11 +1,13 @@
import { lazy } from 'react';
import i18next from 'i18next';
import authRoles from '../../../configs/authRoles';
import Favorites from './Favorites';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'favoritesPage', en);
const Favorites = lazy(() => import('./Favorites'));
const FavoritesConfig = {
settings: {
layout: {
@@ -22,28 +24,3 @@ const FavoritesConfig = {
};
export default FavoritesConfig;
/**
* Lazy load Example
*/
/*
import React from 'react';
const Example = lazy(() => import('./Example'));
const ExampleConfig = {
settings: {
layout: {
config: {},
},
},
routes: [
{
path: 'example',
element: <Example />,
},
],
};
export default ExampleConfig;
*/

View File

@@ -1,7 +1,15 @@
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import FusePageSimple from '@fuse/core/FusePageSimple';
import DemoContent from '@fuse/core/DemoContent';
import { Paper } from '@mui/material';
import { styled } from '@mui/material/styles';
import { PROPERTIES_LAYOUTS } from 'app/configs/consts';
import { selectUserHistory, updateUserFavorites, updateUserHistory } from 'app/store/userSlice';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { usePropertiesHeader } from 'src/app/hooks';
import PropertiesHeader from '../shared-components/PropertiesHeader';
import PropertyGridCard from '../shared-components/PropertyGridCard';
import PropertyListItem from '../shared-components/PropertyListItem';
const Root = styled(FusePageSimple)(({ theme }) => ({
'& .FusePageSimple-header': {
@@ -16,21 +24,83 @@ const Root = styled(FusePageSimple)(({ theme }) => ({
'& .FusePageSimple-sidebarContent': {},
}));
function HistoryPage(props) {
const { t } = useTranslation('historyPage');
function HistoryPage() {
const dispatch = useDispatch();
const items = useSelector(selectUserHistory);
const { categories, activeCategory, onCategory, layouts, activeLayout, onLayout, onItemDelete } =
usePropertiesHeader(items);
const onDelete = useCallback(
(id) => {
const targetItem = items.find((item) => item.id === id);
const newHistory = items.filter((item) => item.id !== id);
onItemDelete(targetItem?.category);
dispatch(updateUserHistory(newHistory));
},
[items, onItemDelete, dispatch]
);
const onFavorite = useCallback(
(id) => {
const targetItem = items.find((item) => item.id === id);
if (!targetItem) {
return;
}
dispatch(updateUserFavorites(targetItem)).catch((error) => console.log(error));
},
[items, dispatch]
);
const renderedItems = useMemo(
() =>
items.map((item, idx) => {
if (activeCategory !== 'all' && item.category !== activeCategory) {
return;
}
// eslint-disable-next-line consistent-return
return activeLayout === PROPERTIES_LAYOUTS.list ? (
<PropertyListItem
{...item}
key={item.title + idx}
onDelete={onDelete}
onFavorite={onFavorite}
/>
) : (
<PropertyGridCard
{...item}
key={item.title + idx}
onDelete={onDelete}
onFavorite={onFavorite}
/>
);
}),
[items, activeCategory, activeLayout, onDelete, onFavorite]
);
return (
<Root
// header={
// <div className="p-24">
// <h4>{t('TITLE')}</h4>
// </div>
// }
content={
<div className="p-24">
<h4>Content</h4>
<br />
<DemoContent />
<div className="w-full p-60">
<Paper className="w-full h-320 mb-[30px] rounded-20 shadow-light" />
<PropertiesHeader
className="mb-40"
categories={categories}
layouts={layouts}
onCategory={onCategory}
onLayout={onLayout}
/>
<div
className={clsx(
'w-full flex flex-wrap justify-center gap-28',
activeLayout === PROPERTIES_LAYOUTS.list && 'flex-col'
)}
>
{renderedItems}
</div>
</div>
}
scroll="content"

View File

@@ -1,11 +1,13 @@
import { lazy } from 'react';
import i18next from 'i18next';
import authRoles from '../../../configs/authRoles';
import History from './History';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'historyPage', en);
const History = lazy(() => import('./History'));
const HistoryConfig = {
settings: {
layout: {
@@ -22,28 +24,3 @@ const HistoryConfig = {
};
export default HistoryConfig;
/**
* Lazy load Example
*/
/*
import React from 'react';
const Example = lazy(() => import('./Example'));
const ExampleConfig = {
settings: {
layout: {
config: {},
},
},
routes: [
{
path: 'example',
element: <Example />,
},
],
};
export default ExampleConfig;
*/

View File

@@ -104,6 +104,7 @@ function ProfilePage({ t }) {
});
const { dirtyFields, errors } = formState;
// eslint-disable-next-line consistent-return
const uploadPicture = async (event) => {
const { target } = event;
if (target.files && target.files[0]) {

View File

@@ -1,7 +1,7 @@
import { lazy } from 'react';
import i18next from 'i18next';
import authRoles from '../../../configs/authRoles';
import authRoles from '../../../configs/consts';
import en from './i18n/en';
i18next.addResourceBundle('en', 'profilePage', en);

View File

@@ -0,0 +1,17 @@
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import Typography from '@mui/material/Typography';
import clsx from 'clsx';
import { memo } from 'react';
function DateMark({ className, update }) {
return (
<span className={clsx('flex justify-center items-center gap-10', className)}>
<FuseSvgIcon>heroicons-outline:calendar</FuseSvgIcon>
<Typography variant="body1" className="text-lg font-medium text-common-secondary">
{update}
</Typography>
</span>
);
}
export default memo(DateMark);

View File

@@ -0,0 +1,21 @@
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import clsx from 'clsx';
import { memo } from 'react';
function UpdateMark({ className, favorite, id, onClick }) {
const hasCallback = typeof onClick !== 'undefined';
return (
<button
className="w-[24px] h-[24px] cursor-pointer"
type="button"
onClick={() => hasCallback && onClick(id)}
>
<FuseSvgIcon className={clsx('w-full h-full', className, favorite && 'text-secondary-main')}>
heroicons-outline:heart
</FuseSvgIcon>
</button>
);
}
export default memo(UpdateMark);

View File

@@ -0,0 +1,25 @@
import Typography from '@mui/material/Typography';
import _ from '@lodash';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
function MetaMark({ className, category, status }) {
const text = useMemo(
() => (status ? `Status: ${_.startCase(status)}` : _.startCase(category)),
[category, status]
);
return (
<Typography
variant="body1"
className={clsx(
'flex justify-center align-center px-20 py-2 font-medium border-2 rounded-8',
className
)}
>
{text}
</Typography>
);
}
export default memo(MetaMark);

View File

@@ -0,0 +1,56 @@
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import Typography from '@mui/material/Typography';
import _ from '@lodash';
import clsx from 'clsx';
import { memo } from 'react';
function PropertiesHeader({ className, categories, layouts, onCategory, onLayout }) {
return (
<div
className={clsx(
'flex items-center gap-44 w-full py-9 px-52 rounded-20 bg-white shadow-light',
className
)}
>
<div className="grow flex items-center justify-start gap-16 py-16 border-r-1 border-common-disabled">
{categories.map(({ name, amount, active }, idx) => (
<button
key={name + idx}
type="button"
className={clsx(
'text-2xl text-common-layout cursor-pointer',
active && 'text-secondary-main font-semibold cursor-default'
)}
onClick={() => onCategory(name)}
>{`${_.startCase(name)} (${amount})`}</button>
))}
</div>
<div className="flex items-center gap-60 py-16">
{layouts.map(({ name, active }, idx) => (
<button
key={name + idx}
type="button"
className={clsx(
'flex justify-center items-center gap-10 cursor-pointer',
active && '!cursor-default'
)}
onClick={() => onLayout(name)}
>
<FuseSvgIcon className={clsx('text-common-secondary', active && 'text-secondary-main')}>
{`heroicons-outline:view-${name}`}
</FuseSvgIcon>
<Typography
variant="body1"
className={clsx('text-2xl text-common-secondary', active && 'text-secondary-main')}
>
{_.startCase(name)}
</Typography>
</button>
))}
</div>
</div>
);
}
export default memo(PropertiesHeader);

View File

@@ -0,0 +1,77 @@
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import Typography from '@mui/material/Typography';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import DateMark from './DateMark';
import FavoriteButton from './FavoriteButton';
import MetaMark from './MetaMark';
import StatisticsValue from './StatisticsValue';
function PropertyGridCard({
id,
image,
title,
category,
status,
update,
favorite,
statistics,
onFavorite,
onDelete,
}) {
return (
<article className="w-[470px] px-20 pt-20 rounded-20 bg-white shadow-light overflow-hidden">
<div className="flex justify-between mb-[25px]">
<div className="flex gap-10">
<MetaMark
category={category}
className="text-common-highlight1 border-common-highlight1"
/>
<MetaMark status={status} className="text-common-highlight2 border-common-highlight2" />
</div>
<div className="flex gap-20">
<FavoriteButton favorite={favorite} id={id} onClick={onFavorite} />
<DateMark update={update} />
</div>
</div>
<div className="flex flex-col items-start mb-[29px]">
<Typography variant="h3" className="mb-[17px] text-3xl font-semibold">
{title}
</Typography>
<img src={image} alt={title} className="w-full h-160 rounded-3xl object-cover" />
<div className="grid grid-cols-2 justify-between w-full">
{statistics.map(({ subject, value, mode }, idx) => (
<StatisticsValue
key={subject + value + idx}
subject={subject}
value={value}
mode={mode}
/>
))}
</div>
</div>
<div className="flex w-[calc(100%+40px)] -mx-20 border-t-1 border-common-disabled">
<button
className="flex justify-center items-center gap-10 w-full py-20 border-r-1 border-common-disabled cursor-pointer"
type="button"
onClick={() => onDelete(id)}
>
<FuseSvgIcon className="text-common-secondary">heroicons-outline:trash</FuseSvgIcon>
<Typography variant="body1" className="text-common-secondary font-medium">
Delete
</Typography>
</button>
<Link
className="flex justify-center items-center w-full py-[22px] text-lg font-semibold text-secondary-main border-l-1 border-white cursor-pointer"
to={`/property/${id}`}
>
Details
</Link>
</div>
</article>
);
}
export default memo(PropertyGridCard);

View File

@@ -0,0 +1,83 @@
import FuseSvgIcon from '@fuse/core/FuseSvgIcon';
import Typography from '@mui/material/Typography';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import DateMark from './DateMark';
import FavoriteButton from './FavoriteButton';
import MetaMark from './MetaMark';
import StatisticsValue from './StatisticsValue';
function PropertyListItem({
id,
image,
title,
category,
status,
update,
favorite,
statistics,
onFavorite,
onDelete,
}) {
return (
<article className="flex w-full p-20 rounded-20 bg-white shadow-light overflow-hidden">
<img
src={image}
alt={title}
className="w-[80px] h-[80px] mr-[15px] rounded-3xl object-cover"
/>
<div className="mr-20">
<div className="flex justify-start gap-60 mb-[22px]">
<div className="flex gap-10">
<MetaMark
category={category}
className="text-common-highlight1 border-common-highlight1"
/>
<MetaMark status={status} className="text-common-highlight2 border-common-highlight2" />
</div>
<div className="flex gap-20">
<FavoriteButton favorite={favorite} id={id} onClick={onFavorite} />
<DateMark update={update} />
</div>
</div>
<Typography variant="h3" className="max-w-[480px] text-3xl font-semibold truncate">
{title}
</Typography>
</div>
<div className="grow flex mr-20">
{statistics.map(
({ subject, value, mode }, idx) =>
(idx === 0 || idx === 1) && (
<StatisticsValue
key={subject + value + idx}
subject={subject}
value={value}
mode={mode}
className="-my-[6px]"
/>
)
)}
</div>
<div className="flex flex-col justify-between gap-16">
<button
className="self-end flex justify-center items-center gap-10 cursor-pointer"
type="button"
onClick={() => onDelete(id)}
>
<FuseSvgIcon className="text-common-secondary">heroicons-outline:trash</FuseSvgIcon>
</button>
<Link
className="flex justify-center items-center px-[53px] py-20 -mr-20 -mb-20 text-lg font-semibold text-secondary-main border-l-1 border-t-1 rounded-tl-[20px] border-common-disabled cursor-pointer"
to={`/property/${id}`}
>
Details
</Link>
</div>
</article>
);
}
export default memo(PropertyListItem);

View File

@@ -0,0 +1,38 @@
import Typography from '@mui/material/Typography';
import { STATISTICS_MODES } from 'app/configs/consts';
import clsx from 'clsx';
import { memo } from 'react';
function StatisticsValue({ className, subject, value, mode }) {
const isPositive = mode === STATISTICS_MODES.positive;
const isExtraPositive = mode === STATISTICS_MODES.extra_positive;
const isNegative = mode === STATISTICS_MODES.negative;
const isExtraNegative = mode === STATISTICS_MODES.extra_negative;
return (
<span
className={clsx(
'max-w-[210px] w-full py-[15px] pl-20 text-left rounded-xl',
className,
isExtraPositive && 'bg-accept-light',
isExtraNegative && 'bg-error-light'
)}
>
<Typography variant="body1" className="text-lg text-left leading-tight">
{subject}
</Typography>
<Typography
variant="h4"
className={clsx(
'text-[28px] font-semibold text-left leading-tight',
(isPositive || isExtraPositive) && 'text-accept-main',
(isNegative || isExtraNegative) && 'text-error-main'
)}
>
{value}
</Typography>
</span>
);
}
export default memo(StatisticsValue);

View File

@@ -20,7 +20,7 @@ export default class AuthService extends FuseUtils.EventEmitter {
.then((userCredential) => firebaseAuth.updateProfile(userCredential.user, { displayName }))
.then(() => {
const userRef = firebaseDb.ref(this.#db, `users/${this.#auth.currentUser.uid}`);
const value = { role: 'user', data: { displayName, email } };
const value = { role: 'user', data: { displayName, email }, favorites: [] };
return firebaseDb.set(userRef, value);
})

View File

@@ -1,35 +1,86 @@
/* eslint import/no-extraneous-dependencies: off */
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import history from '@history';
import browserHistory from '@history';
import _ from '@lodash';
import { setInitialSettings } from 'app/store/fuse/settingsSlice';
import { showMessage } from 'app/store/fuse/messageSlice';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import settingsConfig from 'app/configs/settingsConfig';
import { showMessage } from 'app/store/fuse/messageSlice';
import { setInitialSettings } from 'app/store/fuse/settingsSlice';
import { authService } from '../services';
export const setUser = createAsyncThunk('user/setUser', async (user, { dispatch, getState }) => {
/*
You can redirect the logged-in user to a specific route depending on his role
*/
export const setUser = createAsyncThunk('user/setUser', async (user) => {
// You can redirect the logged-in user to a specific route depending on his role
if (user.loginRedirectUrl) {
settingsConfig.loginRedirectUrl = user.loginRedirectUrl; // for example 'apps/academy'
}
return user;
return _.merge({}, initialState, user);
});
export const updateUserSettings = createAsyncThunk(
'user/updateSettings',
async (settings, { dispatch, getState }) => {
const { user } = getState();
const newUser = _.merge({}, user, { data: { ...settings } });
const newUser = _.omit(_.merge({}, user, { data: { ...settings } }), 'history');
dispatch(updateUserData(newUser));
dispatch(updateUserData(newUser))
.then(() => {
dispatch(showMessage({ message: 'User data saved' }));
})
.catch((error) => {
dispatch(showMessage({ message: error.message }));
});
return newUser;
}
);
export const updateUserFavorites = createAsyncThunk(
'user/updateFavorites',
async (item, { dispatch, getState }) => {
const { user } = getState();
const hasItemInFavorites = user.favorites.find(
(favoriteItem) => favoriteItem.id === item.id && item.favorite
);
const hasItemInHistory = user.history.find((history) => history.id === item.id);
const favorites = hasItemInFavorites
? user.favorites.filter((favorite) => favorite.id !== item.id)
: [...user.favorites, { ...item, favorite: true }];
if (hasItemInHistory) {
const history = user.history.map((historyItem) => {
if (historyItem.id === item.id) {
return { ...historyItem, favorite: !hasItemInFavorites };
}
return historyItem;
});
dispatch(updateUserHistory(history));
}
const newUserData = _.omit({ ...user, favorites }, 'history');
dispatch(updateUserData(newUserData))
.then(() => {
if (hasItemInFavorites) {
dispatch(showMessage({ message: 'The property is removed from favorites' }));
} else {
dispatch(showMessage({ message: 'The property is saved to favorites' }));
}
})
.catch((error) => {
dispatch(showMessage({ message: error.message }));
});
return favorites;
}
);
export const updateUserHistory = (history) => (dispatch) => {
localStorage.setItem('user', JSON.stringify({ history }));
dispatch(userHistoryUpdated(history));
};
export const logoutUser = () => async (dispatch, getState) => {
const { user } = getState();
@@ -38,7 +89,7 @@ export const logoutUser = () => async (dispatch, getState) => {
return null;
}
history.push({
browserHistory.push({
pathname: '/',
});
@@ -53,14 +104,8 @@ export const updateUserData = (user) => async (dispatch, getState) => {
return;
}
authService
.updateUserData(user)
.then(() => {
dispatch(showMessage({ message: 'User data saved' }));
})
.catch((error) => {
dispatch(showMessage({ message: error.message }));
});
// eslint-disable-next-line consistent-return
return authService.updateUserData(user);
};
const initialState = {
@@ -69,6 +114,8 @@ const initialState = {
displayName: 'John Doe',
email: 'johndoe@withinpixels.com',
},
history: [],
favorites: [],
};
const userSlice = createSlice({
@@ -76,15 +123,21 @@ const userSlice = createSlice({
initialState,
reducers: {
userLoggedOut: (state, action) => initialState,
userHistoryUpdated: (state, action) => ({ ...state, history: action.payload }),
},
extraReducers: {
[updateUserSettings.fulfilled]: (state, action) => action.payload,
[updateUserFavorites.fulfilled]: (state, action) => ({ ...state, favorites: action.payload }),
[setUser.fulfilled]: (state, action) => action.payload,
},
});
export const { userLoggedOut } = userSlice.actions;
export const { userLoggedOut, userHistoryUpdated } = userSlice.actions;
export const selectUser = ({ user }) => user;
export const selectUserHistory = ({ user }) => user.history;
export const selectUserFavorites = ({ user }) => user.favorites;
export default userSlice.reducer;

View File

@@ -2,7 +2,7 @@ const config = {
title: 'Layout 1 - Dashboard',
defaults: {
mode: 'container',
containerWidth: 1570,
containerWidth: 1590,
navbar: {
display: true,
folded: true,

View File

@@ -6,7 +6,7 @@ import { navbarCloseMobile, selectFuseNavbar } from 'app/store/fuse/navbarSlice'
import { selectFuseCurrentLayoutConfig } from 'app/store/fuse/settingsSlice';
import NavbarLayout1Content from './NavbarLayout1Content';
const navbarWidth = 280;
const navbarWidth = 330;
const StyledNavBar = styled('div')(({ theme, open, position }) => ({
minWidth: navbarWidth,

View File

@@ -2,45 +2,46 @@
/* Print
/*----------------------------------------------------------------*/
@media all {
/* Never show page breaks in normal view */
.page-break-after,
.page-break-before {
display: none;
}
/* Never show page breaks in normal view */
.page-break-after,
.page-break-before {
display: none;
}
}
@media print {
/* html and body tweaks */
html, body {
height: auto !important;
overflow: initial !important;
background: none
}
/* html and body tweaks */
html,
body {
height: auto !important;
overflow: initial !important;
background: none;
}
/* Page breaks */
.page-break-after {
display: block;
page-break-after: always;
position: relative;
}
/* Page breaks */
.page-break-after {
display: block;
page-break-after: always;
position: relative;
}
.page-break-before {
display: block;
page-break-before: always;
position: relative;
}
.page-break-before {
display: block;
page-break-before: always;
position: relative;
}
/* General styles */
#fuse-toolbar,
#fuse-footer,
#fuse-navbar,
#fuse-settings-presets,
#fuse-layout .ps > .ps__rail-x,
#fuse-layout .ps > .ps__rail-y {
display: none !important;
}
/* General styles */
#fuse-toolbar,
#fuse-footer,
#fuse-navbar,
#fuse-settings-presets,
#fuse-layout .ps > .ps__rail-x,
#fuse-layout .ps > .ps__rail-y {
display: none !important;
}
#fuse-layout .ps {
overflow: visible !important;
}
#fuse-layout .ps {
overflow: visible !important;
}
}

View File

@@ -1,212 +1,211 @@
code[class*="language-"],
pre[class*="language-"] {
text-align: left;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
color: #c3cee3;
background: #263238;
font-family: Roboto Mono,"Liberation Mono",Menlo,Courier,monospace;
font-size: 1em;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
code[class*='language-'],
pre[class*='language-'] {
text-align: left;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
color: #c3cee3;
background: #263238;
font-family: Roboto Mono, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 1em;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
code[class*="language-"]::-moz-selection,
pre[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection,
pre[class*="language-"] ::-moz-selection {
background: #000000;
code[class*='language-']::-moz-selection,
pre[class*='language-']::-moz-selection,
code[class*='language-'] ::-moz-selection,
pre[class*='language-'] ::-moz-selection {
background: #000000;
}
code[class*="language-"]::selection,
pre[class*="language-"]::selection,
code[class*="language-"] ::selection,
pre[class*="language-"] ::selection {
background: #000000;
code[class*='language-']::selection,
pre[class*='language-']::selection,
code[class*='language-'] ::selection,
pre[class*='language-'] ::selection {
background: #000000;
}
:not(pre) > code[class*="language-"] {
white-space: normal;
border-radius: 0.2em;
padding: 0.1em;
:not(pre) > code[class*='language-'] {
white-space: normal;
border-radius: 0.2em;
padding: 0.1em;
}
pre[class*="language-"] {
overflow: auto;
position: relative;
padding: 12px;
border-radius: 4px;;
pre[class*='language-'] {
overflow: auto;
position: relative;
padding: 12px;
border-radius: 4px;
}
.language-css > code,
.language-sass > code,
.language-scss > code {
color: #fd9170;
color: #fd9170;
}
[class*="language-"] .namespace {
opacity: 0.7;
[class*='language-'] .namespace {
opacity: 0.7;
}
.token.plain-text {
color: #c3cee3;
color: #c3cee3;
}
.token.atrule {
color: #c792ea;
color: #c792ea;
}
.token.attr-name {
color: #ffcb6b;
color: #ffcb6b;
}
.token.attr-value {
color: #c3e88d;
color: #c3e88d;
}
.token.attribute {
color: #c3e88d;
color: #c3e88d;
}
.token.boolean {
color: #c792ea;
color: #c792ea;
}
.token.builtin {
color: #ffcb6b;
color: #ffcb6b;
}
.token.cdata {
color: #80cbc4;
color: #80cbc4;
}
.token.char {
color: #80cbc4;
color: #80cbc4;
}
.token.class {
color: #ffcb6b;
color: #ffcb6b;
}
.token.class-name {
color: #82aaff;
color: #82aaff;
}
.token.color {
color: #f2ff00;
color: #f2ff00;
}
.token.comment {
color: #546e7a;
color: #546e7a;
}
.token.constant {
color: #c792ea;
color: #c792ea;
}
.token.deleted {
color: #f07178;
color: #f07178;
}
.token.doctype {
color: #546e7a;
color: #546e7a;
}
.token.entity {
color: #f07178;
color: #f07178;
}
.token.function {
color: #c792ea;
color: #c792ea;
}
.token.hexcode {
color: #f2ff00;
color: #f2ff00;
}
.token.id {
color: #c792ea;
font-weight: bold;
color: #c792ea;
font-weight: bold;
}
.token.important {
color: #c792ea;
font-weight: bold;
color: #c792ea;
font-weight: bold;
}
.token.inserted {
color: #80cbc4;
color: #80cbc4;
}
.token.keyword {
color: #c792ea;
font-style: italic;
color: #c792ea;
font-style: italic;
}
.token.number {
color: #fd9170;
color: #fd9170;
}
.token.operator {
color: #89ddff;
color: #89ddff;
}
.token.prolog {
color: #546e7a;
color: #546e7a;
}
.token.property {
color: #80cbc4;
color: #80cbc4;
}
.token.pseudo-class {
color: #c3e88d;
color: #c3e88d;
}
.token.pseudo-element {
color: #c3e88d;
color: #c3e88d;
}
.token.punctuation {
color: #89ddff;
color: #89ddff;
}
.token.regex {
color: #f2ff00;
color: #f2ff00;
}
.token.selector {
color: #f07178;
color: #f07178;
}
.token.string {
color: #c3e88d;
color: #c3e88d;
}
.token.symbol {
color: #c792ea;
color: #c792ea;
}
.token.tag {
color: #f07178;
color: #f07178;
}
.token.unit {
color: #f07178;
color: #f07178;
}
.token.url {
color: #fd9170;
color: #fd9170;
}
.token.variable {
color: #f07178;
color: #f07178;
}

View File

@@ -2,68 +2,68 @@
Basic Table Styles
*/
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
table.simple {
width: 100%;
border: none;
border-spacing: 0;
text-align: left;
width: 100%;
border: none;
border-spacing: 0;
text-align: left;
}
table.simple thead tr th {
padding: 16px 8px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
white-space: nowrap;
padding: 16px 8px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
white-space: nowrap;
}
table.simple thead tr th:first-child {
padding-left: 24px;
padding-left: 24px;
}
table.simple thead tr th:last-child {
padding-right: 24px;
padding-right: 24px;
}
table.simple tbody tr td {
padding: 12px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
padding: 12px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
table.simple tbody tr td:first-child {
padding-left: 24px;
padding-left: 24px;
}
table.simple tbody tr td:last-child {
padding-right: 24px;
padding-right: 24px;
}
table.simple tbody tr:last-child td {
border-bottom: none;
border-bottom: none;
}
table.simple.clickable tbody tr {
cursor: pointer;
cursor: pointer;
}
table.simple.clickable tbody tr:hover {
background: rgba(0, 0, 0, 0.03);
background: rgba(0, 0, 0, 0.03);
}
table.simple.borderless {
border: none;
border: none;
}
table.simple.borderless tbody tr td{
border: none;
table.simple.borderless tbody tr td {
border: none;
}
table.simple.borderless thead tr th{
border: none;
table.simple.borderless thead tr th {
border: none;
}

View File

@@ -186,6 +186,8 @@ module.exports = {
primary: '#151B30',
secondary: '#6D6D6D',
disabled: '#D9D9D9',
highlight1: '#FFBC6E',
highlight2: '#DB00FF',
},
primary: {
light: '#FFFFFF',