Compare commits

..

19 Commits

Author SHA1 Message Date
SD 08d12cd89e 0.2.3 2024-11-22 20:01:42 +04:00
SD 332595fd39 fix: fix styles for agora 2024-11-22 20:01:29 +04:00
SD 46b0c5b747 0.2.2 2024-11-22 16:03:20 +04:00
SD d866ee2f62 fix: fix agora version 2024-11-22 16:03:00 +04:00
SD 60a35db46b 0.2.1 2024-11-22 13:10:44 +04:00
SD 0222335694 feat: fix agora, fix creating room 2024-11-22 13:10:37 +04:00
SD 9a3aa98158 0.2.0 2024-11-21 17:04:17 +04:00
SD c0feea48e5 feat: create rooms 2024-11-21 17:03:49 +04:00
SD 5b8ba1b5c4 fix: fix account paths, fix local userData after login 2024-10-29 21:57:45 +04:00
SD cd44c9f1a1 0.1.0 2024-10-28 15:29:39 +04:00
SD 5712cbcf56 feat: add errors 2024-10-28 15:28:14 +04:00
SD b31d2cf700 feat: add styles for payment 2024-10-26 00:38:30 +04:00
SD a39f53c57d ::Merge branch 'develop' into stripe 2024-10-17 13:24:29 +04:00
SD 4ac2740942 fix: fix sessions path 2024-10-17 13:22:22 +04:00
SD d5808e96db feat: update views, styles, actions 2024-10-16 21:47:28 +04:00
dzfelix 59de68d611 stripe payment 2024-09-19 19:55:16 +04:00
SD b141a6ad44 merge develop 2024-09-11 15:05:09 +04:00
dzfelix 3b2241892f stripe payment 2024-08-15 14:43:52 +03:00
dzfelix ee4dcb58cc stripe payment 2024-08-15 14:41:35 +03:00
83 changed files with 3890 additions and 171 deletions

3
.env
View File

@ -1,5 +1,8 @@
NEXT_PUBLIC_SERVER_BASE_URL=https://api.bbuddy.expert/api
NEXT_PUBLIC_AGORA_APPID=ed90c9dc42634e5687d4e2e0766b363f
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LVB3LK5pVGxNPeKk4gedt5NW4cb8k7BVXvgOMPTK4x1nnbGTD8BCqDqgInboT6N72YwrTl4tOsVz8rAjbUadX1m00y4Aq5qE8
STRIPE_SECRET_KEY=sk_test_51LVB3LK5pVGxNPeK6j0wCsPqYMoGfcuwf1LpwGEBsr1dUx4NngukyjYL2oMZer5EOlW3lqnVEPjNDruN0OkUohIf00fWFUHN5O
STRIPE_PAYMENT_DESCRIPTION='BBuddy services'
NEXT_PUBLIC_CONTENTFUL_SPACE_ID = voxpxjq7y7vf
NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN = s99GWKfpDKkNwiEJ3pN7US_tmqsGvDlaex-sOJwpzuc

849
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.0.4",
"version": "0.2.3",
"private": true,
"scripts": {
"dev": "next dev -p 4200",
@ -13,10 +13,13 @@
"@ant-design/icons": "^5.2.6",
"@ant-design/nextjs-registry": "^1.0.0",
"@contentful/rich-text-react-renderer": "^15.22.9",
"agora-rtc-react": "^2.1.0",
"@stripe/react-stripe-js": "^2.7.3",
"@stripe/stripe-js": "^4.1.0",
"agora-rtc-react": "2.1.0",
"agora-rtc-sdk-ng": "^4.20.2",
"antd": "^5.12.1",
"antd-img-crop": "^4.21.0",
"antd-style": "^3.6.2",
"axios": "^1.6.5",
"contentful": "^10.13.3",
"dayjs": "^1.11.10",
@ -26,7 +29,9 @@
"react": "^18",
"react-dom": "^18",
"react-slick": "^0.29.0",
"react-stripe-js": "^1.1.5",
"slick-carousel": "^1.8.1",
"stripe": "^16.2.0",
"styled-components": "^6.1.1"
},
"devDependencies": {

View File

@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "GTYAM4FYH3.com.bbuddy.whistle",
"paths": ["/en/experts/*"]
}
]
}
}

View File

@ -0,0 +1,13 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"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",
"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

@ -1,5 +1,5 @@
import { GeneralFilter, ExpertsData, ExpertDetails } from '../types/experts';
import { apiRequest } from './helpers';
import { GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession, SignupSessionData } from '../types/experts';
export const getExpertsList = (locale: string, filter?: GeneralFilter): Promise<ExpertsData> => apiRequest({
url: '/home/coachsearch1',
@ -14,3 +14,18 @@ export const getExpertById = (id: string, locale: string): Promise<ExpertDetails
data: { id },
locale
});
export const getSchedulerByExpertId = (id: string, locale: string): Promise<ExpertScheduler> => apiRequest({
url: '/home/sessionsignupdata',
method: 'post',
data: { id },
locale
});
export const getSchedulerSession = (data: SignupSessionData, locale: string, token: string): Promise<ExpertSchedulerSession> => apiRequest({
url: '/home/sessionsignupsubmit',
method: 'post',
data,
locale,
token
});

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { ProfileData, ProfileRequest } from '../../types/profile';
import { getPersonalData, setPersonData } from '../profile';
import { useLocalStorage } from '../../hooks/useLocalStorage';
@ -18,7 +18,7 @@ export const useProfileSettings = (locale: string) => {
setProfileSettings(data);
})
.catch((err) => {
console.log(err);
})
.finally(() => {
setFetchLoading(false);

View File

@ -0,0 +1,42 @@
'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';
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 fetchData = useCallback(() => {
setLoading(true);
setErrorData(undefined);
setRoom(undefined);
getRoomDetails(locale, jwt, roomId)
.then((room) => {
setRoom(room);
})
.catch((err) => {
setErrorData(err);
})
.finally(() => {
setLoading(false);
})
}, []);
useEffect(() => {
fetchData();
}, []);
return {
fetchData,
loading,
room,
errorData
};
};

109
src/actions/rooms.ts Normal file
View File

@ -0,0 +1,109 @@
import { apiRequest } from './helpers';
import {GetUsersForRooms, Room, RoomEdit, RoomEditDTO} from '../types/rooms';
export const getUpcomingRooms = (locale: string, token: string): Promise<Room[]> => apiRequest({
url: '/home/upcomingsessionsall',
method: 'post',
data: {
sessionType: 'room'
},
locale,
token
});
export const getRecentRooms = (locale: string, token: string): Promise<Room[]> => apiRequest({
url: '/home/historicalmeetings',
method: 'post',
data: {
sessionType: 'room'
},
locale,
token
});
export const getRoomDetails = (locale: string, token: string, id: number): Promise<Room> => apiRequest({
url: '/home/room',
method: 'post',
data: { id },
locale,
token
});
export const deleteRoomClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise<any> => apiRequest({
url: '/home/deleteclientfromroom',
method: 'post',
data,
locale,
token
});
export const deleteRoomSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise<any> => apiRequest({
url: '/home/deletesupervisorfromroom',
method: 'post',
data,
locale,
token
});
export const becomeRoomClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise<any> => apiRequest({
url: '/home/becomeroomclient',
method: 'post',
data,
locale,
token
});
export const becomeRoomSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise<any> => apiRequest({
url: '/home/becomeroomsupervisor',
method: 'post',
data,
locale,
token
});
export const getUsersList = (locale: string, token: string, data: { template: string }): Promise<GetUsersForRooms> => apiRequest({
url: '/home/findusersforroom',
method: 'post',
data,
locale,
token
});
export const addClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise<any> => apiRequest({
url: '/home/addclienttoroom',
method: 'post',
data,
locale,
token
});
export const addSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise<any> => apiRequest({
url: '/home/addsupervisortoroom',
method: 'post',
data,
locale,
token
});
export const createRoom = (locale: string, token: string): Promise<any> => apiRequest({
url: '/home/createroom',
method: 'post',
locale,
token
});
export const updateRoom = (locale: string, token: string, data: RoomEdit): Promise<any> => apiRequest({
url: '/home/updateroom',
method: 'post',
data,
locale,
token
});
export const getRoomById = (locale: string, token: string, id: number): Promise<RoomEditDTO> => apiRequest({
url: '/home/getroomforedit',
method: 'post',
data: { id },
locale,
token
});

View File

@ -91,3 +91,11 @@ export const finishSession = (locale: string, token: string, sessionId: number):
locale,
token
});
export const sessionPaymentConfirm = (locale: string, token: string, sessionId: number): Promise<Session> => apiRequest({
url: '/home/session_pay_confirm',
method: 'post',
data: { id: sessionId },
locale,
token
});

79
src/actions/stripe.ts Normal file
View File

@ -0,0 +1,79 @@
"use server";
import { Stripe } from "stripe";
import { headers } from "next/headers";
import { formatAmountForStripe } from "../utils/stripe-helpers";
import { stripe } from "../lib/stripe";
export async function createCheckoutSession(
data: FormData,
): Promise<{ client_secret: string | null; url: string | null }> {
const ui_mode = data.get(
"uiMode",
) as Stripe.Checkout.SessionCreateParams.UiMode;
console.log('DATA', data)
const origin: string = headers().get("origin") as string;
const checkoutSession: Stripe.Checkout.Session =
await stripe.checkout.sessions.create({
mode: "payment",
submit_type: "donate",
line_items: [
{
quantity: 1,
price_data: {
currency: 'eur',
product_data: {
name: "Custom amount donation",
},
unit_amount: formatAmountForStripe(
Number(data.get("customDonation") as string),
'eur',
),
},
},
],
...(ui_mode === "hosted" && {
success_url: `${origin}/payment/with-checkout/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/with-checkout`,
}),
...(ui_mode === "embedded" && {
return_url: `${origin}/payment/with-embedded-checkout/result?session_id={CHECKOUT_SESSION_ID}`,
}),
ui_mode,
});
return {
client_secret: checkoutSession.client_secret,
url: checkoutSession.url,
};
}
export async function createPaymentIntent(
data: { amount: number, sessionId?: string },
): Promise<{ client_secret: string }> {
const params = {
amount: formatAmountForStripe(
data.amount,
'eur',
),
automatic_payment_methods: { enabled: true },
currency: 'eur',
} as Stripe.PaymentIntentCreateParams;
if (data?.sessionId){
params.metadata = {
sessionId : data.sessionId
}
}
const paymentIntent: Stripe.PaymentIntent =
await stripe.paymentIntents.create(params);
return { client_secret: paymentIntent.client_secret as string };
}
export const getStripePaymentStatus = async (payment_intent: string): Promise<Stripe.PaymentIntent> => await stripe.paymentIntents.retrieve(payment_intent);

View File

@ -1,14 +1,14 @@
import React from 'react';
import { useTranslations } from 'next-intl';
import {getTranslations, unstable_setRequestLocale} from 'next-intl/server';
// import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { getTranslations, unstable_setRequestLocale } from 'next-intl/server';
import { i18nText } from '../../../../i18nKeys';
import {fetchBlogPosts} from "../../../../lib/contentful/blogPosts";
import Link from "next/link";
import { fetchBlogPosts } from '../../../../lib/contentful/blogPosts';
export default async function News({params: {locale}}: { params: { locale: string } }) {
unstable_setRequestLocale(locale);
const t = await getTranslations('Main');
const {data, total} = await fetchBlogPosts({preview: false, sticky: true})
const { data, total } = await fetchBlogPosts({preview: false, sticky: true})
return (
<div className="main-articles">

View File

@ -15,7 +15,8 @@ import React, { ReactNode } from 'react';
export default function MainLayout({ children, news, experts }: {
children: ReactNode,
news: ReactNode,
experts: ReactNode
experts: ReactNode,
payment: ReactNode
}) {
return (
<>
@ -24,4 +25,4 @@ export default function MainLayout({ children, news, experts }: {
{experts}
</>
);
};
}

View File

@ -57,7 +57,7 @@ export default function ExpertProfilePage({ params: { locale } }: { params: { lo
}
}, [jwt]);
return (
return data ? (
<Loader isLoading={loading}>
<ExpertProfile
isFull={isFull}
@ -66,5 +66,5 @@ export default function ExpertProfilePage({ params: { locale } }: { params: { lo
updateData={setData}
/>
</Loader>
);
) : null;
};

View File

@ -18,7 +18,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
<div className="messages-session">
<Link
className="card-profile"
href={'1' as any}
href={'messages/1' as any}
>
<div className="card-profile__header">
<div className="card-profile__header__portrait">
@ -42,7 +42,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
</Link>
<Link
className="card-profile"
href={'2' as any}
href={'messages/2' as any}
>
<div className="card-profile__header">
<div className="card-profile__header__portrait">
@ -63,7 +63,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
</Link>
<Link
className="card-profile"
href={'3' as any}
href={'messages/3' as any}
>
<div className="card-profile__header">
<div className="card-profile__header__portrait">

View File

@ -0,0 +1,57 @@
import React, { Suspense } from 'react';
import { unstable_setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { AccountMenu, RoomDetails, RoomsTabs } from '../../../../../../components/Account';
import { RoomsType } from '../../../../../../types/rooms';
const ROOMS_ROUTES = [RoomsType.UPCOMING, RoomsType.RECENT, RoomsType.NEW];
export async function generateStaticParams({
params: { locale },
}: { params: { locale: string } }) {
return [{ locale, slug: [RoomsType.UPCOMING] }];
}
export default function RoomsDetailItem({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
unstable_setRequestLocale(locale);
const roomType: string = slug?.length > 0 && slug[0] || '';
const roomId: number | null = slug?.length > 1 && Number(slug[1]) || null;
if (!slug?.length || slug?.length > 2) {
notFound();
}
if (ROOMS_ROUTES.includes(roomType as RoomsType) && Number.isInteger(roomId)) {
return (
<Suspense fallback={<p>Loading...</p>}>
<RoomDetails
locale={locale}
roomId={roomId || 0}
activeType={roomType as RoomsType}
/>
</Suspense>
);
}
if (ROOMS_ROUTES.includes(roomType as RoomsType) && !Number.isInteger(roomId)) {
return (
<>
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
<AccountMenu locale={locale}/>
</div>
<div className="col-xl-9 col-lg-8 ">
<div className="page-account__inner">
<Suspense fallback={<p>Loading...</p>}>
<RoomsTabs
locale={locale}
activeTab={roomType as RoomsType}
/>
</Suspense>
</div>
</div>
</>
);
}
return notFound();
};

View File

@ -0,0 +1,12 @@
'use client';
import { redirect } from 'next/navigation';
import { useLocalStorage } from '../../../../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../../../../constants/common';
import { RoomsType } from '../../../../../types/rooms';
export default function RoomsMainPage() {
const [token] = useLocalStorage(AUTH_TOKEN_KEY, '');
return token ? redirect(`rooms/${RoomsType.UPCOMING}`) : null;
};

View File

@ -26,7 +26,7 @@ export default function SessionDetailItem({ params: { locale, slug } }: { params
<Suspense fallback={<p>Loading...</p>}>
<SessionDetails
locale={locale}
sessionId={sessionId}
sessionId={sessionId as number}
activeType={sessionType as SessionType}
/>
</Suspense>

View File

@ -8,5 +8,5 @@ import { SessionType } from '../../../../../types/sessions';
export default function SessionsMainPage() {
const [token] = useLocalStorage(AUTH_TOKEN_KEY, '');
return token ? redirect(SessionType.UPCOMING) : null;
return token ? redirect(`sessions/${SessionType.UPCOMING}`) : null;
};

View File

@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { unstable_setRequestLocale } from 'next-intl/server';
import { useTranslations } from 'next-intl';
import { GeneralTopSection } from '../../../components/Page';
import { ScreenCarousel } from '../../../components/Page/ScreenCarousel/index';
import { ScreenCarousel } from '../../../components/Page/ScreenCarousel';
export const metadata: Metadata = {
title: 'Bbuddy - Become a BB expert',

View File

@ -9,7 +9,6 @@ import {CustomPagination} from "../../../components/view/CustomPagination";
import {DEFAULT_PAGE_SIZE} from "../../../constants/common";
import {BlogPosts} from "../../../components/BlogPosts/BlogPosts";
interface BlogPostPageParams {
slug: string
}

View File

@ -6,7 +6,6 @@ import { getExpertById, getExpertsList } from '../../../../actions/experts';
import {
ExpertCard,
ExpertCertificate,
ExpertInformation,
ExpertPractice
} from '../../../../components/Experts/ExpertDetails';
import { Details } from '../../../../types/education';
@ -82,8 +81,7 @@ export default async function ExpertItem({ params: { expertId = '', locale } }:
</BackButton>
</Suspense>
</div>
<ExpertCard expert={expert} locale={locale} />
<ExpertInformation expert={expert} locale={locale} />
<ExpertCard expert={expert} locale={locale} expertId={expertId}/>
<h2 className="title-h2">{i18nText('expertBackground', locale)}</h2>
<p className="base-text">

View File

@ -42,4 +42,4 @@ export default function LocaleLayout({ children, params: { locale } }: LayoutPro
</ConfigProvider>
</AntdRegistry>
);
};
}

View File

@ -0,0 +1,66 @@
import type { Stripe } from "stripe";
import { NextResponse } from "next/server";
import { stripe } from "../../../lib/stripe";
export async function POST(req: Request) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
await (await req.blob()).text(),
req.headers.get("stripe-signature") as string,
process.env.STRIPE_WEBHOOK_SECRET as string,
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// On error, log and return the error message.
if (err! instanceof Error) console.log(err);
console.log(`❌ Error message: ${errorMessage}`);
return NextResponse.json(
{ message: `Webhook Error: ${errorMessage}` },
{ status: 400 },
);
}
// Successfully constructed event.
console.log("✅ Success:", event.id);
const permittedEvents: string[] = [
"checkout.session.completed",
"payment_intent.succeeded",
"payment_intent.payment_failed",
];
if (permittedEvents.includes(event.type)) {
let data;
try {
switch (event.type) {
case "checkout.session.completed":
data = event.data.object as Stripe.Checkout.Session;
console.log(`💰 CheckoutSession status: ${data.payment_status}`);
break;
case "payment_intent.payment_failed":
data = event.data.object as Stripe.PaymentIntent;
console.log(`❌ Payment failed: ${data.last_payment_error?.message}`);
break;
case "payment_intent.succeeded":
data = event.data.object as Stripe.PaymentIntent;
console.log(`💰 PaymentIntent status: ${data.status}`);
break;
default:
throw new Error(`Unhandled event: ${event.type}`);
}
} catch (error) {
console.log(error);
return NextResponse.json(
{ message: "Webhook handler failed" },
{ status: 500 },
);
}
}
// Return a response to acknowledge receipt of the event.
return NextResponse.json({ message: "Received" }, { status: 200 });
}

View File

@ -1,8 +1,8 @@
'use client';
import React, { FC, useEffect, useState } from 'react';
import { Button, Form, message, Upload } from 'antd';
import type { GetProp, UploadFile, UploadProps } from 'antd';
import { Form, message, Upload } from 'antd';
import type { UploadFile } from 'antd';
import ImgCrop from 'antd-img-crop';
import { CameraOutlined, DeleteOutlined } from '@ant-design/icons';
import { useRouter } from '../../navigation';
@ -12,17 +12,14 @@ import { validateImage } from '../../utils/account';
import { useProfileSettings } from '../../actions/hooks/useProfileSettings';
import { CustomInput } from '../view/CustomInput';
import { OutlinedButton } from '../view/OutlinedButton';
import {FilledButton, FilledSquareButton, FilledYellowButton} from '../view/FilledButton';
import { FilledSquareButton, FilledYellowButton } from '../view/FilledButton';
import { DeleteAccountModal } from '../Modals/DeleteAccountModal';
import { Loader } from '../view/Loader';
import {ButtonProps} from "antd/es/button/button";
type ProfileSettingsProps = {
locale: string;
};
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
export const ProfileSettings: FC<ProfileSettingsProps> = ({ locale }) => {
const [form] = Form.useForm<ProfileRequest>();
const { profileSettings, fetchProfileSettings, save, fetchLoading } = useProfileSettings(locale);
@ -58,7 +55,7 @@ export const ProfileSettings: FC<ProfileSettingsProps> = ({ locale }) => {
const onSaveProfile = () => {
form.validateFields()
.then(({ login, surname, username }) => {
const { phone, role, languagesLinks } = profileSettings;
const { phone, role, languagesLinks } = profileSettings || {};
const newProfile: ProfileRequest = {
phone,
role,
@ -75,7 +72,7 @@ export const ProfileSettings: FC<ProfileSettingsProps> = ({ locale }) => {
reader.readAsDataURL(photo as File);
reader.onloadend = () => {
const newReg = new RegExp('data:image/(png|jpg|jpeg);base64,')
newProfile.faceImage = reader.result.replace(newReg, '');
newProfile.faceImage = reader?.result?.replace(newReg, '');
newProfile.isFaceImageKeepExisting = false;
onSave(newProfile);
@ -181,7 +178,7 @@ export const ProfileSettings: FC<ProfileSettingsProps> = ({ locale }) => {
>
{i18nText('save', locale)}
</FilledYellowButton>
<OutlinedButton onClick={() => router.push('change-password')}>
<OutlinedButton onClick={() => router.push('settings/change-password')}>
{i18nText('changePass', locale)}
</OutlinedButton>
<OutlinedButton

View File

@ -37,7 +37,7 @@ export const Agora = ({ sessionId, secret, stopCalling, remoteUser }: AgoraProps
};
return (
<div className="b-agora__wrap">
<div className="b-agora__wrap b-agora__wrap__single">
<RemoteUserPanel calling={calling} user={remoteUser} />
<div className="b-agora__panel">
<MediaControl

View File

@ -0,0 +1,54 @@
'use client'
import { useJoin } from 'agora-rtc-react';
import { useEffect, useState } from 'react';
import { MediaControl } from './view';
import { UsersGroupPanel } from './components';
type AgoraProps = {
roomId: number;
secret?: string;
stopCalling: () => void;
};
export const AgoraGroup = ({ roomId, secret, stopCalling }: AgoraProps) => {
const [calling, setCalling] = useState(false);
const [micOn, setMic] = useState(false);
const [cameraOn, setCamera] = useState(false);
useEffect(() => {
setCalling(true);
}, []);
useJoin(
{
appid: process.env.NEXT_PUBLIC_AGORA_APPID,
channel: `${roomId}-${secret}`,
token: null,
},
calling,
);
const stop = () => {
stopCalling();
setCalling(false);
};
return (
<>
<div className="b-agora__wrap">
<UsersGroupPanel calling={calling} micOn={micOn} cameraOn={cameraOn}/>
</div>
<div className="b-agora__panel_group">
<MediaControl
calling={calling}
cameraOn={cameraOn}
micOn={micOn}
setCalling={stop}
setCamera={() => setCamera(a => !a)}
setMic={() => setMic(a => !a)}
/>
</div>
</>
);
};

View File

@ -0,0 +1,44 @@
import {
useIsConnected, useLocalCameraTrack, useLocalMicrophoneTrack, usePublish,
useRemoteAudioTracks,
useRemoteUsers,
useRemoteVideoTracks
} from 'agora-rtc-react';
import { LocalUser } from './LocalUser';
import { RemoteVideoPlayer } from './RemoteVideoPlayer';
type UsersGroupPanelProps = {
calling: boolean;
micOn: boolean;
cameraOn: boolean;
};
export const UsersGroupPanel = ({ calling, micOn, cameraOn }: UsersGroupPanelProps) => {
const isConnected = useIsConnected();
const remoteUsers = useRemoteUsers();
const { localMicrophoneTrack } = useLocalMicrophoneTrack(micOn);
const { localCameraTrack } = useLocalCameraTrack(cameraOn);
const { videoTracks } = useRemoteVideoTracks(remoteUsers);
const { audioTracks } = useRemoteAudioTracks(remoteUsers);
usePublish([localMicrophoneTrack, localCameraTrack]);
audioTracks.map(track => track.play());
return calling && isConnected && remoteUsers ? (
<div className={`b-agora__remote_groups gr-${remoteUsers.length + 1}`}>
<div>
<LocalUser
audioTrack={localMicrophoneTrack}
cameraOn={cameraOn}
micOn={micOn}
videoTrack={localCameraTrack}
/>
</div>
{remoteUsers.length > 0 && remoteUsers.map((user) => (
<div key={user.uid}>
<RemoteVideoPlayer track={user.videoTrack} />
</div>
))}
</div>
) : null;
}

View File

@ -3,3 +3,4 @@ export * from './UserCover';
export * from './RemoteUsers';
export * from './LocalUserPanel';
export * from './RemoteUserPanel';
export * from './UsersGroupPanel';

View File

@ -2,7 +2,9 @@
import AgoraRTC, { AgoraRTCProvider } from 'agora-rtc-react';
import { Session } from '../../../types/sessions';
import { Room } from '../../../types/rooms';
import { Agora } from './Agora';
import { AgoraGroup } from './AgoraGroup';
export const AgoraClient = ({ session, stopCalling, isCoach }: { session?: Session, stopCalling: () => void, isCoach: boolean }) => {
const remoteUser = isCoach ? (session?.clients?.length ? session?.clients[0] : undefined) : session?.coach;
@ -20,3 +22,17 @@ export const AgoraClient = ({ session, stopCalling, isCoach }: { session?: Sessi
</AgoraRTCProvider>
) : null;
};
export const AgoraClientGroup = ({ room, stopCalling }: { room?: Room, stopCalling: () => void }) => {
return room ? (
<AgoraRTCProvider client={AgoraRTC.createClient({ mode: "rtc", codec: "vp8" })}>
{room && (
<AgoraGroup
roomId={room.id}
secret={room.secret}
stopCalling={stopCalling}
/>
)}
</AgoraRTCProvider>
) : null;
};

View File

@ -3,3 +3,4 @@
export { AccountMenu } from './AccountMenu';
export { ProfileSettings } from './ProfileSettings';
export * from './sessions';
export * from './rooms';

View File

@ -0,0 +1,45 @@
'use client'
import React, { useEffect, useState } from 'react';
import { EditRoomForm } from './EditRoomForm';
import debounce from 'lodash/debounce';
import { createRoom } from '../../../actions/rooms';
import { Loader } from '../../view/Loader';
import { useRouter } from '../../../navigation';
import { RoomsType } from '../../../types/rooms';
export const CreateRoom = ({ locale, jwt }: { locale: string, jwt: string }) => {
const [roomId, setRoomId] = useState<number>();
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const getRoom = debounce(() => {
createRoom(locale, jwt)
.then((data) => {
setRoomId(data);
})
.finally(() => {
setLoading(false);
})
}, 500);
useEffect(() => {
setLoading(true);
getRoom();
}, []);
return (
<Loader isLoading={loading}>
{roomId && (
<EditRoomForm
roomId={roomId}
locale={locale}
jwt={jwt}
mode="create"
afterSubmit={() => router.push(`/account/rooms/${RoomsType.UPCOMING}`)}
/>
)}
</Loader>
)
};

View File

@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Form, Input, notification } from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { i18nText } from '../../../i18nKeys';
import { Tag } from '../../../types/tags';
import { Slot } from '../../../types/experts';
import { RoomEdit, RoomEditDTO } from '../../../types/rooms';
import { getRoomById, updateRoom } from '../../../actions/rooms';
import { Loader } from '../../view/Loader';
import { CustomInput } from '../../view/CustomInput';
import { CustomSelect } from '../../view/CustomSelect';
import { CustomSwitch } from '../../view/CustomSwitch';
import { CustomMultiSelect } from '../../view/CustomMultiSelect';
import { CustomDatePicker } from '../../view/CustomDatePicker';
type EditRoomFormProps = {
roomId: number,
locale: string,
jwt: string,
mode: 'create' | 'edit';
afterSubmit?: () => void;
}
type RoomFormState = {
title?: string;
description?: string;
date?: Dayjs;
maxCount?: number;
startAt?: string;
supervisor?: boolean;
tags?: number[];
};
export const EditRoomForm = ({ roomId, locale, jwt, mode, afterSubmit }: EditRoomFormProps) => {
const [form] = Form.useForm<RoomFormState>();
const [editingRoom, setEditingRoom] = useState<RoomEditDTO>();
const dateValue = Form.useWatch('date', form);
const [loading, setLoading] = useState<boolean>(false);
const [fetchLoading, setFetchLoading] = useState<boolean>(false);
useEffect(() => {
setFetchLoading(true);
getRoomById(locale, jwt, roomId)
.then((data) => {
setEditingRoom(data);
const { item } = data || {};
if (mode === 'edit' && item) {
form.setFieldsValue({
title: item.title,
description: item.description,
date: item?.scheduledStartAtUtc ? dayjs(item.scheduledStartAtUtc) : undefined,
maxCount: item.maxClients,
startAt: item?.scheduledStartAtUtc,
supervisor: item.isNeedSupervisor,
tags: item.tagIds || undefined
})
}
})
.finally(() => {
setFetchLoading(false);
})
}, []);
const getAvailableSlots = useCallback((): string[] => {
const dateList = new Set<string>();
if (editingRoom?.availableSlots) {
editingRoom.availableSlots.forEach(({ startTime }) => {
const [date] = startTime.split('T');
dateList.add(dayjs(date).format('YYYY-MM-DD'));
});
return Array.from(dateList);
}
return [];
}, [editingRoom?.availableSlots]);
const getTimeOptions = (slots?: Slot[], curDate?: Dayjs) => {
const date = curDate ? curDate.format('YYYY-MM-DD') : '';
if (slots && slots?.length && date) {
return slots.filter(({ startTime }) => dayjs(startTime).format('YYYY-MM-DD') === date)
.map(({ startTime, endTime }) => ({ value: startTime, label: `${dayjs(startTime).format('HH:mm')} - ${dayjs(endTime).format('HH:mm')}` }));
}
return [];
}
const getTagsOptions = (tags?: Tag[]) => {
if (tags) {
return tags.map(({ id, name }) => ({ value: id, label: <span>{name}</span> })) || [];
}
return [];
}
const onSubmit = () => {
setLoading(true);
const { title, description, startAt, maxCount, tags, supervisor } = form.getFieldsValue();
const result: RoomEdit = {
...editingRoom,
id: roomId,
title,
scheduledStartAtUtc: startAt,
maxClients: maxCount,
isNeedSupervisor: supervisor,
tagIds: tags || []
};
if (description) {
result.description = description;
}
updateRoom(locale, jwt, result)
.then(() => {
afterSubmit && afterSubmit();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
})
.finally(() => {
setLoading(false)
});
}
const disabledDate = (current: Dayjs) => current && !getAvailableSlots().includes(current.format('YYYY-MM-DD'));
return (
<Loader isLoading={fetchLoading}>
<Form
form={form}
autoComplete="off"
style={{ display: 'flex', gap: 16, flexDirection: 'column' }}
onFinish={onSubmit}
className="b-room-form"
>
<Form.Item
name="title"
rules={[{ required: true }]}
noStyle
>
<CustomInput
size="small"
placeholder={i18nText('title', locale)}
/>
</Form.Item>
<Form.Item name="description">
<Input.TextArea
className="b-textarea"
rows={4}
maxLength={1000}
placeholder={i18nText('description', locale)}
/>
</Form.Item>
<div className="b-room-form__grid">
<Form.Item
name="date"
rules={[{ required: true }]}
noStyle
>
<CustomDatePicker
locale={locale}
label={i18nText('room.date', locale)}
disabledDate={disabledDate}
/>
</Form.Item>
<Form.Item
name="startAt"
rules={[{ required: true }]}
noStyle
>
<CustomSelect
label={i18nText('room.time', locale)}
options={getTimeOptions(editingRoom?.availableSlots, dateValue)}
disabled={!dateValue}
/>
</Form.Item>
<Form.Item
name="maxCount"
rules={[{ required: true }]}
noStyle
>
<CustomSelect
label={i18nText('room.maxParticipants', locale)}
options={Array.from({ length: 16 }).map((_, i) => ({ value: i+1, label: i+1 }))}
/>
</Form.Item>
<Form.Item
name="supervisor"
valuePropName="checked"
label={i18nText('room.presenceOfSupervisor', locale)}
className="b-room-switch"
>
<CustomSwitch />
</Form.Item>
</div>
<Form.Item
name="tags"
noStyle
>
<CustomMultiSelect
label={i18nText('topics', locale)}
options={getTagsOptions(editingRoom?.tags)}
/>
</Form.Item>
<Button
className="card-detail__apply"
htmlType="submit"
loading={loading}
disabled={loading}
>
{i18nText('room.save', locale)}
</Button>
</Form>
</Loader>
);
};

View File

@ -0,0 +1,66 @@
'use client'
import React, { useState, useEffect } from 'react';
import { RoomsType } from '../../../types/rooms';
import { useSessionTracking } from '../../../actions/hooks/useSessionTracking';
import { AccountMenu } from '../AccountMenu';
import { Loader } from '../../view/Loader';
import { RoomDetailsContent } from './RoomDetailsContent';
import { useRoomDetails } from '../../../actions/hooks/useRoomDetails';
import { AgoraClientGroup } from '../agora';
type RoomDetailsProps = {
locale: string;
roomId: number;
activeType: RoomsType;
};
export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) => {
const { room, errorData, loading, fetchData } = useRoomDetails(locale, roomId);
const tracking = useSessionTracking(locale, roomId);
const [isCalling, setIsCalling] = useState<boolean>(false);
useEffect(() => {
if (isCalling) {
tracking.start();
} else {
tracking.stop();
}
}, [isCalling]);
const stopCalling = () => {
setIsCalling(false);
fetchData();
}
return isCalling
? (
<AgoraClientGroup
room={room}
stopCalling={stopCalling}
/>
) : (
<>
<div className="col-xl-3 col-lg-4 d-none d-lg-block">
<AccountMenu locale={locale} />
</div>
<div className="col-xl-9 col-lg-8 ">
<div className="page-account__inner">
<Loader
isLoading={loading}
errorData={errorData}
refresh={fetchData}
>
<RoomDetailsContent
locale={locale}
room={room}
activeType={activeType}
startRoom={() => setIsCalling(true)}
refresh={fetchData}
/>
</Loader>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,355 @@
'use client'
import React, { useState } 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 { i18nText } from '../../../i18nKeys';
import { LinkButton } from '../../view/LinkButton';
import {
addClient,
addSupervisor,
becomeRoomClient,
becomeRoomSupervisor,
deleteRoomClient,
deleteRoomSupervisor
} from '../../../actions/rooms';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { UserListModal } from '../../Modals/UsersListModal';
import { SessionState } from '../../../types/sessions';
import { EditRoomForm } from './EditRoomForm';
type RoomDetailsContentProps = {
locale: string;
activeType: RoomsType;
room?: Room;
startRoom: () => void;
refresh: () => void;
};
export const RoomDetailsContent = ({ room, startRoom, locale, activeType, refresh }: RoomDetailsContentProps) => {
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [userData] = useLocalStorage(AUTH_USER, '');
const { id: userId = 0 } = userData ? JSON.parse(userData) : {};
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [forSupervisor, setForSupervisor] = useState<boolean>(false);
const startDate = room?.scheduledStartAtUtc ? dayjs(room?.scheduledStartAtUtc).locale(locale) : null;
const endDate = room?.scheduledEndAtUtc ? dayjs(room?.scheduledEndAtUtc).locale(locale) : null;
const today = startDate ? dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD') : false;
const isCreator = room?.coach && room.coach.id === +userId || false;
const isSupervisor = room?.supervisor && room.supervisor.id === +userId || false;
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 goBack = () => router.push(`/account/rooms/${activeType}`);
const checkUserApply = (): boolean => (!room?.supervisor || !isSupervisor) && (!room?.clients || room?.clients && room?.clients.length === 0 || !isClient);
const deleteClient = (clientUserId: number) => {
if (room?.id) {
deleteRoomClient(locale, jwt, { sessionId: room.id, clientUserId })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
});
}
};
const deleteSupervisor = (supervisorUserId?: number) => {
if (room?.id && supervisorUserId) {
deleteRoomSupervisor(locale, jwt, { sessionId: room.id, supervisorUserId })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
})
}
};
const becomeClient = () => {
if (room?.id && userId) {
becomeRoomClient(locale, jwt, { sessionId: room.id, clientUserId: +userId })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
});
}
};
const becomeSupervisor = () => {
if (room?.id && userId) {
becomeRoomSupervisor(locale, jwt, { sessionId: room.id, supervisorUserId: +userId })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
});
}
};
const onInviteSupervisor = () => {
setForSupervisor(true)
setShowModal(true);
};
const onAddUser = (id: number) => {
if (room?.id) {
setShowModal(false);
if (forSupervisor) {
addSupervisor(locale, jwt, { sessionId: room.id, supervisorUserId: id })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
});
} else {
addClient(locale, jwt, { sessionId: room.id, clientUserId: id })
.then(() => {
refresh();
})
.catch((err) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
});
}
}
};
const afterEditing = () => {
setIsEdit(false);
refresh();
}
return !isEdit ? (
<div className="card-detail">
<div>
<Button
className="card-detail__back"
type="link"
icon={<LeftOutlined/>}
onClick={goBack}
>
{i18nText('back', locale)}
</Button>
</div>
<div className="card-detail__name">{room?.title || ''}</div>
<div
className={`card-detail__date${today ? ' chosen' : ''}${activeType === RoomsType.RECENT ? ' history' : ''}`}>
{today
? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`
: `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`}
</div>
{room?.themesTags && room.themesTags.length > 0 && (
<div className="card-detail__skills">
<div className="skills__list">
{room.themesTags.map((skill) => <Tag key={skill?.id}
className="skills__list__item">{skill?.name}</Tag>)}
</div>
</div>
)}
{room?.description && <div className="card-profile__desc">{room.description}</div>}
{activeType === RoomsType.UPCOMING && (isCreator || isSupervisor || isClient) && (
<div className="card-detail__actions">
{(isCreator || isClient || isSupervisor) && (
<Button
className="card-detail__apply"
onClick={startRoom}
>
{isCreator ? i18nText('session.start', locale) : i18nText('session.join', locale)}
</Button>
)}
{isCreator && isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && (
<Button
className="card-detail__filled"
onClick={() => setIsEdit(true)}
>
{i18nText('room.editRoom', locale)}
</Button>
)}
</div>
)}
<div className="card-detail__profile">
<div className="card-detail__profile_title">
<div>{i18nText('room.roomCreator', locale)}</div>
</div>
<div className="card-detail__profile_list">
<div className="card-detail__profile_item">
<div className="card-detail__portrait card-detail__portrait_small">
<Image src={room?.coach?.faceImageUrl || '/images/user-avatar.png'} width={86} height={86} alt=""/>
</div>
<div className="card-detail__inner">
<div className="card-detail__name">{`${room?.coach?.name} ${room?.coach?.surname || ''}`}</div>
</div>
</div>
</div>
</div>
{room?.isNeedSupervisor && (
<div className="card-detail__profile">
<div className="card-detail__profile_title">
<div>{i18nText('room.supervisor', locale)}</div>
</div>
{room?.supervisor && (
<div className="card-detail__profile_list">
<div className="card-detail__profile_item">
<div className="card-detail__portrait card-detail__portrait_small">
<Image src={room?.supervisor?.faceImageUrl || '/images/user-avatar.png'} width={86}
height={86}
alt=""/>
</div>
<div className="card-detail__inner">
<div
className="card-detail__name">{`${room?.supervisor?.name} ${room?.supervisor?.surname || ''}`}</div>
</div>
{isCreator && activeType === RoomsType.UPCOMING && isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && (
<LinkButton
type="link"
style={{alignSelf: 'flex-start'}}
danger
icon={<DeleteOutlined/>}
onClick={() => deleteSupervisor(room?.supervisor?.id)}
/>
)}
</div>
</div>
)}
{room?.supervisor && activeType === RoomsType.RECENT && (
<>
{room?.supervisorComment && (
<div className="card-detail__supervisor-comment">{room.supervisorComment}</div>
)}
</>
)}
{isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && isCreator && activeType === RoomsType.UPCOMING && (
<Button
className="card-detail__filled"
onClick={onInviteSupervisor}
>
{i18nText('room.inviteSupervisor', locale)}
</Button>
)}
{isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && !isCreator && activeType === RoomsType.UPCOMING && checkUserApply() && (
<Button
className="card-detail__apply"
onClick={becomeSupervisor}
>
{i18nText('room.joinSupervisor', locale)}
</Button>
)}
{!room?.supervisor && !isCreator && !checkUserApply() && (
<div className="card-profile__desc">{i18nText('noData', locale)}</div>
)}
</div>
)}
<div className="card-detail__profile">
<div className="card-detail__profile_title">
<div>{i18nText('room.participants', locale)}</div>
<div>{`${room?.clients?.length || 0}/${room?.maxClients}`}</div>
</div>
{room?.clients && room?.clients?.length > 0 && (
<div className="card-detail__profile_list">
{room.clients.map(({id, faceImageUrl, name, surname}) => (
<div key={id} className="card-detail__profile_item">
<div className="card-detail__portrait card-detail__portrait_small">
<Image src={faceImageUrl || '/images/user-avatar.png'} width={86}
height={86}
alt=""/>
</div>
<div className="card-detail__inner">
<div
className="card-detail__name">{`${name} ${surname || ''}`}</div>
</div>
{isCreator && room?.state === SessionState.COACH_APPROVED && activeType === RoomsType.UPCOMING && isTimeBeforeStart && (
<LinkButton
type="link"
style={{alignSelf: 'flex-start'}}
danger
icon={<DeleteOutlined/>}
onClick={() => deleteClient(id)}
/>
)}
</div>
))}
</div>
)}
{isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && isCreator && activeType === RoomsType.UPCOMING && (!room?.clients || (room?.clients && room?.clients?.length < room.maxClients)) && (
<Button
className="card-detail__filled"
onClick={() => setShowModal(true)}
>
{i18nText('room.inviteParticipant', locale)}
</Button>
)}
{isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !isCreator && activeType === RoomsType.UPCOMING && (!room?.clients || (room?.clients && room?.clients?.length < room.maxClients)) && checkUserApply() && (
<Button
className="card-detail__apply"
onClick={becomeClient}
>
{i18nText('room.joinParticipant', locale)}
</Button>
)}
</div>
{room && (
<UserListModal
locale={locale}
jwt={jwt}
isOpen={showModal}
handleCancel={() => setShowModal(false)}
submit={onAddUser}
afterCloseModal={() => setForSupervisor(false)}
room={room}
/>
)}
</div>
) : (
<div className="card-detail">
<div>
<Button
className="card-detail__back"
type="link"
icon={<LeftOutlined/>}
onClick={() => setIsEdit(false)}
>
{i18nText('back', locale)}
</Button>
</div>
<EditRoomForm
roomId={room?.id || 0}
locale={locale}
jwt={jwt}
mode="edit"
afterSubmit={afterEditing}
/>
</div>
);
};

View File

@ -0,0 +1,173 @@
'use client';
import React, { MouseEvent, useCallback, useEffect, useState } from 'react';
import { Empty, Space } from 'antd';
import dayjs from 'dayjs';
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 { RoomsType } from '../../../types/rooms';
import { getRecentRooms, getUpcomingRooms } from '../../../actions/rooms';
import { Loader } from '../../view/Loader';
import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../../constants/common';
import { usePathname, useRouter } from '../../../navigation';
import { i18nText } from '../../../i18nKeys';
import { CreateRoom } from './CreateRoom';
type RoomsTabsProps = {
locale: string;
activeTab: RoomsType;
};
export const RoomsTabs = ({ locale, activeTab }: RoomsTabsProps) => {
const [sort, setSort] = useState<string>();
const [rooms, setRooms] = useState<any>();
const [loading, setLoading] = useState<boolean>(true);
const [errorData, setErrorData] = useState<any>();
const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, '');
const router = useRouter();
const pathname = usePathname();
const fetchData = () => {
setErrorData(undefined);
setLoading(true);
Promise.all([
getUpcomingRooms(locale, jwt),
getRecentRooms(locale, jwt)
])
.then(([upcoming, recent]) => {
setRooms({
[RoomsType.UPCOMING]: upcoming || [],
[RoomsType.RECENT]: recent || []
});
})
.catch((err) => {
setErrorData(err);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchData();
}, []);
const onChangeSort = useCallback((value: string) => {
setSort(value);
}, [sort]);
const onClickSession = (event: MouseEvent<HTMLDivElement>, id: number) => {
event.stopPropagation();
event.preventDefault();
router.push(`${pathname}/${id}`);
};
const getChildren = (list?: any[]) => (
<>
{/* <div className="filter-session">
<div className="filter-session__item">
<CustomSelect
label="Topic"
value={sort}
onChange={onChangeSort}
options={[
{ value: 'topic1', label: 'Topic 1' },
{ value: 'topic2', label: 'Topic 2' },
{ value: 'topic3', label: 'Topic 3' },
{ value: 'topic4', label: 'Topic 4' }
]}
/>
</div>
</div> */}
<div className="list-session">
{list && list?.length > 0 ? list?.map(({ id, scheduledStartAtUtc, scheduledEndAtUtc, title, coach, clients, supervisor, maxClients }) => {
const startDate = dayjs(scheduledStartAtUtc).locale(locale);
const endDate = dayjs(scheduledEndAtUtc).locale(locale);
const today = dayjs().format('YYYY-MM-DD') === startDate.format('YYYY-MM-DD');
return (
<div key={id} className="card-profile session__item" onClick={(e: MouseEvent<HTMLDivElement>) => onClickSession(e, id)}>
<div className="card-profile__header">
<div className="card-profile__header__portrait">
<img src={coach?.faceImageUrl || '/images/person.png'} className="" alt="" />
</div>
<div className="card-profile__header__inner">
<div>
<div className="card-profile__header__name">{`${coach?.name} ${coach?.surname || ''}`}</div>
<div className="card-profile__header__title">{title}</div>
<div className={`card-profile__header__date${activeTab === RoomsType.RECENT ? ' history' : (today ? ' chosen' : '')}`}>
{today
? `${i18nText('today', locale)} ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}`
: `${startDate.format('D MMMM')} ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}`}
</div>
<div className="card-room__details">
{supervisor && (
<>
<div>{i18nText('room.supervisor', locale)}</div>
<div>{`${supervisor?.name} ${supervisor?.surname || ''}`}</div>
</>
)}
<div>{i18nText('room.members', locale)}</div>
<div>{`${clients.length}/${maxClients}`}</div>
</div>
</div>
</div>
</div>
</div>
)
}) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={i18nText('noData', locale)} />
)}
</div>
</>
);
const tabs = [
{
key: RoomsType.UPCOMING,
label: (
<>
{i18nText('room.upcoming', locale)}
{rooms?.upcoming && rooms?.upcoming?.length > 0 ? (<span className="count">{rooms?.upcoming.length}</span>) : null}
</>
),
children: getChildren(rooms?.upcoming)
},
{
key: RoomsType.RECENT,
label: i18nText('room.recent', locale),
children: getChildren(rooms?.recent)
},
{
key: RoomsType.NEW,
label: i18nText('room.newRoom', locale),
children: <CreateRoom locale={locale} jwt={jwt} />
}
];
return (
<Loader
isLoading={loading}
errorData={errorData}
refresh={fetchData}
>
<div className="tabs-session">
{tabs.map(({ key, label }) => (
<Space
key={key}
className={`tabs-session__item ${key === activeTab ? 'active' : ''}`}
onClick={() => router.push(`/account/rooms/${key}`)}
>
{label}
</Space>
))}
</div>
{tabs.filter(({ key }) => key === activeTab)[0].children}
</Loader>
);
};

View File

@ -0,0 +1,6 @@
'use client'
export * from './RoomDetails';
export * from './RoomsTabs';
export * from './RoomDetailsContent';
export * from './CreateRoom';

View File

@ -81,7 +81,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio
const CoachCard = (coach?: PublicUser) => coach ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={coach?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
<Image src={coach?.faceImageUrl || '/images/user-avatar.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
@ -106,7 +106,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio
<div className="card-detail__skills">
<div className="skills__list">
{session?.themesTags?.slice(0, 2).map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
{session?.themesTags?.length > 2
{session?.themesTags && session?.themesTags?.length > 2
? (
<Tag className="skills__list__more">
<Link href={`/experts/${coach?.id}` as any} target="_blank">
@ -128,7 +128,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio
const StudentCard = (student?: PublicUser | null) => student ? (
<div className="card-detail__expert">
<div className="card-detail__portrait">
<Image src={student?.faceImageUrl || '/images/person.png'} width={140} height={140} alt="" />
<Image src={student?.faceImageUrl || '/images/user-avatar.png'} width={140} height={140} alt="" />
</div>
<div className="card-detail__inner">
<div className="card-detail__name">{`${student?.name} ${student?.surname || ''}`}</div>

View File

@ -14,7 +14,7 @@ import { useLocalStorage } from '../../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common';
import { getRecentSessions, getRequestedSessions, getUpcomingSessions } from '../../../actions/sessions';
import { Session, Sessions, SessionType } from '../../../types/sessions';
import { useRouter } from '../../../navigation';
import { useRouter, usePathname } from '../../../navigation';
import { i18nText } from '../../../i18nKeys';
type SessionsTabsProps = {
@ -31,6 +31,7 @@ export const SessionsTabs = ({ locale, activeTab }: SessionsTabsProps) => {
const [userData] = useLocalStorage(AUTH_USER, '');
const { id: userId = 0 } = userData ? JSON.parse(userData) : {};
const router = useRouter();
const pathname = usePathname();
const fetchData = () => {
setErrorData(undefined);
@ -66,7 +67,7 @@ export const SessionsTabs = ({ locale, activeTab }: SessionsTabsProps) => {
const onClickSession = (event: MouseEvent<HTMLDivElement>, id: number) => {
event.stopPropagation();
event.preventDefault();
router.push(`${id}`);
router.push(`${pathname}/${id}`);
};
const getChildren = (list?: Session[]) => (

View File

@ -1,7 +1,7 @@
'use client'
import React, { useState } from 'react';
import {Alert, message} from 'antd';
import { Alert, message } from 'antd';
import Image from 'next/image';
import { i18nText } from '../../i18nKeys';
import { ExpertData, PayInfo, ProfileData } from '../../types/profile';

View File

@ -55,8 +55,6 @@ export const ExpertsAdditionalFilter = ({
};
const search = getSearchParamsString(newFilter);
console.log('here1');
router.push(search ? `${basePath}?${search}#filter` : `${basePath}#filter`);
// router.push({

View File

@ -1,19 +1,25 @@
'use client';
import React, { FC } from 'react';
import React, { FC, useState, useEffect } from 'react';
import Image from 'next/image';
import { Tag, Image as AntdImage, Space } from 'antd';
import { Tag, Image as AntdImage, Space, Button } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, StarFilled } from '@ant-design/icons';
import { SignupSessionData } from '../../types/experts';
import { ExpertDetails, Practice, ThemeGroup } from '../../types/experts';
import { ExpertDocument } from '../../types/file';
import { Locale } from '../../types/locale';
import { CustomRate } from '../view/CustomRate';
import { i18nText } from '../../i18nKeys';
import { FilledYellowButton } from '../view/FilledButton';
import { getStorageValue } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY, SESSION_DATA } from '../../constants/common';
import { ScheduleModal } from '../Modals/ScheduleModal';
import { ScheduleModalResult } from '../Modals/ScheduleModalResult';
type ExpertDetailsProps = {
expert: ExpertDetails;
locale?: string;
expertId?: string;
};
type ExpertPracticeProps = {
@ -22,50 +28,77 @@ type ExpertPracticeProps = {
locale?: string;
};
export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale }) => {
export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId }) => {
const { publicCoachDetails } = expert || {};
return (
<div className="expert-card">
<div className="expert-card__wrap">
<div className="expert-card__avatar">
<Image src={publicCoachDetails?.faceImageUrl || '/images/person.png'} width={216} height={216} alt="" />
</div>
<div className="expert-card__inner">
<h1 className="expert-card__title">{`${publicCoachDetails?.name} ${publicCoachDetails?.surname || ''}`}</h1>
<div className="expert-card__info">
<span>{`${publicCoachDetails?.practiceHours} ${i18nText('practiceHours', locale)}`}</span>
<i>|</i>
<span>{`${publicCoachDetails?.supervisionPerYearId} ${i18nText('supervisionCount', locale)}`}</span>
</div>
<div className="expert-card__rating">
<CustomRate defaultValue={4} character={<StarFilled style={{ fontSize: 32 }} />} disabled />
<span>{`4/5 (${i18nText('outOf', locale)} 345)`}</span>
</div>
</div>
</div>
<div className="expert-card__wrap-btn">
<a href="#" className="btn-apply">
<img src="/images/calendar-outline.svg" className="" alt="" />
{i18nText('schedule', locale)}
</a>
{/*
<a href="#" className="btn-video">
<img src="/images/videocam-outline.svg" className="" alt=""/>
Video
</a>
*/}
</div>
</div>
);
};
export const ExpertInformation: FC<ExpertDetailsProps> = ({ expert, locale }) => {
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 checkSession = (data?: SignupSessionData) => {
if (data?.startAtUtc && data?.tagId) {
const jwt = getStorageValue(AUTH_TOKEN_KEY, '');
sessionStorage?.setItem(SESSION_DATA, JSON.stringify(data));
if (jwt) {
setMode('pay');
} else {
setShowSchedulerModal(false);
const showAuth = new Event('show_auth_enter');
document.dispatchEvent(showAuth);
}
}
}
const handleShowPayForm = () => {
setShowSchedulerModal(true);
setMode('pay');
}
useEffect(() => {
document.addEventListener('show_pay_form', handleShowPayForm);
return () => {
document.removeEventListener('show_pay_form', handleShowPayForm);
};
}, []);
const onSchedulerHandle = () => {
setMode('data');
setShowSchedulerModal(true);
};
return (
<>
<div className="expert-card">
<div className="expert-card__wrap">
<div className="expert-card__avatar">
<Image src={publicCoachDetails?.faceImageUrl || '/images/person.png'} width={216} height={216} alt="" />
</div>
<div className="expert-card__inner">
<h1 className="expert-card__title">{`${publicCoachDetails?.name} ${publicCoachDetails?.surname || ''}`}</h1>
<div className="expert-card__info">
<span>{`${publicCoachDetails?.practiceHours} ${i18nText('practiceHours', locale)}`}</span>
<i>|</i>
<span>{`${publicCoachDetails?.supervisionPerYearId} ${i18nText('supervisionCount', locale)}`}</span>
</div>
<div className="expert-card__rating">
<CustomRate defaultValue={4} character={<StarFilled style={{ fontSize: 32 }} />} disabled />
<span>{`4/5 (${i18nText('outOf', locale)} 345)`}</span>
</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>
</div>
<div className="expert-info">
{/* <h2 className="title-h2">{}</h2> */}
<div className="skills__list">
@ -91,11 +124,22 @@ export const ExpertInformation: FC<ExpertDetailsProps> = ({ expert, locale }) =>
{tags?.map((skill) => <Tag key={skill?.id} className="skills__list__item">{skill?.name}</Tag>)}
</div>
<div className="wrap-btn-prise">
<FilledYellowButton onClick={() => console.log('schedule')}>{i18nText('signUp', locale)}</FilledYellowButton>
<FilledYellowButton onClick={onSchedulerHandle}>{i18nText('signUp', locale)}</FilledYellowButton>
<div className="wrap-btn-prise__text">
{`${sessionCost}`} <span>/ {`${sessionDuration}${isRus ? 'мин' : 'min'}`}</span>
</div>
</div>
<ScheduleModal
open={showSchedulerModal}
handleCancel={() => setShowSchedulerModal(false)}
updateMode={setMode}
mode={mode}
expertId={expertId as string}
locale={locale as string}
sessionCost={sessionCost}
checkSession={checkSession}
/>
<ScheduleModalResult locale={locale as string} />
</>
);
};

View File

@ -114,7 +114,6 @@ export const ExpertsFilter = ({
...getObjectByAdditionalFilter(searchParams)
};
const search = getSearchParamsString(newFilter);
console.log('basePath', basePath);
router.push(search ? `${basePath}?${search}#filter` : `${basePath}#filter`);

View File

@ -1,20 +1,20 @@
'use client';
import React, { FC, useEffect, useState } from 'react';
import {Modal, Button, message, Form, Collapse, GetProp, UploadProps} from 'antd';
import React, { FC, useState } from 'react';
import { Modal, Button, message, Form, Collapse } from 'antd';
import type { CollapseProps } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { i18nText } from '../../i18nKeys';
import { PracticePersonData, PracticeDTO, PracticeData, PracticeCase } from '../../types/practice';
import { PracticePersonData } from '../../types/practice';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import {setEducation} from '../../actions/profile';
import {Certificate, Details, EducationData, EducationDTO, Experience} from "../../types/education";
import {CertificatesContent} from "./educationModalContent/Certificates";
import {EducationsContent} from "./educationModalContent/Educations";
import {TrainingsContent} from "./educationModalContent/Trainings";
import {MbasContent} from "./educationModalContent/Mbas";
import {ExperiencesContent} from "./educationModalContent/Experiences";
import { setEducation } from '../../actions/profile';
import { EducationData, EducationDTO } from '../../types/education';
import { CertificatesContent } from './educationModalContent/Certificates';
import { EducationsContent } from './educationModalContent/Educations';
import { TrainingsContent } from './educationModalContent/Trainings';
import { MbasContent } from './educationModalContent/Mbas';
import { ExperiencesContent } from './educationModalContent/Experiences';
type EditExpertEducationModalProps = {
open: boolean;

View File

@ -0,0 +1,286 @@
'use client';
import React, {FC, useEffect, useState} from 'react';
import classNames from 'classnames';
import { Modal, Menu, Calendar, Radio, Button, Input, message, Form } from 'antd';
import type { CalendarProps, MenuProps } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { CloseOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
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 { getLocale } from '../../utils/locale';
import { AUTH_TOKEN_KEY, SESSION_DATA } from '../../constants/common';
import { ExpertScheduler, SignupSessionData } from '../../types/experts';
import { Tag } from '../../types/tags';
import { getSchedulerByExpertId, getSchedulerSession } from '../../actions/experts';
import { StripeElementsForm } from '../stripe/StripeElementsForm';
import { i18nText } from '../../i18nKeys';
import { CustomSelect } from '../../components/view/CustomSelect';
import { Loader } from '../view/Loader';
import { getStorageValue } from '../../hooks/useLocalStorage';
type ScheduleModalProps = {
open: boolean;
handleCancel: () => void;
mode: 'data' | 'time' | 'pay' | 'finish';
updateMode: (mode: 'data' | 'time' | 'pay' | 'finish') => void;
sessionCost: number;
expertId: string;
locale: string;
checkSession: (data?: SignupSessionData) => void;
};
type MenuItem = Required<MenuProps>['items'][number];
const getCalendarMenu = (start: Dayjs): MenuItem[] => Array.from({ length: 3 })
.map((_: unknown, index: number) => {
const date = index ? start.add(index, 'M') : start.clone();
return {
label: <span className="b-calendar-month">{date.format('MMMM')}</span>,
key: date.format('YYYY-MM-DD')
}
});
export const ScheduleModal: FC<ScheduleModalProps> = ({
open,
handleCancel,
mode,
updateMode,
sessionCost,
locale,
expertId,
checkSession,
}) => {
const [selectDate, setSelectDate] = useState<Dayjs>(dayjs());
const [dates, setDates] = useState<Record<string, { startTime: string, endTime: string }[]> | undefined>();
const [tags, setTags] = useState<Tag[] | undefined>();
const [rawScheduler, setRawScheduler] = useState<ExpertScheduler | null>(null);
const [isPayLoading, setIsPayLoading] = useState<boolean>(false);
const [sessionId, setSessionId] = useState<string>('');
const [form] = Form.useForm<{ clientComment?: string, startAtUtc?: string, tagId?: number }>();
dayjs.locale(locale);
const signupSession = () => {
const data = sessionStorage?.getItem(SESSION_DATA);
const jwt = getStorageValue(AUTH_TOKEN_KEY, '');
if (jwt && data) {
const parseData = JSON.parse(data);
setIsPayLoading(true);
getSchedulerSession(parseData as SignupSessionData, locale || 'en', jwt)
.then((session) => {
setSessionId(session?.sessionId);
console.log(session?.sessionId);
})
.catch((err) => {
console.log(err);
message.error('Не удалось провести оплату')
})
.finally(() => {
sessionStorage?.removeItem(SESSION_DATA);
setIsPayLoading(false);
})
}
};
useEffect(()=> {
if (open && mode !== 'pay') {
getSchedulerByExpertId(expertId as string, locale as string)
.then((data) => {
setRawScheduler(data);
})
.catch((err) => {
console.log(err);
});
}
if (!open) {
form.resetFields();
}
}, [open]);
useEffect(() => {
if (open && mode === 'pay') {
signupSession();
}
}, [mode]);
useEffect(() => {
const map = {} as any
rawScheduler?.availableSlots.forEach((el) => {
const key = dayjs(el.startTime).format('YYYY-MM-DD');
if (!map[key]){
map[key] = []
}
map[key].push(el);
})
setDates(map);
setTags(rawScheduler?.tags)
}, [rawScheduler]);
const onPanelChange = (value: Dayjs) => setSelectDate(value);
const onDateChange: CalendarProps<Dayjs>['onSelect'] = (value, selectInfo) => {
if (selectInfo.source === 'date') {
setSelectDate(value);
updateMode('time');
}
};
const disabledDate = (currentDate: Dayjs) => !dates || !dates[currentDate.format('YYYY-MM-DD')];
const cellRender: CalendarProps<Dayjs>['fullCellRender'] = (date, info) => {
const isWeekend = date.day() === 6 || date.day() === 0;
return React.cloneElement(info.originNode, {
...info.originNode.props,
className: classNames('b-calendar-cell', {
['b-calendar-cell__select']: selectDate.isSame(date, 'date'),
['b-calendar-cell__today']: date.isSame(dayjs(), 'date'),
['b-calendar-cell__weekend']: isWeekend,
}),
children: (
<div>
<span>
{date.get('date')}
</span>
</div>
),
});
};
const onValidate = () => {
form.validateFields()
.then((values) => {
checkSession({ coachId: +expertId, ...values });
})
}
return (
<Modal
className="b-modal"
open={open}
title={undefined}
onOk={undefined}
onCancel={handleCancel}
footer={false}
width={498}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: '#000' }}/>}
>
{mode === 'data' && (
<Calendar
className="b-calendar"
fullscreen={false}
onPanelChange={onPanelChange}
fullCellRender={cellRender}
onSelect={onDateChange}
value={selectDate}
disabledDate={disabledDate}
locale={getLocale(locale)}
validRange={[selectDate.startOf('M'), selectDate.endOf('M')]}
headerRender={({ onChange }) => {
const start = dayjs().startOf('M');
const [activeMonth, setActiveMonth] = useState<string>(start.format('YYYY-MM-DD'));
const onClick: MenuProps['onClick'] = (e) => {
setActiveMonth(e.key);
onChange(dayjs(e.key));
};
return (
<Menu
className="b-calendar-header"
onClick={onClick}
selectedKeys={[activeMonth]}
mode="horizontal"
items={getCalendarMenu(start)}
/>
);
}}
/>
)}
{mode === 'time' && (
<div className="b-schedule-time">
<div className="b-schedule-time-header">
<Button
className="b-button-link-big"
type="link"
onClick={() => updateMode('data')}
icon={<ArrowLeftOutlined />}
iconPosition="start"
>
{selectDate.locale(locale).format('DD MMMM YYYY')}
</Button>
</div>
<Form form={form}>
<div className="b-schedule-select-tag">
{tags && (
<Form.Item
name="tagId"
rules={[{
required: true,
message: ''
}]}
>
<CustomSelect
label={i18nText('selectTopic', locale)}
options={tags?.map(({id, name}) => ({value: id, label: name}))}
/>
</Form.Item>
)}
</div>
<div className="b-schedule-radio-list">
<Form.Item
name="startAtUtc"
rules={[{
required: true,
message: ''
}]}
>
<Radio.Group>
{dates && dates[selectDate.format('YYYY-MM-DD')].map((el: any) => (
<Radio
key={el.startTime}
value={el.startTime}
>
{dayjs(el.startTime).format('HH:mm')} - {dayjs(el.endTime).format('HH:mm')}
</Radio>)
)}
</Radio.Group>
</Form.Item>
</div>
<Form.Item name="clientComment">
<Input.TextArea
className="b-textarea"
rows={2}
placeholder={i18nText('sessionWishes', locale)}
/>
</Form.Item>
</Form>
<Button
className="btn-apply"
onClick={onValidate}
>
{i18nText('pay', locale)}
</Button>
</div>
)}
{mode === 'pay' && (
<div className="b-schedule-payment">
<Loader isLoading={isPayLoading}>
<StripeElementsForm
amount={sessionCost}
locale={locale}
sessionId={sessionId}
/>
</Loader>
</div>
)}
</Modal>
);
};

View File

@ -0,0 +1,73 @@
'use client'
import React, { useEffect, useState } from 'react';
import { Modal, Result } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { useSearchParams, useRouter } from 'next/navigation';
import { Stripe } from 'stripe';
import { getStripePaymentStatus } from '../../actions/stripe';
import { sessionPaymentConfirm } from '../../actions/sessions';
import { getStorageValue } from '../../hooks/useLocalStorage';
import { AUTH_TOKEN_KEY } from '../../constants/common';
import { Session, SessionState } from '../../types/sessions';
import { i18nText } from '../../i18nKeys';
export const ScheduleModalResult = ({ locale }: { locale: string }) => {
const searchParams = useSearchParams();
const [paymentStatus, setPaymentStatus] = useState<Stripe.PaymentIntent.Status | undefined>();
const [session, setSession] = useState<Session | undefined>();
const [error, setError] = useState<any>();
const router = useRouter();
useEffect(() => {
setError(undefined);
const payment_intent = searchParams.get('payment_intent') || false;
if (payment_intent) {
getStripePaymentStatus(payment_intent)
.then((result) => {
setPaymentStatus(result?.status);
if (result?.status === 'succeeded' && result?.metadata?.sessionId) {
const jwt = getStorageValue(AUTH_TOKEN_KEY, '');
sessionPaymentConfirm(locale, jwt, +result.metadata.sessionId)
.then((session) => {
setSession(session);
})
.catch((err: any) => {
setError(err);
});
}
})
.catch((err: any) => {
setError(err);
})
}
}, [searchParams]);
const onClose = () => {
const { origin, pathname } = window?.location || {};
router.push(`${origin}${pathname}`);
setPaymentStatus(undefined);
setSession(undefined);
};
return (
<Modal
className="b-modal"
open={paymentStatus === 'succeeded' && session?.state === SessionState.PAID}
title={undefined}
onOk={undefined}
onCancel={onClose}
footer={false}
width={498}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: '#000' }}/>}
>
<div className="b-schedule-payment-result">
<Result
status="success"
title={i18nText('successPayment', locale)}
/>
</div>
</Modal>
);
}

View File

@ -0,0 +1,113 @@
'use client';
import React, { useCallback, useState } from 'react';
import { Button, Modal, notification } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import debounce from 'lodash/debounce';
import Image from 'next/image';
import { i18nText } from '../../i18nKeys';
import { getUsersList } from '../../actions/rooms';
import { PublicUser } from '../../types/sessions';
import { Room } from '../../types/rooms';
import { CustomInput } from '../view/CustomInput';
import { Loader } from '../view/Loader';
type UserListModalProps = {
room: Room;
isOpen: boolean;
locale: string;
handleCancel: () => void;
jwt: string;
submit: (id: number) => void;
afterCloseModal?: () => void;
};
export const UserListModal = ({ room, isOpen, locale, handleCancel, jwt, submit, afterCloseModal }: UserListModalProps) => {
const [users, setUsers] = useState<PublicUser[] | undefined>();
const [loading, seLoading] = useState<boolean>(false);
const onSearch = useCallback(debounce((e: any) => {
if (e?.target?.value) {
seLoading(true);
getUsersList(locale, jwt, { template: e.target.value })
.then(({ items }) => {
const clients = room?.clients?.map(({ id }) => id);
setUsers(items
? items.filter(({ id }) => !(clients?.length && clients.includes(id) || id === room?.supervisor?.id || id === room?.coach?.id))
: undefined);
})
.catch((err: any) => {
notification.error({
message: 'Error',
description: err?.response?.data?.errMessage
});
})
.finally(() => {
seLoading(false);
});
} else {
setUsers(undefined);
}
}, 300), []);
const onAfterClose = () => {
setUsers(undefined);
if (afterCloseModal) afterCloseModal();
}
return (
<Modal
className="b-modal"
open={isOpen}
title={undefined}
onOk={undefined}
onCancel={handleCancel}
footer={false}
width={498}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: '#000' }}/>}
afterClose={onAfterClose}
>
<div className="b-modal__users-list__content">
<CustomInput
placeholder={i18nText('search', locale)}
onChange={onSearch}
allowClear
/>
{users && (
<div className="b-users-list__wrapper">
<Loader isLoading={loading}>
{users.length > 0 ? (
<div className="b-users-list">
{users.map(({ id, name, surname, faceImageUrl }) => (
<div className="b-users-list-item" key={id}>
<div>
<div className="card-detail__portrait card-detail__portrait_small">
<Image src={faceImageUrl || '/images/user-avatar.png'} width={86}
height={86}
alt=""/>
</div>
<div className="card-detail__inner">
<div
className="card-detail__name">{`${name} ${surname || ''}`}</div>
</div>
</div>
<Button
className="card-detail__filled"
onClick={() => submit(id)}
>
{i18nText('room.invite', locale)}
</Button>
</div>
))}
</div>
) : (
<div className="b-users-list__empty">{i18nText('noData', locale)}</div>
)}
</Loader>
</div>
)}
</div>
</Modal>
)
}

View File

@ -6,7 +6,7 @@ import { AUTH_USER } from '../../../constants/common';
import { SocialConfig } from '../../../constants/social';
import { useOauthWindow } from '../../../hooks/useOauthWindow';
import { getAuth } from '../../../actions/auth';
import { getPersonalData } from '../../../actions/profile';
import {getPersonalData, getUserData} from '../../../actions/profile';
import { CustomInput } from '../../view/CustomInput';
import { CustomInputPassword } from '../../view/CustomInputPassword';
import { FilledButton } from '../../view/FilledButton';
@ -39,7 +39,7 @@ export const EnterContent: FC<EnterProps> = ({
getAuth(locale, { login, password })
.then((data) => {
if (data.jwtToken) {
getPersonalData(locale, data.jwtToken)
getUserData(locale, data.jwtToken)
.then((profile) => {
localStorage.setItem(AUTH_USER, JSON.stringify(profile));
updateToken(data.jwtToken);

View File

@ -23,6 +23,31 @@ function HeaderAuthLinks ({
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment || '';
const [token, setToken] = useLocalStorage(AUTH_TOKEN_KEY, '');
const [isPayPath, setIsPayPath] = useState<boolean>(false);
const onOpen = (mode: 'enter' | 'register' | 'reset' | 'finish') => {
setMode(mode);
setIsOpenModal(true);
};
const handleAuthRegister = () => {
setIsPayPath(true);
onOpen('register');
};
const handleAuthEnter = () => {
setIsPayPath(true);
onOpen('enter');
};
useEffect(() => {
document.addEventListener('show_auth_register', handleAuthRegister);
document.addEventListener('show_auth_enter', handleAuthEnter);
return () => {
document.removeEventListener('show_auth_register', handleAuthRegister);
document.removeEventListener('show_auth_enter', handleAuthEnter);
};
}, []);
useEffect(() => {
if (!isOpenModal) {
@ -30,15 +55,22 @@ function HeaderAuthLinks ({
}
}, [isOpenModal]);
const onOpen = (mode: 'enter' | 'register' | 'reset' | 'finish') => {
setMode(mode);
setIsOpenModal(true);
useEffect(() => {
if (token && isPayPath) {
const showPayForm = new Event('show_pay_form');
document.dispatchEvent(showPayForm);
}
}, [token]);
const addNewEvent = (name: 'show_auth_register' | 'show_auth_enter') => {
const evt = new Event(name);
document.dispatchEvent(evt);
};
return token
? (
<li>
<Link href={'/account/sessions/upcoming' as any} className={pathname === 'account' ? 'active' : ''}>
<Link href={'/account/sessions' as any} className={pathname === 'account' ? 'active' : ''}>
{i18nText('account', locale)}
</Link>
</li>
@ -49,7 +81,7 @@ function HeaderAuthLinks ({
<Button
className="b-header__auth"
type="link"
onClick={() => onOpen('register')}
onClick={() => addNewEvent('show_auth_register')}
>
{i18nText('registration', locale)}
</Button>
@ -61,7 +93,7 @@ function HeaderAuthLinks ({
<Button
className="b-header__auth"
type="link"
onClick={() => onOpen('enter')}
onClick={() => addNewEvent('show_auth_enter')}
>
{i18nText('enter', locale)}
</Button>

View File

@ -0,0 +1,165 @@
'use client';
import React, { FC, useEffect, useState } from 'react';
import type { StripeError } from '@stripe/stripe-js';
import {
useStripe,
useElements,
PaymentElement,
Elements,
} from '@stripe/react-stripe-js';
import { Form, Button, message } from 'antd';
import getStripe from '../../utils/get-stripe';
import { createPaymentIntent} from '../../actions/stripe';
import { Payment } from '../../types/payment';
import { i18nText } from '../../i18nKeys';
import { WithError } from '../view/WithError';
type PaymentFormProps = {
amount: number,
sessionId?: string,
locale: string
}
type PaymentInfo = 'initial' | 'error' | 'processing' | 'requires_payment_method' | 'requires_confirmation' | 'requires_action' | 'succeeded';
const PaymentStatus = ({ status }: { status?: PaymentInfo }) => {
switch (status) {
case 'processing':
case 'requires_payment_method':
case 'requires_confirmation':
return <h2>Processing...</h2>;
case 'requires_action':
return <h2>Authenticating...</h2>;
case 'succeeded':
return <h2>Payment Succeeded</h2>;
default:
return null;
}
};
export const CheckoutForm: FC<PaymentFormProps> = ({ amount, sessionId, locale }) => {
const [form] = Form.useForm<Payment>();
const formAmount = Form.useWatch('amount', form);
const [paymentType, setPaymentType] = useState<string>('');
const [payment, setPayment] = useState<{
status: PaymentInfo
}>({ status: 'initial' });
const [errorData, setErrorData] = useState<any>();
const stripe = useStripe();
const elements = useElements();
useEffect(() => {
elements?.update({ amount: formAmount * 100 });
}, [formAmount]);
const onSubmit = async () => {
try {
if (!elements || !stripe) return;
setErrorData(undefined);
setPayment({ status: "processing" });
const { error: submitError } = await elements.submit();
if (submitError) {
if (submitError.message) {
message.error(submitError.message);
}
return;
}
const { client_secret: clientSecret } = await createPaymentIntent(
{ amount, sessionId }
);
const { error: confirmError } = await stripe!.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: window.location.href,
payment_method_data: {
allow_redisplay: 'limited',
// billing_details: {
// name: input.cardholderName,
// },
},
},
});
if (confirmError) {
setErrorData({
title: i18nText('errorPayment', locale),
message: confirmError.message ?? 'An unknown error occurred'
});
}
} catch (err) {
const { message } = err as StripeError;
setErrorData({
title: i18nText('errorPayment', locale),
message: message ?? 'An unknown error occurred'
});
}
};
return (
<WithError errorData={errorData}>
<Form form={form} onFinish={onSubmit} style={{ display: 'flex', overflow: 'hidden', flexDirection: 'column', gap: 16, justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ width: '100%' }}>
<PaymentElement
onChange={(e) => {
setPaymentType(e.value.type);
}}
/>
</div>
<div>
<PaymentStatus status={payment.status}/>
</div>
<Button
className="btn-apply"
htmlType="submit"
disabled={
!["initial", "succeeded", "error"].includes(payment.status) ||
!stripe
}
>
{`${i18nText('pay', locale)} ${amount}`}
</Button>
</Form>
</WithError>
);
}
export const StripeElementsForm: FC<PaymentFormProps> = ({ amount, sessionId, locale }) => {
return (
<Elements
stripe={getStripe()}
options={{
fonts: [{
cssSrc: 'https://fonts.googleapis.com/css2?family=Comfortaa&display=swap',
}],
appearance: {
variables: {
colorIcon: '#2c7873',
fontSizeBase: '16px',
colorPrimary: '#66A5AD',
colorBackground: '#F8F8F7',
colorText: '#000',
colorDanger: '#ff4d4f',
focusBoxShadow: 'none',
borderRadius: '8px'
},
},
currency: 'eur',
mode: "payment",
amount: amount*100,
}}
>
<CheckoutForm amount={amount} sessionId={sessionId} locale={locale} />
</Elements>
);
};

View File

@ -0,0 +1,60 @@
'use client'
import React, { useEffect, useState } from 'react';
import { DatePicker } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
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 { getLocale } from '../../utils/locale';
export const CustomDatePicker = (props: any) => {
const { label, value, locale, ...other } = props;
const [isActiveLabel, setIsActiveLabel] = useState<boolean>(false);
dayjs.locale(locale);
useEffect(() => {
if (label) {
setIsActiveLabel(!!value);
} else {
setIsActiveLabel(false);
}
}, [value]);
const onOpenChange = (open: boolean) => {
if (open) {
if (!isActiveLabel) setIsActiveLabel(true)
} else {
setIsActiveLabel(!!value)
}
};
return (
<div className={`b-datepicker-wrap ${isActiveLabel ? 'b-datepicker__active' : ''}`}>
<div className="b-datepicker-label">
<span>{label}</span>
</div>
<DatePicker
className="b-datepicker"
format="YYYY-MM-DD"
locale={getLocale(locale)}
value={value}
showNow={false}
onOpenChange={onOpenChange}
needConfirm={false}
placeholder=""
variant="filled"
allowClear={false}
popupClassName="b-datepicker-popup"
minDate={dayjs().startOf('month')}
suffixIcon={<CalendarOutlined style={{ color: '#2c7873', fontSize: 20 }} />}
{...other}
/>
</div>
);
};

View File

@ -16,8 +16,8 @@ export const WithError: FC<WithErrorProps> = ({
return (
<Result
status="error"
title="Submission Failed"
subTitle="Please check and modify the following information before resubmitting."
title={errorData?.title}
subTitle={errorData?.message}
extra={refresh ? (
<Button type="primary" onClick={refresh}>
Refresh page

View File

@ -1,6 +1,7 @@
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 SESSION_DATA = 'bbuddy_session_data';
export const DEFAULT_PAGE_SIZE = 5;
export const DEFAULT_PAGE = 1;

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
function getStorageValue (key: string, defaultValue: any) {
export function getStorageValue (key: string, defaultValue: any) {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(key);
return saved || defaultValue;

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Kommende & letzte Sitzungen',
rooms: 'Zimmer',
notifications: 'Benachrichtigung',
support: 'Hilfe & Support',
information: 'Rechtliche Informationen',
@ -42,13 +43,29 @@ export default {
addComment: 'Neuen Kommentar hinzufügen',
commentPlaceholder: 'Ihr Kommentar',
clientComments: 'Kundenkommentare',
coachComments: 'Trainerkommentare'
coachComments: 'Expertenkommentare'
},
room: {
upcoming: 'Zukünftige Räume',
requested: 'Angeforderte Räume',
recent: 'Kürzliche Räume',
newRoom: 'Neuer Raum'
newRoom: 'Neuer Raum',
editRoom: 'Raum bearbeiten',
date: 'Datum',
time: 'Zeit',
maxParticipants: 'Max. erlaubte Teilnehmer',
presenceOfSupervisor: 'Anwesenheit eines Supervisors',
supervisor: 'Supervisor',
members: 'Mitglieder',
participants: 'Teilnehmer',
roomCreator: 'Raum-Ersteller',
inviteSupervisor: 'Supervisor einladen',
joinSupervisor: 'Als Supervisor beitreten',
inviteParticipant: 'Teilnehmer einladen',
joinParticipant: 'Als Teilnehmer beitreten',
rapport: 'Rapport',
invite: 'Invite',
save: 'Raum speichern'
},
agreementText: 'Folgendes habe ich gelesen und erkläre mich damit einverstanden: Benutzervereinbarung,',
userAgreement: 'Benutzervereinbarung',
@ -110,9 +127,9 @@ export default {
seminars: 'Seminare',
courses: 'Kurse',
mba: 'MBA-Information',
aboutCoach: 'Über Coach',
aboutCoach: 'Über den Experten',
education: 'Bildung',
coaching: 'Coaching',
coaching: 'Expertenprofil',
experiences: 'Praktische Erfahrung',
payInfo: 'Zahlungsdaten',
sessionDuration: 'Sitzungsdauer',
@ -146,6 +163,10 @@ export default {
saturday: 'Sa',
addNew: 'Neu hinzufügen',
mExperiences: 'Führungserfahrung',
pay: 'Zahlung',
sessionWishes: 'Schreiben Sie Ihre Wünsche zur Sitzung',
successPayment: 'Erfolgreiche Zahlung',
errorPayment: 'Zahlungsfehler',
errors: {
invalidEmail: 'Die E-Mail-Adresse ist ungültig',
emptyEmail: 'Bitte geben Sie Ihre E-Mail ein',

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Upcoming & Recent Sessions',
rooms: 'Rooms',
notifications: 'Notification',
support: 'Help & Support',
information: 'Legal Information',
@ -42,13 +43,29 @@ export default {
addComment: 'Add new',
commentPlaceholder: 'Your comment',
clientComments: 'Client Comments',
coachComments: 'Coach Comments'
coachComments: 'Expert Comments'
},
room: {
upcoming: 'Upcoming Rooms',
requested: 'Rooms Requested',
recent: 'Recent Rooms',
newRoom: 'New Room'
newRoom: 'New Room',
editRoom: 'Edit Room',
date: 'Date',
time: 'Time',
maxParticipants: 'Max Participants Allowed',
presenceOfSupervisor: 'Presence of a Supervisor',
supervisor: 'Supervisor',
members: 'Members',
participants: 'Participants',
roomCreator: 'Room Creator',
inviteSupervisor: 'Invite Supervisor',
joinSupervisor: 'Join As A Supervisor',
inviteParticipant: 'Invite Participant',
joinParticipant: 'Join as a participant',
rapport: 'Rapport',
invite: 'Invite',
save: 'Save room'
},
agreementText: 'I have read and agree with the terms of the User Agreement,',
userAgreement: 'User Agreement',
@ -109,10 +126,10 @@ export default {
seminars: 'Seminars',
courses: 'Courses',
mba: 'MBA Information',
aboutCoach: 'About Coach',
aboutCoach: 'About Expert',
skillsInfo: 'Skills Info',
education: 'Education',
coaching: 'Coaching',
coaching: 'Expert profile',
experiences: 'Practical experience',
payInfo: 'Payment Info',
sessionDuration: 'Session duration',
@ -146,6 +163,10 @@ export default {
saturday: 'Sa',
addNew: 'Add New',
mExperiences: 'Managerial Experience',
pay: 'Pay',
sessionWishes: 'Write your wishes about the session',
successPayment: 'Successful Payment',
errorPayment: 'Payment Error',
errors: {
invalidEmail: 'The email address is not valid',
emptyEmail: 'Please enter your E-mail',

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Próximas y recientes sesiones',
rooms: 'Habitaciones',
notifications: 'Notificación',
support: 'Ayuda y asistencia',
information: 'Información jurídica',
@ -42,13 +43,29 @@ export default {
addComment: 'Añadir nuevo comentario',
commentPlaceholder: 'Tu comentario',
clientComments: 'Comentarios del cliente',
coachComments: 'Comentarios del entrenador'
coachComments: 'Comentarios del experto'
},
room: {
upcoming: 'Próximas salas',
requested: 'Salas solicitadas',
recent: 'Salas recientes',
newRoom: 'Nueva sala'
newRoom: 'Nueva sala',
editRoom: 'Editar la sala',
date: 'Fecha',
time: 'Tiempo',
maxParticipants: 'Máximo de participantes permitidos',
presenceOfSupervisor: 'Presencia de un supervisor',
supervisor: 'Supervisor',
members: 'Miembros',
participants: 'Participantes',
roomCreator: 'Creador de salas',
inviteSupervisor: 'Invitar al supervisor',
joinSupervisor: 'Unirse como supervisor',
inviteParticipant: 'Invitar a un participante',
joinParticipant: 'Unirse como participante',
rapport: 'Buena relación',
invite: 'Invitar',
save: 'Guardar sala'
},
agreementText: 'He leído y acepto las condiciones del Acuerdo de usuario,',
userAgreement: 'Acuerdo de usuario',
@ -110,9 +127,9 @@ export default {
seminars: 'Seminarios',
courses: 'Cursos',
mba: 'Información sobre máster en ADE (MBA)',
aboutCoach: 'Sobre el coach',
aboutCoach: 'Acerca del experto',
education: 'Educación',
coaching: 'Coaching',
coaching: 'Perfil del experto',
experiences: 'Experiencia práctica',
payInfo: 'Información de pago',
sessionDuration: 'Duración de la sesión',
@ -146,6 +163,10 @@ export default {
saturday: 'S',
addNew: 'Añadir nuevo',
mExperiences: 'Experiencia de dirección',
pay: 'Pago',
sessionWishes: 'Escribe tus deseos sobre la sesión',
successPayment: 'Pago Exitoso',
errorPayment: 'Error de Pago',
errors: {
invalidEmail: 'La dirección de correo electrónico no es válida',
emptyEmail: 'Introduce tu correo electrónico',

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Sessions futures et récentes',
rooms: 'Chambres',
notifications: 'Notification',
support: 'Aide et support',
information: 'Informations légales',
@ -42,13 +43,29 @@ export default {
addComment: 'Ajouter un nouveau commentaire',
commentPlaceholder: 'Votre commentaire',
clientComments: 'Commentaires du client',
coachComments: 'Commentaires du coach'
coachComments: 'Commentaires de l\'expert'
},
room: {
upcoming: 'Salles futures',
requested: 'Salles demandées',
recent: 'Salles récentes',
newRoom: 'Nouvelle salle'
newRoom: 'Nouvelle salle',
editRoom: 'Modifier la salle',
date: 'Date',
time: 'Temps',
maxParticipants: 'Max de participants autorisés',
presenceOfSupervisor: 'Présence d\'un superviseur',
supervisor: 'Superviseur',
members: 'Membres',
participants: 'Participants',
roomCreator: 'Créateur de la salle',
inviteSupervisor: 'Inviter un superviseur',
joinSupervisor: 'Rejoindre en tant que superviseur',
inviteParticipant: 'Inviter un participant',
joinParticipant: 'Rejoindre en tant que participant',
rapport: 'Rapport',
invite: 'Inviter',
save: 'Sauvegarder la salle'
},
agreementText: 'J\'ai lu et j\'accepte les dispositions de l\'Accord Utilisateur et de la',
userAgreement: '',
@ -110,9 +127,9 @@ export default {
seminars: 'Séminaires',
courses: 'Cours',
mba: 'Infos Maîtrise en gestion',
aboutCoach: 'À propos du coach',
aboutCoach: 'À propos de l\'expert',
education: 'Éducation',
coaching: 'Coaching',
coaching: 'Profil de l\'expert',
experiences: 'Expérience pratique',
payInfo: 'Infos sur le paiement',
sessionDuration: 'Durée de la session',
@ -146,6 +163,10 @@ export default {
saturday: 'Sa',
addNew: 'Ajouter un nouveau',
mExperiences: 'Expérience en gestion',
pay: 'Paiement',
sessionWishes: 'Écrivez vos souhaits concernant la session',
successPayment: 'Paiement Réussi',
errorPayment: 'Erreur de Paiement',
errors: {
invalidEmail: 'L\'adresse e-mail n\'est pas valide',
emptyEmail: 'Veuillez saisir votre e-mail',

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Prossime e recenti sessioni',
rooms: 'Stanze',
notifications: 'Notifica',
support: 'Assistenza e supporto',
information: 'Informazioni legali',
@ -42,13 +43,29 @@ export default {
addComment: 'Aggiungi nuovo commento',
commentPlaceholder: 'Il tuo commento',
clientComments: 'Commenti del cliente',
coachComments: 'Commenti dell\'allenatore'
coachComments: 'Commenti dell\'esperto'
},
room: {
upcoming: 'Prossime sale',
requested: 'Sale richieste',
recent: 'Sale recenti',
newRoom: 'Nuova sala'
newRoom: 'Nuova sala',
editRoom: 'Modifica sala',
date: 'Data',
time: 'Tempo',
maxParticipants: 'Numero massimo di partecipanti consentiti',
presenceOfSupervisor: 'Presenza di un relatore',
supervisor: 'Relatore',
members: 'Iscritti',
participants: 'Partecipanti',
roomCreator: 'Creatore sala',
inviteSupervisor: 'Invita relatore',
joinSupervisor: 'Partecipa come relatore',
inviteParticipant: 'Invita partecipante',
joinParticipant: 'Partecipa come partecipante',
rapport: 'Rapporto',
invite: 'Invita',
save: 'Salva sala'
},
agreementText: 'Ho letto e accetto i termini dell\'Accordo con l\'utente,',
userAgreement: '',
@ -110,9 +127,9 @@ export default {
seminars: 'Seminari',
courses: 'Corsi',
mba: 'Info sull\'MBA',
aboutCoach: 'Informazioni sul coach',
aboutCoach: 'Informazioni sull\'esperto',
education: 'Istruzione',
coaching: 'Coaching',
coaching: 'Profilo dell\'esperto',
experiences: 'Esperienza pratica',
payInfo: 'Info pagamento',
sessionDuration: 'Durata della sessione',
@ -146,6 +163,10 @@ export default {
saturday: 'Sa',
addNew: 'Aggiungi nuovo',
mExperiences: 'Esperienza manageriale',
pay: 'Pagamento',
sessionWishes: 'Scrivi i tuoi desideri riguardo alla sessione',
successPayment: 'Pagamento Riuscito',
errorPayment: 'Errore di Pagamento',
errors: {
invalidEmail: 'L\'indirizzo e-mail non è valido',
emptyEmail: 'Inserisci l\'e-mail',

View File

@ -1,6 +1,7 @@
export default {
accountMenu: {
sessions: 'Предстоящие и недавние сессии',
rooms: 'Комнаты',
notifications: 'Уведомления',
support: 'Служба поддержки',
information: 'Юридическая информация',
@ -42,13 +43,29 @@ export default {
addComment: 'Добавить новый',
commentPlaceholder: 'Ваш комментарий',
clientComments: 'Комментарии клиента',
coachComments: 'Комментарии коуча'
coachComments: 'Комментарии эксперта'
},
room: {
upcoming: 'Предстоящие комнаты',
requested: 'Запрошенные комнаты',
recent: 'Недавние комнаты',
newRoom: 'Новая комната'
newRoom: 'Новая комната',
editRoom: 'Изменить комнату',
date: 'Дата',
time: 'Время',
maxParticipants: 'Макс. кол-во участников',
presenceOfSupervisor: 'Присутствие супервизора',
supervisor: 'Супервайзер',
members: 'Участники',
participants: 'Участники',
roomCreator: 'Создатель комнаты',
inviteSupervisor: 'Пригласить супервизора',
joinSupervisor: 'Присоединиться как супервизор',
inviteParticipant: 'Пригласить участника',
joinParticipant: 'Присоединиться как участник',
rapport: 'Раппорт',
invite: 'Пригласить',
save: 'Сохранить комнату'
},
agreementText: 'Я прочитал и согласен с условиями Пользовательского соглашения,',
userAgreement: 'Пользовательского соглашения',
@ -111,9 +128,9 @@ export default {
courses: 'Курсы',
mba: 'Информация о MBA',
experiences: 'Практический опыт',
aboutCoach: 'О коуче',
aboutCoach: 'Информация об эксперте',
education: 'Образование',
coaching: 'Коучинг',
coaching: 'Профиль эксперта',
payInfo: 'Платежная информация',
sessionDuration: 'Продолжительность сессии',
experienceHours: 'Общее количество часов практического опыта',
@ -146,6 +163,10 @@ export default {
saturday: 'Сб',
addNew: 'Добавить',
mExperiences: 'Управленческий опыт',
pay: 'Оплата',
sessionWishes: 'Напишите свои пожелания по поводу сессии',
successPayment: 'Успешная оплата',
errorPayment: 'Ошибка оплаты',
errors: {
invalidEmail: 'Адрес электронной почты недействителен',
emptyEmail: 'Пожалуйста, введите ваш E-mail',

11
src/lib/stripe.ts Normal file
View File

@ -0,0 +1,11 @@
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2024-06-20",
appInfo: {
name: "bbuddy-ui",
url: "",
},
});

View File

@ -668,6 +668,7 @@ a {
& > div {
display: flex;
gap: 4px;
padding-left: 1px;
&:first-child {
flex-direction: column;

View File

@ -82,6 +82,13 @@
}
}
}
&__users-list__content {
display: flex;
flex-direction: column;
padding: 40px;
gap: 24px;
}
}
.ant-modal-mask {

View File

@ -931,6 +931,10 @@
&.chosen {
color: #D93E5C;
}
&.history {
color: #c4c4c4;
}
}
}
}

View File

@ -2,9 +2,12 @@
&__wrap {
width: 100%;
height: 716px;
border-radius: 16px;
position: relative;
overflow: hidden;
&__single {
border-radius: 16px;
}
}
&__container {
@ -25,6 +28,16 @@
justify-content: space-between;
align-items: flex-end;
z-index: 2;
&_group {
width: 100%;
display: flex;
justify-content: center;
background: rgba(0, 59, 70, 0.4);
padding: 16px;
border-radius: 16px;
margin-top: 24px;
}
}
&__controls {
@ -126,6 +139,48 @@
position: absolute;
display: flex;
}
&_groups {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
& > div {
border-radius: 16px;
overflow: hidden;
video {
object-fit: contain !important;
}
}
&.gr-1 {
& > div {
width: 100%;
}
}
&.gr-2, &.gr-3, &.gr-4 {
& > div {
flex: calc((100% - 16px) / 2) 0;
}
}
&.gr-5, &.gr-6, &.gr-7, &.gr-8, &.gr-9 {
& > div {
flex: calc((100% - 16px * 2) / 3) 0;
}
}
&.gr-10, &.gr-11, &.gr-12, &.gr-13, &.gr-14, &.gr-15, &.gr-16 {
& > div {
flex: calc((100% - 16px * 3) / 4) 0;
}
}
}
}
&__video {

View File

@ -18,6 +18,11 @@
background: lightgray 50%;
box-shadow: 0 8px 16px 0 rgba(102, 165, 173, 0.32);
overflow: hidden;
&_small {
width: 86px;
height: 86px;
}
}
&__inner {
@ -41,6 +46,17 @@
line-height: 120%;
}
&__supervisor-comment {
width: 100%;
background: #E4F5FA;
padding: 8px;
border-radius: 0 8px 8px 8px;
color: #66A5AD;
@include rem(13);
font-weight: 500;
line-height: 120%;
}
&__comments {
display: flex;
flex-direction: column;
@ -200,6 +216,31 @@
}
}
&__filled {
user-select: none;
outline: none !important;
border: none !important;
text-decoration: none;
cursor: pointer;
border-radius: 8px !important;
background: #66A5AD !important;
box-shadow: none !important;
display: flex;
height: 54px !important;
padding: 15px 24px;
justify-content: center;
align-items: center;
color: #fff !important;
@include rem(15);
font-style: normal;
font-weight: 400;
line-height: 160%;
&:hover, &:active {
color: #fff !important;
}
}
&__header {
display: flex;
padding-bottom: 8px;
@ -268,6 +309,54 @@
overflow: hidden;
}
&__profile {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
align-items: flex-start;
border-top: 1px solid #C4DFE6;
&_title {
width: 100%;
gap: 16px;
display: flex;
justify-content: space-between;
div {
@include rem(18);
font-weight: 600;
line-height: 150%;
color: #6FB98F;
&:first-child {
color: #003B46;
}
}
}
&_list {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
&_item {
display: flex;
gap: 16px;
justify-content: space-between;
.card-detail__inner {
justify-content: center;
}
.card-detail__name {
color: #2C7873;
}
}
}
&__footer {
display: flex;
justify-content: flex-end;

View File

@ -0,0 +1,61 @@
.b-calendar {
padding: 44px 40px !important;
&-month {
text-transform: capitalize;
}
&-header {
justify-content: center;
border-bottom: none !important;
}
&-cell {
span {
color: #66A5AD;
}
&__weekend {
span {
color: #FFBD00;
}
}
}
.ant-picker-body {
margin-bottom: -42px !important;
}
.ant-picker-panel {
border-top: none !important;
margin-top: 12px;
}
.ant-picker-cell {
opacity: 0 !important;
&-disabled {
&::before {
background: transparent !important;
}
span {
color: rgba(0, 0, 0, 0.25) !important;
}
}
&.ant-picker-cell-in-view {
opacity: 1 !important;
background: transparent !important;
}
}
th, td {
vertical-align: middle !important;
height: 40px !important;
}
th {
color: #66A5AD !important;
}
}

View File

@ -0,0 +1,128 @@
.b-datepicker {
width: 100% !important;
height: 54px !important;
&.ant-picker-filled {
background: transparent !important;
z-index: 1;
padding-top: 22px !important;
padding-left: 16px !important;
&:hover {
border-color: #2c7873 !important;
}
.ant-picker-input {
input {
font-size: 14px !important;
}
}
}
.ant-picker-suffix {
margin-top: -20px;
}
&-wrap {
position: relative;
width: 100%;
background-color: #F8F8F7;
border-radius: 8px;
&.b-datepicker__active .b-datepicker-label {
font-size: 12px;
font-weight: 300;
line-height: 14px;
top: 8px;
}
}
&-label {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
color: #000;
opacity: .3;
position: absolute;
left: 16px;
top: 15px;
right: 22px;
z-index: 0;
transition: all .1s ease;
overflow: hidden;
text-overflow: ellipsis;
span {
white-space: nowrap;
}
}
&-popup {
padding: 16px !important;
.ant-picker-date-panel {
padding: 16px 8px !important;
}
.ant-picker-header-view {
color: #2c7873 !important;
}
.ant-picker-header {
border: none !important;
.ant-picker-header-super-prev-btn, .ant-picker-header-super-next-btn {
display: none !important;
}
}
.ant-picker-cell {
opacity: 0 !important;
padding: 0 !important;
&:not(.ant-picker-cell-disabled) {
color: #66A5AD !important;
&:hover {
.ant-picker-cell-inner {
color: #6FB98F !important;
background: transparent !important;
}
}
}
&-selected:not(.ant-picker-cell-disabled) .ant-picker-cell-inner {
color: #6FB98F !important;
background: transparent !important;
}
&-disabled {
color: rgba(0, 0, 0, 0.25) !important;
&::before {
background: transparent !important;
}
}
&.ant-picker-cell-in-view {
opacity: 1 !important;
background: transparent !important;
}
}
.ant-picker-cell-inner::before {
border: none !important;
}
th, td {
vertical-align: middle !important;
height: 36px !important;
}
th {
color: #66A5AD !important;
}
}
}

View File

@ -0,0 +1,3 @@
.ant-form-item-has-error .ant-radio-inner {
border-color: #ff4d4f !important;
}

View File

@ -0,0 +1,86 @@
.card-room {
&__details {
width: 100%;
display: grid;
grid-template-columns: 120px auto;
gap: 4px 8px;
div {
@include rem(13);
font-weight: 500;
line-height: 120%;
color: #2C7873;
&:nth-child(2n) {
color: #6FB98F;
}
}
}
}
.b-users-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
padding: 0 16px;
&__empty {
color: gray;
}
&-item {
padding: 0 0 16px;
border-bottom: 1px solid #C4DFE6;
display: flex;
flex-direction: column;
gap: 16px;
&:last-child {
border-bottom: none;
padding: 0;
}
& > div {
display: flex;
gap: 16px;
align-items: center;
}
}
}
.b-room-form {
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
align-items: center;
}
.ant-form-item {
margin-bottom: 0 !important;
}
.card-detail__apply {
align-self: flex-start;
}
.b-room-switch {
label {
margin-right: 24px;
&:after {
display: none !important;
}
}
& > div {
justify-content: space-between;
}
}
}
.ant-select-item-option-content {
span {
text-transform: capitalize;
}
}

View File

@ -0,0 +1,31 @@
.b-schedule {
&-time {
padding: 44px 40px;
display: flex;
flex-direction: column;
gap: 24px;
.b-button-link-big {
font-size: 24px;
line-height: 32px;
color: #6FB98F !important;
font-family: var(--font-comfortaa);
padding: 0 !important;
border: none !important;
text-transform: capitalize;
}
}
&-radio-list {
.ant-radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
}
&-payment {
padding: 44px 40px;
min-height: 300px;
}
}

View File

@ -3,11 +3,12 @@
height: 54px !important;
.ant-select-selector {
background-color: #F8F8F7 !important;
background-color: transparent !important;
border-color: #F8F8F7 !important;
border-radius: 8px !important;
padding: 22px 16px 8px !important;
box-shadow: none !important;
z-index: 1;
.ant-select-selection-item {
font-size: 15px !important;
@ -17,6 +18,12 @@
}
}
&.ant-select-status-error {
.ant-select-selector {
border-color: #ff4d4f !important;
}
}
.ant-select-selection-overflow-item {
margin-right: 4px;
}
@ -35,6 +42,9 @@
&-wrap {
position: relative;
width: 100%;
background-color: #F8F8F7;
border-radius: 8px;
&.b-multiselect__active .b-multiselect-label {
font-size: 12px;
font-weight: 300;
@ -44,12 +54,12 @@
}
&-label {
font-size: 15px;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
color: #000;
opacity: .3;
opacity: .4;
position: absolute;
left: 16px;
top: 15px;
@ -70,11 +80,12 @@
height: 54px !important;
.ant-select-selector {
background-color: #F8F8F7 !important;
background-color: transparent !important;
border-color: #F8F8F7 !important;
border-radius: 8px !important;
padding: 22px 16px 8px !important;
box-shadow: none !important;
z-index: 1;
.ant-select-selection-item {
font-size: 15px !important;
@ -84,6 +95,12 @@
}
}
&.ant-select-status-error {
.ant-select-selector {
border-color: #ff4d4f !important;
}
}
.ant-select-arrow {
color: #2c7873 !important;
}
@ -98,6 +115,8 @@
&-wrap {
position: relative;
width: 100%;
background-color: #F8F8F7;
border-radius: 8px;
&.b-select__active .b-select-label {
font-size: 12px;
@ -113,7 +132,7 @@
font-weight: 400;
line-height: 24px;
color: #000;
opacity: .3;
opacity: .4;
position: absolute;
left: 16px;
top: 15px;

View File

@ -13,7 +13,7 @@
.ant-picker-input {
input {
font-size: 15px !important;
font-size: 14px !important;
}
}
}

View File

@ -9,3 +9,8 @@
@import "_practice.scss";
@import "_collapse.scss";
@import "_timepicker.scss";
@import "_datepicker.scss";
@import "_calendar.scss";
@import "_schedule.scss";
@import "_radio.scss";
@import "_room.scss";

View File

@ -1,5 +1,4 @@
import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful'
import {BlogPostFields} from "./blogPost";
import {ContentImage} from "../lib/contentful/contentImage";
export interface AuthorFields {

View File

@ -70,3 +70,24 @@ export type ExpertDetails = {
associations?: Association[];
associationLevels?: AssociationLevel[];
};
export type Slot = {
startTime: string;
endTime: string;
}
export type ExpertScheduler = {
tags: Tag[],
availableSlots: Slot[];
}
export type ExpertSchedulerSession = {
sessionId: string
};
export type SignupSessionData = {
coachId: number,
tagId?: number,
startAtUtc?: string,
clientComment?: string
};

3
src/types/payment.ts Normal file
View File

@ -0,0 +1,3 @@
export type Payment = {
amount: number;
}

44
src/types/rooms.ts Normal file
View File

@ -0,0 +1,44 @@
import { PublicUser, Session, SessionState } from './sessions';
import { Tag } from './tags';
import { Slot } from './experts';
export enum RoomsType {
UPCOMING = 'upcoming',
RECENT = 'recent',
NEW = 'new',
}
export type Record = {
id: number;
sessionId: number;
sid?: string;
resourceId?: string;
readyForLoad?: boolean;
cname?: string;
}
export type Room = Session & { recordings?: Record[] };
export type GetUsersForRooms = {
items?: PublicUser[],
isTooManyResults?: boolean;
}
export type RoomEdit = {
id: number,
scheduledStartAtUtc?: string,
scheduledEndAtUtc?: string,
state?: SessionState,
cost?: number,
maxClients?: number,
title?: string,
description?: string,
isNeedSupervisor?: boolean,
tagIds?: number[]
};
export type RoomEditDTO = {
item: RoomEdit;
tags?: Tag[];
availableSlots: Slot[];
};

View File

@ -6,6 +6,8 @@ export type PublicUser = {
name?: string;
surname?: string;
faceImageUrl?: string;
coachBotId?: number;
parentId?: number;
};
// type User = {
@ -148,6 +150,7 @@ export type Session = {
themesTags?: SessionTag[];
coachComments?: SessionComment[];
clientComments?: SessionComment[];
creatorId?: number;
};
export enum SessionType {

View File

@ -2,7 +2,7 @@ import { message } from 'antd';
import type { UploadFile } from 'antd';
import { i18nText } from '../i18nKeys';
const ROUTES = ['sessions', 'notifications', 'support', 'information', 'settings', 'messages', 'expert-profile'];
const ROUTES = ['sessions', 'rooms', 'notifications', 'support', 'information', 'settings', 'messages', 'expert-profile'];
const COUNTS: Record<string, number> = {
sessions: 12,
notifications: 5,

12
src/utils/get-stripe.ts Normal file
View File

@ -0,0 +1,12 @@
import { Stripe, loadStripe } from '@stripe/stripe-js';
let stripePromise: Promise<Stripe | null>;
export default function getStripe(): Promise<Stripe | null> {
if (!stripePromise)
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string,
);
return stripePromise;
}

28
src/utils/locale.ts Normal file
View File

@ -0,0 +1,28 @@
import locale_ru from 'antd/lib/calendar/locale/ru_RU';
import locale_en from 'antd/lib/calendar/locale/en_GB';
import locale_de from 'antd/lib/calendar/locale/de_DE';
import locale_it from 'antd/lib/calendar/locale/it_IT';
import locale_es from 'antd/lib/calendar/locale/es_ES';
import locale_fr from 'antd/lib/calendar/locale/fr_FR';
// for calendars
export const getLocale = (locale: string) => {
if (locale) {
switch (locale) {
case 'ru':
return locale_ru;
case 'de':
return locale_de;
case 'fr':
return locale_fr;
case 'it':
return locale_it;
case 'es':
return locale_es;
default:
return locale_en;
}
}
return locale_en;
};

View File

@ -0,0 +1,30 @@
export function formatAmountForDisplay(
amount: number,
currency: string,
): string {
let numberFormat = new Intl.NumberFormat(["en-US"], {
style: "currency",
currency: currency,
currencyDisplay: "symbol",
});
return numberFormat.format(amount);
}
export function formatAmountForStripe(
amount: number,
currency: string,
): number {
let numberFormat = new Intl.NumberFormat(["en-US"], {
style: "currency",
currency: currency,
currencyDisplay: "symbol",
});
const parts = numberFormat.formatToParts(amount);
let zeroDecimalCurrency: boolean = true;
for (let part of parts) {
if (part.type === "decimal") {
zeroDecimalCurrency = false;
}
}
return zeroDecimalCurrency ? amount : Math.round(amount * 100);
}