feat: add full expert's search, add account sessions
This commit is contained in:
parent
bdd382042c
commit
6232330f2e
|
@ -96,7 +96,8 @@
|
|||
"find": "Find",
|
||||
"search": "Search for an Expert",
|
||||
"sort": "Sort",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"direction": "Direction"
|
||||
},
|
||||
"list": {
|
||||
"price": "0€",
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
"@ant-design/cssinjs": "^1.18.1",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.1",
|
||||
"antd-img-crop": "^4.21.0",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "14.0.3",
|
||||
"next-intl": "^3.3.1",
|
||||
|
@ -961,6 +963,21 @@
|
|||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/antd-img-crop": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/antd-img-crop/-/antd-img-crop-4.21.0.tgz",
|
||||
"integrity": "sha512-YA5GUMfwoDoSJNWinGOmtYFDFLf+t7Rhfg7ZusbHgFpKCq8n9W0005LeCWgSP4C0iK3vxNHAT3DaRa3rTgKFlQ==",
|
||||
"dependencies": {
|
||||
"compare-versions": "6.1.0",
|
||||
"react-easy-crop": "^5.0.4",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"antd": ">=4.0.0",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
|
@ -1450,6 +1467,11 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/compare-versions": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
|
||||
"integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
|
||||
|
@ -3454,6 +3476,11 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
@ -4391,6 +4418,24 @@
|
|||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.5.tgz",
|
||||
"integrity": "sha512-GH7Jw3898ytSaN4i4Oxi7j3BKzapZ2pVgnKIl+gFIUjA+NsDgdBSIpiBQUrPFIvHzSnPmz0kGCpX95X6NgsDzA==",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop/node_modules/tslib": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
|
||||
"integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ=="
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
@ -5965,6 +6010,16 @@
|
|||
"throttle-debounce": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"antd-img-crop": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/antd-img-crop/-/antd-img-crop-4.21.0.tgz",
|
||||
"integrity": "sha512-YA5GUMfwoDoSJNWinGOmtYFDFLf+t7Rhfg7ZusbHgFpKCq8n9W0005LeCWgSP4C0iK3vxNHAT3DaRa3rTgKFlQ==",
|
||||
"requires": {
|
||||
"compare-versions": "6.1.0",
|
||||
"react-easy-crop": "^5.0.4",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
|
@ -6307,6 +6362,11 @@
|
|||
"delayed-stream": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"compare-versions": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
|
||||
"integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
|
||||
},
|
||||
"compute-scroll-into-view": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
|
||||
|
@ -7794,6 +7854,11 @@
|
|||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
||||
"dev": true
|
||||
},
|
||||
"normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
@ -8432,6 +8497,22 @@
|
|||
"scheduler": "^0.23.0"
|
||||
}
|
||||
},
|
||||
"react-easy-crop": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.5.tgz",
|
||||
"integrity": "sha512-GH7Jw3898ytSaN4i4Oxi7j3BKzapZ2pVgnKIl+gFIUjA+NsDgdBSIpiBQUrPFIvHzSnPmz0kGCpX95X6NgsDzA==",
|
||||
"requires": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
|
||||
"integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
|
@ -12,7 +12,9 @@
|
|||
"@ant-design/cssinjs": "^1.18.1",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.1",
|
||||
"antd-img-crop": "^4.21.0",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "14.0.3",
|
||||
"next-intl": "^3.3.1",
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
After Width: | Height: | Size: 247 KiB |
|
@ -1,9 +1,9 @@
|
|||
import { apiClient } from '../lib/apiClient';
|
||||
import { Filter, ExpertsData, ExpertDetails } from '../types/experts';
|
||||
import { GeneralFilter, ExpertsData, ExpertDetails } from '../types/experts';
|
||||
|
||||
export const getExpertsList = async (filter: Filter, locale: string) => {
|
||||
export const getExpertsList = async (filter: GeneralFilter, locale: string) => {
|
||||
const response = await apiClient.post(
|
||||
'/home/coachsearch',
|
||||
'/home/coachsearch1',
|
||||
{ ...filter },
|
||||
{
|
||||
headers: {
|
||||
|
@ -13,7 +13,7 @@ export const getExpertsList = async (filter: Filter, locale: string) => {
|
|||
}
|
||||
);
|
||||
|
||||
return response.data?.coaches as ExpertsData || null;
|
||||
return response.data as ExpertsData || null;
|
||||
};
|
||||
|
||||
export const getExpertById = async (id: string, locale: string) => {
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Profile } from '../../types/profile';
|
||||
import { getPersonalData } from '../profile';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { AUTH_TOKEN_KEY } from '../../constants/common';
|
||||
|
||||
export const useProfileSettings = (locale: string) => {
|
||||
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
|
||||
const [profileSettings, setProfileSettings] = useState<Profile | undefined>();
|
||||
const [fetchLoading, setFetchLoading] = useState<boolean>(false);
|
||||
const [saveLoading, setSaveLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt) {
|
||||
getPersonalData(locale, jwt)
|
||||
.then(({ data }) => {
|
||||
setProfileSettings(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
})
|
||||
.finally(() => {
|
||||
setFetchLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
|
||||
}, []);
|
||||
|
||||
return {
|
||||
fetchLoading,
|
||||
save,
|
||||
saveLoading,
|
||||
profileSettings
|
||||
};
|
||||
};
|
|
@ -1,7 +1,9 @@
|
|||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import { Profile } from '../types/profile';
|
||||
import { Session, SessionsFilter } from '../types/sessions';
|
||||
|
||||
export const setPersonData = (person: { login: string, password: string, role: string }, locale: string, jwt: string): Promise<AxiosResponse> => (
|
||||
export const setPersonData = (person: { login: string, password: string, role: string, languagesLinks: any[] }, locale: string, jwt: string): Promise<AxiosResponse<{ userData: Profile }>> => (
|
||||
apiClient.post(
|
||||
'/home/applyperson1',
|
||||
{ ...person },
|
||||
|
@ -13,3 +15,61 @@ export const setPersonData = (person: { login: string, password: string, role: s
|
|||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const getPersonalData = (locale: string, jwt: string): Promise<AxiosResponse<Profile>> => (
|
||||
apiClient.post(
|
||||
'/home/userdata',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-User-Language': locale,
|
||||
Authorization: `Bearer ${jwt}`
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const getUpcomingSessions = (locale: string, jwt: string, filter?: SessionsFilter): Promise<AxiosResponse<Session[]>> => (
|
||||
apiClient.post(
|
||||
'/home/upcomingsessionsall',
|
||||
{
|
||||
sessionType: 'session',
|
||||
...(filter || {})
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-User-Language': locale,
|
||||
Authorization: `Bearer ${jwt}`
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const getRequestedSessions = (locale: string, jwt: string): Promise<AxiosResponse<{ requestedSessions: Session[] }>> => (
|
||||
apiClient.post(
|
||||
'/home/coachhomedata',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-User-Language': locale,
|
||||
Authorization: `Bearer ${jwt}`
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const getRecentSessions = (locale: string, jwt: string, filter?: SessionsFilter): Promise<AxiosResponse<Session[]>> => (
|
||||
apiClient.post(
|
||||
'/home/historicalmeetings ',
|
||||
{
|
||||
sessionType: 'session',
|
||||
...(filter || {})
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-User-Language': locale,
|
||||
Authorization: `Bearer ${jwt}`
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { apiClient } from '../lib/apiClient';
|
||||
import { SearchData } from '../types/tags';
|
||||
import { SearchData, Languages } from '../types/tags';
|
||||
|
||||
export const getTagList = async (locale: string) => {
|
||||
const response = await apiClient.post(
|
||||
|
@ -15,3 +15,17 @@ export const getTagList = async (locale: string) => {
|
|||
|
||||
return response.data as SearchData || null;
|
||||
};
|
||||
|
||||
export const getLanguages = async (locale: string) => {
|
||||
const response = await apiClient.get(
|
||||
'/home/languages',
|
||||
{
|
||||
headers: {
|
||||
'X-User-Language': locale,
|
||||
Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJuYW1laWQiOiIxNzIiLCJuYmYiOjE3MDM2ODMyMDgsImV4cCI6MTczNTIxOTIwOCwiaWF0IjoxNzAzNjgzMjA4fQ.KgnYfKO7oVFLlDuKhfyNN6RAaXKdeSzJd7F4r6_15AA'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data as Languages || null;
|
||||
};
|
||||
|
|
|
@ -1,30 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AccountMenu } from '../../../../components/Account';
|
||||
import { i18nText } from '../../../../i18nKeys';
|
||||
|
||||
type AccountInnerLayoutProps = {
|
||||
children: ReactNode;
|
||||
params: Record<string, string>;
|
||||
params: { locale: string };
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bbuddy User Account'
|
||||
};
|
||||
|
||||
export function generateStaticParams({
|
||||
params: { locale },
|
||||
}: { params: { locale: string } }) {
|
||||
const result: { locale: string, userId: string }[] = [];
|
||||
const users = [{ userId: '1' }, { userId: '2' }];
|
||||
|
||||
users.forEach(({ userId }) => {
|
||||
result.push({ locale, userId });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const ROUTES = ['sessions', 'notifications', 'support', 'information', 'settings', 'messages', 'work-with-us'];
|
||||
const COUNTS: Record<string, number> = {
|
||||
sessions: 12,
|
||||
|
@ -33,12 +17,10 @@ const COUNTS: Record<string, number> = {
|
|||
};
|
||||
|
||||
|
||||
export default function AccountInnerLayout({ children }: AccountInnerLayoutProps) {
|
||||
const t = useTranslations('Account');
|
||||
|
||||
export default function AccountInnerLayout({ children, params: { locale } }: AccountInnerLayoutProps) {
|
||||
const getMenuConfig = () => ROUTES.map((path) => ({
|
||||
path,
|
||||
title: t(`menu.${path}`),
|
||||
title: i18nText(`accountMenu.${path}`, locale),
|
||||
count: COUNTS[path] || undefined
|
||||
}));
|
||||
|
||||
|
|
|
@ -8,12 +8,13 @@ export const metadata: Metadata = {
|
|||
description: 'Bbuddy desc sessions'
|
||||
};
|
||||
|
||||
export default function Sessions() {
|
||||
export default function Sessions({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = useTranslations('Account.Sessions');
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p>Loading...</p>}>
|
||||
<SessionsTabs
|
||||
locale={locale}
|
||||
intlConfig={{
|
||||
upcoming: t('upcoming-sessions'),
|
||||
requested: t('sessions-requested'),
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import React, {Suspense} from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '../../../../../navigation';
|
||||
import { CustomInput } from '../../../../../components/view';
|
||||
import { ProfileSettings } from '../../../../../components/Account';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Bbuddy - Account - Profile Settings',
|
||||
description: 'Bbuddy desc Profile settings'
|
||||
};
|
||||
|
||||
export default function Settings({ params }: { params: { userId: string } }) {
|
||||
export default function Settings({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = useTranslations('Account.Settings');
|
||||
|
||||
return (
|
||||
|
@ -17,33 +16,18 @@ export default function Settings({ params }: { params: { userId: string } }) {
|
|||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item active" aria-current="page">{t('title')}</li>
|
||||
</ol>
|
||||
<form className="form-settings" action="">
|
||||
<div className="user-avatar">
|
||||
<div className="user-avatar__edit">
|
||||
<input className="" type="file" id="input-file" />
|
||||
<label htmlFor="input-file" className="form-label" />
|
||||
</div>
|
||||
<div className="user-avatar__text">{t('photo-desc')}</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<CustomInput placeholder={t('name')} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<CustomInput placeholder={t('surname')} />
|
||||
</div>
|
||||
<div className="form-field date">
|
||||
<CustomInput placeholder={t('birthday')} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<CustomInput type="email" placeholder={t('email')} />
|
||||
</div>
|
||||
<div className="form-link">
|
||||
<Link href={'change-password' as any}>
|
||||
{t('change-password')}
|
||||
</Link>
|
||||
</div>
|
||||
<button className="btn-apply">{t('save')}</button>
|
||||
</form>
|
||||
<Suspense>
|
||||
<ProfileSettings
|
||||
locale={locale}
|
||||
photoDesc={t('photo-desc')}
|
||||
placeholderName={t('name')}
|
||||
placeholderSurname={t('surname')}
|
||||
placeholderBirthday={t('birthday')}
|
||||
placeholderEmail={t('email')}
|
||||
changePasswordLink={t('change-password')}
|
||||
saveButton={t('save')}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -30,7 +30,7 @@ export async function generateStaticParams({
|
|||
"durationTo": null
|
||||
}, locale);
|
||||
|
||||
experts?.forEach(({ id }) => {
|
||||
experts?.coaches?.forEach(({ id }) => {
|
||||
result.push({ locale, expertId: id.toString() });
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { Comfortaa, Inter } from 'next/font/google';
|
||||
|
||||
export const comfortaa = Comfortaa({
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-comfortaa',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const inter = Inter({
|
||||
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button, ConfigProvider } from 'antd';
|
||||
import { comfortaa, inter } from './fonts';
|
||||
import StyledRegistry from '../lib/StyleRegistry';
|
||||
import StyledComponentsRegistry from '../lib/AntdRegistry';
|
||||
import theme from '../constants/theme';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<html className={`${comfortaa.variable} ${inter.variable}`}>
|
||||
<body>
|
||||
<StyledRegistry>
|
||||
<StyledComponentsRegistry>
|
||||
<ConfigProvider theme={theme}>
|
||||
<div className="b-wrapper">
|
||||
<div className="b-content">
|
||||
<header className="b-header">
|
||||
<div className="b-inner">
|
||||
<Link href={'/' as any} className="b-header__logo">
|
||||
<img
|
||||
src="/images/logo-header.svg"
|
||||
className="img-default"
|
||||
alt=""
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="page-error">
|
||||
<div className="b-inner">
|
||||
<div className="error__image">
|
||||
<img src="/images/server-error.png" alt="" />
|
||||
</div>
|
||||
<div className="error__description">
|
||||
<div className="error__subtitle">Something wrong</div>
|
||||
<Button className="btn-apply error__button" onClick={() => reset()}>
|
||||
Refresh page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="b-footer">
|
||||
<div className="b-inner">
|
||||
<div className="row">
|
||||
<div className="col" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</StyledComponentsRegistry>
|
||||
</StyledRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Comfortaa, Inter } from 'next/font/google';
|
||||
import { comfortaa, inter } from './fonts';
|
||||
import '../styles/style.scss';
|
||||
|
||||
type RootLayoutProps = {
|
||||
|
@ -7,20 +7,6 @@ type RootLayoutProps = {
|
|||
params: { locale: string };
|
||||
};
|
||||
|
||||
const comfortaa = Comfortaa({
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-comfortaa',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children, params: { locale } }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang={locale} className={`${comfortaa.variable} ${inter.variable}`}>
|
||||
|
|
|
@ -1,9 +1,57 @@
|
|||
import React from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import Link from 'next/link';
|
||||
import { comfortaa, inter } from './fonts';
|
||||
import StyledComponentsRegistry from '../lib/AntdRegistry';
|
||||
import StyledRegistry from '../lib/StyleRegistry';
|
||||
import theme from '../constants/theme';
|
||||
import { BackButton } from '../components/view';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
not found
|
||||
</div>
|
||||
<html className={`${comfortaa.variable} ${inter.variable}`}>
|
||||
<body>
|
||||
<StyledRegistry>
|
||||
<StyledComponentsRegistry>
|
||||
<ConfigProvider theme={theme}>
|
||||
<div className="b-wrapper">
|
||||
<div className="b-content">
|
||||
<header className="b-header">
|
||||
<div className="b-inner">
|
||||
<Link href={'/' as any} className="b-header__logo">
|
||||
<img
|
||||
src="/images/logo-header.svg"
|
||||
className="img-default"
|
||||
alt=""
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="page-error">
|
||||
<div className="b-inner">
|
||||
<div className="error__image">
|
||||
<img src="/images/not-found.png" alt="" />
|
||||
</div>
|
||||
<div className="error__description">
|
||||
<div className="error__code">404</div>
|
||||
<div className="error__subtitle">We can't seem to find a page you're looking for</div>
|
||||
<BackButton className="btn-apply error__button" text="Go back" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="b-footer">
|
||||
<div className="b-inner">
|
||||
<div className="row">
|
||||
<div className="col" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</StyledComponentsRegistry>
|
||||
</StyledRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import styled from 'styled-components';
|
|||
import { Button } from 'antd';
|
||||
import { useSelectedLayoutSegment, usePathname } from 'next/navigation';
|
||||
import { Link } from '../../navigation';
|
||||
import { AUTH_TOKEN_KEY } from '../../constants/common';
|
||||
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../constants/common';
|
||||
import { deleteStorageKey } from '../../hooks/useLocalStorage';
|
||||
|
||||
const Logout = styled(Button)`
|
||||
|
@ -26,9 +26,14 @@ export const AccountMenu = ({ menu }: { menu: { path: string, title: string, cou
|
|||
|
||||
const onLogout = () => {
|
||||
deleteStorageKey(AUTH_TOKEN_KEY);
|
||||
deleteStorageKey(AUTH_USER);
|
||||
window?.location?.replace(`/${paths.split('/')[1]}/`);
|
||||
};
|
||||
|
||||
const onDeleteAccount = () => {
|
||||
console.log('delete');
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="list-sidebar">
|
||||
{menu.map(({ path, title, count }) => (
|
||||
|
@ -49,6 +54,14 @@ export const AccountMenu = ({ menu }: { menu: { path: string, title: string, cou
|
|||
Log Out
|
||||
</Logout>
|
||||
</li>
|
||||
<li className="list-sidebar__item">
|
||||
<Logout
|
||||
type="link"
|
||||
onClick={onDeleteAccount}
|
||||
>
|
||||
Delete account
|
||||
</Logout>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
'use client';
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Form, Upload, Button } from 'antd';
|
||||
import type { UploadFile, UploadProps, GetProp } from 'antd';
|
||||
import ImgCrop from 'antd-img-crop';
|
||||
import { CameraOutlined } from '@ant-design/icons';
|
||||
import { Link } from '../../navigation';
|
||||
import { CustomInput } from '../view';
|
||||
import { Profile } from '../../types/profile';
|
||||
import { useProfileSettings } from '../../actions/hooks/useProfileSettings';
|
||||
|
||||
type ProfileSettingsProps = {
|
||||
locale: string;
|
||||
photoDesc?: string;
|
||||
placeholderName?: string;
|
||||
placeholderSurname?: string;
|
||||
placeholderBirthday?: string;
|
||||
placeholderEmail?: string;
|
||||
changePasswordLink?: string;
|
||||
saveButton?: string;
|
||||
};
|
||||
|
||||
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
|
||||
|
||||
export const ProfileSettings: FC<ProfileSettingsProps> = ({
|
||||
locale,
|
||||
photoDesc,
|
||||
placeholderName,
|
||||
placeholderSurname,
|
||||
placeholderBirthday,
|
||||
placeholderEmail,
|
||||
changePasswordLink,
|
||||
saveButton
|
||||
}) => {
|
||||
const [form] = Form.useForm<Profile>();
|
||||
const { profileSettings } = useProfileSettings(locale);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileSettings) {
|
||||
form.setFieldsValue(profileSettings);
|
||||
}
|
||||
}, [profileSettings]);
|
||||
|
||||
const [fileList, setFileList] = useState<UploadFile[]>();
|
||||
|
||||
const onChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
setFileList(newFileList);
|
||||
};
|
||||
|
||||
const onPreview = async (file: UploadFile) => {
|
||||
let src = file.url as string;
|
||||
if (!src) {
|
||||
src = await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.originFileObj as FileType);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
});
|
||||
}
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
const imgWindow = window.open(src);
|
||||
imgWindow?.document.write(image.outerHTML);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} className="form-settings">
|
||||
<div className="user-avatar">
|
||||
<div className="user-avatar__edit">
|
||||
<input className="" type="file" id="input-file" />
|
||||
<label htmlFor="input-file" className="form-label" />
|
||||
</div>
|
||||
<div className="user-avatar__text">{photoDesc}</div>
|
||||
</div>
|
||||
<ImgCrop rotationSlider>
|
||||
<Upload
|
||||
action="https://run.mocky.io/v3/435e224c-44fb-4773-9faf-380c5e6a2188"
|
||||
fileList={fileList}
|
||||
onChange={onChange}
|
||||
onPreview={onPreview}
|
||||
>
|
||||
<Button icon={<CameraOutlined />}>Click to Upload</Button>
|
||||
</Upload>
|
||||
</ImgCrop>
|
||||
<div className="form-field">
|
||||
<Form.Item name="username">
|
||||
<CustomInput placeholder={placeholderName} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<Form.Item name="surname">
|
||||
<CustomInput placeholder={placeholderSurname} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* <div className="form-field">
|
||||
<Form.Item name="birthday">
|
||||
<CustomInput placeholder={placeholderBirthday} />
|
||||
</Form.Item>
|
||||
</div> */}
|
||||
<div className="form-field">
|
||||
<Form.Item name="login">
|
||||
<CustomInput type="email" placeholder={placeholderEmail} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="form-link">
|
||||
<Link href={'change-password' as any}>
|
||||
{changePasswordLink}
|
||||
</Link>
|
||||
</div>
|
||||
<button className="btn-apply">{saveButton}</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
|
@ -1,20 +1,66 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CustomSelect } from '../view';
|
||||
import * as dayjs from 'dayjs';
|
||||
import * as isToday from 'dayjs/plugin/isToday';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/de';
|
||||
import 'dayjs/locale/it';
|
||||
import 'dayjs/locale/fr';
|
||||
import 'dayjs/locale/es';
|
||||
import { CustomSelect, Loader } from '../view';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../constants/common';
|
||||
import { getRecentSessions, getRequestedSessions, getUpcomingSessions } from '../../actions/profile';
|
||||
import { Session, Sessions, SessionType } from '../../types/sessions';
|
||||
|
||||
dayjs.extend(isToday);
|
||||
|
||||
const Tab = styled.div``;
|
||||
|
||||
export const SessionsTabs = ({ intlConfig }: { intlConfig: Record<string, string> }) => {
|
||||
export const SessionsTabs = ({ intlConfig, locale }: { intlConfig: Record<string, string>, locale: string }) => {
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [sort, setSort] = useState<string>();
|
||||
const [sessions, setSessions] = useState<Sessions>();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [errorData, setErrorData] = useState<any>();
|
||||
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
|
||||
const [userId] = useLocalStorage(AUTH_USER, '');
|
||||
|
||||
const fetchData = () => {
|
||||
setErrorData(undefined);
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
getUpcomingSessions(locale, jwt),
|
||||
getRequestedSessions(locale, jwt),
|
||||
getRecentSessions(locale, jwt)
|
||||
])
|
||||
.then(([upcoming, requested, recent]) => {
|
||||
setSessions({
|
||||
[SessionType.UPCOMING]: upcoming.data || [],
|
||||
[SessionType.REQUESTED]: requested.data?.requestedSessions || [],
|
||||
[SessionType.RECENT]: recent.data || []
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrorData(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const onChangeSort = useCallback((value: string) => {
|
||||
setSort(value);
|
||||
}, [sort]);
|
||||
|
||||
const getChildren = () => (
|
||||
const getChildren = (list?: Session[]) => (
|
||||
<>
|
||||
<div className="filter-session">
|
||||
<div className="filter-session__item">
|
||||
|
@ -32,92 +78,74 @@ export const SessionsTabs = ({ intlConfig }: { intlConfig: Record<string, string
|
|||
</div>
|
||||
</div>
|
||||
<div className="list-session">
|
||||
<div className="card-profile">
|
||||
<div className="card-profile__header">
|
||||
<div className="card-profile__header__portrait">
|
||||
<img src="/images/person.png" className="" alt="" />
|
||||
</div>
|
||||
<div className="card-profile__header__inner">
|
||||
<div className="card-profile__header__name">Matthew Weeks</div>
|
||||
<div className="card-profile__header__title">
|
||||
Personal Growth Course
|
||||
</div>
|
||||
<div className="card-profile__header__date chosen">
|
||||
Today 10:00 AM - 10:30 AM
|
||||
{list?.length > 0 ? list?.map(({ id, scheduledStartAtUtc, scheduledEndAtUtc, title, coach, clients }) => {
|
||||
const client = clients?.length ? clients[0] : null;
|
||||
const current = +userId !== client?.id ? client : coach;
|
||||
const startDate = dayjs(scheduledStartAtUtc).locale(locale);
|
||||
const endDate = dayjs(scheduledEndAtUtc).locale(locale);
|
||||
const today = startDate.isToday();
|
||||
|
||||
return (
|
||||
<div key={id} className="card-profile session__item">
|
||||
<div className="card-profile__header">
|
||||
<div className="card-profile__header__portrait">
|
||||
<img src={current?.faceImageUrl || '/images/person.png'} className="" alt="" />
|
||||
</div>
|
||||
<div className="card-profile__header__inner">
|
||||
<div>
|
||||
<div className="card-profile__header__name">{`${current?.name} ${current?.surname || ''}`}</div>
|
||||
<div className="card-profile__header__title">{title}</div>
|
||||
<div className={`card-profile__header__date${today ? ' chosen': ''}`}>
|
||||
{today
|
||||
? `Today ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}`
|
||||
: `${startDate.format('D MMMM')} ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-profile">
|
||||
<div className="card-profile__header">
|
||||
<div className="card-profile__header__portrait">
|
||||
<img src="/images/person.png" className="" alt="" />
|
||||
</div>
|
||||
<div className="card-profile__header__inner">
|
||||
<div className="card-profile__header__name">Matthew Weeks</div>
|
||||
<div className="card-profile__header__title">
|
||||
Personal Growth Course
|
||||
</div>
|
||||
<div className="card-profile__header__date">
|
||||
8 december at 10:00 AM - 10:30 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-profile">
|
||||
<div className="card-profile__header">
|
||||
<div className="card-profile__header__portrait">
|
||||
<img src="/images/person.png" className="" alt="" />
|
||||
</div>
|
||||
<div className="card-profile__header__inner">
|
||||
<div className="card-profile__header__name">Matthew Weeks</div>
|
||||
<div className="card-profile__header__title">
|
||||
Personal Growth Course
|
||||
</div>
|
||||
<div className="card-profile__header__date">
|
||||
8 december at 10:00 AM - 10:30 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div>not found</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'upcoming',
|
||||
key: SessionType.UPCOMING,
|
||||
label: (
|
||||
<>
|
||||
{intlConfig?.upcoming || 'Tab 1'}
|
||||
<span className="count">3</span>
|
||||
{sessions?.upcoming?.length > 0 ? (<span className="count">{sessions?.upcoming.length}</span>) : null}
|
||||
</>
|
||||
),
|
||||
children: getChildren()
|
||||
children: getChildren(sessions?.upcoming)
|
||||
},
|
||||
{
|
||||
key: 'requested',
|
||||
key: SessionType.REQUESTED,
|
||||
label: (
|
||||
<>
|
||||
{intlConfig?.requested || 'Tab 2'}
|
||||
<span className="count">2</span>
|
||||
{sessions?.requested?.length > 0 ? (<span className="count">{sessions?.requested.length}</span>) : null}
|
||||
</>
|
||||
),
|
||||
children: getChildren()
|
||||
children: getChildren(sessions?.requested)
|
||||
},
|
||||
{
|
||||
key: 'recent',
|
||||
label: (
|
||||
<>
|
||||
{intlConfig?.recent || 'Tab 3'}
|
||||
</>
|
||||
),
|
||||
children: getChildren()
|
||||
key: SessionType.RECENT,
|
||||
label: intlConfig?.recent || 'Tab 3',
|
||||
children: getChildren(sessions?.recent)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Loader
|
||||
isLoading={loading}
|
||||
errorData={errorData}
|
||||
refresh={fetchData}
|
||||
>
|
||||
<div className="tabs-session">
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
|
@ -130,6 +158,6 @@ export const SessionsTabs = ({ intlConfig }: { intlConfig: Record<string, string
|
|||
))}
|
||||
</div>
|
||||
{tabs[activeTab].children}
|
||||
</>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export { AccountMenu } from './AccountMenu';
|
||||
export { SessionsTabs } from './SessionsTabs';
|
||||
export { ProfileSettings } from './ProfileSettings';
|
||||
|
|
|
@ -1,74 +1,53 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRouter } from '../../navigation';
|
||||
import { AdditionalFilter } from '../../types/experts';
|
||||
import { LOCALES } from '../../constants/locale';
|
||||
import { getObjectByFilter, getObjectByAdditionalFilter } from '../../utils/filter';
|
||||
import { CustomInput, CustomSelect, CustomMultiSelect } from '../view';
|
||||
import { CustomInput, CustomSelect } from '../view';
|
||||
|
||||
type ExpertAdditionalFilterProps = {
|
||||
searchPlaceholder: string;
|
||||
sortLabel: string;
|
||||
langLabel: string;
|
||||
buttonFind: string;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
export const ExpertsAdditionalFilter = ({
|
||||
searchPlaceholder,
|
||||
sortLabel,
|
||||
langLabel,
|
||||
buttonFind,
|
||||
basePath,
|
||||
}: ExpertAdditionalFilterProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<AdditionalFilter | undefined>(getObjectByAdditionalFilter(searchParams));
|
||||
|
||||
const onChangeInput = useCallback((e: any) => {
|
||||
if (e?.target?.value) {
|
||||
setFilter({
|
||||
...filter,
|
||||
text: e.target.value
|
||||
});
|
||||
} else {
|
||||
if (filter?.text) {
|
||||
const newFilter = { ...filter };
|
||||
delete newFilter.text;
|
||||
const onChangeInput = useCallback(debounce((e: any) => {
|
||||
const newFilter: AdditionalFilter = { ...filter };
|
||||
|
||||
setFilter(newFilter);
|
||||
}
|
||||
if (e?.target?.value) {
|
||||
newFilter.text = e.target.value;
|
||||
} else {
|
||||
delete newFilter?.text;
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
setFilter(newFilter);
|
||||
}, 300), [filter]);
|
||||
|
||||
const onChangeSort = useCallback((value: string) => {
|
||||
const newFilter: AdditionalFilter = { ...filter };
|
||||
|
||||
if (value) {
|
||||
newFilter.sort = value;
|
||||
newFilter.coachSort = value;
|
||||
} else {
|
||||
delete newFilter?.sort;
|
||||
delete newFilter?.coachSort;
|
||||
}
|
||||
|
||||
setFilter(newFilter);
|
||||
}, [filter]);
|
||||
|
||||
const onChangeLang = useCallback((value: string[]) => {
|
||||
const newFilter: AdditionalFilter = { ...filter };
|
||||
|
||||
if (value.length > 0) {
|
||||
newFilter.language = value;
|
||||
} else {
|
||||
delete newFilter?.language;
|
||||
}
|
||||
|
||||
setFilter(newFilter);
|
||||
}, [filter]);
|
||||
|
||||
const goToFilterPage = useCallback(() => {
|
||||
useEffect(() => {
|
||||
router.push({
|
||||
pathname: basePath as any,
|
||||
query: {
|
||||
|
@ -76,7 +55,7 @@ export const ExpertsAdditionalFilter = ({
|
|||
...filter
|
||||
}
|
||||
})
|
||||
}, [filter, searchParams, router]);
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className="main-find__search">
|
||||
|
@ -85,35 +64,22 @@ export const ExpertsAdditionalFilter = ({
|
|||
placeholder={searchPlaceholder}
|
||||
defaultValue={filter?.text}
|
||||
onChange={onChangeInput}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className="main-find__search__sort">
|
||||
<CustomSelect
|
||||
label={sortLabel}
|
||||
value={filter?.sort}
|
||||
value={filter?.coachSort}
|
||||
onChange={onChangeSort}
|
||||
options={[
|
||||
{ value: 'byTop', label: 'By top views' },
|
||||
{ value: 'byPriceAsc', label: 'By price ascending' },
|
||||
{ value: 'byPriceDesc', label: 'By price descending' },
|
||||
{ value: 'byRating', label: 'By rating' }
|
||||
// { value: 'byTop', label: 'By top views' },
|
||||
{ value: 'byPriceAscending', label: 'By price ascending' },
|
||||
{ value: 'byPriceDescending', label: 'By price descending' }
|
||||
// { value: 'byRating', label: 'By rating' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="main-find__search__language">
|
||||
<CustomMultiSelect
|
||||
label={langLabel}
|
||||
value={filter?.language}
|
||||
onChange={onChangeLang}
|
||||
options={Object.entries(LOCALES).map(([ value, label ]) => ({ value, label }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="btn-apply"
|
||||
onClick={goToFilterPage}
|
||||
>
|
||||
{buttonFind}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getTagList } from '../../actions/tags';
|
||||
import { DEFAULT_PAGE_SIZE } from '../../constants/common';
|
||||
import { getFilter } from '../../utils/filter';
|
||||
import { getTagList, getLanguages } from '../../actions/tags';
|
||||
import { getExpertsList } from '../../actions/experts';
|
||||
import { ExpertsFilter } from './Filter';
|
||||
import { ExpertsAdditionalFilter } from './AdditionalFilter';
|
||||
|
@ -10,12 +11,14 @@ import { ExpertsList } from './ExpertsList';
|
|||
type ExpertsProps = {
|
||||
basePath?: string;
|
||||
locale: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export const Experts = async ({ basePath = '/', locale }: ExpertsProps) => {
|
||||
export const Experts = async ({ basePath = '/', locale, pageSize = DEFAULT_PAGE_SIZE }: ExpertsProps) => {
|
||||
const t = await getTranslations('Experts');
|
||||
const searchData = await getTagList(locale);
|
||||
const filter = getFilter(searchData);
|
||||
const languages = await getLanguages(locale);
|
||||
const filter = getFilter({ searchData, pageSize });
|
||||
const experts = await getExpertsList(filter, locale);
|
||||
|
||||
return (
|
||||
|
@ -23,6 +26,7 @@ export const Experts = async ({ basePath = '/', locale }: ExpertsProps) => {
|
|||
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
|
||||
<ExpertsFilter
|
||||
searchData={searchData}
|
||||
languages={languages}
|
||||
basePath={basePath}
|
||||
priceTitle={t('filter.price', { from: searchData?.sessionCostMin || 0, to: searchData?.sessionCostMax || 0 })}
|
||||
durationTitle={t('filter.duration', { from: searchData?.sessionDurationMin || 0, to: searchData?.sessionDurationMax || 0 })}
|
||||
|
@ -33,19 +37,19 @@ export const Experts = async ({ basePath = '/', locale }: ExpertsProps) => {
|
|||
<ExpertsAdditionalFilter
|
||||
searchPlaceholder={t('filter.search')}
|
||||
sortLabel={t('filter.sort')}
|
||||
langLabel={t('filter.language')}
|
||||
buttonFind={t('filter.find')}
|
||||
basePath={basePath}
|
||||
/>
|
||||
<ExpertsList
|
||||
locale={locale}
|
||||
data={experts}
|
||||
basePath={basePath}
|
||||
baseFilter={filter}
|
||||
priceTitle={t('list.price')}
|
||||
durationTitle={t('list.duration')}
|
||||
detailButton={t('list.details')}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,9 +6,9 @@ import { List, Tag } from 'antd';
|
|||
import { RightOutlined } from '@ant-design/icons';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import Image from 'next/image';
|
||||
import { Link } from '../../navigation';
|
||||
import { ExpertsData, Filter } from '../../types/experts';
|
||||
import { getObjectByFilter } from '../../utils/filter';
|
||||
import { Link, useRouter } from '../../navigation';
|
||||
import { ExpertsData, Filter, GeneralFilter } from '../../types/experts';
|
||||
import { getObjectByFilter, getObjectByAdditionalFilter } from '../../utils/filter';
|
||||
import { getExpertsList } from '../../actions/experts';
|
||||
import { CustomPagination, CustomSpin } from '../view';
|
||||
|
||||
|
@ -19,6 +19,8 @@ type ExpertListProps = {
|
|||
detailButton: string;
|
||||
locale: string;
|
||||
baseFilter: Filter;
|
||||
pageSize: number;
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
export const ExpertsList = ({
|
||||
|
@ -27,35 +29,66 @@ export const ExpertsList = ({
|
|||
durationTitle,
|
||||
detailButton,
|
||||
locale,
|
||||
baseFilter
|
||||
baseFilter,
|
||||
pageSize,
|
||||
basePath
|
||||
}: ExpertListProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const getTitle = (str: string, value?: any): string => (value ? str.replace('0', value) : str);
|
||||
const [experts, setExperts] = useState<ExpertsData | undefined>();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const filter = {
|
||||
...baseFilter,
|
||||
...getObjectByFilter(searchParams)
|
||||
...getObjectByFilter(searchParams),
|
||||
...getObjectByAdditionalFilter(searchParams)
|
||||
};
|
||||
|
||||
if (!isEqual(baseFilter, filter)) {
|
||||
setLoading(true);
|
||||
getExpertsList(filter, locale)
|
||||
.then((experts) => {
|
||||
setExperts(experts);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
})
|
||||
;
|
||||
} else {
|
||||
setLoading(false);
|
||||
setExperts(data);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return experts ? (
|
||||
const onChangePage = (page: number) => {
|
||||
const newFilter: GeneralFilter = {
|
||||
...getObjectByFilter(searchParams),
|
||||
...getObjectByAdditionalFilter(searchParams)
|
||||
};
|
||||
|
||||
if (page !== 1) {
|
||||
newFilter.page = page;
|
||||
} else {
|
||||
delete newFilter.page;
|
||||
}
|
||||
|
||||
router.push({
|
||||
pathname: basePath as any,
|
||||
query: newFilter
|
||||
});
|
||||
};
|
||||
|
||||
const currentPage = searchParams.has('page') ? Number(searchParams.get('page')) : baseFilter.page;
|
||||
|
||||
return !loading ? experts?.coaches && (
|
||||
<>
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
size="large"
|
||||
className="search-result"
|
||||
dataSource={experts}
|
||||
dataSource={experts.coaches}
|
||||
renderItem={(item) => (
|
||||
<List.Item key={item?.id} className="card-profile">
|
||||
<List.Item.Meta
|
||||
|
@ -67,11 +100,18 @@ export const ExpertsList = ({
|
|||
)}
|
||||
description={(
|
||||
<div className="card-profile__header__inner">
|
||||
<Link href={`/experts/${item?.id}` as any}>
|
||||
<div className="card-profile__header__name">{`${item.name} ${item?.surname || ''}`}</div>
|
||||
</Link>
|
||||
<div className="card-profile__header__price">
|
||||
{getTitle(priceTitle, item?.sessionCost)} <span>/ {getTitle(durationTitle, item?.sessionDuration)}</span>
|
||||
<div>
|
||||
<Link href={`/experts/${item?.id}` as any}>
|
||||
<div className="card-profile__header__name">{`${item.name} ${item?.surname || ''}`}</div>
|
||||
</Link>
|
||||
<div className="card-profile__header__price">
|
||||
{getTitle(priceTitle, item?.sessionCost)} <span>/ {getTitle(durationTitle, item?.sessionDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-profile__header__lang">
|
||||
{item?.coachLanguages?.map((lang) => (
|
||||
<Tag key={lang} className="skills__list__item">{lang}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -101,7 +141,13 @@ export const ExpertsList = ({
|
|||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<CustomPagination total={20} />
|
||||
{experts.total > pageSize && (
|
||||
<CustomPagination
|
||||
total={experts.total}
|
||||
pageSize={pageSize}
|
||||
onChange={onChangePage}
|
||||
current={currentPage}
|
||||
/>)}
|
||||
</>
|
||||
) : <CustomSpin />;
|
||||
};
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
'use client';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Button, List } from 'antd';
|
||||
import { useSearchParams, usePathname } from 'next/navigation';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Collapse, List } from 'antd';
|
||||
import type { CollapseProps } from 'antd';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from '../../navigation';
|
||||
import { Filter, GeneralFilter } from '../../types/experts';
|
||||
import { SearchData, Tag } from '../../types/tags';
|
||||
import { getObjectByFilter, getObjectByAdditionalFilter, getSearchParamsString } from '../../utils/filter';
|
||||
import { CustomSwitch, CustomSlider } from '../view';
|
||||
import { Filter } from '../../types/experts';
|
||||
import { Languages, SearchData, Tag } from '../../types/tags';
|
||||
import { getObjectByFilter, getObjectByAdditionalFilter } from '../../utils/filter';
|
||||
import { CustomSwitch, CustomSlider, CustomInput } from '../view';
|
||||
|
||||
type ExpertsFilterProps = {
|
||||
searchData?: SearchData;
|
||||
languages?: Languages;
|
||||
basePath: string;
|
||||
priceTitle: string;
|
||||
durationTitle: string;
|
||||
|
@ -19,6 +21,7 @@ type ExpertsFilterProps = {
|
|||
|
||||
export const ExpertsFilter = ({
|
||||
searchData,
|
||||
languages,
|
||||
basePath,
|
||||
priceTitle,
|
||||
durationTitle,
|
||||
|
@ -26,23 +29,43 @@ export const ExpertsFilter = ({
|
|||
}: ExpertsFilterProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [filter, setFilter] = useState<Filter | undefined>(getObjectByFilter(searchParams));
|
||||
const [openedTabs, setOpenedTabs] = useState<string[]>([]);
|
||||
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
|
||||
const [searchLang, setSearchLang] = useState<string>();
|
||||
const [searchTags, setSearchTags] = useState<string>();
|
||||
|
||||
const onChangeTags = useCallback((id: number, checked: boolean) => {
|
||||
let themesTagIds = filter?.themesTagIds || [];
|
||||
useEffect(() => {
|
||||
const tags = searchData?.themesGroups
|
||||
? searchData.themesGroups.reduce<Tag[]>((acc, item) => {
|
||||
if (item?.tags) {
|
||||
return [
|
||||
...acc,
|
||||
...item.tags.map((tag) => ({ ...tag, group: item.name }))
|
||||
]
|
||||
}
|
||||
|
||||
if (checked && !themesTagIds?.includes(id)) {
|
||||
themesTagIds.push(id);
|
||||
return acc;
|
||||
}, [])
|
||||
: [];
|
||||
|
||||
setFilteredTags(tags);
|
||||
}, [searchData?.themesGroups]);
|
||||
|
||||
const onChangeFilterArr = useCallback((field: string, id: number | string, checked: boolean) => {
|
||||
let arr = (filter && filter[field]) || [];
|
||||
|
||||
if (checked && !arr?.includes(id)) {
|
||||
arr.push(id);
|
||||
}
|
||||
|
||||
if (!checked && themesTagIds?.includes(id)) {
|
||||
themesTagIds = themesTagIds.filter((tId) => tId != id);
|
||||
if (!checked && arr?.includes(id)) {
|
||||
arr = arr.filter((tId) => tId != id);
|
||||
}
|
||||
|
||||
setFilter({
|
||||
...filter,
|
||||
themesTagIds
|
||||
...(filter || {}),
|
||||
[field]: arr
|
||||
});
|
||||
}, [filter, searchParams, searchData]);
|
||||
|
||||
|
@ -100,7 +123,19 @@ export const ExpertsFilter = ({
|
|||
})
|
||||
}, [filter, searchParams, searchData]);
|
||||
|
||||
const getList = useCallback((data: Tag[]) => (
|
||||
const getSelectedLanguage = useCallback((): string => {
|
||||
const cur = languages ? languages.filter(({ code }) => filter?.userLanguages ? filter.userLanguages.includes(code) : false) : [];
|
||||
|
||||
return cur ? cur.map(({ nativeSpelling }) => nativeSpelling).join(', ') : '';
|
||||
}, [filter, languages]);
|
||||
|
||||
const getSelectedTags = useCallback((): string => {
|
||||
const cur = filteredTags.filter(({ id }) => filter?.themesTagIds ? filter.themesTagIds.includes(id) : false);
|
||||
|
||||
return cur ? cur.map(({ name }) => name).join(', ') : '';
|
||||
}, [filter, filteredTags]);
|
||||
|
||||
const getList = useCallback((fieldName: string, data: { id: number | string, name: string }[]) => (
|
||||
<div className="b-filter__inner">
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
|
@ -113,8 +148,8 @@ export const ExpertsFilter = ({
|
|||
<div className="b-filter__item">
|
||||
<div className="b-filter__title">{name}</div>
|
||||
<CustomSwitch
|
||||
defaultChecked={filter?.themesTagIds?.includes(id) || false}
|
||||
onChange={(checked: boolean) => onChangeTags(id, checked)}
|
||||
defaultChecked={(filter && filter[fieldName]?.includes(id)) || false}
|
||||
onChange={(checked: boolean) => onChangeFilterArr(fieldName, id, checked)}
|
||||
/>
|
||||
</div>
|
||||
</List.Item>
|
||||
|
@ -123,35 +158,120 @@ export const ExpertsFilter = ({
|
|||
</div>
|
||||
), [filter, searchParams, searchData]);
|
||||
|
||||
const getLangList = () => {
|
||||
const reg = searchLang ? new RegExp(searchLang, 'ig') : '';
|
||||
const langList = reg ? (languages || []).filter(({ code, nativeSpelling }) => reg.test(code) || reg.test(nativeSpelling)) : languages;
|
||||
return langList?.length && getList('userLanguages', langList.map(({ code, nativeSpelling }) => ({ id: code, name: nativeSpelling })))
|
||||
};
|
||||
|
||||
const getTagsList = () => {
|
||||
const reg = searchTags ? new RegExp(searchTags, 'ig') : '';
|
||||
|
||||
if (reg) {
|
||||
const tagsList = filteredTags.filter(({ name, group }) => reg.test(name) || reg.test(group));
|
||||
return getList('themesTagIds', tagsList);
|
||||
}
|
||||
|
||||
return searchData?.themesGroups?.length ? searchData.themesGroups.map(({ id, name, tags }) => (
|
||||
<div key={id}>
|
||||
<h3 className="title-h5">{name}</h3>
|
||||
{getList('themesTagIds', tags)}
|
||||
</div>
|
||||
)) : null;
|
||||
};
|
||||
|
||||
const getCollapsedPanels: CollapseProps['items'] = [
|
||||
{
|
||||
key: 'userLanguages',
|
||||
label: (
|
||||
<>
|
||||
<div className="b-filter__collapsed__title">Session Language</div>
|
||||
{!openedTabs.includes('userLanguages') && filter?.userLanguages?.length > 0 && (
|
||||
<div className="b-filter__collapsed__desc">{getSelectedLanguage()}</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<CustomInput
|
||||
placeholder="Search"
|
||||
value={searchLang}
|
||||
onChange={(e) => setSearchLang(e.target?.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="b-filter__content">
|
||||
{getLangList()}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'themesTagIds',
|
||||
label: (
|
||||
<>
|
||||
<div className="b-filter__collapsed__title">Direction</div>
|
||||
{!openedTabs.includes('themesTagIds') && filter?.themesTagIds?.length > 0 && (
|
||||
<div className="b-filter__collapsed__desc">{getSelectedTags()}</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<CustomInput
|
||||
placeholder="Search"
|
||||
value={searchTags}
|
||||
onChange={(e) => setSearchTags(e.target?.value)}
|
||||
allowClear
|
||||
/>
|
||||
<div className="b-filter__content">{getTagsList()}</div>
|
||||
</>
|
||||
),
|
||||
}
|
||||
];
|
||||
|
||||
const onChangeTab = (keys: (string | string[])) => {
|
||||
if (Array.isArray(keys)) {
|
||||
setOpenedTabs(keys);
|
||||
if (!keys.includes('userLanguages') && searchLang) setSearchLang('');
|
||||
if (!keys.includes('themesTagIds') && searchTags) setSearchTags('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="b-filter">
|
||||
{searchData?.themesGroups?.length && searchData.themesGroups.map(({ id, name, tags }) => (
|
||||
<div key={id}>
|
||||
<h3 className="title-h3">{name}</h3>
|
||||
{getList(tags)}
|
||||
<Collapse
|
||||
ghost
|
||||
items={getCollapsedPanels}
|
||||
expandIconPosition="end"
|
||||
onChange={onChangeTab}
|
||||
/>
|
||||
<div className="b-filter__block">
|
||||
<h3 className="title-h3">Price</h3>
|
||||
<div className="b-filter__slider">
|
||||
<CustomSlider
|
||||
range
|
||||
step={1}
|
||||
defaultValue={[filter?.priceFrom || searchData?.sessionCostMin || 0, filter?.priceTo || searchData?.sessionCostMax || 0]}
|
||||
min={searchData?.sessionCostMin || 0}
|
||||
max={searchData?.sessionCostMax || 0}
|
||||
onChange={onChangePrice}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<h3 className="title-h3">{priceTitle}</h3>
|
||||
<div className="b-filter__slider">
|
||||
<CustomSlider
|
||||
range
|
||||
step={1}
|
||||
defaultValue={[filter?.priceFrom || searchData?.sessionCostMin || 0, filter?.priceTo || searchData?.sessionCostMax || 0]}
|
||||
min={searchData?.sessionCostMin || 0}
|
||||
max={searchData?.sessionCostMax || 0}
|
||||
onChange={onChangePrice}
|
||||
/>
|
||||
<div className="b-filter__description">{priceTitle}</div>
|
||||
</div>
|
||||
<h3 className="title-h3">{durationTitle}</h3>
|
||||
<div className="b-filter__slider">
|
||||
<CustomSlider
|
||||
range
|
||||
step={1}
|
||||
defaultValue={[filter?.durationFrom || searchData?.sessionDurationMin || 0, filter?.durationTo || searchData?.sessionDurationMax || 0]}
|
||||
min={searchData?.sessionDurationMin || 0}
|
||||
max={searchData?.sessionDurationMax || 0}
|
||||
onChange={onChangeDuration}
|
||||
/>
|
||||
<div className="b-filter__block">
|
||||
<h3 className="title-h3">Duration</h3>
|
||||
<div className="b-filter__slider">
|
||||
<CustomSlider
|
||||
range
|
||||
step={1}
|
||||
defaultValue={[filter?.durationFrom || searchData?.sessionDurationMin || 0, filter?.durationTo || searchData?.sessionDurationMax || 0]}
|
||||
min={searchData?.sessionDurationMin || 0}
|
||||
max={searchData?.sessionDurationMax || 0}
|
||||
onChange={onChangeDuration}
|
||||
/>
|
||||
</div>
|
||||
<div className="b-filter__description">{durationTitle}</div>
|
||||
</div>
|
||||
<Button
|
||||
className="btn-apply"
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import React, { FC, useState } from 'react';
|
||||
import { Form, FormInstance, notification } from 'antd';
|
||||
import Image from 'next/image';
|
||||
import { AUTH_USER } from '../../../constants/common';
|
||||
import { getAuth } from '../../../actions/auth';
|
||||
import { getPersonalData } from '../../../actions/profile';
|
||||
import { CustomInput, CustomInputPassword, FilledButton, OutlinedButton, LinkButton } from '../../view';
|
||||
|
||||
type EnterProps = {
|
||||
|
@ -28,8 +30,12 @@ export const EnterContent: FC<EnterProps> = ({
|
|||
getAuth(locale, { login, password })
|
||||
.then(({ data }) => {
|
||||
if (data.jwtToken) {
|
||||
updateToken(data.jwtToken);
|
||||
handleCancel();
|
||||
getPersonalData(locale, data.jwtToken)
|
||||
.then(({ data: profile }) => {
|
||||
localStorage.setItem(AUTH_USER, profile.id.toString());
|
||||
updateToken(data.jwtToken);
|
||||
handleCancel();
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -46,7 +52,7 @@ export const EnterContent: FC<EnterProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Form form={form} autoComplete="off" style={{ display: 'flex', gap: 16, flexDirection: 'column' }}>
|
||||
<Form form={form} style={{ display: 'flex', gap: 16, flexDirection: 'column' }}>
|
||||
<Form.Item
|
||||
name="login"
|
||||
noStyle
|
||||
|
@ -78,6 +84,8 @@ export const EnterContent: FC<EnterProps> = ({
|
|||
<CustomInputPassword
|
||||
size="small"
|
||||
placeholder="Password"
|
||||
autoComplete="off"
|
||||
onPressEnter={onLogin}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { FC, useState } from 'react';
|
||||
import { Form, FormInstance, notification } from 'antd';
|
||||
import { AUTH_USER } from '../../../constants/common';
|
||||
import { getRegister } from '../../../actions/auth';
|
||||
import { setPersonData } from '../../../actions/profile';
|
||||
import { CustomInput, CustomInputPassword, FilledButton, OutlinedButton } from '../../view';
|
||||
|
@ -28,15 +29,16 @@ export const RegisterContent: FC<RegisterProps> = ({
|
|||
getRegister(locale)
|
||||
.then(({ data }) => {
|
||||
if (data.jwtToken) {
|
||||
setPersonData( { login, password, role: 'client' }, locale, data.jwtToken)
|
||||
.then(() => {
|
||||
setPersonData( { login, password, role: 'client', languagesLinks: [] }, locale, data.jwtToken)
|
||||
.then(({ data: profile }) => {
|
||||
updateToken(data.jwtToken);
|
||||
localStorage.setItem(AUTH_USER, profile.userData.id.toString());
|
||||
handleCancel();
|
||||
})
|
||||
.catch((error) => {
|
||||
notification.error({
|
||||
message: 'Error',
|
||||
description: error?.response?.data?.errMessage
|
||||
description: error?.response?.data?.errors?.toString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
'use client'
|
||||
|
||||
export * from './FinishContent';
|
||||
export * from './ResetContent';
|
||||
export * from './EnterContent';
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export const BackButton = ({ className, text }: { className?: string, text: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -8,8 +8,13 @@ const Input = styled(AntdInput)`
|
|||
border: 1px solid #F8F8F7 !important;
|
||||
border-radius: 8px !important;
|
||||
color: #000 !important;
|
||||
align-items: center;
|
||||
|
||||
&:focus, &:hover {
|
||||
input {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
&:focus, &:hover, &:focus-within {
|
||||
border-color: #66A5AD !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
|
|
@ -4,8 +4,6 @@ import React from 'react';
|
|||
import styled from 'styled-components';
|
||||
import { Pagination as AntdPagination, PaginationProps } from 'antd';
|
||||
|
||||
const DEFAULT_SIZE = 5;
|
||||
|
||||
const Pagination = styled(AntdPagination)`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
@ -52,7 +50,6 @@ export const CustomPagination = (props: PaginationProps) => (
|
|||
<Pagination
|
||||
itemRender={itemRender}
|
||||
defaultCurrent={1}
|
||||
defaultPageSize={DEFAULT_SIZE}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -38,7 +38,7 @@ const Slider = styled(AntdSlider)`
|
|||
|
||||
&:focus, &:hover {
|
||||
&::after {
|
||||
box-shadow: 0 0 0 10px rgba(102, 165, 173, .2) !important;
|
||||
box-shadow: 0 0 0 12px rgba(102, 165, 173, .2) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import React, { FC, ReactNode } from 'react';
|
||||
import { CustomSpin } from './CustomSpin';
|
||||
import { WithError } from './WithError';
|
||||
|
||||
type LoaderProps = {
|
||||
children?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
errorData?: any;
|
||||
refresh?: () => void;
|
||||
};
|
||||
|
||||
export const Loader: FC<LoaderProps> = ({
|
||||
children,
|
||||
isLoading,
|
||||
errorData,
|
||||
refresh
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return <CustomSpin />
|
||||
}
|
||||
|
||||
return (
|
||||
<WithError
|
||||
errorData={errorData}
|
||||
refresh={refresh}
|
||||
>
|
||||
{children}
|
||||
</WithError>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import React, { FC, ReactNode } from 'react';
|
||||
import { Button, Result } from 'antd';
|
||||
|
||||
type WithErrorProps = {
|
||||
children?: ReactNode;
|
||||
errorData?: any;
|
||||
refresh?: () => void;
|
||||
};
|
||||
|
||||
export const WithError: FC<WithErrorProps> = ({
|
||||
children,
|
||||
errorData,
|
||||
refresh
|
||||
}) => {
|
||||
if (errorData) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Submission Failed"
|
||||
subTitle="Please check and modify the following information before resubmitting."
|
||||
extra={refresh ? (
|
||||
<Button type="primary" onClick={refresh}>
|
||||
Refresh page
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
|
@ -12,3 +12,6 @@ export * from './CustomSpin';
|
|||
export * from './FilledButton';
|
||||
export * from './OutlinedButton';
|
||||
export * from './LinkButton';
|
||||
export * from './BackButton';
|
||||
export * from './WithError';
|
||||
export * from './Loader';
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
export const BASE_URL = process.env.NEXT_PUBLIC_SERVER_BASE_URL || 'https://api.bbuddy.expert/api';
|
||||
export const AUTH_TOKEN_KEY = 'bbuddy_token';
|
||||
export const AUTH_USER = 'bbuddy_auth_user';
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 5;
|
||||
export const DEFAULT_PAGE = 1;
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
function getStorageValue(key: string, defaultValue: any) {
|
||||
function getStorageValue (key: string, defaultValue: any) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(key);
|
||||
return saved || defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export function deleteStorageKey(key: string) {
|
||||
export function deleteStorageKey (key: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export default {
|
||||
accountMenu: {
|
||||
sessions: 'Upcoming & Recent Sessions',
|
||||
notifications: 'Notification',
|
||||
support: 'Help & Support',
|
||||
information: 'Legal Information',
|
||||
settings: 'Profile Settings',
|
||||
messages: 'Messages',
|
||||
'work-with-us': 'Work With Us'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { Locale } from '../types/locale';
|
||||
import en from './en';
|
||||
import ru from './ru';
|
||||
|
||||
const MESSAGES = {
|
||||
[Locale.en]: en,
|
||||
[Locale.ru]: ru,
|
||||
[Locale.de]: en,
|
||||
[Locale.fr]: en,
|
||||
[Locale.it]: en,
|
||||
[Locale.es]: en
|
||||
};
|
||||
|
||||
const getValue = (keys: string[], dictionary: any) => {
|
||||
const keysClone = [...keys];
|
||||
const firstElement = keysClone.shift();
|
||||
|
||||
if (firstElement && dictionary[firstElement]) {
|
||||
return typeof dictionary[firstElement] === 'string' ? dictionary[firstElement] : getValue(keysClone, dictionary[firstElement]);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const i18nText = (key: string, locale?: string): string => getValue(key.split('.'), MESSAGES[locale || Locale.en]);
|
|
@ -0,0 +1,11 @@
|
|||
export default {
|
||||
accountMenu: {
|
||||
sessions: 'Upcoming & Recent Sessions',
|
||||
notifications: 'Notification',
|
||||
support: 'Help & Support',
|
||||
information: 'Legal Information',
|
||||
settings: 'Profile Settings',
|
||||
messages: 'Messages',
|
||||
'work-with-us': 'Work With Us'
|
||||
}
|
||||
}
|
|
@ -155,7 +155,7 @@ a {
|
|||
}
|
||||
|
||||
&__link {
|
||||
color: #FF8A00;
|
||||
color: #FF8A00 !important;
|
||||
@include rem(15);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
@ -295,12 +295,84 @@ a {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title-h5 {
|
||||
color: #003B46;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.title-h3 {
|
||||
color: #003B46;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: 1px solid #C4DFE6;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 16px 0 !important;
|
||||
align-items: center !important;
|
||||
|
||||
.ant-collapse-expand-icon {
|
||||
height: 32px !important;
|
||||
width: 32px;
|
||||
padding-inline-start: 0 !important;
|
||||
justify-content: center;
|
||||
color: #2C7873;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
height: 265px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
padding-right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
// scrollbar-width: thin;
|
||||
// scrollbar-color: #2C7873 #C4DFE6;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #C4DFE6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #2C7873;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__range {
|
||||
|
@ -320,7 +392,7 @@ a {
|
|||
&__slider {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin: 8px 0 16px -5px;
|
||||
margin: 0 0 0 -5px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
|
@ -331,6 +403,19 @@ a {
|
|||
line-height: 160%;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__collapsed {
|
||||
&__title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 32px;
|
||||
color: #003B46;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: #6FB98F;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base-btn {
|
||||
|
@ -510,6 +595,12 @@ a {
|
|||
border-block-end: 1px solid #C4DFE6 !important;
|
||||
margin: 0 0 16px !important;
|
||||
|
||||
&.session {
|
||||
&__item {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
padding-bottom: 8px;
|
||||
|
@ -518,6 +609,10 @@ a {
|
|||
align-self: stretch;
|
||||
margin-block-end: 0 !important;
|
||||
|
||||
.ant-list-item-meta-avatar {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
&__portrait {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
|
@ -538,10 +633,20 @@ a {
|
|||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1 0 0;
|
||||
justify-content: space-between;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
&:first-child {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
.page-error {
|
||||
position: relative;
|
||||
padding: 88px 0;
|
||||
|
||||
.b-inner {
|
||||
display: flex;
|
||||
gap: 50px;
|
||||
justify-content: center;
|
||||
|
||||
& > div {
|
||||
flex-basis: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
&__image {
|
||||
img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-size: 180px;
|
||||
line-height: 180px;
|
||||
font-weight: bold;
|
||||
color: #c4c4c4;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: #6FB98F;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.b-inner {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
}
|
||||
}
|
|
@ -529,6 +529,24 @@
|
|||
padding-right: 8px;
|
||||
position: relative;
|
||||
|
||||
&__block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 0 0 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #C4DFE6;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: #6FB98F;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
&:before {
|
||||
content: "";
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
@import "_header.scss";
|
||||
@import "_menu-mobile.scss";
|
||||
@import "_main.scss";
|
||||
@import "_error.scss";
|
||||
@import "_bb-experts.scss";
|
||||
@import "_bb-clients.scss";
|
||||
@import "_faq.scss";
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { Tag } from './tags';
|
||||
|
||||
// export type Filter = Record<string, any>;
|
||||
import {Tag, ThemeGroups} from './tags';
|
||||
|
||||
export type GeneralFilter = Filter & AdditionalFilter;
|
||||
|
||||
|
@ -10,12 +8,15 @@ export type Filter = {
|
|||
priceTo?: number | null;
|
||||
durationFrom?: number | null;
|
||||
durationTo?: number | null;
|
||||
userLanguages?: string[];
|
||||
pageSize?: number;
|
||||
page?: number;
|
||||
tagLanguage?: string;
|
||||
};
|
||||
|
||||
export type AdditionalFilter = {
|
||||
text?: string;
|
||||
sort?: string;
|
||||
language?: string[];
|
||||
coachSort?: string;
|
||||
};
|
||||
|
||||
export type File = {
|
||||
|
@ -83,9 +84,18 @@ export interface ExpertItem {
|
|||
speciality?: string;
|
||||
specialityDesc?: string;
|
||||
description?: string;
|
||||
coachLanguages?: string[]
|
||||
}
|
||||
|
||||
export type ExpertsData = ExpertItem[];
|
||||
export type ExpertsData = {
|
||||
coaches: ExpertItem[];
|
||||
themesGroups?: ThemeGroups[],
|
||||
sessionCostMin?: number;
|
||||
sessionCostMax?: number;
|
||||
sessionDurationMin?: number;
|
||||
sessionDurationMax?: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ExpertDetails = {
|
||||
publicCoachDetails: ExpertItem & {
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
export type Profile = {
|
||||
id: number;
|
||||
username?: string;
|
||||
surname?: string;
|
||||
fillProgress?: string;
|
||||
faceImageUrl?: string;
|
||||
role?: string;
|
||||
login?: string;
|
||||
hasPassword?: boolean;
|
||||
hasExternalLogin?: boolean;
|
||||
isTestMode?: boolean;
|
||||
};
|
|
@ -0,0 +1,57 @@
|
|||
type User = {
|
||||
id: number;
|
||||
login?: string;
|
||||
name?: string;
|
||||
surname?: string;
|
||||
faceImageUrl?: string;
|
||||
};
|
||||
|
||||
type SessionTag = {
|
||||
id: number;
|
||||
groupId?: number;
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
isSelected?: boolean;
|
||||
canDeleted?: boolean;
|
||||
};
|
||||
|
||||
export type SessionsFilter = {
|
||||
tags?: (number | string)[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
id: number;
|
||||
scheduledStartAtUtc?: string;
|
||||
scheduledEndAtUtc?: string;
|
||||
state?: string;
|
||||
clientComment?: string;
|
||||
secret?: string;
|
||||
cost?: number;
|
||||
themesTagName?: string;
|
||||
totalDuration?: number;
|
||||
type?: string;
|
||||
maxClients: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
isNeedSupervisor?: boolean;
|
||||
supervisorComment?: string;
|
||||
user?: User;
|
||||
coach?: User;
|
||||
supervisor?: User;
|
||||
clients?: User[];
|
||||
themesTags?: SessionTag[]
|
||||
};
|
||||
|
||||
export enum SessionType {
|
||||
UPCOMING = 'upcoming',
|
||||
REQUESTED = 'requested',
|
||||
RECENT = 'recent'
|
||||
}
|
||||
|
||||
export type Sessions = {
|
||||
[SessionType.UPCOMING]?: Session[];
|
||||
[SessionType.REQUESTED]?: Session[];
|
||||
[SessionType.RECENT]?: Session[];
|
||||
};
|
|
@ -19,3 +19,11 @@ export type SearchData = {
|
|||
sessionDurationMin: number;
|
||||
sessionDurationMax: number;
|
||||
};
|
||||
|
||||
export type Language = {
|
||||
id: number;
|
||||
code: string;
|
||||
nativeSpelling: string;
|
||||
}
|
||||
|
||||
export type Languages = Language[];
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { SearchData } from '../types/tags';
|
||||
import { AdditionalFilter, Filter, GeneralFilter } from '../types/experts';
|
||||
import { DEFAULT_PAGE } from '../constants/common';
|
||||
|
||||
export const getDefaultFilter = (searchData: SearchData | null): Filter => {
|
||||
export const getDefaultFilter = (searchData: SearchData | null, pageSize?: number): Filter => {
|
||||
const themesTagIds = searchData?.themesGroups?.reduce<number[]>((result, { tags }) => {
|
||||
const t = tags?.map(({ id }) => id) || [];
|
||||
|
||||
|
@ -16,15 +17,23 @@ export const getDefaultFilter = (searchData: SearchData | null): Filter => {
|
|||
priceFrom: null,
|
||||
priceTo: null,
|
||||
durationFrom: null,
|
||||
durationTo: null
|
||||
durationTo: null,
|
||||
pageSize,
|
||||
page: DEFAULT_PAGE
|
||||
} as Filter;
|
||||
};
|
||||
|
||||
export const getFilter = (searchData: SearchData | null, searchParams?: { [key: string]: string | string[] | undefined }): Filter => {
|
||||
const filter = getDefaultFilter(searchData);
|
||||
type FilterProps = {
|
||||
searchData: SearchData | null,
|
||||
pageSize?: number,
|
||||
searchParams?: { [key: string]: string | string[] | undefined }
|
||||
}
|
||||
|
||||
export const getFilter = ({ searchData, searchParams, pageSize }: FilterProps): Filter => {
|
||||
const filter = getDefaultFilter(searchData, pageSize);
|
||||
|
||||
if (searchParams) {
|
||||
const { themesTagIds, priceFrom, priceTo, durationFrom, durationTo } = searchParams;
|
||||
const { themesTagIds, priceFrom, priceTo, durationFrom, durationTo, page, pageSize } = searchParams;
|
||||
|
||||
if (themesTagIds) {
|
||||
if (Array.isArray(themesTagIds) && themesTagIds?.length > 0) {
|
||||
|
@ -51,20 +60,33 @@ export const getFilter = (searchData: SearchData | null, searchParams?: { [key:
|
|||
if (durationTo) {
|
||||
filter.durationTo = +durationTo;
|
||||
}
|
||||
|
||||
if (page) {
|
||||
filter.page = +page;
|
||||
}
|
||||
|
||||
if (pageSize) {
|
||||
filter.pageSize = +pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
export const getObjectByFilter = (searchParams?: any): Filter | undefined => {
|
||||
export const getObjectByFilter = (searchParams?: any): Filter => {
|
||||
const filter: Filter = {};
|
||||
|
||||
let tags = searchParams?.getAll('themesTagIds');
|
||||
let languages = searchParams?.getAll('userLanguages');
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
filter.themesTagIds = tags.map((id: string) => Number(id));
|
||||
}
|
||||
|
||||
if (languages && languages.length > 0) {
|
||||
filter.userLanguages = languages.map((code: string) => code);
|
||||
}
|
||||
|
||||
if (searchParams?.has('priceFrom')) {
|
||||
filter.priceFrom = Number(searchParams.get('priceFrom'));
|
||||
}
|
||||
|
@ -81,6 +103,10 @@ export const getObjectByFilter = (searchParams?: any): Filter | undefined => {
|
|||
filter.durationTo = Number(searchParams.get('durationTo'));
|
||||
}
|
||||
|
||||
if (searchParams?.has('page')) {
|
||||
filter.page = Number(searchParams.get('page'));
|
||||
}
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
|
@ -91,12 +117,8 @@ export const getObjectByAdditionalFilter = (searchParams?: any): AdditionalFilte
|
|||
additionalFilter.text = searchParams.get('text');
|
||||
}
|
||||
|
||||
if (searchParams?.has('sort')) {
|
||||
additionalFilter.sort = searchParams.get('sort');
|
||||
}
|
||||
|
||||
if (searchParams?.has('language')) {
|
||||
additionalFilter.language = searchParams.getAll('language');
|
||||
if (searchParams?.has('coachSort')) {
|
||||
additionalFilter.coachSort = searchParams.get('coachSort');
|
||||
}
|
||||
|
||||
return additionalFilter;
|
||||
|
|
Loading…
Reference in New Issue