Compare commits

..

7 Commits
master ... chat

Author SHA1 Message Date
SD 87b14e8716 fix: fix styles 2024-12-20 18:53:05 +04:00
SD 52fba3a879 merge 2024-12-13 21:29:56 +04:00
SD dbd5eaa014 0.2.5 2024-12-05 20:50:54 +04:00
SD 3ab06523cc feat: update mobile assetlinks 2024-12-05 20:31:55 +04:00
SD a13eac0ac4 0.2.4 2024-11-28 18:27:48 +04:00
SD 79a133c3ca feat: add supervisor report modal 2024-11-28 18:27:16 +04:00
dzfelix 61de5c81e7 char implementation 2024-10-29 16:53:53 +03:00
33 changed files with 1381 additions and 559 deletions

1
.env
View File

@ -7,3 +7,4 @@ STRIPE_PAYMENT_DESCRIPTION='BBuddy services'
NEXT_PUBLIC_CONTENTFUL_SPACE_ID = voxpxjq7y7vf
NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN = s99GWKfpDKkNwiEJ3pN7US_tmqsGvDlaex-sOJwpzuc
NEXT_PUBLIC_CONTENTFUL_PREVIEW_ACCESS_TOKEN = Z9WOKpLDbKNj7xVOmT_VXYNLH0AZwISFvQsq0PQlHfE

861
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "bbuddy-ui",
"version": "0.2.3",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "next dev -p 4200",
@ -13,6 +13,7 @@
"@ant-design/icons": "^5.2.6",
"@ant-design/nextjs-registry": "^1.0.0",
"@contentful/rich-text-react-renderer": "^15.22.9",
"@microsoft/signalr": "^8.0.7",
"@stripe/react-stripe-js": "^2.7.3",
"@stripe/stripe-js": "^4.1.0",
"agora-rtc-react": "2.1.0",
@ -28,6 +29,7 @@
"next-intl": "^3.3.1",
"react": "^18",
"react-dom": "^18",
"react-signalr": "^0.2.24",
"react-slick": "^0.29.0",
"react-stripe-js": "^1.1.5",
"slick-carousel": "^1.8.1",

View File

@ -6,6 +6,7 @@
"package_name": "com.bbuddy.whistle",
"sha256_cert_fingerprints": [
"87:A2:49:9A:F4:05:9C:06:3C:3D:F3:10:88:F5:49:6D:5F:F2:BC:1E:90:0D:F2:37:A5:BA:37:19:5C:A3:75:C2",
"D0:28:97:E7:64:5D:ED:8D:7F:F1:41:B2:E8:F6:AB:7B:EE:FB:A3:1A:A2:D7:92:D4:C5:41:9A:3C:47:CE:EB:43",
"86:42:FE:EA:44:22:9D:16:7F:FC:70:92:A6:39:9D:B1:C3:F1:DE:21:32:4A:45:8C:07:98:39:55:AF:47:32:66"
]
}

View File

@ -0,0 +1,16 @@
import {apiRequest} from "../helpers";
export const getChatList = (locale: string, token: string): Promise<any> => apiRequest({
url: '/chat/chatList',
method: 'get',
locale,
token
});
export const getChatMessages = (locale: string, token: string, group: number): Promise<any> => apiRequest({
url: '/chat/chat_messages/'+group,
method: 'get',
locale,
token
});

View File

@ -23,22 +23,22 @@ export const apiRequest = async <T = any, K = any>(
return response.data;
} catch (err) {
// const {
// response: {
// status: responseCode = null,
// statusText = '',
// data: { message = '', status: errorKey = '' } = {},
// } = {},
// code: statusCode = '',
// } = err as AxiosError;
//
// throw new Error(
// JSON.stringify({
// statusCode,
// statusMessage: message || statusText,
// responseCode,
// errorKey,
// }),
// );
const {
response: {
status: responseCode = null,
statusText = '',
data,
} = {},
code: statusCode = '',
} = err as AxiosError;
throw new Error(
JSON.stringify({
statusCode,
statusMessage: statusText,
responseCode,
details: data
}),
);
}
};

View File

@ -1,16 +1,18 @@
'use client'
import { useCallback, useEffect, useState } from 'react';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { Room } from '../../types/rooms';
import { getRoomDetails } from '../rooms';
import {useCallback, useEffect, useState} from 'react';
import {useLocalStorage} from '../../hooks/useLocalStorage';
import {AUTH_TOKEN_KEY} from '../../constants/common';
import {Room} from '../../types/rooms';
import {getRoomDetails} from '../rooms';
import {SessionState} from "../../types/sessions";
export const useRoomDetails = (locale: string, roomId: number) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [room, setRoom] = useState<Room>();
const [errorData, setErrorData] = useState<any>();
const [loading, setLoading] = useState<boolean>(false);
const [isStarted, setIsStarted] = useState(false);
const fetchData = useCallback(() => {
setLoading(true);
@ -33,10 +35,17 @@ export const useRoomDetails = (locale: string, roomId: number) => {
fetchData();
}, []);
useEffect(() => {
if (room?.state === SessionState.STARTED) {
setIsStarted(true);
}
}, [room?.state])
return {
fetchData,
loading,
room,
errorData
errorData,
isStarted
};
};

View File

@ -1,5 +1,5 @@
import { apiRequest } from './helpers';
import {GetUsersForRooms, Room, RoomEdit, RoomEditDTO} from '../types/rooms';
import {GetUsersForRooms, Report, ReportData, Room, RoomEdit, RoomEditDTO} from '../types/rooms';
export const getUpcomingRooms = (locale: string, token: string): Promise<Room[]> => apiRequest({
url: '/home/upcomingsessionsall',
@ -107,3 +107,19 @@ export const getRoomById = (locale: string, token: string, id: number): Promise<
locale,
token
});
// report
export const getReport = (locale: string, token: string, id: number): Promise<Report[]> => apiRequest({
url: `/home/getsessionsupervisorscores?sessionId=${id}`,
method: 'post',
locale,
token
});
export const saveReport = (locale: string, token: string, data: ReportData): Promise<any> => apiRequest({
url: '/home/setsessionsupervisorscores',
method: 'post',
data,
locale,
token
});

View File

@ -1,8 +1,9 @@
'use client'
import React from 'react';
import { unstable_setRequestLocale } from 'next-intl/server';
import { Link } from '../../../../../../navigation';
import { i18nText } from '../../../../../../i18nKeys';
import {ChatMessages} from "../../../../../../components/Chat/ChatMessages";
/*
export function generateStaticParams({
params: { locale },
}: { params: { locale: string } }) {
@ -15,56 +16,22 @@ export function generateStaticParams({
return result;
}
*
*/
export default function Message({ params }: { params: { locale: string, textId: string } }) {
unstable_setRequestLocale(params.locale);
export default function Message({ params: { locale, textId } }: { params: { locale: string, textId: string } }) {
return (
<>
<ol className="breadcrumb">
<li className="breadcrumb-item">
<Link href={'/account/messages' as any}>
{i18nText('accountMenu.messages', params.locale)}
{i18nText('accountMenu.messages', locale)}
</Link>
</li>
<li className="breadcrumb-item active" aria-current="page">{`Person ${params.textId}`}</li>
</ol>
<div className="b-message">
<div className="b-message__inner">
<div className="b-message__list b-message__list--me">
<div className="b-message__item ">
<div className="b-message__avatar">
<img src="/images/person.png" className="" alt="" />
</div>
<div className="b-message__text">
🤩 It all for you!
<span className="date">07.09.2022</span>
</div>
</div>
</div>
<div className="b-message__list">
<div className="b-message__item">
<div className="b-message__avatar">
<img src="/images/person.png" className="" alt="" />
</div>
<div className="b-message__text">
🤩 It all for you!
<span className="date">07.09.2022</span>
</div>
</div>
</div>
</div>
<form className="b-message__form" action="">
<textarea placeholder="Type your message here" name="" id="" />
<label className="b-message__upload-file">
<input type="file" required />
</label>
<div className="b-message__microphone" />
<button className="b-message__btn" type="submit" />
</form>
</div>
<ChatMessages locale={locale} groupId={parseInt(textId)} />
</>
);
};

View File

@ -1,12 +1,10 @@
'use client'
import React, { Suspense } from 'react';
import { unstable_setRequestLocale } from 'next-intl/server';
import { Link } from '../../../../../navigation';
import { CustomInput } from '../../../../../components/view/CustomInput';
import { i18nText } from '../../../../../i18nKeys';
import {ChatList} from "../../../../../components/Chat/ChatList";
export default function Messages({ params: { locale } }: { params: { locale: string } }) {
unstable_setRequestLocale(locale);
export default function Messages({ params: { locale } }: { params: { locale: string } }) {
return (
<>
<ol className="breadcrumb">
@ -15,74 +13,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
<Suspense>
<CustomInput placeholder={i18nText('name', locale)} />
</Suspense>
<div className="messages-session">
<Link
className="card-profile"
href={'messages/1' as any}
>
<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 style={{ width: '100%' }}>
<div className="card-profile__header__name">
David
<span className="count">14</span>
</div>
<div className="card-profile__header__title">
Lorem ipsum dolor sit at, consecte...
</div>
<div className="card-profile__header__date ">
25 may
</div>
</div>
</div>
</div>
</Link>
<Link
className="card-profile"
href={'messages/2' as any}
>
<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 style={{ width: '100%' }}>
<div className="card-profile__header__name">David</div>
<div className="card-profile__header__title">
Lorem ipsum dolor sit at, consecte...
</div>
<div className="card-profile__header__date ">
25 may
</div>
</div>
</div>
</div>
</Link>
<Link
className="card-profile"
href={'messages/3' as any}
>
<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 style={{ width: '100%' }}>
<div className="card-profile__header__name">David</div>
<div className="card-profile__header__title">
Lorem ipsum dolor sit at, consecte...
</div>
<div className="card-profile__header__date ">
25 may
</div>
</div>
</div>
</div>
</Link>
</div>
<ChatList locale={locale}/>
</>
);
};

View File

@ -4,21 +4,44 @@ import { Button } from 'antd';
import { useSelectedLayoutSegment, usePathname } from 'next/navigation';
import { Link } from '../../navigation';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../constants/common';
import { deleteStorageKey } from '../../hooks/useLocalStorage';
import {deleteStorageKey, useLocalStorage} from '../../hooks/useLocalStorage';
import { i18nText } from '../../i18nKeys';
import { getMenuConfig } from '../../utils/account';
import {useEffect, useState} from "react";
import {getChatList} from "../../actions/chat/groups";
export const AccountMenu = ({ locale }: { locale: string }) => {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment || '';
const paths = usePathname();
const menu: { path: string, title: string, count?: number }[] = getMenuConfig(locale);
const [counts, setCounts] = useState<any>({});
const onLogout = () => {
deleteStorageKey(AUTH_TOKEN_KEY);
deleteStorageKey(AUTH_USER);
window?.location?.replace(`/${paths.split('/')[1]}/`);
};
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
let init = false
useEffect(() => {
if (jwt && locale && !init) {
init = true;
getChatList(locale, jwt).then((payload: any)=> {
if (payload?.directs) {
let summ = 0;
payload?.directs.forEach((el: any) => {
summ = summ + el.newMessagesCount
})
setCounts({'messages': summ})
}
})
}
}, [jwt, locale])
const getterCount = (path: string, count: number)=> {
return counts[path]? counts[path] : count
}
return (
<ul className="list-sidebar">
@ -26,8 +49,8 @@ export const AccountMenu = ({ locale }: { locale: string }) => {
<li key={path} className="list-sidebar__item">
<Link href={`/account/${path}` as any} className={path === pathname ? 'active' : ''}>
{title}
{count ? (
<span className="count">{count}</span>
{getterCount(path, count) ? (
<span className="count">{getterCount(path, count)}</span>
) : null}
</Link>
</li>

View File

@ -1,6 +1,6 @@
'use client'
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { RoomsType } from '../../../types/rooms';
import { useSessionTracking } from '../../../actions/hooks/useSessionTracking';
import { AccountMenu } from '../AccountMenu';
@ -8,6 +8,9 @@ import { Loader } from '../../view/Loader';
import { RoomDetailsContent } from './RoomDetailsContent';
import { useRoomDetails } from '../../../actions/hooks/useRoomDetails';
import { AgoraClientGroup } from '../agora';
import { SupervisorReportModal } from '../../Modals/SupervisorReportModal';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { AUTH_USER } from '../../../constants/common';
type RoomDetailsProps = {
locale: string;
@ -16,9 +19,13 @@ type RoomDetailsProps = {
};
export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) => {
const { room, errorData, loading, fetchData } = useRoomDetails(locale, roomId);
const { room, errorData, loading, fetchData, isStarted } = useRoomDetails(locale, roomId);
const tracking = useSessionTracking(locale, roomId);
const [isCalling, setIsCalling] = useState<boolean>(false);
const [isOpenReport, setIsOpenReport] = useState<boolean>(false);
const [userData] = useLocalStorage(AUTH_USER, '');
const { id: userId = 0 } = userData ? JSON.parse(userData) : {};
const isSupervisor = room?.supervisor && room.supervisor.id === +userId || false;
useEffect(() => {
if (isCalling) {
@ -28,6 +35,12 @@ export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) =>
}
}, [isCalling]);
useEffect(() => {
if (isSupervisor && isStarted) {
setIsOpenReport(true);
}
}, [isStarted]);
const stopCalling = () => {
setIsCalling(false);
fetchData();
@ -60,6 +73,15 @@ export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) =>
/>
</Loader>
</div>
{isSupervisor && room?.id && (
<SupervisorReportModal
open={isOpenReport}
handleCancel={() => setIsOpenReport(false)}
locale={locale}
refresh={fetchData}
roomId={room.id}
/>
)}
</div>
</>
);

View File

@ -1,22 +1,23 @@
'use client'
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Button, notification, Tag } from 'antd';
import { DeleteOutlined, LeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import Image from 'next/image';
import { useRouter } from '../../../navigation';
import { Room, RoomsType } from '../../../types/rooms';
import { Report, Room, RoomsType } from '../../../types/rooms';
import { i18nText } from '../../../i18nKeys';
import { LinkButton } from '../../view/LinkButton';
import {
import {
addClient,
addSupervisor,
becomeRoomClient,
becomeRoomSupervisor,
deleteRoomClient,
deleteRoomSupervisor
} from '../../../actions/rooms';
deleteRoomSupervisor,
getReport
} from '../../../actions/rooms';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { UserListModal } from '../../Modals/UsersListModal';
@ -46,6 +47,16 @@ export const RoomDetailsContent = ({ room, startRoom, locale, activeType, refres
const isClient = room?.clients && room.clients.length > 0 && room.clients.map(({ id }) => id).includes(+userId) || false;
const isTimeBeforeStart = room?.scheduledStartAtUtc ? dayjs() < dayjs(room.scheduledStartAtUtc) : false;
const [isEdit, setIsEdit] = useState<boolean>(false);
const [report, setReport] = useState<Report[] | undefined>();
useEffect(() => {
if (room?.id && room?.supervisor && activeType === RoomsType.RECENT) {
getReport(locale, jwt, room.id)
.then((data) => {
setReport(data);
})
}
}, [room])
const goBack = () => router.push(`/account/rooms/${activeType}`);
@ -248,6 +259,17 @@ export const RoomDetailsContent = ({ room, startRoom, locale, activeType, refres
{room?.supervisorComment && (
<div className="card-detail__supervisor-comment">{room.supervisorComment}</div>
)}
{report && report.length > 0 && (
<div className="card-detail__report-list">
{report.map(({ key, score }) => (
<div key={key}>
<div>{i18nText(`room.rating_${key?.toLowerCase()}`, locale)}</div>
<div className="card-detail__report-list_divider" />
<div>{score || 0}</div>
</div>
))}
</div>
)}
</>
)}
{isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && isCreator && activeType === RoomsType.UPCOMING && (

View File

@ -0,0 +1,76 @@
'use client'
import React, {useEffect, useState} from 'react';
import {AUTH_TOKEN_KEY} from '../../constants/common';
import {getChatList, getChatMessages} from "../../actions/chat/groups";
import {useLocalStorage} from "../../hooks/useLocalStorage";
import {Link} from "../../navigation";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import {message} from "antd";
import {Loader} from "../view/Loader";
dayjs.extend(relativeTime);
type CompProps = {
locale: string;
};
export const ChatList = ({ locale }: CompProps) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [chats, setСhats] = useState<any | undefined>();
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
if (jwt) {
setLoading(true);
Promise.all([
getChatList(locale, jwt),
])
.then(([_groups]) => {
setСhats(_groups)
})
.catch((e) => {
console.log(e)
message.error('Не удалось загрузить данные');
})
.finally(() => {
setLoading(false);
})
}
},[jwt])
return (
<div className="messages-session">
<Loader isLoading={loading}>
{chats?.directs.map((item: any, i: number) => (
<Link
key={'chat'+i}
className="card-profile"
href={'messages/'+item.group.id as any}
>
<div className="card-profile__header">
<div className="card-profile__header__portrait">
<img src={item.faceImageUrl} className="" alt="" />
</div>
<div className="card-profile__header__inner">
<div style={{ width: '100%' }}>
<div className="card-profile__header__name">
{item.firstName}
{item.newMessagesCount && (
<span className="count">{item.newMessagesCount}</span>
)}
</div>
<div className="card-profile__header__title">
{item?.lastMessage?.text}
</div>
<div className="card-profile__header__date ">
{dayjs(item?.lastMessage?.sentAt).fromNow()}
</div>
</div>
</div>
</div>
</Link>
))}
</Loader>
</div>
)
}

View File

@ -0,0 +1,200 @@
'use client'
import React, {useEffect, useState} from 'react';
import {AUTH_TOKEN_KEY} from '../../constants/common';
import {getChatList, getChatMessages} from "../../actions/chat/groups";
import {useLocalStorage} from "../../hooks/useLocalStorage";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import {message} from "antd";
import {Loader} from "../view/Loader";
import SignalrConnection from "../../lib/signalr-connection";
import {CheckOutlined} from "@ant-design/icons";
dayjs.extend(relativeTime);
type CompProps = {
locale: string;
groupId: number
};
export const ChatMessages = ({ locale, groupId }: CompProps) => {
const { newMessage, joinChat, readMessages, addListener } = SignalrConnection();
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
//const messages = await getChatMessages(locale, jwt, groupId)
const [loading, setLoading] = useState<boolean>(false);
const [text, setText] = useState('');
const [me, setMe] = useState<any | undefined>();
const [notMe, setNotMe] = useState<any | undefined>();
const [messages, setMessages] = useState<any[]>([]);
useEffect(() => {
if (jwt) {
setLoading(true);
Promise.all([
getChatList(locale, jwt),
getChatMessages(locale, jwt, groupId),
])
.then(([_groups, _messages]) => {
//
const _group = _groups.directs.find( (el: any) => el.group.id === groupId)
if (_group.group.members[0].userId === parseInt(_group.userId)){
setMe(_group.group.members[0].user)
setNotMe(_group.group.members[1].user)
} else {
setMe(_group.group.members[1].user)
setNotMe(_group.group.members[0].user)
}
setMessages(_messages.messages);
})
.catch((e) => {
console.log(e)
message.error('Не удалось загрузить данные');
})
.finally(() => {
setLoading(false);
})
}
},[jwt])
const onConnected = (flag: boolean) =>{
if (flag) {
joinChat(groupId)
readUreaded()
}
}
const readUreaded = () => {
const msgs = [] as number[];
messages.forEach((message: any) => {
if (!message.seen && message.sentByMe === false){
msgs.push(message.id)
}
})
readMessages(msgs)
}
const onReceiveMessage = (payload: any) => {
const _messages = [... messages];
let flag = false;
const msg = {
data : payload.data,
dataType: null,
id: payload.id,
receiverId:null,
seen: false,
senderId: payload.creatorId,
sentAt: payload.createdUtc+'.000Z',
sentByMe: payload.creatorId === me.id,
text: payload.content,
type:"text"
}
_messages.forEach((item: any, i) => {
if (item.id === msg.id){
_messages[i] = msg
flag = true
}
})
//console.log(payload, flag)
if (flag){
setMessages([..._messages]);
} else {
setMessages([msg, ..._messages]);
}
readMessages([msg.id])
}
const onOpponentRead = (payload: any) => {
console.log('onOpponentRead', payload)
const _messages = [... messages] as any[];
_messages.forEach((item: any, i) => {
if (item.id === payload.messageId){
_messages[i].seen = true
}
})
setMessages([..._messages])
}
useEffect(() => {
addListener('onConnected', onConnected)
addListener('ReceiveMessage', onReceiveMessage)
addListener('MessageWasRead', onOpponentRead)
}, [messages, me, setMessages]);
const handleSendMessages = () => {
newMessage({
GroupId: groupId.toString(),
CreatorId: me.id,
Content: text
})
setText('')
}
const onEnterPress = (e: any) => {
if(e.keyCode == 13 && e.shiftKey == false) {
e.preventDefault();
handleSendMessages();
}
}
const handleChangeMessage = (e: any) =>{
const { value } = e.target;
e.preventDefault();
setText(value);
return true
}
const messageRender = (message: any, i:number) => {
const item = message.sentByMe ? me : notMe
const date = dayjs(message.sentAt).fromNow()
const imgSrc = item?.faceImage?.descriptor ? 'http://static.bbuddy.expert/' + item.faceImage.descriptor.split(':')[1] : ''
return (
<div
className={message.sentByMe ? 'b-message__list b-message__list--me' : 'b-message__list'}
key={'message'+i}
>
<div className="b-message__item ">
<div className="b-message__avatar">
{imgSrc && (<img src={imgSrc} className="" alt=""/>)}
</div>
<div className="b-message__text" style={{minWidth: '150px'}}>
{message.text}
<span className="date">
<span className="checks" style={{color: message.seen ? 'green' : 'blue'}}>
<CheckOutlined />
{ message?.seen && (<CheckOutlined style={{marginLeft: '-10px'}} />)}
</span>
{date}
</span>
</div>
</div>
</div>
)
}
return (
<div className="b-message">
<Loader isLoading={loading}>
<div className="b-message__inner">
{messages.map((el, i)=> (messageRender(el, i)))}
</div>
</Loader>
<div className="b-message__form">
<textarea placeholder="Type your message here" onKeyDown={onEnterPress}
onChange={handleChangeMessage} value={text}/>
<button className="b-message__btn" type="submit" onClick={handleSendMessages}/>
</div>
</div>
)
}

View File

@ -15,6 +15,9 @@ import { getStorageValue } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY, SESSION_DATA } from '../../constants/common';
import { ScheduleModal } from '../Modals/ScheduleModal';
import { ScheduleModalResult } from '../Modals/ScheduleModalResult';
import SignalrConnection from '../../lib/signalr-connection';
import { useRouter } from '../../navigation';
import { useLocalStorage } from '../../hooks/useLocalStorage';
type ExpertDetailsProps = {
expert: ExpertDetails;
@ -32,8 +35,33 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
const { publicCoachDetails } = expert || {};
const [showSchedulerModal, setShowSchedulerModal] = useState<boolean>(false);
const [mode, setMode] = useState<'data' | 'time' | 'pay' | 'finish'>('data');
const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0, coachLanguages = [] } } = expert || {};
const isRus = locale === Locale.ru;
const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0, coachLanguages = [] , id, botUserId} } = expert || {};
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const { joinChatPerson, addListener, closeConnection } = SignalrConnection();
const router = useRouter();
const onConnected = (flag: boolean)=>{
if (flag){
console.log('here');
}
}
useEffect(() => {
addListener('onConnected', onConnected);
document?.addEventListener('show_pay_form', handleShowPayForm);
return () => {
closeConnection();
document?.removeEventListener('show_pay_form', handleShowPayForm);
}
}, []);
const handleJoinChat = (id: number) => {
joinChatPerson(id).then((res: any) => {
router.push(`/account/messages/${res.id}` as string);
})
}
const checkSession = (data?: SignupSessionData) => {
if (data?.startAtUtc && data?.tagId) {
@ -44,7 +72,7 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
} else {
setShowSchedulerModal(false);
const showAuth = new Event('show_auth_enter');
document.dispatchEvent(showAuth);
document?.dispatchEvent(showAuth);
}
}
}
@ -54,13 +82,6 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
setMode('pay');
}
useEffect(() => {
document.addEventListener('show_pay_form', handleShowPayForm);
return () => {
document.removeEventListener('show_pay_form', handleShowPayForm);
};
}, []);
const onSchedulerHandle = () => {
setMode('data');
setShowSchedulerModal(true);
@ -86,18 +107,24 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
</div>
</div>
</div>
<div className="expert-card__wrap-btn">
<Button className="btn-apply" onClick={onSchedulerHandle}>
<img src="/images/calendar-outline.svg" className="" alt="" />
{i18nText('schedule', locale)}
</Button>
{/*
<a href="#" className="btn-video">
<img src="/images/videocam-outline.svg" className="" alt=""/>
Video
</a>
*/}
</div>
{jwt && (
<div className="expert-card__wrap-btn">
<Button className="btn-apply" onClick={() => handleJoinChat(id)}>
{i18nText('chat.join', locale)}
</Button>
{botUserId && (
<Button className="btn-apply" onClick={() => handleJoinChat(botUserId)}>
{i18nText('chat.joinAI', locale)}
</Button>
)}
{/*
<a href="#" className="btn-video">
<img src="/images/videocam-outline.svg" className="" alt=""/>
Video
</a>
*/}
</div>
)}
</div>
<div className="expert-info">
{/* <h2 className="title-h2">{}</h2> */}

View File

@ -0,0 +1,121 @@
'use client';
import React, { FC, useEffect, useRef, useState } from 'react';
import { Modal, Button, message, Input } from 'antd';
import { CloseOutlined, StarFilled } from '@ant-design/icons';
import { i18nText } from '../../i18nKeys';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { getReport, saveReport } from '../../actions/rooms';
import { Report, ReportData } from '../../types/rooms';
import { CustomRate } from '../view/CustomRate';
type SupervisorReportModalProps = {
open: boolean;
handleCancel: () => void;
locale: string;
refresh: () => void;
roomId: number;
};
export const SupervisorReportModal: FC<SupervisorReportModalProps> = ({
open,
handleCancel,
locale,
roomId,
refresh
}) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [loading, setLoading] = useState<boolean>(false);
const [report, setReport] = useState<Report[] | undefined>();
const reasonRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
getReport(locale, jwt, roomId)
.then((data) => {
setReport(data);
})
.catch(() => {
message.error('Не удалось получить отчет');
})
}, []);
const onSaveRate = () => {
const result: ReportData = {
sessionId: roomId,
sessionSupervisorScores: report || [],
supervisorComment: reasonRef?.current?.resizableTextArea?.textArea?.value || ''
};
setLoading(true);
saveReport(locale, jwt, result)
.then(() => {
handleCancel();
refresh();
})
.catch(() => {
message.error('Не удалось сохранить отчет');
})
.finally(() => {
setLoading(false);
})
}
const onChangeRate = (val: number, id: number) => {
setReport(report ? report.map((item) => {
if (item.evaluationCriteriaId === id) {
return {
...item,
score: val
};
}
return item;
}) : undefined);
}
return (
<Modal
className="b-modal"
open={open}
title={undefined}
onOk={undefined}
onCancel={handleCancel}
footer={false}
width={498}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: '#000' }}/>}
>
<div className="b-modal__report__content">
<div className="b-rate-list">
{report && report.length > 0 && report.map(({ key, evaluationCriteriaId, score }) => (
<div key={evaluationCriteriaId} className="b-rate-list__item">
<div className="b-rate-list__item_title">{i18nText(`room.rating_${key?.toLowerCase()}`, locale)}</div>
<CustomRate
defaultValue={score || 0}
character={<StarFilled style={{ fontSize: 32 }} />}
onChange={(val: number) => onChangeRate(val, evaluationCriteriaId)}
/>
</div>
))}
</div>
<div>
<Input.TextArea
ref={reasonRef}
className="b-textarea"
rows={1}
placeholder={i18nText('room.tellAboutReason', locale)}
/>
</div>
<div>
<Button
className="btn-apply"
onClick={onSaveRate}
loading={loading}
>
{i18nText('room.rate', locale)}
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Als Teilnehmer beitreten',
rapport: 'Rapport',
invite: 'Invite',
save: 'Raum speichern'
save: 'Raum speichern',
rate: 'Bewerten',
tellAboutReason: 'Sag uns, was passiert ist',
rating_raport: 'Rapport',
rating_position_and_presence: 'Position oder Präsenz eines Coaches',
rating_balance_and_frustration: 'Balance zwischen Unterstützung und Frustration',
rating_agreement: 'Erstellung einer Coaching-Vereinbarung (Sitzungsvertrag)',
rating_planning_and_goals: 'Planung und Zielsetzung',
rating_reality: 'Klärung der Realität',
rating_opportunities: 'Neue Möglichkeiten gefunden',
rating_action_plan: 'Es wurde ein Aktionsplan erstellt',
rating_motivation: 'Motivationsquellen gefunden',
rating_next_session_stretch: 'Es ist noch Zeit bis zur nächsten Sitzung',
rating_relationship: 'Aufbau einer vertrauensvollen Beziehung zum Klienten',
rating_listening: 'Tiefes, aktives Zuhören',
rating_questions: 'Verwendung „starker“ Fragen',
rating_communication: 'Direkte Kommunikation',
rating_awareness: 'Entwicklung und Stimulierung des Bewusstseins',
rating_progress: 'Fortschritts- und Verantwortungsmanagement'
},
agreementText: 'Folgendes habe ich gelesen und erkläre mich damit einverstanden: Benutzervereinbarung,',
userAgreement: 'Benutzervereinbarung',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Schreiben Sie Ihre Wünsche zur Sitzung',
successPayment: 'Erfolgreiche Zahlung',
errorPayment: 'Zahlungsfehler',
chat: {
join: 'Chat starten',
joinAI: 'Start AI chat'
},
errors: {
invalidEmail: 'Die E-Mail-Adresse ist ungültig',
emptyEmail: 'Bitte geben Sie Ihre E-Mail ein',

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Join as a participant',
rapport: 'Rapport',
invite: 'Invite',
save: 'Save room'
save: 'Save room',
rate: 'Rate',
tellAboutReason: 'Tell us what happened',
rating_raport: 'Rapport',
rating_position_and_presence: 'Coaching position or coaching presence',
rating_balance_and_frustration: 'Balance of support and frustration',
rating_agreement: 'Creating a coaching agreement (session contract)',
rating_planning_and_goals: 'Planning and goal setting',
rating_reality: 'Clarifying reality',
rating_opportunities: 'New opportunities found',
rating_action_plan: 'An action plan has been drawn up',
rating_motivation: 'Sources of motivation found',
rating_next_session_stretch: 'There is a stretch for the next session',
rating_relationship: 'Establishing a trusting relationship with the client',
rating_listening: 'Deep, active listening',
rating_questions: 'Using "strong" questions',
rating_communication: 'Direct communication',
rating_awareness: 'Developing and stimulating awareness',
rating_progress: 'Progress and Responsibility Management'
},
agreementText: 'I have read and agree with the terms of the User Agreement,',
userAgreement: 'User Agreement',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Write your wishes about the session',
successPayment: 'Successful Payment',
errorPayment: 'Payment Error',
chat: {
join: 'Start chat',
joinAI: 'Start AI chat'
},
errors: {
invalidEmail: 'The email address is not valid',
emptyEmail: 'Please enter your E-mail',

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Unirse como participante',
rapport: 'Buena relación',
invite: 'Invitar',
save: 'Guardar sala'
save: 'Guardar sala',
rate: 'Valorar',
tellAboutReason: 'Cuéntanos qué ha pasado',
rating_raport: 'Buena relación',
rating_position_and_presence: 'Puesto de coach o presencia de coach',
rating_balance_and_frustration: 'Equilibrio entre apoyo y frustración',
rating_agreement: 'Crear un acuerdo de coaching (contrato de sesión)',
rating_planning_and_goals: 'Planear y establecer los objetivos',
rating_reality: 'Clarificar la realidad',
rating_opportunities: 'Nuevas oportunidades encontradas',
rating_action_plan: 'Se ha diseñado un plan de acción',
rating_motivation: 'Fuentes de motivación encontradas',
rating_next_session_stretch: 'Queda un poco para la siguiente sesión',
rating_relationship: 'Establecer una relación de confianza con el cliente',
rating_listening: 'Escucha activa y profunda',
rating_questions: 'Usar preguntas "contundentes"',
rating_communication: 'Comunicación directa',
rating_awareness: 'Desarrollar y estimular la conciencia',
rating_progress: 'Progreso y gestión de la responsabilidad'
},
agreementText: 'He leído y acepto las condiciones del Acuerdo de usuario,',
userAgreement: 'Acuerdo de usuario',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Escribe tus deseos sobre la sesión',
successPayment: 'Pago Exitoso',
errorPayment: 'Error de Pago',
chat: {
join: 'Empezar un chat',
joinAI: 'Start AI chat'
},
errors: {
invalidEmail: 'La dirección de correo electrónico no es válida',
emptyEmail: 'Introduce tu correo electrónico',

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Rejoindre en tant que participant',
rapport: 'Rapport',
invite: 'Inviter',
save: 'Sauvegarder la salle'
save: 'Sauvegarder la salle',
rate: 'Noter',
tellAboutReason: 'Dites-nous ce qui s\'est passé',
rating_raport: 'Rapport',
rating_position_and_presence: 'Poste de coach ou présence de coach',
rating_balance_and_frustration: 'Équilibre entre assistance et frustration',
rating_agreement: 'Création d\'un contrat de coaching (contrat de séance)',
rating_planning_and_goals: 'Planification et définition des objectifs',
rating_reality: 'Clarification de la réalité',
rating_opportunities: 'Nouvelles opportunités trouvées',
rating_action_plan: 'Un plan d\'action a été établi',
rating_motivation: 'Sources de motivation trouvées',
rating_next_session_stretch: 'Une période est présente pour la prochaine session',
rating_relationship: 'Établissement d\'une relation de confiance avec le client',
rating_listening: 'Écoute approfondie et active',
rating_questions: 'Utilisation de questions «fortes»',
rating_communication: 'Communication directe',
rating_awareness: 'Développement et stimulation de la prise de conscience',
rating_progress: 'Gestion de la progression et de la responsabilité'
},
agreementText: 'J\'ai lu et j\'accepte les dispositions de l\'Accord Utilisateur et de la',
userAgreement: '',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Écrivez vos souhaits concernant la session',
successPayment: 'Paiement Réussi',
errorPayment: 'Erreur de Paiement',
chat: {
join: 'Commencer la discussion',
joinAI: 'Start AI chat'
},
errors: {
invalidEmail: 'L\'adresse e-mail n\'est pas valide',
emptyEmail: 'Veuillez saisir votre e-mail',

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Partecipa come partecipante',
rapport: 'Rapporto',
invite: 'Invita',
save: 'Salva sala'
save: 'Salva sala',
rate: 'Valuta',
tellAboutReason: 'Descrivi cosa è successo',
rating_raport: 'Rapporto',
rating_position_and_presence: 'Posizione di coaching o presenza di coaching',
rating_balance_and_frustration: 'Equilibrio tra sostegno e frustrazione',
rating_agreement: 'Creazione di un accordo di coaching (contratto di sessione)',
rating_planning_and_goals: 'Pianificazione e definizione di obiettivi',
rating_reality: 'Chiarimento della realtà',
rating_opportunities: 'Nuove opportunità trovate',
rating_action_plan: 'È stato elaborato un piano d\'azione',
rating_motivation: 'Fonti di motivazione trovate',
rating_next_session_stretch: 'Esiste un\'estensione per la prossima sessione',
rating_relationship: 'Instaurazione di un rapporto di fiducia con il cliente',
rating_listening: 'Ascolto profondo e attivo',
rating_questions: 'Utilizzo di domande "forti"',
rating_communication: 'Comunicazione diretta',
rating_awareness: 'Sviluppo e stimolo della consapevolezza',
rating_progress: 'Gestione dei progressi e delle responsabilità'
},
agreementText: 'Ho letto e accetto i termini dell\'Accordo con l\'utente,',
userAgreement: '',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Scrivi i tuoi desideri riguardo alla sessione',
successPayment: 'Pagamento Riuscito',
errorPayment: 'Errore di Pagamento',
chat: {
join: 'Avvia chat',
joinAI: 'Start AI chat'
},
errors: {
invalidEmail: 'L\'indirizzo e-mail non è valido',
emptyEmail: 'Inserisci l\'e-mail',

View File

@ -65,7 +65,25 @@ export default {
joinParticipant: 'Присоединиться как участник',
rapport: 'Раппорт',
invite: 'Пригласить',
save: 'Сохранить комнату'
save: 'Сохранить комнату',
rate: 'Оценить',
tellAboutReason: 'Расскажите, что произошло',
rating_raport: 'Раппорт',
rating_position_and_presence: 'Коуч-позиция или коучинговое присутствие',
rating_balance_and_frustration: 'Баланс поддержки и фрустрации',
rating_agreement: 'Создание коучингового соглашения (контракт на сессию)',
rating_planning_and_goals: 'Планирование и постановка целей',
rating_reality: 'Прояснение реальности',
rating_opportunities: 'Найдены новые возможности',
rating_action_plan: 'Составлен план действий',
rating_motivation: 'Найдены источники мотивации',
rating_next_session_stretch: 'Есть "протяжка" на следующую сессию',
rating_relationship: 'Установление доверительных отношений с клиентом',
rating_listening: 'Глубокое активное слушание',
rating_questions: 'Использование сильных вопросов',
rating_communication: 'Прямая коммуникация',
rating_awareness: 'Развитие и стимулирование осознанности',
rating_progress: 'Управление прогрессом и ответственностью'
},
agreementText: 'Я прочитал и согласен с условиями Пользовательского соглашения,',
userAgreement: 'Пользовательского соглашения',
@ -167,6 +185,10 @@ export default {
sessionWishes: 'Напишите свои пожелания по поводу сессии',
successPayment: 'Успешная оплата',
errorPayment: 'Ошибка оплаты',
chat: {
join: 'Начать чат',
joinAI: 'Начать чат с ИИ'
},
errors: {
invalidEmail: 'Адрес электронной почты недействителен',
emptyEmail: 'Пожалуйста, введите ваш E-mail',

View File

@ -0,0 +1,79 @@
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
import { IHttpConnectionOptions } from "@microsoft/signalr/src/IHttpConnectionOptions";
import { AUTH_TOKEN_KEY, BASE_URL } from '../constants/common';
import { IChatMessage } from '../types/chat';
const chatMessageMethodName = 'ReceiveMessage';
const chatUserStatusChangeMethodName = 'chatUserStatusChange';
const chatSeenMethodName = 'MessageWasRead';
const chatTypingMethodName = 'directTyping';
const sendChatTextMethodName = 'SendMessage';
const sendChatTypingMethodName = 'DirectIsTyping';
const serverError = 'Error';
const sendChatSeenMethodName = 'ConfirmMessageRead';
const sendChatMessagesSeenMethodName = 'ConfirmMessagesRead';
const joinChatMethodName = 'JoinChat';
const leaveChatMethodName = 'LeaveChat';
const joinChatsMethodName = 'JoinChats';
const leaveChatsMethodName = 'LeaveChats';
const chatsReceiveMessageMethodName = 'ChatsMessageCreated';
class SignalConnector {
private connection: HubConnection;
private events ={} as any;
static instance: SignalConnector;
constructor() {
const options = {
accessTokenFactory: () => localStorage.getItem(AUTH_TOKEN_KEY)
} as IHttpConnectionOptions;
this.connection = new HubConnectionBuilder()
.withUrl(`${BASE_URL}/hubs/chat`, options)
.withAutomaticReconnect()
.configureLogging(LogLevel.Debug)
.build();
this.connection.start().then(()=>{
this.events?.onConnected(true)
for (const k in this.events) {
if (k != 'onConnected'){
this.connection.on(k, (payload: any) => {
this.events[k](payload);
});
}
}
}).catch(err => console.log('SignalR Error', err));
}
public addListener = (name: string, func: any)=> {
this.events[name] = func;
}
public closeConnection = () => {
this.connection.stop();
}
public newMessage = (message: IChatMessage) => {
this.connection.invoke(sendChatTextMethodName, message).then(x => console.log('NewMsg',x))
}
public joinChat = (groupId: number) => {
this.connection.invoke(joinChatMethodName, groupId, null).then(x => console.log(joinChatMethodName, x))
}
public joinChatPerson = (accId: number) => {
return this.connection.invoke(joinChatMethodName, 0, accId)
}
public readMessages = (messagesId: number[]) => {
this.connection.invoke(sendChatMessagesSeenMethodName, messagesId).then(x => console.log(sendChatMessagesSeenMethodName, x))
}
public static getInstance(): SignalConnector {
if (!SignalConnector.instance)
SignalConnector.instance = new SignalConnector();
return SignalConnector.instance;
}
}
export default SignalConnector.getInstance;

View File

@ -823,6 +823,7 @@ a {
flex: 0 0 100%;
display: flex;
gap: 16px;
flex-direction: column;
.btn-apply,
.btn-video {

View File

@ -13,6 +13,8 @@
}
&__inner {
display: flex;
flex-direction: column-reverse;
flex-grow: 1;
height: 0;
position: relative;
@ -94,7 +96,8 @@
align-items: center;
textarea {
width: calc(100% - 136px);
resize: none;
width: calc(100% - 40px);
height: 24px;
padding: 0 8px;
border-radius: 0;

View File

@ -26,7 +26,7 @@
}
}
&__comment__content {
&__comment__content, &__report__content {
display: flex;
flex-direction: column;
padding: 44px 40px;

View File

@ -57,6 +57,30 @@
line-height: 120%;
}
&__report-list {
display: flex;
width: 100%;
flex-direction: column;
gap: 8px;
& > div {
width: 100%;
color: #4E7C86;
@include rem(13);
font-weight: 500;
line-height: 120%;
display: flex;
gap: 8px;
justify-content: space-between;
align-items: flex-end;
}
&_divider {
flex: 1;
border-bottom: 1px solid #E4F5FA;
}
}
&__comments {
display: flex;
flex-direction: column;

View File

@ -14,4 +14,25 @@
color: #c4dfe6 !important;
}
}
&-list {
display: flex;
flex-direction: column;
gap: 24px;
&__item {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
&_title {
color: #2C7873;
@include rem(13);
font-weight: 500;
line-height: 120%;
text-align: center;
}
}
}
}

12
src/types/chat.ts Normal file
View File

@ -0,0 +1,12 @@
export interface IChatMessage {
GroupId: string;
CreatorId: number;
Content: string;
}
export interface IChatJoin {
GroupId: string;
CreatorId: number;
Content: string;
}

View File

@ -37,6 +37,7 @@ export type ThemeGroup = {
export interface ExpertItem {
id: number;
botUserId?: number;
name: string;
surname?: string;
faceImageUrl?: string;

View File

@ -42,3 +42,16 @@ export type RoomEditDTO = {
tags?: Tag[];
availableSlots: Slot[];
};
export type Report = {
evaluationCriteriaId: number,
evaluationCriteriaName?: string,
score?: number,
key?: string
};
export type ReportData = {
sessionId: number,
sessionSupervisorScores: Report[],
supervisorComment?: string
};

View File

@ -5,8 +5,7 @@ import { i18nText } from '../i18nKeys';
const ROUTES = ['sessions', 'rooms', 'notifications', 'support', 'information', 'settings', 'messages', 'expert-profile'];
const COUNTS: Record<string, number> = {
sessions: 12,
notifications: 5,
messages: 113
notifications: 5
};
export const getMenuConfig = (locale: string) => ROUTES.map((path) => ({