From ee4dcb58cc9387a48326f2a036b4438810ae7701 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Thu, 15 Aug 2024 14:41:35 +0300 Subject: [PATCH 01/20] stripe payment --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index fdfb4c7..5197400 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,13 @@ "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", "@ant-design/nextjs-registry": "^1.0.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", "dayjs": "^1.11.10", "lodash": "^4.17.21", @@ -25,6 +28,7 @@ "react-dom": "^18", "react-slick": "^0.29.0", "slick-carousel": "^1.8.1", + "stripe": "^16.2.0", "styled-components": "^6.1.1" }, "devDependencies": { From 3b2241892f2ee4a2fabe82939c978ad638ff0585 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Thu, 15 Aug 2024 14:43:52 +0300 Subject: [PATCH 02/20] stripe payment --- .env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env b/.env index 91a7cbb..2a52281 100644 --- a/.env +++ b/.env @@ -1,3 +1,6 @@ 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' From 59de68d611cd24afb0b10c3c0c9fd62ba8dc1157 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Sat, 13 Jul 2024 13:13:58 +0300 Subject: [PATCH 03/20] stripe payment --- src/actions/experts.ts | 33 ++- src/actions/stripe.ts | 78 +++++ src/app/[locale]/(main)/layout.tsx | 5 +- src/app/[locale]/experts/[expertId]/page.tsx | 3 +- src/app/[locale]/layout.tsx | 2 +- src/app/[locale]/payment/@payment/page.tsx | 20 ++ src/app/[locale]/payment/page.tsx | 28 ++ src/app/[locale]/payment/result/layout.tsx | 19 ++ src/app/[locale]/payment/result/page.tsx | 27 ++ src/app/api/webhooks/route.ts | 66 +++++ src/components/Experts/ExpertDetails.tsx | 32 +- src/components/Experts/Filter.tsx | 1 - src/components/Modals/SchedulerModal.tsx | 290 +++++++++++++++++++ src/components/stripe/ElementsForm.tsx | 195 +++++++++++++ src/components/stripe/PrintObject.tsx | 10 + src/components/stripe/StripeTestCards.tsx | 19 ++ src/lib/stripe.ts | 11 + src/types/experts.ts | 26 ++ src/types/payment.ts | 3 + src/utils/get-stripe.ts | 15 + src/utils/stripe-helpers.ts | 30 ++ 21 files changed, 904 insertions(+), 9 deletions(-) create mode 100644 src/actions/stripe.ts create mode 100644 src/app/[locale]/payment/@payment/page.tsx create mode 100644 src/app/[locale]/payment/page.tsx create mode 100644 src/app/[locale]/payment/result/layout.tsx create mode 100644 src/app/[locale]/payment/result/page.tsx create mode 100644 src/app/api/webhooks/route.ts create mode 100644 src/components/Modals/SchedulerModal.tsx create mode 100644 src/components/stripe/ElementsForm.tsx create mode 100644 src/components/stripe/PrintObject.tsx create mode 100644 src/components/stripe/StripeTestCards.tsx create mode 100644 src/lib/stripe.ts create mode 100644 src/types/payment.ts create mode 100644 src/utils/get-stripe.ts create mode 100644 src/utils/stripe-helpers.ts diff --git a/src/actions/experts.ts b/src/actions/experts.ts index 93aa3fa..9450e58 100644 --- a/src/actions/experts.ts +++ b/src/actions/experts.ts @@ -1,5 +1,6 @@ -import { GeneralFilter, ExpertsData, ExpertDetails } from '../types/experts'; import { apiRequest } from './helpers'; +import { apiClient } from '../lib/apiClient'; +import {GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession} from '../types/experts'; export const getExpertsList = (locale: string, filter?: GeneralFilter): Promise => apiRequest({ url: '/home/coachsearch1', @@ -14,3 +15,33 @@ export const getExpertById = (id: string, locale: string): Promise { + const response = await apiClient.post( + '/home/sessionsignupdata', + { id: expertId }, + { + headers: { + 'X-User-Language': locale, + Authorization: `Bearer ${jwt}` + } + } + ); + + return response.data as ExpertScheduler || null; +}; + +export const getSchedulerSession = async (data: { coachId: number, tagId: number, startAtUtc: string, clientComment: string }, locale: string, jwt: string) => { + const response = await apiClient.post( + '/home/sessionsignupsubmit', + data, + { + headers: { + 'X-User-Language': locale, + Authorization: `Bearer ${jwt}` + } + } + ); + + return response.data as ExpertSchedulerSession || null; +}; diff --git a/src/actions/stripe.ts b/src/actions/stripe.ts new file mode 100644 index 0000000..c0d2254 --- /dev/null +++ b/src/actions/stripe.ts @@ -0,0 +1,78 @@ +"use server"; + +import {PaymentIntentCreateParams, 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: any, +): Promise<{ client_secret: string }> { + + const params = { + amount: formatAmountForStripe( + Number(data['amount'] as string), + 'eur', + ), + automatic_payment_methods: { enabled: true }, + currency: 'eur', + } as PaymentIntentCreateParams; + + // additional params + 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 }; +} \ No newline at end of file diff --git a/src/app/[locale]/(main)/layout.tsx b/src/app/[locale]/(main)/layout.tsx index a22926a..ed99329 100644 --- a/src/app/[locale]/(main)/layout.tsx +++ b/src/app/[locale]/(main)/layout.tsx @@ -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} ); -}; +} diff --git a/src/app/[locale]/experts/[expertId]/page.tsx b/src/app/[locale]/experts/[expertId]/page.tsx index 3da5bee..42a7b8f 100644 --- a/src/app/[locale]/experts/[expertId]/page.tsx +++ b/src/app/[locale]/experts/[expertId]/page.tsx @@ -12,6 +12,7 @@ import { import { Details } from '../../../../types/education'; import { BackButton } from '../../../../components/view/BackButton'; import { i18nText } from '../../../../i18nKeys'; +import {SchedulerModal} from "../../../../components/Modals/SchedulerModal"; export const metadata: Metadata = { title: 'Bbuddy - Experts item', @@ -82,7 +83,7 @@ export default async function ExpertItem({ params: { expertId = '', locale } }: - +

{i18nText('expertBackground', locale)}

diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 3ab1d50..fdb0b55 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -42,4 +42,4 @@ export default function LocaleLayout({ children, params: { locale } }: LayoutPro ); -}; +} diff --git a/src/app/[locale]/payment/@payment/page.tsx b/src/app/[locale]/payment/@payment/page.tsx new file mode 100644 index 0000000..c93d91d --- /dev/null +++ b/src/app/[locale]/payment/@payment/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +import {ElementsForm} from "../../../../components/stripe/ElementsForm"; + +export const metadata: Metadata = { + title: "Payment", +}; + +export default function PaymentElementPage({ + searchParams, + }: { + searchParams?: { payment_intent_client_secret?: string }; +}) { + return ( +
+

Pay

+ +
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/payment/page.tsx b/src/app/[locale]/payment/page.tsx new file mode 100644 index 0000000..55064d7 --- /dev/null +++ b/src/app/[locale]/payment/page.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import { unstable_setRequestLocale } from 'next-intl/server'; +import { useTranslations } from 'next-intl'; +import { GeneralTopSection } from '../../../components/Page'; +import PaymentElementPage from "./@payment/page"; + +export const metadata: Metadata = { + title: 'Bbuddy - Take the lead with BB', + description: 'Bbuddy desc Take the lead with BB' +}; + +export default function BbClientPage({ params: { locale } }: { params: { locale: string } }) { + unstable_setRequestLocale(locale); + const t = useTranslations('BbClient'); + + return ( + <> + +
+ +
+ + ); +}; diff --git a/src/app/[locale]/payment/result/layout.tsx b/src/app/[locale]/payment/result/layout.tsx new file mode 100644 index 0000000..bea1996 --- /dev/null +++ b/src/app/[locale]/payment/result/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import React from "react"; + +export const metadata: Metadata = { + title: "Payment Intent Result", +}; + +export default function ResultLayout({ + children, + }: { + children: React.ReactNode; +}) { + return ( +
+

Payment Intent Result

+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/app/[locale]/payment/result/page.tsx b/src/app/[locale]/payment/result/page.tsx new file mode 100644 index 0000000..dad26cd --- /dev/null +++ b/src/app/[locale]/payment/result/page.tsx @@ -0,0 +1,27 @@ +import type { Stripe } from "stripe"; + +import { stripe} from "../../../../lib/stripe"; +import PrintObject from "../../../../components/stripe/PrintObject"; + +export default async function ResultPage({ + searchParams, + }: { + searchParams: { payment_intent: string }; +}) { + if (!searchParams.payment_intent) + throw new Error("Please provide a valid payment_intent (`pi_...`)"); + + const paymentIntent: Stripe.PaymentIntent = + await stripe.paymentIntents.retrieve(searchParams.payment_intent); + + // Тут под идее тыкнуться в бек на тему того - прошла ли оплата. в зависимости от этого показать что все ок или нет + // также стоит расшить ссылкой КУДА переходить после того как показали что все ок. + + return ( + <> +

Status: {paymentIntent.status}

+

Payment Intent response:

+ + + ); +} \ No newline at end of file diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..6fa9a39 --- /dev/null +++ b/src/app/api/webhooks/route.ts @@ -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 }); +} \ No newline at end of file diff --git a/src/components/Experts/ExpertDetails.tsx b/src/components/Experts/ExpertDetails.tsx index fec8476..d6fafef 100644 --- a/src/components/Experts/ExpertDetails.tsx +++ b/src/components/Experts/ExpertDetails.tsx @@ -1,19 +1,26 @@ 'use client'; -import React, { FC } from 'react'; +import React, {FC, useEffect, useState} from 'react'; import Image from 'next/image'; import { Tag, Image as AntdImage, Space } from 'antd'; import { ZoomInOutlined, ZoomOutOutlined, StarFilled } from '@ant-design/icons'; +import { ExpertScheduler } 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 {getSchedulerByExpertId} from "../../actions/experts"; +import {useLocalStorage} from "../../hooks/useLocalStorage"; +import {AUTH_TOKEN_KEY} from "../../constants/common"; +import dayjs from "dayjs"; +import {SchedulerModal} from "../Modals/SchedulerModal"; type ExpertDetailsProps = { expert: ExpertDetails; locale?: string; + expertId?: string; }; type ExpertPracticeProps = { @@ -22,8 +29,18 @@ type ExpertPracticeProps = { locale?: string; }; -export const ExpertCard: FC = ({ expert, locale }) => { +export const ExpertCard: FC = ({ expert, locale, expertId }) => { const { publicCoachDetails } = expert || {}; + const [showSchedulerModal, setShowSchedulerModal] = useState(false); + const [mode, setMode] = useState<'data' | 'time' | 'pay' | 'finish'>('data'); + const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0 } } = expert || {}; + + const onSchedulerHandle = async () => { + console.log('sessionCost', sessionCost); + setMode('data'); + setShowSchedulerModal(true) + // отмаппим. + } return (
@@ -45,7 +62,7 @@ export const ExpertCard: FC = ({ expert, locale }) => {
- + {i18nText('schedule', locale)} @@ -56,6 +73,15 @@ export const ExpertCard: FC = ({ expert, locale }) => { */}
+ setShowSchedulerModal(false)} + updateMode={setMode} + mode={mode} + expertId={expertId as string} + locale={locale as string} + sessionCost={sessionCost} + /> ); }; diff --git a/src/components/Experts/Filter.tsx b/src/components/Experts/Filter.tsx index 846dd34..debb1d1 100644 --- a/src/components/Experts/Filter.tsx +++ b/src/components/Experts/Filter.tsx @@ -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`); diff --git a/src/components/Modals/SchedulerModal.tsx b/src/components/Modals/SchedulerModal.tsx new file mode 100644 index 0000000..77d6c82 --- /dev/null +++ b/src/components/Modals/SchedulerModal.tsx @@ -0,0 +1,290 @@ +'use client'; + +import React, {Dispatch, FC, SetStateAction, useEffect, useState} from 'react'; +import { usePathname } from 'next/navigation'; +import classNames from 'classnames'; +import Link from 'next/link'; +import {Modal, Form, Calendar, Radio } from 'antd'; +import type { CalendarProps, RadioChangeEvent } from 'antd'; +import { CloseOutlined } from '@ant-design/icons'; +import { RegisterContent, ResetContent, FinishContent, EnterContent } from './authModalContent'; +import dayjs, { Dayjs } from 'dayjs'; +import {ExpertDetails, ExpertScheduler, Tags} from "../../types/experts"; +import { createStyles } from 'antd-style'; +import {useLocalStorage} from "../../hooks/useLocalStorage"; +import {AUTH_TOKEN_KEY} from "../../constants/common"; +import {getSchedulerByExpertId, getSchedulerSession} from "../../actions/experts"; +import {ElementsForm} from "../stripe/ElementsForm"; + +type SchedulerModalProps = { + open: boolean; + handleCancel: () => void; + mode: 'data' | 'time' | 'pay' | 'finish'; + updateMode: (mode: 'data' | 'time' | 'pay' | 'finish') => void; + sessionCost: number; + expertId: string; + locale: string; +}; + +const useStyle = createStyles(({ token, css, cx }) => { + const lunar = css` + color: ${token.colorTextTertiary}; + font-size: ${token.fontSizeSM}px; + `; + return { + wrapper: css` + width: 450px; + border: 1px solid ${token.colorBorderSecondary}; + border-radius: ${token.borderRadiusOuter}; + padding: 5px; + `, + dateCell: css` + position: relative; + &:before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + max-width: 40px; + max-height: 40px; + background: transparent; + transition: background 300ms; + border-radius: ${token.borderRadiusOuter}px; + border: 1px solid transparent; + box-sizing: border-box; + } + &:hover:before { + background: rgba(0, 0, 0, 0.04); + } + `, + today: css` + &:before { + border: 1px solid ${token.colorPrimary}; + } + `, + text: css` + position: relative; + z-index: 1; + `, + lunar, + current: css` + color: ${token.colorTextLightSolid}; + &:before { + background: ${token.colorPrimary}; + } + &:hover:before { + background: ${token.colorPrimary}; + opacity: 0.8; + } + .${cx(lunar)} { + color: ${token.colorTextLightSolid}; + opacity: 0.9; + } + `, + monthCell: css` + width: 120px; + color: ${token.colorTextBase}; + border-radius: ${token.borderRadiusOuter}px; + padding: 5px 0; + &:hover { + background: rgba(0, 0, 0, 0.04); + } + `, + monthCellCurrent: css` + color: ${token.colorTextLightSolid}; + background: ${token.colorPrimary}; + &:hover { + background: ${token.colorPrimary}; + opacity: 0.8; + } + `, + weekend: css` + color: ${token.colorError}; + &.gray { + opacity: 0.4; + } + `, + }; +}); + +export const SchedulerModal: FC = ({ + open, + handleCancel, + mode, + updateMode, + sessionCost, + locale, + expertId, +}) => { + const { styles } = useStyle({ test: true }); + const [selectDate, setSelectDate] = React.useState(dayjs()); + const [dates, setDates] = React.useState(); + const [tags, setTags] = React.useState([]); + const [tag, setTag] = React.useState(-1); + const [slot, setSlot] = React.useState(''); + const [sessionId, setSessionId] = React.useState(-1); + const [rawScheduler, setRawScheduler] = useState(null); + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + + useEffect( ()=> { + async function loadScheduler(){ + const rawScheduler = await getSchedulerByExpertId(expertId as string, locale as string, jwt) + setRawScheduler(rawScheduler) + } + if (open) { + loadScheduler() + } + }, [open]) + + 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) + + }) + console.log(rawScheduler, map) + setDates(map) + setTags(rawScheduler?.tags) + }, [rawScheduler]); + + const onPanelChange = (value: Dayjs, mode: CalendarProps['mode']) => { + console.log(value.format('YYYY-MM-DD'), mode); + }; + + const onDateChange: CalendarProps['onSelect'] = (value, selectInfo) => { + if (selectInfo.source === 'date') { + setSelectDate(value); + updateMode('time') + } + }; + + const disabledDate = (currentDate: Dayjs) => { + return !dates || !dates[currentDate.format('YYYY-MM-DD')] + } + + const handleTimeslot = (e: RadioChangeEvent) => { + setSlot(e.target.value.startTime) + console.log('radio checked', e.target.value); + }; + + const handleTag = (e: RadioChangeEvent) => { + setTag(e.target.value) + console.log('tag radio checked', e.target.value); + }; + + const handleSingupSession = async () => { + const data = {coachId: expertId, tagId: tag, startAtUtc: slot, clientComment:''} + console.log(data) + const session = await getSchedulerSession({coachId: expertId, tagId: tag, startAtUtc: slot, clientComment:''}, locale, jwt) + console.log(session); + // тут должна быть проверка все ли с регистрацией сессии + setSessionId(session?.sessionId) + updateMode('pay') + } + + const currentDay = dayjs() + + const cellRender: CalendarProps['fullCellRender'] = (date, info) => { + const isWeekend = date.day() === 6 || date.day() === 0; + return React.cloneElement(info.originNode, { + ...info.originNode.props, + className: classNames(styles.dateCell, { + [styles.current]: selectDate.isSame(date, 'date'), + [styles.today]: date.isSame(dayjs(), 'date'), + }), + children: ( +
+ + {date.get('date')} + +
+ ), + }); + } + + + return ( + } + > +
+ {tags && ( + + {tags?.map((tag)=>( + {tag.name} + ) + )} + + ) + } +
+ {mode === 'data' && ( + { + const start = 0; + const end = 12; + const monthOptions = []; + + let current = currentDay.clone(); + const localeData = value.locale(); + const months = []; + + for(let i=0; i<6; i++){ + const m = current.clone() + months.push(m); + current = current.add(1,'month') + } + return (<> + {months.map((m, i)=>( + + ))} + ) + }} + + /> + )} + {mode === 'time' && ( + <> +
+ +
+ + + {dates[selectDate.format('YYYY-MM-DD')].map( (el) => { + return ({dayjs(el.startTime).format('hh-mm')} - {dayjs(el.endTime).format('hh-mm')}) + })} + + + + )} + {mode === 'pay' && ( + + )} +
+ ); +}; diff --git a/src/components/stripe/ElementsForm.tsx b/src/components/stripe/ElementsForm.tsx new file mode 100644 index 0000000..0e3da2f --- /dev/null +++ b/src/components/stripe/ElementsForm.tsx @@ -0,0 +1,195 @@ +"use client"; + +import type { StripeError } from "@stripe/stripe-js"; + +import * as React from "react"; +import { + useStripe, + useElements, + PaymentElement, + Elements, +} from "@stripe/react-stripe-js"; + +import StripeTestCards from "./StripeTestCards"; + +import getStripe from "../../utils/get-stripe"; +import { createPaymentIntent} from "../../actions/stripe"; +import {Form} from "antd"; +import {Payment} from "../../types/payment"; +import {CustomInput} from "../view/CustomInput"; +import {i18nText} from "../../i18nKeys"; +import {FC, useEffect} from "react"; +import {getPersonalData} from "../../actions/profile"; + +type PaymentFormProps = { + amount: number, + sessionId?: string +} + + +export const CheckoutForm: FC = ({amount, sessionId}) => { + const [input, setInput] = React.useState<{ + paySumm: number; + cardholderName: string; + }>({ + paySumm: 1, + cardholderName: "", + }); + const [form, ] = Form.useForm(); + const formAmount = Form.useWatch('amount', form); + const [paymentType, setPaymentType] = React.useState(""); + const [payment, setPayment] = React.useState<{ + status: "initial" | "processing" | "error"; + }>({ status: "initial" }); + const [errorMessage, setErrorMessage] = React.useState(""); + + const stripe = useStripe(); + const elements = useElements(); + + const PaymentStatus = ({ status }: { status: string }) => { + switch (status) { + case "processing": + case "requires_payment_method": + case "requires_confirmation": + return

Processing...

; + + case "requires_action": + return

Authenticating...

; + + case "succeeded": + return

Payment Succeeded 🥳

; + + case "error": + return ( + <> +

Error 😭

+

{errorMessage}

+ + ); + + default: + return null; + } + }; + + useEffect(() => { + elements?.update({ amount: formAmount * 100 }); + }, [formAmount]); + + const handleInputChange: React.ChangeEventHandler = (e) => { + setInput({ + ...input, + [e.currentTarget.name]: e.currentTarget.value, + }); + }; + + const onSubmit = async (data) => { + try { + if (!elements || !stripe) return; + + setPayment({ status: "processing" }); + + const { error: submitError } = await elements.submit(); + + if (submitError) { + setPayment({ status: "error" }); + setErrorMessage(submitError.message ?? "An unknown error occurred"); + + return; + } + + // Create a PaymentIntent with the specified amount. + console.log('DATA', data); + const { client_secret: clientSecret } = await createPaymentIntent( + {amount: amount}, + ); + + // Use your card Element with other Stripe.js APIs + const { error: confirmError } = await stripe!.confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: `${window.location.origin}/ru/payment/result`, + payment_method_data: { + allow_redisplay: 'limited', + billing_details: { + name: input.cardholderName, + }, + }, + }, + }); + + if (confirmError) { + setPayment({ status: "error" }); + setErrorMessage(confirmError.message ?? "An unknown error occurred"); + } + } catch (err) { + const { message } = err as StripeError; + + setPayment({ status: "error" }); + setErrorMessage(message ?? "An unknown error occurred"); + } + }; + + + + return ( + <> +
+
+ + Your payment details: + {paymentType === "card" ? ( + + ) : null} +
+ { + setPaymentType(e.value.type); + }} + /> +
+
+ +
+ + + ); +} + +export const ElementsForm: FC = ({amount, sessionId}) => { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/stripe/PrintObject.tsx b/src/components/stripe/PrintObject.tsx new file mode 100644 index 0000000..b7cd424 --- /dev/null +++ b/src/components/stripe/PrintObject.tsx @@ -0,0 +1,10 @@ +import type { Stripe } from "stripe"; + +export default function PrintObject({ + content, + }: { + content: Stripe.PaymentIntent | Stripe.Checkout.Session; +}): JSX.Element { + const formattedContent: string = JSON.stringify(content, null, 2); + return
{formattedContent}
; +} \ No newline at end of file diff --git a/src/components/stripe/StripeTestCards.tsx b/src/components/stripe/StripeTestCards.tsx new file mode 100644 index 0000000..cf40281 --- /dev/null +++ b/src/components/stripe/StripeTestCards.tsx @@ -0,0 +1,19 @@ +export default function StripeTestCards(): JSX.Element { + return ( +
+ Use any of the{" "} + + Stripe test cards + {" "} + for demo, e.g.{" "} +
+ 4242424242424242 +
+ . +
+ ); +} \ No newline at end of file diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..08773a7 --- /dev/null +++ b/src/lib/stripe.ts @@ -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: "", + }, +}); \ No newline at end of file diff --git a/src/types/experts.ts b/src/types/experts.ts index 5917d31..27fe753 100644 --- a/src/types/experts.ts +++ b/src/types/experts.ts @@ -70,3 +70,29 @@ export type ExpertDetails = { associations?: Association[]; associationLevels?: AssociationLevel[]; }; + +export type Tags = { + id: number, + groupId: number, + name: string, + couchCount: number, + group: { + id: number, + name: string, + tags: string[]; + } +} + +export type Slot = { + startTime: string; + endTime: string; +} + +export type ExpertScheduler = { + tags: Tags[], + availableSlots: Slot[]; +} + +export type ExpertSchedulerSession = { + sessionId: string +} \ No newline at end of file diff --git a/src/types/payment.ts b/src/types/payment.ts new file mode 100644 index 0000000..cdadd42 --- /dev/null +++ b/src/types/payment.ts @@ -0,0 +1,3 @@ +export type Payment = { + amount: number; +} \ No newline at end of file diff --git a/src/utils/get-stripe.ts b/src/utils/get-stripe.ts new file mode 100644 index 0000000..6930d5b --- /dev/null +++ b/src/utils/get-stripe.ts @@ -0,0 +1,15 @@ +/** + * This is a singleton to ensure we only instantiate Stripe once. + */ +import { Stripe, loadStripe } from "@stripe/stripe-js"; + +let stripePromise: Promise; + +export default function getStripe(): Promise { + if (!stripePromise) + stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string, + ); + + return stripePromise; +} \ No newline at end of file diff --git a/src/utils/stripe-helpers.ts b/src/utils/stripe-helpers.ts new file mode 100644 index 0000000..09144bd --- /dev/null +++ b/src/utils/stripe-helpers.ts @@ -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); +} \ No newline at end of file From d5808e96db75e3e101bcc479dc00aad29cd4f6d3 Mon Sep 17 00:00:00 2001 From: SD Date: Wed, 16 Oct 2024 21:47:28 +0400 Subject: [PATCH 04/20] feat: update views, styles, actions --- src/actions/experts.ts | 44 +-- src/app/[locale]/experts/[expertId]/page.tsx | 3 - .../ExpertProfile/ExpertProfile.tsx | 2 +- src/components/Experts/ExpertDetails.tsx | 107 +++---- src/components/Modals/ScheduleModal.tsx | 274 +++++++++++++++++ src/components/Modals/SchedulerModal.tsx | 290 ------------------ src/i18nKeys/de.ts | 8 +- src/i18nKeys/en.ts | 8 +- src/i18nKeys/es.ts | 8 +- src/i18nKeys/fr.ts | 8 +- src/i18nKeys/it.ts | 8 +- src/i18nKeys/ru.ts | 8 +- src/styles/view/_calendar.scss | 62 ++++ src/styles/view/_schedule.scss | 26 ++ src/styles/view/style.scss | 2 + src/types/experts.ts | 23 +- 16 files changed, 467 insertions(+), 414 deletions(-) create mode 100644 src/components/Modals/ScheduleModal.tsx delete mode 100644 src/components/Modals/SchedulerModal.tsx create mode 100644 src/styles/view/_calendar.scss create mode 100644 src/styles/view/_schedule.scss diff --git a/src/actions/experts.ts b/src/actions/experts.ts index 9450e58..3b7cd51 100644 --- a/src/actions/experts.ts +++ b/src/actions/experts.ts @@ -1,6 +1,5 @@ import { apiRequest } from './helpers'; -import { apiClient } from '../lib/apiClient'; -import {GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession} from '../types/experts'; +import { GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession, SignupSessionData } from '../types/experts'; export const getExpertsList = (locale: string, filter?: GeneralFilter): Promise => apiRequest({ url: '/home/coachsearch1', @@ -16,32 +15,17 @@ export const getExpertById = (id: string, locale: string): Promise { - const response = await apiClient.post( - '/home/sessionsignupdata', - { id: expertId }, - { - headers: { - 'X-User-Language': locale, - Authorization: `Bearer ${jwt}` - } - } - ); +export const getSchedulerByExpertId = (id: string, locale: string): Promise => apiRequest({ + url: '/home/sessionsignupdata', + method: 'post', + data: { id }, + locale +}); - return response.data as ExpertScheduler || null; -}; - -export const getSchedulerSession = async (data: { coachId: number, tagId: number, startAtUtc: string, clientComment: string }, locale: string, jwt: string) => { - const response = await apiClient.post( - '/home/sessionsignupsubmit', - data, - { - headers: { - 'X-User-Language': locale, - Authorization: `Bearer ${jwt}` - } - } - ); - - return response.data as ExpertSchedulerSession || null; -}; +export const getSchedulerSession = (data: SignupSessionData, locale: string, token: string): Promise => apiRequest({ + url: '/home/sessionsignupsubmit', + method: 'post', + data, + locale, + token +}); diff --git a/src/app/[locale]/experts/[expertId]/page.tsx b/src/app/[locale]/experts/[expertId]/page.tsx index 42a7b8f..40058c8 100644 --- a/src/app/[locale]/experts/[expertId]/page.tsx +++ b/src/app/[locale]/experts/[expertId]/page.tsx @@ -6,13 +6,11 @@ import { getExpertById, getExpertsList } from '../../../../actions/experts'; import { ExpertCard, ExpertCertificate, - ExpertInformation, ExpertPractice } from '../../../../components/Experts/ExpertDetails'; import { Details } from '../../../../types/education'; import { BackButton } from '../../../../components/view/BackButton'; import { i18nText } from '../../../../i18nKeys'; -import {SchedulerModal} from "../../../../components/Modals/SchedulerModal"; export const metadata: Metadata = { title: 'Bbuddy - Experts item', @@ -84,7 +82,6 @@ export default async function ExpertItem({ params: { expertId = '', locale } }: -

{i18nText('expertBackground', locale)}

diff --git a/src/components/ExpertProfile/ExpertProfile.tsx b/src/components/ExpertProfile/ExpertProfile.tsx index c7e1e71..eb7b78f 100644 --- a/src/components/ExpertProfile/ExpertProfile.tsx +++ b/src/components/ExpertProfile/ExpertProfile.tsx @@ -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'; diff --git a/src/components/Experts/ExpertDetails.tsx b/src/components/Experts/ExpertDetails.tsx index d6fafef..842d538 100644 --- a/src/components/Experts/ExpertDetails.tsx +++ b/src/components/Experts/ExpertDetails.tsx @@ -1,8 +1,8 @@ 'use client'; -import React, {FC, useEffect, useState} from 'react'; +import React, { FC, useState } 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 { ExpertScheduler } from '../../types/experts'; import { ExpertDetails, Practice, ThemeGroup } from '../../types/experts'; @@ -15,7 +15,7 @@ import {getSchedulerByExpertId} from "../../actions/experts"; import {useLocalStorage} from "../../hooks/useLocalStorage"; import {AUTH_TOKEN_KEY} from "../../constants/common"; import dayjs from "dayjs"; -import {SchedulerModal} from "../Modals/SchedulerModal"; +import {ScheduleModal} from "../Modals/ScheduleModal"; type ExpertDetailsProps = { expert: ExpertDetails; @@ -33,65 +33,47 @@ export const ExpertCard: FC = ({ expert, locale, expertId }) const { publicCoachDetails } = expert || {}; const [showSchedulerModal, setShowSchedulerModal] = useState(false); const [mode, setMode] = useState<'data' | 'time' | 'pay' | 'finish'>('data'); - const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0 } } = expert || {}; - - const onSchedulerHandle = async () => { - console.log('sessionCost', sessionCost); - setMode('data'); - setShowSchedulerModal(true) - // отмаппим. - } - - return ( -

-
-
- -
-
-

{`${publicCoachDetails?.name} ${publicCoachDetails?.surname || ''}`}

-
- {`${publicCoachDetails?.practiceHours} ${i18nText('practiceHours', locale)}`} - | - {`${publicCoachDetails?.supervisionPerYearId} ${i18nText('supervisionCount', locale)}`} -
-
- } disabled /> - {`4/5 (${i18nText('outOf', locale)} 345)`} -
-
-
- - setShowSchedulerModal(false)} - updateMode={setMode} - mode={mode} - expertId={expertId as string} - locale={locale as string} - sessionCost={sessionCost} - /> -
- ); -}; - -export const ExpertInformation: FC = ({ expert, locale }) => { const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0, coachLanguages = [] } } = expert || {}; const isRus = locale === Locale.ru; + const onSchedulerHandle = () => { + setMode('data'); + setShowSchedulerModal(true) + }; + return ( <> +
+
+
+ +
+
+

{`${publicCoachDetails?.name} ${publicCoachDetails?.surname || ''}`}

+
+ {`${publicCoachDetails?.practiceHours} ${i18nText('practiceHours', locale)}`} + | + {`${publicCoachDetails?.supervisionPerYearId} ${i18nText('supervisionCount', locale)}`} +
+
+ } disabled /> + {`4/5 (${i18nText('outOf', locale)} 345)`} +
+
+
+
+ + {/* + + + Video + + */} +
+
{/*

{}

*/}
@@ -117,11 +99,20 @@ export const ExpertInformation: FC = ({ expert, locale }) => {tags?.map((skill) => {skill?.name})}
- console.log('schedule')}>{i18nText('signUp', locale)} + {i18nText('signUp', locale)}
{`${sessionCost}€`} / {`${sessionDuration}${isRus ? 'мин' : 'min'}`}
+ setShowSchedulerModal(false)} + updateMode={setMode} + mode={mode} + expertId={expertId as string} + locale={locale as string} + sessionCost={sessionCost} + /> ); }; diff --git a/src/components/Modals/ScheduleModal.tsx b/src/components/Modals/ScheduleModal.tsx new file mode 100644 index 0000000..b9d31bd --- /dev/null +++ b/src/components/Modals/ScheduleModal.tsx @@ -0,0 +1,274 @@ +'use client'; + +import React, { FC, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { Modal, Menu, Calendar, Radio, Button, Input, message } from 'antd'; +import type { CalendarProps, RadioChangeEvent, MenuProps } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { CloseOutlined } from '@ant-design/icons'; +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'; +import { RegisterContent, ResetContent, FinishContent, EnterContent } from './authModalContent'; +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 { ExpertScheduler, SignupSessionData } from "../../types/experts"; +import { Tag } from "../../types/tags"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { AUTH_TOKEN_KEY } from "../../constants/common"; +import { getSchedulerByExpertId, getSchedulerSession } from "../../actions/experts"; +import { ElementsForm } from "../stripe/ElementsForm"; +import { i18nText } from '../../i18nKeys'; +import { CustomSelect } from '../../components/view/CustomSelect'; + +type ScheduleModalProps = { + open: boolean; + handleCancel: () => void; + mode: 'data' | 'time' | 'pay' | 'finish'; + updateMode: (mode: 'data' | 'time' | 'pay' | 'finish') => void; + sessionCost: number; + expertId: string; + locale: string; +}; + +type MenuItem = Required['items'][number]; + +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; +}; + +const getCalendarMenu = (start: Dayjs): MenuItem[] => Array.from({ length: 3 }) + .map((_: unknown, index: number) => { + const date = index ? start.add(index, 'M') : start.clone(); + return { + label: {date.format('MMMM')}, + key: date.format('YYYY-MM-DD') + } + }); + +export const ScheduleModal: FC = ({ + open, + handleCancel, + mode, + updateMode, + sessionCost, + locale, + expertId, +}) => { + const [selectDate, setSelectDate] = useState(dayjs()); + const [dates, setDates] = useState(); + const [tags, setTags] = useState(); + const [sessionData, setSesssionData] = useState({ coachId: +expertId }); + const [sessionId, setSessionId] = useState(-1); + const [rawScheduler, setRawScheduler] = useState(null); + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const [isPayLoading, setIsPayLoading] = useState(false); + + dayjs.locale(locale); + + useEffect(()=> { + if (open) { + getSchedulerByExpertId(expertId as string, locale as string) + .then((data) => { + setRawScheduler(data); + }) + .catch((err) => { + console.log(err); + }); + } + }, [open]); + + 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['onSelect'] = (value, selectInfo) => { + if (selectInfo.source === 'date') { + setSelectDate(value); + updateMode('time'); + } + }; + + const disabledDate = (currentDate: Dayjs) => !dates || !dates[currentDate.format('YYYY-MM-DD')]; + + const onChangeTimeSlot = (e: RadioChangeEvent) => setSesssionData({ ...sessionData, startAtUtc: e.target.value.startTime }); + + const onChangeTag = (tagId: number) => setSesssionData({ ...sessionData, tagId }); + + const singupSession = () => { + console.log(sessionData); + if (sessionData?.startAtUtc && sessionData?.tagId) { + if (jwt) { + setIsPayLoading(true); + getSchedulerSession(sessionData, locale, jwt) + .then((session) => { + console.log(session); + // тут должна быть проверка все ли с регистрацией сессии + setSessionId(+session?.sessionId); + updateMode('pay'); + }) + .catch((err) => { + console.log(err); + message.error('Не удалось провести оплату') + }) + .finally(() => { + setIsPayLoading(false); + }) + } else { + + } + } + } + + const cellRender: CalendarProps['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: ( +
+ + {date.get('date')} + +
+ ), + }); + }; + + return ( + } + > + {mode === 'data' && ( + { + const start = dayjs().startOf('M'); + const [activeMonth, setActiveMonth] = useState(start.format('YYYY-MM-DD')); + + const onClick: MenuProps['onClick'] = (e) => { + setActiveMonth(e.key); + onChange(dayjs(e.key)); + }; + + return ( + + ); + }} + /> + )} + {mode === 'time' && ( +
+
+ +
+
+ {tags && ( + ({ value: id, label: name }))} + onChange={onChangeTag} + /> + )} +
+
+ + {dates[selectDate.format('YYYY-MM-DD')].map((el) => { + return ({dayjs(el.startTime).format('HH:mm')} - {dayjs(el.endTime).format('HH:mm')}) + })} + +
+
+ setSesssionData({ ...sessionData, clientComment: e.target.value })} + /> +
+ +
+ )} + {mode === 'pay' && ( + + )} + + ); +}; diff --git a/src/components/Modals/SchedulerModal.tsx b/src/components/Modals/SchedulerModal.tsx deleted file mode 100644 index 77d6c82..0000000 --- a/src/components/Modals/SchedulerModal.tsx +++ /dev/null @@ -1,290 +0,0 @@ -'use client'; - -import React, {Dispatch, FC, SetStateAction, useEffect, useState} from 'react'; -import { usePathname } from 'next/navigation'; -import classNames from 'classnames'; -import Link from 'next/link'; -import {Modal, Form, Calendar, Radio } from 'antd'; -import type { CalendarProps, RadioChangeEvent } from 'antd'; -import { CloseOutlined } from '@ant-design/icons'; -import { RegisterContent, ResetContent, FinishContent, EnterContent } from './authModalContent'; -import dayjs, { Dayjs } from 'dayjs'; -import {ExpertDetails, ExpertScheduler, Tags} from "../../types/experts"; -import { createStyles } from 'antd-style'; -import {useLocalStorage} from "../../hooks/useLocalStorage"; -import {AUTH_TOKEN_KEY} from "../../constants/common"; -import {getSchedulerByExpertId, getSchedulerSession} from "../../actions/experts"; -import {ElementsForm} from "../stripe/ElementsForm"; - -type SchedulerModalProps = { - open: boolean; - handleCancel: () => void; - mode: 'data' | 'time' | 'pay' | 'finish'; - updateMode: (mode: 'data' | 'time' | 'pay' | 'finish') => void; - sessionCost: number; - expertId: string; - locale: string; -}; - -const useStyle = createStyles(({ token, css, cx }) => { - const lunar = css` - color: ${token.colorTextTertiary}; - font-size: ${token.fontSizeSM}px; - `; - return { - wrapper: css` - width: 450px; - border: 1px solid ${token.colorBorderSecondary}; - border-radius: ${token.borderRadiusOuter}; - padding: 5px; - `, - dateCell: css` - position: relative; - &:before { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - margin: auto; - max-width: 40px; - max-height: 40px; - background: transparent; - transition: background 300ms; - border-radius: ${token.borderRadiusOuter}px; - border: 1px solid transparent; - box-sizing: border-box; - } - &:hover:before { - background: rgba(0, 0, 0, 0.04); - } - `, - today: css` - &:before { - border: 1px solid ${token.colorPrimary}; - } - `, - text: css` - position: relative; - z-index: 1; - `, - lunar, - current: css` - color: ${token.colorTextLightSolid}; - &:before { - background: ${token.colorPrimary}; - } - &:hover:before { - background: ${token.colorPrimary}; - opacity: 0.8; - } - .${cx(lunar)} { - color: ${token.colorTextLightSolid}; - opacity: 0.9; - } - `, - monthCell: css` - width: 120px; - color: ${token.colorTextBase}; - border-radius: ${token.borderRadiusOuter}px; - padding: 5px 0; - &:hover { - background: rgba(0, 0, 0, 0.04); - } - `, - monthCellCurrent: css` - color: ${token.colorTextLightSolid}; - background: ${token.colorPrimary}; - &:hover { - background: ${token.colorPrimary}; - opacity: 0.8; - } - `, - weekend: css` - color: ${token.colorError}; - &.gray { - opacity: 0.4; - } - `, - }; -}); - -export const SchedulerModal: FC = ({ - open, - handleCancel, - mode, - updateMode, - sessionCost, - locale, - expertId, -}) => { - const { styles } = useStyle({ test: true }); - const [selectDate, setSelectDate] = React.useState(dayjs()); - const [dates, setDates] = React.useState(); - const [tags, setTags] = React.useState([]); - const [tag, setTag] = React.useState(-1); - const [slot, setSlot] = React.useState(''); - const [sessionId, setSessionId] = React.useState(-1); - const [rawScheduler, setRawScheduler] = useState(null); - const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); - - useEffect( ()=> { - async function loadScheduler(){ - const rawScheduler = await getSchedulerByExpertId(expertId as string, locale as string, jwt) - setRawScheduler(rawScheduler) - } - if (open) { - loadScheduler() - } - }, [open]) - - 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) - - }) - console.log(rawScheduler, map) - setDates(map) - setTags(rawScheduler?.tags) - }, [rawScheduler]); - - const onPanelChange = (value: Dayjs, mode: CalendarProps['mode']) => { - console.log(value.format('YYYY-MM-DD'), mode); - }; - - const onDateChange: CalendarProps['onSelect'] = (value, selectInfo) => { - if (selectInfo.source === 'date') { - setSelectDate(value); - updateMode('time') - } - }; - - const disabledDate = (currentDate: Dayjs) => { - return !dates || !dates[currentDate.format('YYYY-MM-DD')] - } - - const handleTimeslot = (e: RadioChangeEvent) => { - setSlot(e.target.value.startTime) - console.log('radio checked', e.target.value); - }; - - const handleTag = (e: RadioChangeEvent) => { - setTag(e.target.value) - console.log('tag radio checked', e.target.value); - }; - - const handleSingupSession = async () => { - const data = {coachId: expertId, tagId: tag, startAtUtc: slot, clientComment:''} - console.log(data) - const session = await getSchedulerSession({coachId: expertId, tagId: tag, startAtUtc: slot, clientComment:''}, locale, jwt) - console.log(session); - // тут должна быть проверка все ли с регистрацией сессии - setSessionId(session?.sessionId) - updateMode('pay') - } - - const currentDay = dayjs() - - const cellRender: CalendarProps['fullCellRender'] = (date, info) => { - const isWeekend = date.day() === 6 || date.day() === 0; - return React.cloneElement(info.originNode, { - ...info.originNode.props, - className: classNames(styles.dateCell, { - [styles.current]: selectDate.isSame(date, 'date'), - [styles.today]: date.isSame(dayjs(), 'date'), - }), - children: ( -
- - {date.get('date')} - -
- ), - }); - } - - - return ( - } - > -
- {tags && ( - - {tags?.map((tag)=>( - {tag.name} - ) - )} - - ) - } -
- {mode === 'data' && ( - { - const start = 0; - const end = 12; - const monthOptions = []; - - let current = currentDay.clone(); - const localeData = value.locale(); - const months = []; - - for(let i=0; i<6; i++){ - const m = current.clone() - months.push(m); - current = current.add(1,'month') - } - return (<> - {months.map((m, i)=>( - - ))} - ) - }} - - /> - )} - {mode === 'time' && ( - <> -
- -
- - - {dates[selectDate.format('YYYY-MM-DD')].map( (el) => { - return ({dayjs(el.startTime).format('hh-mm')} - {dayjs(el.endTime).format('hh-mm')}) - })} - - - - )} - {mode === 'pay' && ( - - )} -
- ); -}; diff --git a/src/i18nKeys/de.ts b/src/i18nKeys/de.ts index 3a2f8c0..cc1bc64 100644 --- a/src/i18nKeys/de.ts +++ b/src/i18nKeys/de.ts @@ -42,7 +42,7 @@ export default { addComment: 'Neuen Kommentar hinzufügen', commentPlaceholder: 'Ihr Kommentar', clientComments: 'Kundenkommentare', - coachComments: 'Trainerkommentare' + coachComments: 'Expertenkommentare' }, room: { upcoming: 'Zukünftige Räume', @@ -110,9 +110,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 +146,8 @@ export default { saturday: 'Sa', addNew: 'Neu hinzufügen', mExperiences: 'Führungserfahrung', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'Die E-Mail-Adresse ist ungültig', emptyEmail: 'Bitte geben Sie Ihre E-Mail ein', diff --git a/src/i18nKeys/en.ts b/src/i18nKeys/en.ts index 4be1bc4..c74544a 100644 --- a/src/i18nKeys/en.ts +++ b/src/i18nKeys/en.ts @@ -42,7 +42,7 @@ export default { addComment: 'Add new', commentPlaceholder: 'Your comment', clientComments: 'Client Comments', - coachComments: 'Coach Comments' + coachComments: 'Expert Comments' }, room: { upcoming: 'Upcoming Rooms', @@ -109,10 +109,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 +146,8 @@ export default { saturday: 'Sa', addNew: 'Add New', mExperiences: 'Managerial Experience', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'The email address is not valid', emptyEmail: 'Please enter your E-mail', diff --git a/src/i18nKeys/es.ts b/src/i18nKeys/es.ts index f869ff2..b81773e 100644 --- a/src/i18nKeys/es.ts +++ b/src/i18nKeys/es.ts @@ -42,7 +42,7 @@ 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', @@ -110,9 +110,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 +146,8 @@ export default { saturday: 'S', addNew: 'Añadir nuevo', mExperiences: 'Experiencia de dirección', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'La dirección de correo electrónico no es válida', emptyEmail: 'Introduce tu correo electrónico', diff --git a/src/i18nKeys/fr.ts b/src/i18nKeys/fr.ts index 6bad2ed..21bc050 100644 --- a/src/i18nKeys/fr.ts +++ b/src/i18nKeys/fr.ts @@ -42,7 +42,7 @@ 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', @@ -110,9 +110,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 +146,8 @@ export default { saturday: 'Sa', addNew: 'Ajouter un nouveau', mExperiences: 'Expérience en gestion', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'L\'adresse e-mail n\'est pas valide', emptyEmail: 'Veuillez saisir votre e-mail', diff --git a/src/i18nKeys/it.ts b/src/i18nKeys/it.ts index 81a7d39..06215a2 100644 --- a/src/i18nKeys/it.ts +++ b/src/i18nKeys/it.ts @@ -42,7 +42,7 @@ 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', @@ -110,9 +110,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 +146,8 @@ export default { saturday: 'Sa', addNew: 'Aggiungi nuovo', mExperiences: 'Esperienza manageriale', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'L\'indirizzo e-mail non è valido', emptyEmail: 'Inserisci l\'e-mail', diff --git a/src/i18nKeys/ru.ts b/src/i18nKeys/ru.ts index b7db7b9..4d7c128 100644 --- a/src/i18nKeys/ru.ts +++ b/src/i18nKeys/ru.ts @@ -42,7 +42,7 @@ export default { addComment: 'Добавить новый', commentPlaceholder: 'Ваш комментарий', clientComments: 'Комментарии клиента', - coachComments: 'Комментарии коуча' + coachComments: 'Комментарии эксперта' }, room: { upcoming: 'Предстоящие комнаты', @@ -111,9 +111,9 @@ export default { courses: 'Курсы', mba: 'Информация о MBA', experiences: 'Практический опыт', - aboutCoach: 'О коуче', + aboutCoach: 'Информация об эксперте', education: 'Образование', - coaching: 'Коучинг', + coaching: 'Профиль эксперта', payInfo: 'Платежная информация', sessionDuration: 'Продолжительность сессии', experienceHours: 'Общее количество часов практического опыта', @@ -146,6 +146,8 @@ export default { saturday: 'Сб', addNew: 'Добавить', mExperiences: 'Управленческий опыт', + pay: 'Pay', + sessionWishes: 'Write your wishes about the session', errors: { invalidEmail: 'Адрес электронной почты недействителен', emptyEmail: 'Пожалуйста, введите ваш E-mail', diff --git a/src/styles/view/_calendar.scss b/src/styles/view/_calendar.scss new file mode 100644 index 0000000..fe04d1e --- /dev/null +++ b/src/styles/view/_calendar.scss @@ -0,0 +1,62 @@ +.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; + } +} \ No newline at end of file diff --git a/src/styles/view/_schedule.scss b/src/styles/view/_schedule.scss new file mode 100644 index 0000000..5c8f75c --- /dev/null +++ b/src/styles/view/_schedule.scss @@ -0,0 +1,26 @@ +.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; + } + } +} \ No newline at end of file diff --git a/src/styles/view/style.scss b/src/styles/view/style.scss index b626bbd..569d2c4 100644 --- a/src/styles/view/style.scss +++ b/src/styles/view/style.scss @@ -9,3 +9,5 @@ @import "_practice.scss"; @import "_collapse.scss"; @import "_timepicker.scss"; +@import "_calendar.scss"; +@import "_schedule.scss"; diff --git a/src/types/experts.ts b/src/types/experts.ts index 27fe753..c79322b 100644 --- a/src/types/experts.ts +++ b/src/types/experts.ts @@ -71,28 +71,23 @@ export type ExpertDetails = { associationLevels?: AssociationLevel[]; }; -export type Tags = { - id: number, - groupId: number, - name: string, - couchCount: number, - group: { - id: number, - name: string, - tags: string[]; - } -} - export type Slot = { startTime: string; endTime: string; } export type ExpertScheduler = { - tags: Tags[], + tags: Tag[], availableSlots: Slot[]; } export type ExpertSchedulerSession = { sessionId: string -} \ No newline at end of file +}; + +export type SignupSessionData = { + coachId: number, + tagId?: number, + startAtUtc?: string, + clientComment?: string +}; \ No newline at end of file From b31d2cf7000a0b964572a8b4478158ea88f53cc6 Mon Sep 17 00:00:00 2001 From: SD Date: Sat, 26 Oct 2024 00:38:30 +0400 Subject: [PATCH 05/20] feat: add styles for payment --- package-lock.json | 63 ++++++ package.json | 1 + src/actions/sessions.ts | 8 + src/actions/stripe.ts | 15 +- src/app/[locale]/(main)/@news/page.tsx | 10 +- .../account/(account)/expert-profile/page.tsx | 4 +- .../(simple)/sessions/[...slug]/page.tsx | 2 +- src/app/[locale]/bb-expert/page.tsx | 2 +- src/app/[locale]/blog/page.tsx | 1 - src/app/[locale]/payment/@payment/page.tsx | 20 -- src/app/[locale]/payment/page.tsx | 28 --- src/app/[locale]/payment/result/layout.tsx | 19 -- src/app/[locale]/payment/result/page.tsx | 27 --- src/components/Experts/ExpertDetails.tsx | 43 +++- src/components/Modals/ScheduleModal.tsx | 103 +++++---- src/components/Modals/ScheduleModalResult.tsx | 73 +++++++ .../Page/Header/HeaderAuthLinks.tsx | 42 +++- src/components/stripe/ElementsForm.tsx | 195 ------------------ src/components/stripe/PrintObject.tsx | 10 - src/components/stripe/StripeElementsForm.tsx | 159 ++++++++++++++ src/components/stripe/StripeTestCards.tsx | 19 -- src/constants/common.ts | 1 + src/hooks/useLocalStorage.ts | 2 +- src/i18nKeys/de.ts | 6 +- src/i18nKeys/en.ts | 2 + src/i18nKeys/es.ts | 6 +- src/i18nKeys/fr.ts | 6 +- src/i18nKeys/it.ts | 6 +- src/i18nKeys/ru.ts | 6 +- src/styles/view/_schedule.scss | 5 + src/styles/view/_select.scss | 14 +- src/utils/get-stripe.ts | 5 +- 32 files changed, 491 insertions(+), 412 deletions(-) delete mode 100644 src/app/[locale]/payment/@payment/page.tsx delete mode 100644 src/app/[locale]/payment/page.tsx delete mode 100644 src/app/[locale]/payment/result/layout.tsx delete mode 100644 src/app/[locale]/payment/result/page.tsx create mode 100644 src/components/Modals/ScheduleModalResult.tsx delete mode 100644 src/components/stripe/ElementsForm.tsx delete mode 100644 src/components/stripe/PrintObject.tsx create mode 100644 src/components/stripe/StripeElementsForm.tsx delete mode 100644 src/components/stripe/StripeTestCards.tsx diff --git a/package-lock.json b/package-lock.json index a9ddb6f..725febc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "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" @@ -5718,6 +5719,68 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-stripe-js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-stripe-js/-/react-stripe-js-1.1.5.tgz", + "integrity": "sha512-4lIucgf/FZj6Uxvf/TH+QQa/Qi3FXigwN/QY6H7naPyoEfw9LOuTzdgPAmm7aeSXj8nZJXVoigiGzzFZchXjew==", + "license": "MIT", + "dependencies": { + "@stripe/react-stripe-js": "1.7.2", + "@stripe/stripe-js": "1.29.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/react-stripe-js/node_modules/@stripe/react-stripe-js": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-1.7.2.tgz", + "integrity": "sha512-IAVg2nPUPoSwI//XDRCO7D8mGeK4+N3Xg63fYZHmlfEWAuFVcuaqJKTT67uzIdKYZhHZ/NMdZw/ttz+GOjP/rQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.26.0", + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.0" + } + }, + "node_modules/react-stripe-js/node_modules/@stripe/stripe-js": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.29.0.tgz", + "integrity": "sha512-OsUxk0VLlum8E2d6onlEdKuQcvLMs7qTrOXCnl/BGV3fAm65qr6h3e1IZ5AX4lgUlPRrzRcddSOA5DvkKKYLvg==", + "license": "MIT" + }, + "node_modules/react-stripe-js/node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, + "node_modules/react-stripe-js/node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", diff --git a/package.json b/package.json index ddc0c3e..8339375 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "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" diff --git a/src/actions/sessions.ts b/src/actions/sessions.ts index 3ae9872..4bdc084 100644 --- a/src/actions/sessions.ts +++ b/src/actions/sessions.ts @@ -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 => apiRequest({ + url: '/home/session_pay_confirm', + method: 'post', + data: { id: sessionId }, + locale, + token +}); diff --git a/src/actions/stripe.ts b/src/actions/stripe.ts index c0d2254..989796d 100644 --- a/src/actions/stripe.ts +++ b/src/actions/stripe.ts @@ -1,6 +1,6 @@ "use server"; -import {PaymentIntentCreateParams, Stripe} from "stripe"; +import { Stripe } from "stripe"; import { headers } from "next/headers"; @@ -52,20 +52,19 @@ export async function createCheckoutSession( } export async function createPaymentIntent( - data: any, + data: { amount: number, sessionId?: string }, ): Promise<{ client_secret: string }> { const params = { amount: formatAmountForStripe( - Number(data['amount'] as string), + data.amount, 'eur', ), automatic_payment_methods: { enabled: true }, currency: 'eur', - } as PaymentIntentCreateParams; + } as Stripe.PaymentIntentCreateParams; - // additional params - if (data.sessionId){ + if (data?.sessionId){ params.metadata = { sessionId : data.sessionId } @@ -75,4 +74,6 @@ export async function createPaymentIntent( await stripe.paymentIntents.create(params); return { client_secret: paymentIntent.client_secret as string }; -} \ No newline at end of file +} + +export const getStripePaymentStatus = async (payment_intent: string): Promise => await stripe.paymentIntents.retrieve(payment_intent); \ No newline at end of file diff --git a/src/app/[locale]/(main)/@news/page.tsx b/src/app/[locale]/(main)/@news/page.tsx index 2dadcf5..014a183 100644 --- a/src/app/[locale]/(main)/@news/page.tsx +++ b/src/app/[locale]/(main)/@news/page.tsx @@ -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 (
diff --git a/src/app/[locale]/account/(account)/expert-profile/page.tsx b/src/app/[locale]/account/(account)/expert-profile/page.tsx index 7cda5f2..1a46bcf 100644 --- a/src/app/[locale]/account/(account)/expert-profile/page.tsx +++ b/src/app/[locale]/account/(account)/expert-profile/page.tsx @@ -57,7 +57,7 @@ export default function ExpertProfilePage({ params: { locale } }: { params: { lo } }, [jwt]); - return ( + return data ? ( - ); + ) : null; }; diff --git a/src/app/[locale]/account/(simple)/sessions/[...slug]/page.tsx b/src/app/[locale]/account/(simple)/sessions/[...slug]/page.tsx index 8a9ed64..59339bf 100644 --- a/src/app/[locale]/account/(simple)/sessions/[...slug]/page.tsx +++ b/src/app/[locale]/account/(simple)/sessions/[...slug]/page.tsx @@ -26,7 +26,7 @@ export default function SessionDetailItem({ params: { locale, slug } }: { params Loading...

}>
diff --git a/src/app/[locale]/bb-expert/page.tsx b/src/app/[locale]/bb-expert/page.tsx index 40c2ede..3cdf5af 100644 --- a/src/app/[locale]/bb-expert/page.tsx +++ b/src/app/[locale]/bb-expert/page.tsx @@ -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', diff --git a/src/app/[locale]/blog/page.tsx b/src/app/[locale]/blog/page.tsx index f23d5f1..7569741 100644 --- a/src/app/[locale]/blog/page.tsx +++ b/src/app/[locale]/blog/page.tsx @@ -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 } diff --git a/src/app/[locale]/payment/@payment/page.tsx b/src/app/[locale]/payment/@payment/page.tsx deleted file mode 100644 index c93d91d..0000000 --- a/src/app/[locale]/payment/@payment/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Metadata } from "next"; - -import {ElementsForm} from "../../../../components/stripe/ElementsForm"; - -export const metadata: Metadata = { - title: "Payment", -}; - -export default function PaymentElementPage({ - searchParams, - }: { - searchParams?: { payment_intent_client_secret?: string }; -}) { - return ( -
-

Pay

- -
- ); -} \ No newline at end of file diff --git a/src/app/[locale]/payment/page.tsx b/src/app/[locale]/payment/page.tsx deleted file mode 100644 index 55064d7..0000000 --- a/src/app/[locale]/payment/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import type { Metadata } from 'next'; -import { unstable_setRequestLocale } from 'next-intl/server'; -import { useTranslations } from 'next-intl'; -import { GeneralTopSection } from '../../../components/Page'; -import PaymentElementPage from "./@payment/page"; - -export const metadata: Metadata = { - title: 'Bbuddy - Take the lead with BB', - description: 'Bbuddy desc Take the lead with BB' -}; - -export default function BbClientPage({ params: { locale } }: { params: { locale: string } }) { - unstable_setRequestLocale(locale); - const t = useTranslations('BbClient'); - - return ( - <> - -
- -
- - ); -}; diff --git a/src/app/[locale]/payment/result/layout.tsx b/src/app/[locale]/payment/result/layout.tsx deleted file mode 100644 index bea1996..0000000 --- a/src/app/[locale]/payment/result/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Metadata } from "next"; -import React from "react"; - -export const metadata: Metadata = { - title: "Payment Intent Result", -}; - -export default function ResultLayout({ - children, - }: { - children: React.ReactNode; -}) { - return ( -
-

Payment Intent Result

- {children} -
- ); -} \ No newline at end of file diff --git a/src/app/[locale]/payment/result/page.tsx b/src/app/[locale]/payment/result/page.tsx deleted file mode 100644 index dad26cd..0000000 --- a/src/app/[locale]/payment/result/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Stripe } from "stripe"; - -import { stripe} from "../../../../lib/stripe"; -import PrintObject from "../../../../components/stripe/PrintObject"; - -export default async function ResultPage({ - searchParams, - }: { - searchParams: { payment_intent: string }; -}) { - if (!searchParams.payment_intent) - throw new Error("Please provide a valid payment_intent (`pi_...`)"); - - const paymentIntent: Stripe.PaymentIntent = - await stripe.paymentIntents.retrieve(searchParams.payment_intent); - - // Тут под идее тыкнуться в бек на тему того - прошла ли оплата. в зависимости от этого показать что все ок или нет - // также стоит расшить ссылкой КУДА переходить после того как показали что все ок. - - return ( - <> -

Status: {paymentIntent.status}

-

Payment Intent response:

- - - ); -} \ No newline at end of file diff --git a/src/components/Experts/ExpertDetails.tsx b/src/components/Experts/ExpertDetails.tsx index 842d538..4b27ce3 100644 --- a/src/components/Experts/ExpertDetails.tsx +++ b/src/components/Experts/ExpertDetails.tsx @@ -1,21 +1,20 @@ 'use client'; -import React, { FC, useState } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import Image from 'next/image'; import { Tag, Image as AntdImage, Space, Button } from 'antd'; import { ZoomInOutlined, ZoomOutOutlined, StarFilled } from '@ant-design/icons'; -import { ExpertScheduler } from '../../types/experts'; +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 {getSchedulerByExpertId} from "../../actions/experts"; -import {useLocalStorage} from "../../hooks/useLocalStorage"; -import {AUTH_TOKEN_KEY} from "../../constants/common"; -import dayjs from "dayjs"; -import {ScheduleModal} from "../Modals/ScheduleModal"; +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; @@ -36,9 +35,35 @@ export const ExpertCard: FC = ({ expert, locale, expertId }) 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) + setShowSchedulerModal(true); }; return ( @@ -112,7 +137,9 @@ export const ExpertCard: FC = ({ expert, locale, expertId }) expertId={expertId as string} locale={locale as string} sessionCost={sessionCost} + checkSession={checkSession} /> + ); }; diff --git a/src/components/Modals/ScheduleModal.tsx b/src/components/Modals/ScheduleModal.tsx index b9d31bd..0ab0947 100644 --- a/src/components/Modals/ScheduleModal.tsx +++ b/src/components/Modals/ScheduleModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { FC, useEffect, useState } from 'react'; +import React, {FC, useEffect, useState} from 'react'; import classNames from 'classnames'; import { Modal, Menu, Calendar, Radio, Button, Input, message } from 'antd'; import type { CalendarProps, RadioChangeEvent, MenuProps } from 'antd'; @@ -12,7 +12,6 @@ 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'; -import { RegisterContent, ResetContent, FinishContent, EnterContent } from './authModalContent'; import dayjs, { Dayjs } from 'dayjs'; import 'dayjs/locale/ru'; import 'dayjs/locale/en'; @@ -20,14 +19,15 @@ import 'dayjs/locale/de'; import 'dayjs/locale/it'; import 'dayjs/locale/fr'; import 'dayjs/locale/es'; -import { ExpertScheduler, SignupSessionData } from "../../types/experts"; -import { Tag } from "../../types/tags"; -import { useLocalStorage } from "../../hooks/useLocalStorage"; -import { AUTH_TOKEN_KEY } from "../../constants/common"; -import { getSchedulerByExpertId, getSchedulerSession } from "../../actions/experts"; -import { ElementsForm } from "../stripe/ElementsForm"; +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; @@ -37,6 +37,7 @@ type ScheduleModalProps = { sessionCost: number; expertId: string; locale: string; + checkSession: (data?: SignupSessionData) => void; }; type MenuItem = Required['items'][number]; @@ -79,20 +80,44 @@ export const ScheduleModal: FC = ({ sessionCost, locale, expertId, + checkSession, }) => { const [selectDate, setSelectDate] = useState(dayjs()); const [dates, setDates] = useState(); const [tags, setTags] = useState(); - const [sessionData, setSesssionData] = useState({ coachId: +expertId }); - const [sessionId, setSessionId] = useState(-1); + const [sessionData, setSessionData] = useState({ coachId: +expertId }); const [rawScheduler, setRawScheduler] = useState(null); - const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); const [isPayLoading, setIsPayLoading] = useState(false); + const [sessionId, setSessionId] = useState(''); 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) { + if (open && mode !== 'pay') { + setSessionData({ coachId: +expertId }); getSchedulerByExpertId(expertId as string, locale as string) .then((data) => { setRawScheduler(data); @@ -103,6 +128,12 @@ export const ScheduleModal: FC = ({ } }, [open]); + useEffect(() => { + if (open && mode === 'pay') { + signupSession(); + } + }, [mode]); + useEffect(() => { const map = {} as any rawScheduler?.availableSlots.forEach((el) => { @@ -127,34 +158,9 @@ export const ScheduleModal: FC = ({ const disabledDate = (currentDate: Dayjs) => !dates || !dates[currentDate.format('YYYY-MM-DD')]; - const onChangeTimeSlot = (e: RadioChangeEvent) => setSesssionData({ ...sessionData, startAtUtc: e.target.value.startTime }); + const onChangeTimeSlot = (e: RadioChangeEvent) => setSessionData({ ...sessionData, startAtUtc: e.target.value.startTime }); - const onChangeTag = (tagId: number) => setSesssionData({ ...sessionData, tagId }); - - const singupSession = () => { - console.log(sessionData); - if (sessionData?.startAtUtc && sessionData?.tagId) { - if (jwt) { - setIsPayLoading(true); - getSchedulerSession(sessionData, locale, jwt) - .then((session) => { - console.log(session); - // тут должна быть проверка все ли с регистрацией сессии - setSessionId(+session?.sessionId); - updateMode('pay'); - }) - .catch((err) => { - console.log(err); - message.error('Не удалось провести оплату') - }) - .finally(() => { - setIsPayLoading(false); - }) - } else { - - } - } - } + const onChangeTag = (tagId: number) => setSessionData({ ...sessionData, tagId }); const cellRender: CalendarProps['fullCellRender'] = (date, info) => { const isWeekend = date.day() === 6 || date.day() === 0; @@ -243,7 +249,7 @@ export const ScheduleModal: FC = ({
- {dates[selectDate.format('YYYY-MM-DD')].map((el) => { + {dates[selectDate.format('YYYY-MM-DD')].map((el: any) => { return ({dayjs(el.startTime).format('HH:mm')} - {dayjs(el.endTime).format('HH:mm')}) })} @@ -251,23 +257,30 @@ export const ScheduleModal: FC = ({
setSesssionData({ ...sessionData, clientComment: e.target.value })} + onChange={(e) => setSessionData({ ...sessionData, clientComment: e.target.value })} />
)} {mode === 'pay' && ( - +
+ + + +
)} ); diff --git a/src/components/Modals/ScheduleModalResult.tsx b/src/components/Modals/ScheduleModalResult.tsx new file mode 100644 index 0000000..ff9f5e6 --- /dev/null +++ b/src/components/Modals/ScheduleModalResult.tsx @@ -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(); + const [session, setSession] = useState(); + const [error, setError] = useState(); + 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 ( + } + > +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Page/Header/HeaderAuthLinks.tsx b/src/components/Page/Header/HeaderAuthLinks.tsx index 8d0fc7a..269ce98 100644 --- a/src/components/Page/Header/HeaderAuthLinks.tsx +++ b/src/components/Page/Header/HeaderAuthLinks.tsx @@ -23,6 +23,31 @@ function HeaderAuthLinks ({ const selectedLayoutSegment = useSelectedLayoutSegment(); const pathname = selectedLayoutSegment || ''; const [token, setToken] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const [isPayPath, setIsPayPath] = useState(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,9 +55,16 @@ 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 @@ -49,7 +81,7 @@ function HeaderAuthLinks ({ @@ -61,7 +93,7 @@ function HeaderAuthLinks ({ diff --git a/src/components/stripe/ElementsForm.tsx b/src/components/stripe/ElementsForm.tsx deleted file mode 100644 index 0e3da2f..0000000 --- a/src/components/stripe/ElementsForm.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import type { StripeError } from "@stripe/stripe-js"; - -import * as React from "react"; -import { - useStripe, - useElements, - PaymentElement, - Elements, -} from "@stripe/react-stripe-js"; - -import StripeTestCards from "./StripeTestCards"; - -import getStripe from "../../utils/get-stripe"; -import { createPaymentIntent} from "../../actions/stripe"; -import {Form} from "antd"; -import {Payment} from "../../types/payment"; -import {CustomInput} from "../view/CustomInput"; -import {i18nText} from "../../i18nKeys"; -import {FC, useEffect} from "react"; -import {getPersonalData} from "../../actions/profile"; - -type PaymentFormProps = { - amount: number, - sessionId?: string -} - - -export const CheckoutForm: FC = ({amount, sessionId}) => { - const [input, setInput] = React.useState<{ - paySumm: number; - cardholderName: string; - }>({ - paySumm: 1, - cardholderName: "", - }); - const [form, ] = Form.useForm(); - const formAmount = Form.useWatch('amount', form); - const [paymentType, setPaymentType] = React.useState(""); - const [payment, setPayment] = React.useState<{ - status: "initial" | "processing" | "error"; - }>({ status: "initial" }); - const [errorMessage, setErrorMessage] = React.useState(""); - - const stripe = useStripe(); - const elements = useElements(); - - const PaymentStatus = ({ status }: { status: string }) => { - switch (status) { - case "processing": - case "requires_payment_method": - case "requires_confirmation": - return

Processing...

; - - case "requires_action": - return

Authenticating...

; - - case "succeeded": - return

Payment Succeeded 🥳

; - - case "error": - return ( - <> -

Error 😭

-

{errorMessage}

- - ); - - default: - return null; - } - }; - - useEffect(() => { - elements?.update({ amount: formAmount * 100 }); - }, [formAmount]); - - const handleInputChange: React.ChangeEventHandler = (e) => { - setInput({ - ...input, - [e.currentTarget.name]: e.currentTarget.value, - }); - }; - - const onSubmit = async (data) => { - try { - if (!elements || !stripe) return; - - setPayment({ status: "processing" }); - - const { error: submitError } = await elements.submit(); - - if (submitError) { - setPayment({ status: "error" }); - setErrorMessage(submitError.message ?? "An unknown error occurred"); - - return; - } - - // Create a PaymentIntent with the specified amount. - console.log('DATA', data); - const { client_secret: clientSecret } = await createPaymentIntent( - {amount: amount}, - ); - - // Use your card Element with other Stripe.js APIs - const { error: confirmError } = await stripe!.confirmPayment({ - elements, - clientSecret, - confirmParams: { - return_url: `${window.location.origin}/ru/payment/result`, - payment_method_data: { - allow_redisplay: 'limited', - billing_details: { - name: input.cardholderName, - }, - }, - }, - }); - - if (confirmError) { - setPayment({ status: "error" }); - setErrorMessage(confirmError.message ?? "An unknown error occurred"); - } - } catch (err) { - const { message } = err as StripeError; - - setPayment({ status: "error" }); - setErrorMessage(message ?? "An unknown error occurred"); - } - }; - - - - return ( - <> -
-
- - Your payment details: - {paymentType === "card" ? ( - - ) : null} -
- { - setPaymentType(e.value.type); - }} - /> -
-
- -
- - - ); -} - -export const ElementsForm: FC = ({amount, sessionId}) => { - return ( - - - - ) -} \ No newline at end of file diff --git a/src/components/stripe/PrintObject.tsx b/src/components/stripe/PrintObject.tsx deleted file mode 100644 index b7cd424..0000000 --- a/src/components/stripe/PrintObject.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Stripe } from "stripe"; - -export default function PrintObject({ - content, - }: { - content: Stripe.PaymentIntent | Stripe.Checkout.Session; -}): JSX.Element { - const formattedContent: string = JSON.stringify(content, null, 2); - return
{formattedContent}
; -} \ No newline at end of file diff --git a/src/components/stripe/StripeElementsForm.tsx b/src/components/stripe/StripeElementsForm.tsx new file mode 100644 index 0000000..fc51dc6 --- /dev/null +++ b/src/components/stripe/StripeElementsForm.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { 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 } from 'antd'; +import getStripe from '../../utils/get-stripe'; +import { createPaymentIntent} from '../../actions/stripe'; +import { Payment } from '../../types/payment'; +import { i18nText } from '../../i18nKeys'; + +type PaymentFormProps = { + amount: number, + sessionId?: string, + locale: string +} + +export const CheckoutForm: FC = ({ amount, sessionId, locale }) => { + const [form] = Form.useForm(); + const formAmount = Form.useWatch('amount', form); + const [paymentType, setPaymentType] = useState(''); + const [payment, setPayment] = useState<{ + status: "initial" | "processing" | "error"; + }>({ status: "initial" }); + const [errorMessage, setErrorMessage] = useState(''); + const stripe = useStripe(); + const elements = useElements(); + + const PaymentStatus = ({ status }: { status: string }) => { + switch (status) { + case "processing": + case "requires_payment_method": + case "requires_confirmation": + return

Processing...

; + + case "requires_action": + return

Authenticating...

; + + case "succeeded": + return

Payment Succeeded

; + + case "error": + console.log('errorMessage', errorMessage); + return null; + + default: + return null; + } + }; + + useEffect(() => { + elements?.update({ amount: formAmount * 100 }); + }, [formAmount]); + + const onSubmit = async () => { + try { + if (!elements || !stripe) return; + + setPayment({ status: "processing" }); + + const { error: submitError } = await elements.submit(); + + if (submitError) { + setPayment({ status: "error" }); + setErrorMessage(submitError.message ?? "An unknown error occurred"); + + 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) { + setPayment({ status: "error" }); + setErrorMessage(confirmError.message ?? "An unknown error occurred"); + } + } catch (err) { + const { message } = err as StripeError; + + setPayment({ status: "error" }); + setErrorMessage(message ?? "An unknown error occurred"); + } + }; + + return ( +
+
+ { + setPaymentType(e.value.type); + }} + /> +
+
+ +
+ +
+ ); +} + +export const StripeElementsForm: FC = ({ amount, sessionId, locale }) => { + return ( + + + + ); +}; diff --git a/src/components/stripe/StripeTestCards.tsx b/src/components/stripe/StripeTestCards.tsx deleted file mode 100644 index cf40281..0000000 --- a/src/components/stripe/StripeTestCards.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export default function StripeTestCards(): JSX.Element { - return ( -
- Use any of the{" "} - - Stripe test cards - {" "} - for demo, e.g.{" "} -
- 4242424242424242 -
- . -
- ); -} \ No newline at end of file diff --git a/src/constants/common.ts b/src/constants/common.ts index c5abf3b..ec7be9b 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -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; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 9221a91..d746b67 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -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; diff --git a/src/i18nKeys/de.ts b/src/i18nKeys/de.ts index cc1bc64..0207b55 100644 --- a/src/i18nKeys/de.ts +++ b/src/i18nKeys/de.ts @@ -146,8 +146,10 @@ export default { saturday: 'Sa', addNew: 'Neu hinzufügen', mExperiences: 'Führungserfahrung', - pay: 'Pay', - sessionWishes: 'Write your wishes about the session', + pay: 'Zahlung', + sessionWishes: 'Schreiben Sie Ihre Wünsche zur Sitzung', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'Die E-Mail-Adresse ist ungültig', emptyEmail: 'Bitte geben Sie Ihre E-Mail ein', diff --git a/src/i18nKeys/en.ts b/src/i18nKeys/en.ts index c74544a..b4823ea 100644 --- a/src/i18nKeys/en.ts +++ b/src/i18nKeys/en.ts @@ -148,6 +148,8 @@ export default { mExperiences: 'Managerial Experience', pay: 'Pay', sessionWishes: 'Write your wishes about the session', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'The email address is not valid', emptyEmail: 'Please enter your E-mail', diff --git a/src/i18nKeys/es.ts b/src/i18nKeys/es.ts index b81773e..263078d 100644 --- a/src/i18nKeys/es.ts +++ b/src/i18nKeys/es.ts @@ -146,8 +146,10 @@ export default { saturday: 'S', addNew: 'Añadir nuevo', mExperiences: 'Experiencia de dirección', - pay: 'Pay', - sessionWishes: 'Write your wishes about the session', + pay: 'Pago', + sessionWishes: 'Escribe tus deseos sobre la sesión', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'La dirección de correo electrónico no es válida', emptyEmail: 'Introduce tu correo electrónico', diff --git a/src/i18nKeys/fr.ts b/src/i18nKeys/fr.ts index 21bc050..6ef4be4 100644 --- a/src/i18nKeys/fr.ts +++ b/src/i18nKeys/fr.ts @@ -146,8 +146,10 @@ export default { saturday: 'Sa', addNew: 'Ajouter un nouveau', mExperiences: 'Expérience en gestion', - pay: 'Pay', - sessionWishes: 'Write your wishes about the session', + pay: 'Paiement', + sessionWishes: 'Écrivez vos souhaits concernant la session', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'L\'adresse e-mail n\'est pas valide', emptyEmail: 'Veuillez saisir votre e-mail', diff --git a/src/i18nKeys/it.ts b/src/i18nKeys/it.ts index 06215a2..d2b0fdd 100644 --- a/src/i18nKeys/it.ts +++ b/src/i18nKeys/it.ts @@ -146,8 +146,10 @@ export default { saturday: 'Sa', addNew: 'Aggiungi nuovo', mExperiences: 'Esperienza manageriale', - pay: 'Pay', - sessionWishes: 'Write your wishes about the session', + pay: 'Pagamento', + sessionWishes: 'Scrivi i tuoi desideri riguardo alla sessione', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'L\'indirizzo e-mail non è valido', emptyEmail: 'Inserisci l\'e-mail', diff --git a/src/i18nKeys/ru.ts b/src/i18nKeys/ru.ts index 4d7c128..0a795f8 100644 --- a/src/i18nKeys/ru.ts +++ b/src/i18nKeys/ru.ts @@ -146,8 +146,10 @@ export default { saturday: 'Сб', addNew: 'Добавить', mExperiences: 'Управленческий опыт', - pay: 'Pay', - sessionWishes: 'Write your wishes about the session', + pay: 'Оплата', + sessionWishes: 'Напишите свои пожелания по поводу сессии', + successPayment: 'Success', + errorPayment: 'Error', errors: { invalidEmail: 'Адрес электронной почты недействителен', emptyEmail: 'Пожалуйста, введите ваш E-mail', diff --git a/src/styles/view/_schedule.scss b/src/styles/view/_schedule.scss index 5c8f75c..49cab71 100644 --- a/src/styles/view/_schedule.scss +++ b/src/styles/view/_schedule.scss @@ -23,4 +23,9 @@ gap: 12px; } } + + &-payment { + padding: 44px 40px; + min-height: 300px; + } } \ No newline at end of file diff --git a/src/styles/view/_select.scss b/src/styles/view/_select.scss index a68558e..3feaf85 100644 --- a/src/styles/view/_select.scss +++ b/src/styles/view/_select.scss @@ -3,7 +3,7 @@ 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; @@ -35,6 +35,9 @@ &-wrap { position: relative; width: 100%; + background-color: #F8F8F7; + border-radius: 8px; + &.b-multiselect__active .b-multiselect-label { font-size: 12px; font-weight: 300; @@ -49,7 +52,7 @@ font-weight: 400; line-height: 24px; color: #000; - opacity: .3; + opacity: .4; position: absolute; left: 16px; top: 15px; @@ -70,11 +73,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; @@ -98,6 +102,8 @@ &-wrap { position: relative; width: 100%; + background-color: #F8F8F7; + border-radius: 8px; &.b-select__active .b-select-label { font-size: 12px; @@ -113,7 +119,7 @@ font-weight: 400; line-height: 24px; color: #000; - opacity: .3; + opacity: .4; position: absolute; left: 16px; top: 15px; diff --git a/src/utils/get-stripe.ts b/src/utils/get-stripe.ts index 6930d5b..e4cf2dd 100644 --- a/src/utils/get-stripe.ts +++ b/src/utils/get-stripe.ts @@ -1,7 +1,4 @@ -/** - * This is a singleton to ensure we only instantiate Stripe once. - */ -import { Stripe, loadStripe } from "@stripe/stripe-js"; +import { Stripe, loadStripe } from '@stripe/stripe-js'; let stripePromise: Promise; From 5712cbcf565236060178076720808638d763c0d4 Mon Sep 17 00:00:00 2001 From: SD Date: Mon, 28 Oct 2024 15:28:14 +0400 Subject: [PATCH 06/20] feat: add errors --- src/components/Modals/ScheduleModal.tsx | 97 ++++++++------ src/components/Modals/ScheduleModalResult.tsx | 2 +- src/components/stripe/StripeElementsForm.tsx | 120 +++++++++--------- src/components/view/WithError.tsx | 4 +- src/i18nKeys/de.ts | 4 +- src/i18nKeys/en.ts | 4 +- src/i18nKeys/es.ts | 4 +- src/i18nKeys/fr.ts | 4 +- src/i18nKeys/it.ts | 4 +- src/i18nKeys/ru.ts | 4 +- src/styles/view/_radio.scss | 3 + src/styles/view/_select.scss | 12 ++ src/styles/view/style.scss | 1 + 13 files changed, 155 insertions(+), 108 deletions(-) create mode 100644 src/styles/view/_radio.scss diff --git a/src/components/Modals/ScheduleModal.tsx b/src/components/Modals/ScheduleModal.tsx index 0ab0947..93ede9c 100644 --- a/src/components/Modals/ScheduleModal.tsx +++ b/src/components/Modals/ScheduleModal.tsx @@ -2,8 +2,8 @@ import React, {FC, useEffect, useState} from 'react'; import classNames from 'classnames'; -import { Modal, Menu, Calendar, Radio, Button, Input, message } from 'antd'; -import type { CalendarProps, RadioChangeEvent, MenuProps } from 'antd'; +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 locale_ru from 'antd/lib/calendar/locale/ru_RU'; @@ -57,7 +57,7 @@ const getLocale = (locale: string) => { return locale_es; default: return locale_en; - }; + } } return locale_en; @@ -83,12 +83,12 @@ export const ScheduleModal: FC = ({ checkSession, }) => { const [selectDate, setSelectDate] = useState(dayjs()); - const [dates, setDates] = useState(); + const [dates, setDates] = useState | undefined>(); const [tags, setTags] = useState(); - const [sessionData, setSessionData] = useState({ coachId: +expertId }); const [rawScheduler, setRawScheduler] = useState(null); const [isPayLoading, setIsPayLoading] = useState(false); const [sessionId, setSessionId] = useState(''); + const [form] = Form.useForm<{ clientComment?: string, startAtUtc?: string, tagId?: number }>(); dayjs.locale(locale); @@ -117,7 +117,6 @@ export const ScheduleModal: FC = ({ useEffect(()=> { if (open && mode !== 'pay') { - setSessionData({ coachId: +expertId }); getSchedulerByExpertId(expertId as string, locale as string) .then((data) => { setRawScheduler(data); @@ -126,6 +125,10 @@ export const ScheduleModal: FC = ({ console.log(err); }); } + + if (!open) { + form.resetFields(); + } }, [open]); useEffect(() => { @@ -158,10 +161,6 @@ export const ScheduleModal: FC = ({ const disabledDate = (currentDate: Dayjs) => !dates || !dates[currentDate.format('YYYY-MM-DD')]; - const onChangeTimeSlot = (e: RadioChangeEvent) => setSessionData({ ...sessionData, startAtUtc: e.target.value.startTime }); - - const onChangeTag = (tagId: number) => setSessionData({ ...sessionData, tagId }); - const cellRender: CalendarProps['fullCellRender'] = (date, info) => { const isWeekend = date.day() === 6 || date.day() === 0; return React.cloneElement(info.originNode, { @@ -181,6 +180,13 @@ export const ScheduleModal: FC = ({ }); }; + const onValidate = () => { + form.validateFields() + .then((values) => { + checkSession({ coachId: +expertId, ...values }); + }) + } + return ( = ({ {selectDate.locale(locale).format('DD MMMM YYYY')}
-
- {tags && ( - ({ value: id, label: name }))} - onChange={onChangeTag} +
+
+ {tags && ( + + ({value: id, label: name}))} + /> + + )} +
+
+ + + {dates && dates[selectDate.format('YYYY-MM-DD')].map((el: any) => ( + + {dayjs(el.startTime).format('HH:mm')} - {dayjs(el.endTime).format('HH:mm')} + ) + )} + + +
+ + - )} -
-
- - {dates[selectDate.format('YYYY-MM-DD')].map((el: any) => { - return ({dayjs(el.startTime).format('HH:mm')} - {dayjs(el.endTime).format('HH:mm')}) - })} - -
-
- setSessionData({ ...sessionData, clientComment: e.target.value })} - /> -
+ + diff --git a/src/components/Modals/ScheduleModalResult.tsx b/src/components/Modals/ScheduleModalResult.tsx index ff9f5e6..58d97c0 100644 --- a/src/components/Modals/ScheduleModalResult.tsx +++ b/src/components/Modals/ScheduleModalResult.tsx @@ -45,7 +45,7 @@ export const ScheduleModalResult = ({ locale }: { locale: string }) => { const onClose = () => { const { origin, pathname } = window?.location || {}; - + router.push(`${origin}${pathname}`); setPaymentStatus(undefined); setSession(undefined); diff --git a/src/components/stripe/StripeElementsForm.tsx b/src/components/stripe/StripeElementsForm.tsx index fc51dc6..004653c 100644 --- a/src/components/stripe/StripeElementsForm.tsx +++ b/src/components/stripe/StripeElementsForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import type { StripeError } from '@stripe/stripe-js'; import { useStripe, @@ -8,11 +8,12 @@ import { PaymentElement, Elements, } from '@stripe/react-stripe-js'; -import { Form, Button } from 'antd'; +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, @@ -20,39 +21,37 @@ type PaymentFormProps = { 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

Processing...

; + + case 'requires_action': + return

Authenticating...

; + + case 'succeeded': + return

Payment Succeeded

; + + default: + return null; + } +}; + export const CheckoutForm: FC = ({ amount, sessionId, locale }) => { const [form] = Form.useForm(); const formAmount = Form.useWatch('amount', form); const [paymentType, setPaymentType] = useState(''); const [payment, setPayment] = useState<{ - status: "initial" | "processing" | "error"; - }>({ status: "initial" }); - const [errorMessage, setErrorMessage] = useState(''); + status: PaymentInfo + }>({ status: 'initial' }); + const [errorData, setErrorData] = useState(); const stripe = useStripe(); const elements = useElements(); - const PaymentStatus = ({ status }: { status: string }) => { - switch (status) { - case "processing": - case "requires_payment_method": - case "requires_confirmation": - return

Processing...

; - - case "requires_action": - return

Authenticating...

; - - case "succeeded": - return

Payment Succeeded

; - - case "error": - console.log('errorMessage', errorMessage); - return null; - - default: - return null; - } - }; - useEffect(() => { elements?.update({ amount: formAmount * 100 }); }, [formAmount]); @@ -61,13 +60,15 @@ export const CheckoutForm: FC = ({ amount, sessionId, locale } try { if (!elements || !stripe) return; + setErrorData(undefined); setPayment({ status: "processing" }); const { error: submitError } = await elements.submit(); if (submitError) { - setPayment({ status: "error" }); - setErrorMessage(submitError.message ?? "An unknown error occurred"); + if (submitError.message) { + message.error(submitError.message); + } return; } @@ -91,40 +92,45 @@ export const CheckoutForm: FC = ({ amount, sessionId, locale } }); if (confirmError) { - setPayment({ status: "error" }); - setErrorMessage(confirmError.message ?? "An unknown error occurred"); + setErrorData({ + title: i18nText('errorPayment', locale), + message: confirmError.message ?? 'An unknown error occurred' + }); } } catch (err) { const { message } = err as StripeError; - - setPayment({ status: "error" }); - setErrorMessage(message ?? "An unknown error occurred"); + setErrorData({ + title: i18nText('errorPayment', locale), + message: message ?? 'An unknown error occurred' + }); } }; return ( -
-
- { - setPaymentType(e.value.type); - }} - /> -
-
- -
- -
+ +
+
+ { + setPaymentType(e.value.type); + }} + /> +
+
+ +
+ +
+
); } @@ -143,7 +149,7 @@ export const StripeElementsForm: FC = ({ amount, sessionId, lo colorPrimary: '#66A5AD', colorBackground: '#F8F8F7', colorText: '#000', - colorDanger: '#D93E5C', + colorDanger: '#ff4d4f', focusBoxShadow: 'none', borderRadius: '8px' }, diff --git a/src/components/view/WithError.tsx b/src/components/view/WithError.tsx index 454a45f..f7b4a3f 100644 --- a/src/components/view/WithError.tsx +++ b/src/components/view/WithError.tsx @@ -16,8 +16,8 @@ export const WithError: FC = ({ return ( Refresh page diff --git a/src/i18nKeys/de.ts b/src/i18nKeys/de.ts index 0207b55..b34ec39 100644 --- a/src/i18nKeys/de.ts +++ b/src/i18nKeys/de.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Führungserfahrung', pay: 'Zahlung', sessionWishes: 'Schreiben Sie Ihre Wünsche zur Sitzung', - successPayment: 'Success', - errorPayment: 'Error', + successPayment: 'Erfolgreiche Zahlung', + errorPayment: 'Zahlungsfehler', errors: { invalidEmail: 'Die E-Mail-Adresse ist ungültig', emptyEmail: 'Bitte geben Sie Ihre E-Mail ein', diff --git a/src/i18nKeys/en.ts b/src/i18nKeys/en.ts index b4823ea..e8f55da 100644 --- a/src/i18nKeys/en.ts +++ b/src/i18nKeys/en.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Managerial Experience', pay: 'Pay', sessionWishes: 'Write your wishes about the session', - successPayment: 'Success', - errorPayment: 'Error', + successPayment: 'Successful Payment', + errorPayment: 'Payment Error', errors: { invalidEmail: 'The email address is not valid', emptyEmail: 'Please enter your E-mail', diff --git a/src/i18nKeys/es.ts b/src/i18nKeys/es.ts index 263078d..16b9dfd 100644 --- a/src/i18nKeys/es.ts +++ b/src/i18nKeys/es.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Experiencia de dirección', pay: 'Pago', sessionWishes: 'Escribe tus deseos sobre la sesión', - successPayment: 'Success', - errorPayment: 'Error', + 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', diff --git a/src/i18nKeys/fr.ts b/src/i18nKeys/fr.ts index 6ef4be4..b4ee9e6 100644 --- a/src/i18nKeys/fr.ts +++ b/src/i18nKeys/fr.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Expérience en gestion', pay: 'Paiement', sessionWishes: 'Écrivez vos souhaits concernant la session', - successPayment: 'Success', - errorPayment: 'Error', + successPayment: 'Paiement Réussi', + errorPayment: 'Erreur de Paiement', errors: { invalidEmail: 'L\'adresse e-mail n\'est pas valide', emptyEmail: 'Veuillez saisir votre e-mail', diff --git a/src/i18nKeys/it.ts b/src/i18nKeys/it.ts index d2b0fdd..5633747 100644 --- a/src/i18nKeys/it.ts +++ b/src/i18nKeys/it.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Esperienza manageriale', pay: 'Pagamento', sessionWishes: 'Scrivi i tuoi desideri riguardo alla sessione', - successPayment: 'Success', - errorPayment: 'Error', + successPayment: 'Pagamento Riuscito', + errorPayment: 'Errore di Pagamento', errors: { invalidEmail: 'L\'indirizzo e-mail non è valido', emptyEmail: 'Inserisci l\'e-mail', diff --git a/src/i18nKeys/ru.ts b/src/i18nKeys/ru.ts index 0a795f8..e8252b6 100644 --- a/src/i18nKeys/ru.ts +++ b/src/i18nKeys/ru.ts @@ -148,8 +148,8 @@ export default { mExperiences: 'Управленческий опыт', pay: 'Оплата', sessionWishes: 'Напишите свои пожелания по поводу сессии', - successPayment: 'Success', - errorPayment: 'Error', + successPayment: 'Успешная оплата', + errorPayment: 'Ошибка оплаты', errors: { invalidEmail: 'Адрес электронной почты недействителен', emptyEmail: 'Пожалуйста, введите ваш E-mail', diff --git a/src/styles/view/_radio.scss b/src/styles/view/_radio.scss new file mode 100644 index 0000000..8e3bf9b --- /dev/null +++ b/src/styles/view/_radio.scss @@ -0,0 +1,3 @@ +.ant-form-item-has-error .ant-radio-inner { + border-color: #ff4d4f !important; +} \ No newline at end of file diff --git a/src/styles/view/_select.scss b/src/styles/view/_select.scss index 3feaf85..2bd47c1 100644 --- a/src/styles/view/_select.scss +++ b/src/styles/view/_select.scss @@ -17,6 +17,12 @@ } } + &.ant-select-status-error { + .ant-select-selector { + border-color: #ff4d4f !important; + } + } + .ant-select-selection-overflow-item { margin-right: 4px; } @@ -88,6 +94,12 @@ } } + &.ant-select-status-error { + .ant-select-selector { + border-color: #ff4d4f !important; + } + } + .ant-select-arrow { color: #2c7873 !important; } diff --git a/src/styles/view/style.scss b/src/styles/view/style.scss index 569d2c4..09086f3 100644 --- a/src/styles/view/style.scss +++ b/src/styles/view/style.scss @@ -11,3 +11,4 @@ @import "_timepicker.scss"; @import "_calendar.scss"; @import "_schedule.scss"; +@import "_radio.scss"; From cd44c9f1a1f60e9903c0c2654cd6ae8e46a9e917 Mon Sep 17 00:00:00 2001 From: SD Date: Mon, 28 Oct 2024 15:29:39 +0400 Subject: [PATCH 07/20] 0.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 725febc..64d17c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.0.4", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.0.4", + "version": "0.1.0", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 8339375..6d8782a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.0.4", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev -p 4200", From 5b8ba1b5c45675af91decb76b2f28471c3a1a302 Mon Sep 17 00:00:00 2001 From: SD Date: Tue, 29 Oct 2024 21:57:45 +0400 Subject: [PATCH 08/20] fix: fix account paths, fix local userData after login --- src/actions/hooks/useProfileSettings.ts | 4 ++-- .../[locale]/account/(account)/messages/page.tsx | 6 +++--- src/components/Account/ProfileSettings.tsx | 15 ++++++--------- src/components/Account/sessions/SessionsTabs.tsx | 5 +++-- .../Modals/authModalContent/EnterContent.tsx | 4 ++-- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/actions/hooks/useProfileSettings.ts b/src/actions/hooks/useProfileSettings.ts index bff3878..365d08d 100644 --- a/src/actions/hooks/useProfileSettings.ts +++ b/src/actions/hooks/useProfileSettings.ts @@ -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); diff --git a/src/app/[locale]/account/(account)/messages/page.tsx b/src/app/[locale]/account/(account)/messages/page.tsx index 2038deb..630fed0 100644 --- a/src/app/[locale]/account/(account)/messages/page.tsx +++ b/src/app/[locale]/account/(account)/messages/page.tsx @@ -18,7 +18,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
@@ -42,7 +42,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
@@ -63,7 +63,7 @@ export default function Messages({ params: { locale } }: { params: { locale: str
diff --git a/src/components/Account/ProfileSettings.tsx b/src/components/Account/ProfileSettings.tsx index cfbb04b..2194da7 100644 --- a/src/components/Account/ProfileSettings.tsx +++ b/src/components/Account/ProfileSettings.tsx @@ -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>[0]; - export const ProfileSettings: FC = ({ locale }) => { const [form] = Form.useForm(); const { profileSettings, fetchProfileSettings, save, fetchLoading } = useProfileSettings(locale); @@ -58,7 +55,7 @@ export const ProfileSettings: FC = ({ 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 = ({ 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 = ({ locale }) => { > {i18nText('save', locale)} - router.push('change-password')}> + router.push('settings/change-password')}> {i18nText('changePass', locale)} { 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, id: number) => { event.stopPropagation(); event.preventDefault(); - router.push(`${id}`); + router.push(`${pathname}/${id}`); }; const getChildren = (list?: Session[]) => ( diff --git a/src/components/Modals/authModalContent/EnterContent.tsx b/src/components/Modals/authModalContent/EnterContent.tsx index 004a776..6bcc377 100644 --- a/src/components/Modals/authModalContent/EnterContent.tsx +++ b/src/components/Modals/authModalContent/EnterContent.tsx @@ -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 = ({ 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); From c0feea48e52fe17514b7e417745ccc73e5190b35 Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 21 Nov 2024 17:03:49 +0400 Subject: [PATCH 09/20] feat: create rooms --- src/actions/hooks/useRoomDetails.ts | 42 +++ src/actions/rooms.ts | 109 ++++++ .../account/(simple)/rooms/[...slug]/page.tsx | 57 +++ .../[locale]/account/(simple)/rooms/page.tsx | 12 + src/components/Account/agora/Agora.tsx | 2 +- src/components/Account/agora/AgoraGroup.tsx | 54 +++ .../agora/components/UsersGroupPanel.tsx | 44 +++ .../Account/agora/components/index.ts | 1 + src/components/Account/agora/index.tsx | 16 + src/components/Account/index.ts | 1 + src/components/Account/rooms/CreateRoom.tsx | 46 +++ src/components/Account/rooms/EditRoomForm.tsx | 220 +++++++++++ src/components/Account/rooms/RoomDetails.tsx | 66 ++++ .../Account/rooms/RoomDetailsContent.tsx | 355 ++++++++++++++++++ src/components/Account/rooms/RoomsTabs.tsx | 173 +++++++++ src/components/Account/rooms/index.tsx | 6 + .../sessions/SessionDetailsContent.tsx | 6 +- src/components/Experts/AdditionalFilter.tsx | 2 - .../Modals/EditExpertEducationModal.tsx | 20 +- src/components/Modals/ScheduleModal.tsx | 28 +- src/components/Modals/UsersListModal.tsx | 113 ++++++ src/components/view/CustomDatePicker.tsx | 60 +++ src/i18nKeys/de.ts | 19 +- src/i18nKeys/en.ts | 19 +- src/i18nKeys/es.ts | 19 +- src/i18nKeys/fr.ts | 19 +- src/i18nKeys/it.ts | 19 +- src/i18nKeys/ru.ts | 19 +- src/styles/_default.scss | 1 + src/styles/_modal.scss | 7 + src/styles/_pages.scss | 4 + src/styles/sessions/_agora.scss | 53 ++- src/styles/sessions/_details.scss | 89 +++++ src/styles/view/_calendar.scss | 1 - src/styles/view/_datepicker.scss | 128 +++++++ src/styles/view/_room.scss | 86 +++++ src/styles/view/_select.scss | 3 +- src/styles/view/_timepicker.scss | 2 +- src/styles/view/style.scss | 2 + src/types/author.ts | 1 - src/types/rooms.ts | 44 +++ src/types/sessions.ts | 3 + src/utils/account.ts | 2 +- src/utils/locale.ts | 28 ++ 44 files changed, 1946 insertions(+), 55 deletions(-) create mode 100644 src/actions/hooks/useRoomDetails.ts create mode 100644 src/actions/rooms.ts create mode 100644 src/app/[locale]/account/(simple)/rooms/[...slug]/page.tsx create mode 100644 src/app/[locale]/account/(simple)/rooms/page.tsx create mode 100644 src/components/Account/agora/AgoraGroup.tsx create mode 100644 src/components/Account/agora/components/UsersGroupPanel.tsx create mode 100644 src/components/Account/rooms/CreateRoom.tsx create mode 100644 src/components/Account/rooms/EditRoomForm.tsx create mode 100644 src/components/Account/rooms/RoomDetails.tsx create mode 100644 src/components/Account/rooms/RoomDetailsContent.tsx create mode 100644 src/components/Account/rooms/RoomsTabs.tsx create mode 100644 src/components/Account/rooms/index.tsx create mode 100644 src/components/Modals/UsersListModal.tsx create mode 100644 src/components/view/CustomDatePicker.tsx create mode 100644 src/styles/view/_datepicker.scss create mode 100644 src/styles/view/_room.scss create mode 100644 src/types/rooms.ts create mode 100644 src/utils/locale.ts diff --git a/src/actions/hooks/useRoomDetails.ts b/src/actions/hooks/useRoomDetails.ts new file mode 100644 index 0000000..029337f --- /dev/null +++ b/src/actions/hooks/useRoomDetails.ts @@ -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(); + const [errorData, setErrorData] = useState(); + const [loading, setLoading] = useState(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 + }; +}; diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts new file mode 100644 index 0000000..dd8f03d --- /dev/null +++ b/src/actions/rooms.ts @@ -0,0 +1,109 @@ +import { apiRequest } from './helpers'; +import {GetUsersForRooms, Room, RoomEdit, RoomEditDTO} from '../types/rooms'; + +export const getUpcomingRooms = (locale: string, token: string): Promise => apiRequest({ + url: '/home/upcomingsessionsall', + method: 'post', + data: { + sessionType: 'room' + }, + locale, + token +}); + +export const getRecentRooms = (locale: string, token: string): Promise => apiRequest({ + url: '/home/historicalmeetings', + method: 'post', + data: { + sessionType: 'room' + }, + locale, + token +}); + +export const getRoomDetails = (locale: string, token: string, id: number): Promise => apiRequest({ + url: '/home/room', + method: 'post', + data: { id }, + locale, + token +}); + +export const deleteRoomClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise => apiRequest({ + url: '/home/deleteclientfromroom', + method: 'post', + data, + locale, + token +}); + +export const deleteRoomSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise => apiRequest({ + url: '/home/deletesupervisorfromroom', + method: 'post', + data, + locale, + token +}); + +export const becomeRoomClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise => apiRequest({ + url: '/home/becomeroomclient', + method: 'post', + data, + locale, + token +}); + +export const becomeRoomSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise => apiRequest({ + url: '/home/becomeroomsupervisor', + method: 'post', + data, + locale, + token +}); + +export const getUsersList = (locale: string, token: string, data: { template: string }): Promise => apiRequest({ + url: '/home/findusersforroom', + method: 'post', + data, + locale, + token +}); + +export const addClient = (locale: string, token: string, data: { sessionId: number, clientUserId: number }): Promise => apiRequest({ + url: '/home/addclienttoroom', + method: 'post', + data, + locale, + token +}); + +export const addSupervisor = (locale: string, token: string, data: { sessionId: number, supervisorUserId: number }): Promise => apiRequest({ + url: '/home/addsupervisortoroom', + method: 'post', + data, + locale, + token +}); + +export const createRoom = (locale: string, token: string): Promise => apiRequest({ + url: '/home/createroom', + method: 'post', + locale, + token +}); + +export const updateRoom = (locale: string, token: string, data: RoomEdit): Promise => apiRequest({ + url: '/home/updateroom', + method: 'post', + data, + locale, + token +}); + +export const getRoomById = (locale: string, token: string, id: number): Promise => apiRequest({ + url: '/home/getroomforedit', + method: 'post', + data: { id }, + locale, + token +}); diff --git a/src/app/[locale]/account/(simple)/rooms/[...slug]/page.tsx b/src/app/[locale]/account/(simple)/rooms/[...slug]/page.tsx new file mode 100644 index 0000000..b6f024c --- /dev/null +++ b/src/app/[locale]/account/(simple)/rooms/[...slug]/page.tsx @@ -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 ( + Loading...

}> + +
+ ); + } + + if (ROOMS_ROUTES.includes(roomType as RoomsType) && !Number.isInteger(roomId)) { + return ( + <> +
+ +
+
+
+ Loading...

}> + +
+
+
+ + ); + } + + return notFound(); +}; diff --git a/src/app/[locale]/account/(simple)/rooms/page.tsx b/src/app/[locale]/account/(simple)/rooms/page.tsx new file mode 100644 index 0000000..b4ed706 --- /dev/null +++ b/src/app/[locale]/account/(simple)/rooms/page.tsx @@ -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; +}; diff --git a/src/components/Account/agora/Agora.tsx b/src/components/Account/agora/Agora.tsx index 6be491d..79c5bbb 100644 --- a/src/components/Account/agora/Agora.tsx +++ b/src/components/Account/agora/Agora.tsx @@ -37,7 +37,7 @@ export const Agora = ({ sessionId, secret, stopCalling, remoteUser }: AgoraProps }; return ( -
+
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 ( + <> +
+ +
+
+ setCamera(a => !a)} + setMic={() => setMic(a => !a)} + /> +
+ + ); +}; diff --git a/src/components/Account/agora/components/UsersGroupPanel.tsx b/src/components/Account/agora/components/UsersGroupPanel.tsx new file mode 100644 index 0000000..1823c3b --- /dev/null +++ b/src/components/Account/agora/components/UsersGroupPanel.tsx @@ -0,0 +1,44 @@ +import { + type IRemoteVideoTrack, + 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 { audioTracks } = useRemoteAudioTracks(remoteUsers); + + usePublish([localMicrophoneTrack, localCameraTrack]); + audioTracks.map(track => track.play()); + + return calling && isConnected && remoteUsers ? ( +
+
+ +
+ {remoteUsers.length > 0 && remoteUsers.map(({ uid, videoTrack }) => ( +
+ +
+ ))} +
+ ) : null; +} diff --git a/src/components/Account/agora/components/index.ts b/src/components/Account/agora/components/index.ts index 99a5a9d..5e48733 100644 --- a/src/components/Account/agora/components/index.ts +++ b/src/components/Account/agora/components/index.ts @@ -3,3 +3,4 @@ export * from './UserCover'; export * from './RemoteUsers'; export * from './LocalUserPanel'; export * from './RemoteUserPanel'; +export * from './UsersGroupPanel'; diff --git a/src/components/Account/agora/index.tsx b/src/components/Account/agora/index.tsx index cbca252..1daeaf3 100644 --- a/src/components/Account/agora/index.tsx +++ b/src/components/Account/agora/index.tsx @@ -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 ) : null; }; + +export const AgoraClientGroup = ({ room, stopCalling }: { room?: Room, stopCalling: () => void }) => { + return room ? ( + + {room && ( + + )} + + ) : null; +}; diff --git a/src/components/Account/index.ts b/src/components/Account/index.ts index 8f56865..9ade85d 100644 --- a/src/components/Account/index.ts +++ b/src/components/Account/index.ts @@ -3,3 +3,4 @@ export { AccountMenu } from './AccountMenu'; export { ProfileSettings } from './ProfileSettings'; export * from './sessions'; +export * from './rooms'; diff --git a/src/components/Account/rooms/CreateRoom.tsx b/src/components/Account/rooms/CreateRoom.tsx new file mode 100644 index 0000000..f5fd592 --- /dev/null +++ b/src/components/Account/rooms/CreateRoom.tsx @@ -0,0 +1,46 @@ +'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(); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const getRoom = debounce(() => { + setRoomId(2556); + // createRoom(locale, jwt) + // .then((data) => { + // setRoomId(data); + // }) + // .finally(() => { + // setLoading(false); + // }) + }, 500); + + useEffect(() => { + // setLoading(true); + getRoom(); + }, []); + + return ( + + {roomId && ( + router.push(`/account/rooms/${RoomsType.UPCOMING}`)} + /> + )} + + ) +}; diff --git a/src/components/Account/rooms/EditRoomForm.tsx b/src/components/Account/rooms/EditRoomForm.tsx new file mode 100644 index 0000000..4b826c8 --- /dev/null +++ b/src/components/Account/rooms/EditRoomForm.tsx @@ -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(); + const [editingRoom, setEditingRoom] = useState(); + const dateValue = Form.useWatch('date', form); + const [loading, setLoading] = useState(false); + const [fetchLoading, setFetchLoading] = useState(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(); + if (editingRoom?.availableSlots) { + editingRoom.availableSlots.forEach(({ startTime }) => { + const [date] = startTime.split('T'); + dateList.add(date); + }); + + return Array.from(dateList); + } + + return []; + }, [editingRoom?.availableSlots]); + + const getTimeOptions = (slots?: Slot[], curDate?: Dayjs) => { + const date = curDate ? curDate.utc().format('YYYY-MM-DD') : ''; + if (slots && slots?.length && date) { + return slots.filter(({ startTime }) => startTime.indexOf(date) > -1) + .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: {name} })) || []; + } + + 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 ( + +
+ + + + + + +
+ + + + + + + + ({ value: i+1, label: i+1 }))} + /> + + + + +
+ + + + +
+
+ ); +}; diff --git a/src/components/Account/rooms/RoomDetails.tsx b/src/components/Account/rooms/RoomDetails.tsx new file mode 100644 index 0000000..d3d7a78 --- /dev/null +++ b/src/components/Account/rooms/RoomDetails.tsx @@ -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(false); + + useEffect(() => { + if (isCalling) { + tracking.start(); + } else { + tracking.stop(); + } + }, [isCalling]); + + const stopCalling = () => { + setIsCalling(false); + fetchData(); + } + + return isCalling + ? ( + + ) : ( + <> +
+ +
+
+
+ + setIsCalling(true)} + refresh={fetchData} + /> + +
+
+ + ); +}; diff --git a/src/components/Account/rooms/RoomDetailsContent.tsx b/src/components/Account/rooms/RoomDetailsContent.tsx new file mode 100644 index 0000000..00dcb82 --- /dev/null +++ b/src/components/Account/rooms/RoomDetailsContent.tsx @@ -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(false); + const [forSupervisor, setForSupervisor] = useState(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(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 ? ( +
+
+ +
+
{room?.title || ''}
+
+ {today + ? `${i18nText('today', locale)} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}` + : `${startDate?.format('D MMMM')} ${startDate?.format('HH:mm')} - ${endDate?.format('HH:mm')}`} +
+ {room?.themesTags && room.themesTags.length > 0 && ( +
+
+ {room.themesTags.map((skill) => {skill?.name})} +
+
+ )} + {room?.description &&
{room.description}
} + {activeType === RoomsType.UPCOMING && (isCreator || isSupervisor || isClient) && ( +
+ {(isCreator || isClient || isSupervisor) && ( + + )} + {isCreator && isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && ( + + )} +
+ )} +
+
+
{i18nText('room.roomCreator', locale)}
+
+
+
+
+ +
+
+
{`${room?.coach?.name} ${room?.coach?.surname || ''}`}
+
+
+
+
+ {room?.isNeedSupervisor && ( +
+
+
{i18nText('room.supervisor', locale)}
+
+ {room?.supervisor && ( +
+
+
+ +
+
+
{`${room?.supervisor?.name} ${room?.supervisor?.surname || ''}`}
+
+ {isCreator && activeType === RoomsType.UPCOMING && isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && ( + } + onClick={() => deleteSupervisor(room?.supervisor?.id)} + /> + )} +
+
+ )} + {room?.supervisor && activeType === RoomsType.RECENT && ( + <> + {room?.supervisorComment && ( +
{room.supervisorComment}
+ )} + + )} + {isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && isCreator && activeType === RoomsType.UPCOMING && ( + + )} + {isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && !isCreator && activeType === RoomsType.UPCOMING && checkUserApply() && ( + + )} + {!room?.supervisor && !isCreator && !checkUserApply() && ( +
{i18nText('noData', locale)}
+ )} +
+ )} +
+
+
{i18nText('room.participants', locale)}
+
{`${room?.clients?.length || 0}/${room?.maxClients}`}
+
+ {room?.clients && room?.clients?.length > 0 && ( +
+ {room.clients.map(({id, faceImageUrl, name, surname}) => ( +
+
+ +
+
+
{`${name} ${surname || ''}`}
+
+ {isCreator && room?.state === SessionState.COACH_APPROVED && activeType === RoomsType.UPCOMING && isTimeBeforeStart && ( + } + onClick={() => deleteClient(id)} + /> + )} +
+ ))} +
+ )} + {isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && isCreator && activeType === RoomsType.UPCOMING && (!room?.clients || (room?.clients && room?.clients?.length < room.maxClients)) && ( + + )} + {isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !isCreator && activeType === RoomsType.UPCOMING && (!room?.clients || (room?.clients && room?.clients?.length < room.maxClients)) && checkUserApply() && ( + + )} +
+ {room && ( + setShowModal(false)} + submit={onAddUser} + afterCloseModal={() => setForSupervisor(false)} + room={room} + /> + )} +
+ ) : ( +
+
+ +
+ +
+ ); +}; diff --git a/src/components/Account/rooms/RoomsTabs.tsx b/src/components/Account/rooms/RoomsTabs.tsx new file mode 100644 index 0000000..8f4dd7d --- /dev/null +++ b/src/components/Account/rooms/RoomsTabs.tsx @@ -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(); + const [rooms, setRooms] = useState(); + const [loading, setLoading] = useState(true); + const [errorData, setErrorData] = useState(); + 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, id: number) => { + event.stopPropagation(); + event.preventDefault(); + router.push(`${pathname}/${id}`); + }; + + const getChildren = (list?: any[]) => ( + <> + {/*
+
+ +
+
*/} +
+ {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 ( +
) => onClickSession(e, id)}> +
+
+ +
+
+
+
{`${coach?.name} ${coach?.surname || ''}`}
+
{title}
+
+ {today + ? `${i18nText('today', locale)} ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}` + : `${startDate.format('D MMMM')} ${startDate.format('HH:mm')} - ${endDate.format('HH:mm')}`} +
+
+ {supervisor && ( + <> +
{i18nText('room.supervisor', locale)}
+
{`${supervisor?.name} ${supervisor?.surname || ''}`}
+ + )} +
{i18nText('room.members', locale)}
+
{`${clients.length}/${maxClients}`}
+
+
+
+
+
+ ) + }) : ( + + )} +
+ + ); + + const tabs = [ + { + key: RoomsType.UPCOMING, + label: ( + <> + {i18nText('room.upcoming', locale)} + {rooms?.upcoming && rooms?.upcoming?.length > 0 ? ({rooms?.upcoming.length}) : 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: + } + ]; + + return ( + +
+ {tabs.map(({ key, label }) => ( + router.push(`/account/rooms/${key}`)} + > + {label} + + ))} +
+ {tabs.filter(({ key }) => key === activeTab)[0].children} +
+ ); +}; diff --git a/src/components/Account/rooms/index.tsx b/src/components/Account/rooms/index.tsx new file mode 100644 index 0000000..4441047 --- /dev/null +++ b/src/components/Account/rooms/index.tsx @@ -0,0 +1,6 @@ +'use client' + +export * from './RoomDetails'; +export * from './RoomsTabs'; +export * from './RoomDetailsContent'; +export * from './CreateRoom'; diff --git a/src/components/Account/sessions/SessionDetailsContent.tsx b/src/components/Account/sessions/SessionDetailsContent.tsx index f32622e..212b15c 100644 --- a/src/components/Account/sessions/SessionDetailsContent.tsx +++ b/src/components/Account/sessions/SessionDetailsContent.tsx @@ -81,7 +81,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio const CoachCard = (coach?: PublicUser) => coach ? (
- +
@@ -106,7 +106,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio
{session?.themesTags?.slice(0, 2).map((skill) => {skill?.name})} - {session?.themesTags?.length > 2 + {session?.themesTags && session?.themesTags?.length > 2 ? ( @@ -128,7 +128,7 @@ export const SessionDetailsContent = ({ session, locale, activeType, startSessio const StudentCard = (student?: PublicUser | null) => student ? (
- +
{`${student?.name} ${student?.surname || ''}`}
diff --git a/src/components/Experts/AdditionalFilter.tsx b/src/components/Experts/AdditionalFilter.tsx index 5b692ce..383c12d 100644 --- a/src/components/Experts/AdditionalFilter.tsx +++ b/src/components/Experts/AdditionalFilter.tsx @@ -55,8 +55,6 @@ export const ExpertsAdditionalFilter = ({ }; const search = getSearchParamsString(newFilter); - console.log('here1'); - router.push(search ? `${basePath}?${search}#filter` : `${basePath}#filter`); // router.push({ diff --git a/src/components/Modals/EditExpertEducationModal.tsx b/src/components/Modals/EditExpertEducationModal.tsx index e2720e5..5d52663 100644 --- a/src/components/Modals/EditExpertEducationModal.tsx +++ b/src/components/Modals/EditExpertEducationModal.tsx @@ -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; diff --git a/src/components/Modals/ScheduleModal.tsx b/src/components/Modals/ScheduleModal.tsx index 93ede9c..5397223 100644 --- a/src/components/Modals/ScheduleModal.tsx +++ b/src/components/Modals/ScheduleModal.tsx @@ -6,12 +6,6 @@ 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 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'; import dayjs, { Dayjs } from 'dayjs'; import 'dayjs/locale/ru'; import 'dayjs/locale/en'; @@ -19,6 +13,7 @@ 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'; @@ -42,27 +37,6 @@ type ScheduleModalProps = { type MenuItem = Required['items'][number]; -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; -}; - const getCalendarMenu = (start: Dayjs): MenuItem[] => Array.from({ length: 3 }) .map((_: unknown, index: number) => { const date = index ? start.add(index, 'M') : start.clone(); diff --git a/src/components/Modals/UsersListModal.tsx b/src/components/Modals/UsersListModal.tsx new file mode 100644 index 0000000..f81443d --- /dev/null +++ b/src/components/Modals/UsersListModal.tsx @@ -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(); + const [loading, seLoading] = useState(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 ( + } + afterClose={onAfterClose} + > +
+ + {users && ( +
+ + {users.length > 0 ? ( +
+ {users.map(({ id, name, surname, faceImageUrl }) => ( +
+
+
+ +
+
+
{`${name} ${surname || ''}`}
+
+
+ +
+ ))} +
+ ) : ( +
{i18nText('noData', locale)}
+ )} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/view/CustomDatePicker.tsx b/src/components/view/CustomDatePicker.tsx new file mode 100644 index 0000000..96361a6 --- /dev/null +++ b/src/components/view/CustomDatePicker.tsx @@ -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(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 ( +
+
+ {label} +
+ } + {...other} + /> +
+ ); +}; diff --git a/src/i18nKeys/de.ts b/src/i18nKeys/de.ts index b34ec39..53e2986 100644 --- a/src/i18nKeys/de.ts +++ b/src/i18nKeys/de.ts @@ -1,6 +1,7 @@ export default { accountMenu: { sessions: 'Kommende & letzte Sitzungen', + rooms: 'Zimmer', notifications: 'Benachrichtigung', support: 'Hilfe & Support', information: 'Rechtliche Informationen', @@ -48,7 +49,23 @@ export default { 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', diff --git a/src/i18nKeys/en.ts b/src/i18nKeys/en.ts index e8f55da..bc6b080 100644 --- a/src/i18nKeys/en.ts +++ b/src/i18nKeys/en.ts @@ -1,6 +1,7 @@ export default { accountMenu: { sessions: 'Upcoming & Recent Sessions', + rooms: 'Rooms', notifications: 'Notification', support: 'Help & Support', information: 'Legal Information', @@ -48,7 +49,23 @@ export default { 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', diff --git a/src/i18nKeys/es.ts b/src/i18nKeys/es.ts index 16b9dfd..2922e94 100644 --- a/src/i18nKeys/es.ts +++ b/src/i18nKeys/es.ts @@ -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', @@ -48,7 +49,23 @@ export default { 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', diff --git a/src/i18nKeys/fr.ts b/src/i18nKeys/fr.ts index b4ee9e6..ab93f21 100644 --- a/src/i18nKeys/fr.ts +++ b/src/i18nKeys/fr.ts @@ -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', @@ -48,7 +49,23 @@ export default { 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: '', diff --git a/src/i18nKeys/it.ts b/src/i18nKeys/it.ts index 5633747..f2c17c6 100644 --- a/src/i18nKeys/it.ts +++ b/src/i18nKeys/it.ts @@ -1,6 +1,7 @@ export default { accountMenu: { sessions: 'Prossime e recenti sessioni', + rooms: 'Stanze', notifications: 'Notifica', support: 'Assistenza e supporto', information: 'Informazioni legali', @@ -48,7 +49,23 @@ export default { 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: '', diff --git a/src/i18nKeys/ru.ts b/src/i18nKeys/ru.ts index e8252b6..7ef3681 100644 --- a/src/i18nKeys/ru.ts +++ b/src/i18nKeys/ru.ts @@ -1,6 +1,7 @@ export default { accountMenu: { sessions: 'Предстоящие и недавние сессии', + rooms: 'Комнаты', notifications: 'Уведомления', support: 'Служба поддержки', information: 'Юридическая информация', @@ -48,7 +49,23 @@ export default { upcoming: 'Предстоящие комнаты', requested: 'Запрошенные комнаты', recent: 'Недавние комнаты', - newRoom: 'Новая комната' + newRoom: 'Новая комната', + editRoom: 'Изменить комнату', + date: 'Дата', + time: 'Время', + maxParticipants: 'Макс. кол-во участников', + presenceOfSupervisor: 'Присутствие супервизора', + supervisor: 'Супервайзер', + members: 'Участники', + participants: 'Участники', + roomCreator: 'Создатель комнаты', + inviteSupervisor: 'Пригласить супервизора', + joinSupervisor: 'Присоединиться как супервизор', + inviteParticipant: 'Пригласить участника', + joinParticipant: 'Присоединиться как участник', + rapport: 'Раппорт', + invite: 'Пригласить', + save: 'Сохранить комнату' }, agreementText: 'Я прочитал и согласен с условиями Пользовательского соглашения,', userAgreement: 'Пользовательского соглашения', diff --git a/src/styles/_default.scss b/src/styles/_default.scss index 93f42f9..c871c19 100644 --- a/src/styles/_default.scss +++ b/src/styles/_default.scss @@ -668,6 +668,7 @@ a { & > div { display: flex; gap: 4px; + padding-left: 1px; &:first-child { flex-direction: column; diff --git a/src/styles/_modal.scss b/src/styles/_modal.scss index 05be32d..f72e0a1 100644 --- a/src/styles/_modal.scss +++ b/src/styles/_modal.scss @@ -82,6 +82,13 @@ } } } + + &__users-list__content { + display: flex; + flex-direction: column; + padding: 40px; + gap: 24px; + } } .ant-modal-mask { diff --git a/src/styles/_pages.scss b/src/styles/_pages.scss index eb8eafc..4579515 100644 --- a/src/styles/_pages.scss +++ b/src/styles/_pages.scss @@ -931,6 +931,10 @@ &.chosen { color: #D93E5C; } + + &.history { + color: #c4c4c4; + } } } } diff --git a/src/styles/sessions/_agora.scss b/src/styles/sessions/_agora.scss index 4e49e64..3114ae6 100644 --- a/src/styles/sessions/_agora.scss +++ b/src/styles/sessions/_agora.scss @@ -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,44 @@ 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 { + flex: calc((100% - 16px) / 3) 0; + } + + &.gr-10, &.gr-11, &.gr-12, &.gr-13, &.gr-14, &.gr-15, &.gr-16 { + flex: calc((100% - 16px) / 4) 0; + } + } } &__video { diff --git a/src/styles/sessions/_details.scss b/src/styles/sessions/_details.scss index e8430da..c421f92 100644 --- a/src/styles/sessions/_details.scss +++ b/src/styles/sessions/_details.scss @@ -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; diff --git a/src/styles/view/_calendar.scss b/src/styles/view/_calendar.scss index fe04d1e..3431bf4 100644 --- a/src/styles/view/_calendar.scss +++ b/src/styles/view/_calendar.scss @@ -48,7 +48,6 @@ opacity: 1 !important; background: transparent !important; } - } th, td { diff --git a/src/styles/view/_datepicker.scss b/src/styles/view/_datepicker.scss new file mode 100644 index 0000000..ede86fb --- /dev/null +++ b/src/styles/view/_datepicker.scss @@ -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; + } + } +} diff --git a/src/styles/view/_room.scss b/src/styles/view/_room.scss new file mode 100644 index 0000000..71c7087 --- /dev/null +++ b/src/styles/view/_room.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/styles/view/_select.scss b/src/styles/view/_select.scss index 2bd47c1..fe853b6 100644 --- a/src/styles/view/_select.scss +++ b/src/styles/view/_select.scss @@ -8,6 +8,7 @@ border-radius: 8px !important; padding: 22px 16px 8px !important; box-shadow: none !important; + z-index: 1; .ant-select-selection-item { font-size: 15px !important; @@ -53,7 +54,7 @@ } &-label { - font-size: 15px; + font-size: 14px; font-style: normal; font-weight: 400; line-height: 24px; diff --git a/src/styles/view/_timepicker.scss b/src/styles/view/_timepicker.scss index 6d5cda3..e98e282 100644 --- a/src/styles/view/_timepicker.scss +++ b/src/styles/view/_timepicker.scss @@ -13,7 +13,7 @@ .ant-picker-input { input { - font-size: 15px !important; + font-size: 14px !important; } } } diff --git a/src/styles/view/style.scss b/src/styles/view/style.scss index 09086f3..4eb5a4d 100644 --- a/src/styles/view/style.scss +++ b/src/styles/view/style.scss @@ -9,6 +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"; diff --git a/src/types/author.ts b/src/types/author.ts index f836cc4..b36d83e 100644 --- a/src/types/author.ts +++ b/src/types/author.ts @@ -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 { diff --git a/src/types/rooms.ts b/src/types/rooms.ts new file mode 100644 index 0000000..95f4bf3 --- /dev/null +++ b/src/types/rooms.ts @@ -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[]; +}; diff --git a/src/types/sessions.ts b/src/types/sessions.ts index 2c271ab..6d3a7d7 100644 --- a/src/types/sessions.ts +++ b/src/types/sessions.ts @@ -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 { diff --git a/src/utils/account.ts b/src/utils/account.ts index ca29021..aeb2ec1 100644 --- a/src/utils/account.ts +++ b/src/utils/account.ts @@ -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 = { sessions: 12, notifications: 5, diff --git a/src/utils/locale.ts b/src/utils/locale.ts new file mode 100644 index 0000000..596a35c --- /dev/null +++ b/src/utils/locale.ts @@ -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; +}; \ No newline at end of file From 9a3aa9815842d06ffd489cca923b06b75c311d5d Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 21 Nov 2024 17:04:17 +0400 Subject: [PATCH 10/20] 0.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64d17c5..f2e4376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 6d8782a..0930225 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev -p 4200", From 022233569483cc2a18ed3adcb84b3e75cfd17a29 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 13:10:37 +0400 Subject: [PATCH 11/20] feat: fix agora, fix creating room --- public/.well-known/apple-app-site-association | 11 +++++++++++ public/.well-known/assetlinks.json | 13 +++++++++++++ .../agora/components/UsersGroupPanel.tsx | 8 ++++---- src/components/Account/rooms/CreateRoom.tsx | 17 ++++++++--------- src/components/Account/rooms/EditRoomForm.tsx | 6 +++--- 5 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 public/.well-known/apple-app-site-association create mode 100644 public/.well-known/assetlinks.json diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..90ddeb9 --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,11 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "GTYAM4FYH3.com.bbuddy.whistle", + "paths": ["/en/experts/*"] + } + ] + } +} \ No newline at end of file diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..6733add --- /dev/null +++ b/public/.well-known/assetlinks.json @@ -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" + ] + } + } +] diff --git a/src/components/Account/agora/components/UsersGroupPanel.tsx b/src/components/Account/agora/components/UsersGroupPanel.tsx index 1823c3b..be7f901 100644 --- a/src/components/Account/agora/components/UsersGroupPanel.tsx +++ b/src/components/Account/agora/components/UsersGroupPanel.tsx @@ -1,5 +1,4 @@ import { - type IRemoteVideoTrack, useIsConnected, useLocalCameraTrack, useLocalMicrophoneTrack, usePublish, useRemoteAudioTracks, useRemoteUsers, @@ -19,6 +18,7 @@ export const UsersGroupPanel = ({ calling, micOn, cameraOn }: UsersGroupPanelPro const remoteUsers = useRemoteUsers(); const { localMicrophoneTrack } = useLocalMicrophoneTrack(micOn); const { localCameraTrack } = useLocalCameraTrack(cameraOn); + const { videoTracks } = useRemoteVideoTracks(remoteUsers); const { audioTracks } = useRemoteAudioTracks(remoteUsers); usePublish([localMicrophoneTrack, localCameraTrack]); @@ -34,9 +34,9 @@ export const UsersGroupPanel = ({ calling, micOn, cameraOn }: UsersGroupPanelPro videoTrack={localCameraTrack} />
- {remoteUsers.length > 0 && remoteUsers.map(({ uid, videoTrack }) => ( -
- + {remoteUsers.length > 0 && remoteUsers.map((user) => ( +
+
))}
diff --git a/src/components/Account/rooms/CreateRoom.tsx b/src/components/Account/rooms/CreateRoom.tsx index f5fd592..971305e 100644 --- a/src/components/Account/rooms/CreateRoom.tsx +++ b/src/components/Account/rooms/CreateRoom.tsx @@ -15,18 +15,17 @@ export const CreateRoom = ({ locale, jwt }: { locale: string, jwt: string }) => const router = useRouter(); const getRoom = debounce(() => { - setRoomId(2556); - // createRoom(locale, jwt) - // .then((data) => { - // setRoomId(data); - // }) - // .finally(() => { - // setLoading(false); - // }) + createRoom(locale, jwt) + .then((data) => { + setRoomId(data); + }) + .finally(() => { + setLoading(false); + }) }, 500); useEffect(() => { - // setLoading(true); + setLoading(true); getRoom(); }, []); diff --git a/src/components/Account/rooms/EditRoomForm.tsx b/src/components/Account/rooms/EditRoomForm.tsx index 4b826c8..899f7d0 100644 --- a/src/components/Account/rooms/EditRoomForm.tsx +++ b/src/components/Account/rooms/EditRoomForm.tsx @@ -67,7 +67,7 @@ export const EditRoomForm = ({ roomId, locale, jwt, mode, afterSubmit }: EditRoo if (editingRoom?.availableSlots) { editingRoom.availableSlots.forEach(({ startTime }) => { const [date] = startTime.split('T'); - dateList.add(date); + dateList.add(dayjs(date).format('YYYY-MM-DD')); }); return Array.from(dateList); @@ -77,9 +77,9 @@ export const EditRoomForm = ({ roomId, locale, jwt, mode, afterSubmit }: EditRoo }, [editingRoom?.availableSlots]); const getTimeOptions = (slots?: Slot[], curDate?: Dayjs) => { - const date = curDate ? curDate.utc().format('YYYY-MM-DD') : ''; + const date = curDate ? curDate.format('YYYY-MM-DD') : ''; if (slots && slots?.length && date) { - return slots.filter(({ startTime }) => startTime.indexOf(date) > -1) + 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')}` })); } From 60a35db46b88697372d47a54699dfb2fcd13cd6d Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 13:10:44 +0400 Subject: [PATCH 12/20] 0.2.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2e4376..fb1eddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 0930225..b1fdc7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.2.0", + "version": "0.2.1", "private": true, "scripts": { "dev": "next dev -p 4200", From d866ee2f62d1d046c1de8d3268d10cf07fa81187 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 16:03:00 +0400 Subject: [PATCH 13/20] fix: fix agora version --- package-lock.json | 27 +++++++++++++++++++++------ package.json | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb1eddc..8964c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@contentful/rich-text-react-renderer": "^15.22.9", "@stripe/react-stripe-js": "^2.7.3", "@stripe/stripe-js": "^4.1.0", - "agora-rtc-react": "^2.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", @@ -835,7 +835,8 @@ "node_modules/@next/env": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.3.tgz", - "integrity": "sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==" + "integrity": "sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==", + "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { "version": "14.2.6", @@ -853,6 +854,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -868,6 +870,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -883,6 +886,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -898,6 +902,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -913,6 +918,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -928,6 +934,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -943,6 +950,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -958,6 +966,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -973,6 +982,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -1197,6 +1207,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.4.0" } @@ -1425,9 +1436,10 @@ } }, "node_modules/agora-rtc-react": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/agora-rtc-react/-/agora-rtc-react-2.3.0.tgz", - "integrity": "sha512-6D0uvXoZFlwQ/DClceJ1PUCpaHv3ebfMKFOnU0DXbiLpeMeYWM2uyuvfrcDjg4fGf033wPEzXVJHS0wx/miyJw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/agora-rtc-react/-/agora-rtc-react-2.1.0.tgz", + "integrity": "sha512-3FGteA7FG51oK5MusbYNgAcKZaAQK+4sbEz4F0DPzcpDxqNANpocJDqOsmXoUAj5yDBsBZelmagU3abd++6RGA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -3487,7 +3499,8 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", @@ -4538,6 +4551,7 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/next/-/next-14.0.3.tgz", "integrity": "sha512-AbYdRNfImBr3XGtvnwOxq8ekVCwbFTv/UJoLwmaX89nk9i051AEY4/HAWzU0YpaTDw8IofUpmuIlvzWF13jxIw==", + "license": "MIT", "dependencies": { "@next/env": "14.0.3", "@swc/helpers": "0.5.2", @@ -6847,6 +6861,7 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" diff --git a/package.json b/package.json index b1fdc7b..a2afc0e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@contentful/rich-text-react-renderer": "^15.22.9", "@stripe/react-stripe-js": "^2.7.3", "@stripe/stripe-js": "^4.1.0", - "agora-rtc-react": "^2.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", From 46b0c5b747637d8086097a4ff52eb80a6e344283 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 16:03:20 +0400 Subject: [PATCH 14/20] 0.2.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8964c34..a28e2be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index a2afc0e..424b469 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.2.1", + "version": "0.2.2", "private": true, "scripts": { "dev": "next dev -p 4200", From 332595fd39e5ef86539a350fe1fc85383210e738 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 20:01:29 +0400 Subject: [PATCH 15/20] fix: fix styles for agora --- src/styles/sessions/_agora.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/styles/sessions/_agora.scss b/src/styles/sessions/_agora.scss index 3114ae6..e4baf4f 100644 --- a/src/styles/sessions/_agora.scss +++ b/src/styles/sessions/_agora.scss @@ -170,11 +170,15 @@ } &.gr-5, &.gr-6, &.gr-7, &.gr-8, &.gr-9 { - flex: calc((100% - 16px) / 3) 0; + & > div { + flex: calc((100% - 16px * 2) / 3) 0; + } } &.gr-10, &.gr-11, &.gr-12, &.gr-13, &.gr-14, &.gr-15, &.gr-16 { - flex: calc((100% - 16px) / 4) 0; + & > div { + flex: calc((100% - 16px * 3) / 4) 0; + } } } } From 08d12cd89e147461f4f16ba70c70327a0d30f5ae Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 22 Nov 2024 20:01:42 +0400 Subject: [PATCH 16/20] 0.2.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a28e2be..4fbecb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 424b469..1d22567 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.2.2", + "version": "0.2.3", "private": true, "scripts": { "dev": "next dev -p 4200", From 79a133c3cafdf0bab06bc467349c89f9b012d94e Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 28 Nov 2024 18:27:16 +0400 Subject: [PATCH 17/20] feat: add supervisor report modal --- src/actions/hooks/useRoomDetails.ts | 21 ++- src/actions/rooms.ts | 18 ++- src/components/Account/rooms/RoomDetails.tsx | 26 +++- .../Account/rooms/RoomDetailsContent.tsx | 32 ++++- .../Modals/SupervisorReportModal.tsx | 121 ++++++++++++++++++ src/i18nKeys/de.ts | 20 ++- src/i18nKeys/en.ts | 20 ++- src/i18nKeys/es.ts | 20 ++- src/i18nKeys/fr.ts | 20 ++- src/i18nKeys/it.ts | 20 ++- src/i18nKeys/ru.ts | 20 ++- src/styles/_modal.scss | 2 +- src/styles/sessions/_details.scss | 24 ++++ src/styles/view/_rate.scss | 21 +++ src/types/rooms.ts | 13 ++ 15 files changed, 377 insertions(+), 21 deletions(-) create mode 100644 src/components/Modals/SupervisorReportModal.tsx diff --git a/src/actions/hooks/useRoomDetails.ts b/src/actions/hooks/useRoomDetails.ts index 029337f..ff3163d 100644 --- a/src/actions/hooks/useRoomDetails.ts +++ b/src/actions/hooks/useRoomDetails.ts @@ -1,16 +1,18 @@ 'use client' -import { useCallback, useEffect, useState } from 'react'; -import { useLocalStorage } from '../../hooks/useLocalStorage'; -import { AUTH_TOKEN_KEY } from '../../constants/common'; -import { Room } from '../../types/rooms'; -import { getRoomDetails } from '../rooms'; +import {useCallback, useEffect, useState} from 'react'; +import {useLocalStorage} from '../../hooks/useLocalStorage'; +import {AUTH_TOKEN_KEY} from '../../constants/common'; +import {Room} from '../../types/rooms'; +import {getRoomDetails} from '../rooms'; +import {SessionState} from "../../types/sessions"; export const useRoomDetails = (locale: string, roomId: number) => { const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); const [room, setRoom] = useState(); const [errorData, setErrorData] = useState(); const [loading, setLoading] = useState(false); + const [isStarted, setIsStarted] = useState(false); const fetchData = useCallback(() => { setLoading(true); @@ -33,10 +35,17 @@ export const useRoomDetails = (locale: string, roomId: number) => { fetchData(); }, []); + useEffect(() => { + if (room?.state === SessionState.STARTED) { + setIsStarted(true); + } + }, [room?.state]) + return { fetchData, loading, room, - errorData + errorData, + isStarted }; }; diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index dd8f03d..c1a8b83 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -1,5 +1,5 @@ import { apiRequest } from './helpers'; -import {GetUsersForRooms, Room, RoomEdit, RoomEditDTO} from '../types/rooms'; +import {GetUsersForRooms, Report, ReportData, Room, RoomEdit, RoomEditDTO} from '../types/rooms'; export const getUpcomingRooms = (locale: string, token: string): Promise => apiRequest({ url: '/home/upcomingsessionsall', @@ -107,3 +107,19 @@ export const getRoomById = (locale: string, token: string, id: number): Promise< locale, token }); + +// report +export const getReport = (locale: string, token: string, id: number): Promise => apiRequest({ + url: `/home/getsessionsupervisorscores?sessionId=${id}`, + method: 'post', + locale, + token +}); + +export const saveReport = (locale: string, token: string, data: ReportData): Promise => apiRequest({ + url: '/home/setsessionsupervisorscores', + method: 'post', + data, + locale, + token +}); diff --git a/src/components/Account/rooms/RoomDetails.tsx b/src/components/Account/rooms/RoomDetails.tsx index d3d7a78..cf3c0d2 100644 --- a/src/components/Account/rooms/RoomDetails.tsx +++ b/src/components/Account/rooms/RoomDetails.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { RoomsType } from '../../../types/rooms'; import { useSessionTracking } from '../../../actions/hooks/useSessionTracking'; import { AccountMenu } from '../AccountMenu'; @@ -8,6 +8,9 @@ import { Loader } from '../../view/Loader'; import { RoomDetailsContent } from './RoomDetailsContent'; import { useRoomDetails } from '../../../actions/hooks/useRoomDetails'; import { AgoraClientGroup } from '../agora'; +import { SupervisorReportModal } from '../../Modals/SupervisorReportModal'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; +import { AUTH_USER } from '../../../constants/common'; type RoomDetailsProps = { locale: string; @@ -16,9 +19,13 @@ type RoomDetailsProps = { }; export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) => { - const { room, errorData, loading, fetchData } = useRoomDetails(locale, roomId); + const { room, errorData, loading, fetchData, isStarted } = useRoomDetails(locale, roomId); const tracking = useSessionTracking(locale, roomId); const [isCalling, setIsCalling] = useState(false); + const [isOpenReport, setIsOpenReport] = useState(false); + const [userData] = useLocalStorage(AUTH_USER, ''); + const { id: userId = 0 } = userData ? JSON.parse(userData) : {}; + const isSupervisor = room?.supervisor && room.supervisor.id === +userId || false; useEffect(() => { if (isCalling) { @@ -28,6 +35,12 @@ export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) => } }, [isCalling]); + useEffect(() => { + if (isSupervisor && isStarted) { + setIsOpenReport(true); + } + }, [isStarted]); + const stopCalling = () => { setIsCalling(false); fetchData(); @@ -60,6 +73,15 @@ export const RoomDetails = ({ roomId, locale, activeType }: RoomDetailsProps) => />
+ {isSupervisor && room?.id && ( + setIsOpenReport(false)} + locale={locale} + refresh={fetchData} + roomId={room.id} + /> + )}
); diff --git a/src/components/Account/rooms/RoomDetailsContent.tsx b/src/components/Account/rooms/RoomDetailsContent.tsx index 00dcb82..ade0e89 100644 --- a/src/components/Account/rooms/RoomDetailsContent.tsx +++ b/src/components/Account/rooms/RoomDetailsContent.tsx @@ -1,22 +1,23 @@ 'use client' -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Button, notification, Tag } from 'antd'; import { DeleteOutlined, LeftOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import Image from 'next/image'; import { useRouter } from '../../../navigation'; -import { Room, RoomsType } from '../../../types/rooms'; +import { Report, Room, RoomsType } from '../../../types/rooms'; import { i18nText } from '../../../i18nKeys'; import { LinkButton } from '../../view/LinkButton'; -import { +import { addClient, addSupervisor, becomeRoomClient, becomeRoomSupervisor, deleteRoomClient, - deleteRoomSupervisor - } from '../../../actions/rooms'; + deleteRoomSupervisor, + getReport +} from '../../../actions/rooms'; import { AUTH_TOKEN_KEY, AUTH_USER } from '../../../constants/common'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { UserListModal } from '../../Modals/UsersListModal'; @@ -46,6 +47,16 @@ export const RoomDetailsContent = ({ room, startRoom, locale, activeType, refres const isClient = room?.clients && room.clients.length > 0 && room.clients.map(({ id }) => id).includes(+userId) || false; const isTimeBeforeStart = room?.scheduledStartAtUtc ? dayjs() < dayjs(room.scheduledStartAtUtc) : false; const [isEdit, setIsEdit] = useState(false); + const [report, setReport] = useState(); + + useEffect(() => { + if (room?.id && room?.supervisor && activeType === RoomsType.RECENT) { + getReport(locale, jwt, room.id) + .then((data) => { + setReport(data); + }) + } + }, [room]) const goBack = () => router.push(`/account/rooms/${activeType}`); @@ -248,6 +259,17 @@ export const RoomDetailsContent = ({ room, startRoom, locale, activeType, refres {room?.supervisorComment && (
{room.supervisorComment}
)} + {report && report.length > 0 && ( +
+ {report.map(({ key, score }) => ( +
+
{i18nText(`room.rating_${key?.toLowerCase()}`, locale)}
+
+
{score || 0}
+
+ ))} +
+ )} )} {isTimeBeforeStart && room?.state === SessionState.COACH_APPROVED && !room?.supervisor && isCreator && activeType === RoomsType.UPCOMING && ( diff --git a/src/components/Modals/SupervisorReportModal.tsx b/src/components/Modals/SupervisorReportModal.tsx new file mode 100644 index 0000000..f2d5223 --- /dev/null +++ b/src/components/Modals/SupervisorReportModal.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React, { FC, useEffect, useRef, useState } from 'react'; +import { Modal, Button, message, Input } from 'antd'; +import { CloseOutlined, StarFilled } from '@ant-design/icons'; +import { i18nText } from '../../i18nKeys'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { AUTH_TOKEN_KEY } from '../../constants/common'; +import { getReport, saveReport } from '../../actions/rooms'; +import { Report, ReportData } from '../../types/rooms'; +import { CustomRate } from '../view/CustomRate'; + +type SupervisorReportModalProps = { + open: boolean; + handleCancel: () => void; + locale: string; + refresh: () => void; + roomId: number; +}; + +export const SupervisorReportModal: FC = ({ + open, + handleCancel, + locale, + roomId, + refresh +}) => { + const [jwt] = useLocalStorage(AUTH_TOKEN_KEY, ''); + const [loading, setLoading] = useState(false); + const [report, setReport] = useState(); + const reasonRef = useRef(null); + + useEffect(() => { + getReport(locale, jwt, roomId) + .then((data) => { + setReport(data); + }) + .catch(() => { + message.error('Не удалось получить отчет'); + }) + }, []); + + const onSaveRate = () => { + const result: ReportData = { + sessionId: roomId, + sessionSupervisorScores: report || [], + supervisorComment: reasonRef?.current?.resizableTextArea?.textArea?.value || '' + }; + + setLoading(true); + saveReport(locale, jwt, result) + .then(() => { + handleCancel(); + refresh(); + }) + .catch(() => { + message.error('Не удалось сохранить отчет'); + }) + .finally(() => { + setLoading(false); + }) + } + + const onChangeRate = (val: number, id: number) => { + setReport(report ? report.map((item) => { + if (item.evaluationCriteriaId === id) { + return { + ...item, + score: val + }; + } + + return item; + }) : undefined); + } + + return ( + } + > +
+
+ {report && report.length > 0 && report.map(({ key, evaluationCriteriaId, score }) => ( +
+
{i18nText(`room.rating_${key?.toLowerCase()}`, locale)}
+ } + onChange={(val: number) => onChangeRate(val, evaluationCriteriaId)} + /> +
+ ))} +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/i18nKeys/de.ts b/src/i18nKeys/de.ts index 53e2986..7134d77 100644 --- a/src/i18nKeys/de.ts +++ b/src/i18nKeys/de.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Als Teilnehmer beitreten', rapport: 'Rapport', invite: 'Invite', - save: 'Raum speichern' + save: 'Raum speichern', + rate: 'Bewerten', + tellAboutReason: 'Sag uns, was passiert ist', + rating_raport: 'Rapport', + rating_position_and_presence: 'Position oder Präsenz eines Coaches', + rating_balance_and_frustration: 'Balance zwischen Unterstützung und Frustration', + rating_agreement: 'Erstellung einer Coaching-Vereinbarung (Sitzungsvertrag)', + rating_planning_and_goals: 'Planung und Zielsetzung', + rating_reality: 'Klärung der Realität', + rating_opportunities: 'Neue Möglichkeiten gefunden', + rating_action_plan: 'Es wurde ein Aktionsplan erstellt', + rating_motivation: 'Motivationsquellen gefunden', + rating_next_session_stretch: 'Es ist noch Zeit bis zur nächsten Sitzung', + rating_relationship: 'Aufbau einer vertrauensvollen Beziehung zum Klienten', + rating_listening: 'Tiefes, aktives Zuhören', + rating_questions: 'Verwendung „starker“ Fragen', + rating_communication: 'Direkte Kommunikation', + rating_awareness: 'Entwicklung und Stimulierung des Bewusstseins', + rating_progress: 'Fortschritts- und Verantwortungsmanagement' }, agreementText: 'Folgendes habe ich gelesen und erkläre mich damit einverstanden: Benutzervereinbarung,', userAgreement: 'Benutzervereinbarung', diff --git a/src/i18nKeys/en.ts b/src/i18nKeys/en.ts index bc6b080..fab0990 100644 --- a/src/i18nKeys/en.ts +++ b/src/i18nKeys/en.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Join as a participant', rapport: 'Rapport', invite: 'Invite', - save: 'Save room' + save: 'Save room', + rate: 'Rate', + tellAboutReason: 'Tell us what happened', + rating_raport: 'Rapport', + rating_position_and_presence: 'Coaching position or coaching presence', + rating_balance_and_frustration: 'Balance of support and frustration', + rating_agreement: 'Creating a coaching agreement (session contract)', + rating_planning_and_goals: 'Planning and goal setting', + rating_reality: 'Clarifying reality', + rating_opportunities: 'New opportunities found', + rating_action_plan: 'An action plan has been drawn up', + rating_motivation: 'Sources of motivation found', + rating_next_session_stretch: 'There is a stretch for the next session', + rating_relationship: 'Establishing a trusting relationship with the client', + rating_listening: 'Deep, active listening', + rating_questions: 'Using "strong" questions', + rating_communication: 'Direct communication', + rating_awareness: 'Developing and stimulating awareness', + rating_progress: 'Progress and Responsibility Management' }, agreementText: 'I have read and agree with the terms of the User Agreement,', userAgreement: 'User Agreement', diff --git a/src/i18nKeys/es.ts b/src/i18nKeys/es.ts index 2922e94..a68ff8b 100644 --- a/src/i18nKeys/es.ts +++ b/src/i18nKeys/es.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Unirse como participante', rapport: 'Buena relación', invite: 'Invitar', - save: 'Guardar sala' + save: 'Guardar sala', + rate: 'Valorar', + tellAboutReason: 'Cuéntanos qué ha pasado', + rating_raport: 'Buena relación', + rating_position_and_presence: 'Puesto de coach o presencia de coach', + rating_balance_and_frustration: 'Equilibrio entre apoyo y frustración', + rating_agreement: 'Crear un acuerdo de coaching (contrato de sesión)', + rating_planning_and_goals: 'Planear y establecer los objetivos', + rating_reality: 'Clarificar la realidad', + rating_opportunities: 'Nuevas oportunidades encontradas', + rating_action_plan: 'Se ha diseñado un plan de acción', + rating_motivation: 'Fuentes de motivación encontradas', + rating_next_session_stretch: 'Queda un poco para la siguiente sesión', + rating_relationship: 'Establecer una relación de confianza con el cliente', + rating_listening: 'Escucha activa y profunda', + rating_questions: 'Usar preguntas "contundentes"', + rating_communication: 'Comunicación directa', + rating_awareness: 'Desarrollar y estimular la conciencia', + rating_progress: 'Progreso y gestión de la responsabilidad' }, agreementText: 'He leído y acepto las condiciones del Acuerdo de usuario,', userAgreement: 'Acuerdo de usuario', diff --git a/src/i18nKeys/fr.ts b/src/i18nKeys/fr.ts index ab93f21..5d49044 100644 --- a/src/i18nKeys/fr.ts +++ b/src/i18nKeys/fr.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Rejoindre en tant que participant', rapport: 'Rapport', invite: 'Inviter', - save: 'Sauvegarder la salle' + save: 'Sauvegarder la salle', + rate: 'Noter', + tellAboutReason: 'Dites-nous ce qui s\'est passé', + rating_raport: 'Rapport', + rating_position_and_presence: 'Poste de coach ou présence de coach', + rating_balance_and_frustration: 'Équilibre entre assistance et frustration', + rating_agreement: 'Création d\'un contrat de coaching (contrat de séance)', + rating_planning_and_goals: 'Planification et définition des objectifs', + rating_reality: 'Clarification de la réalité', + rating_opportunities: 'Nouvelles opportunités trouvées', + rating_action_plan: 'Un plan d\'action a été établi', + rating_motivation: 'Sources de motivation trouvées', + rating_next_session_stretch: 'Une période est présente pour la prochaine session', + rating_relationship: 'Établissement d\'une relation de confiance avec le client', + rating_listening: 'Écoute approfondie et active', + rating_questions: 'Utilisation de questions «fortes»', + rating_communication: 'Communication directe', + rating_awareness: 'Développement et stimulation de la prise de conscience', + rating_progress: 'Gestion de la progression et de la responsabilité' }, agreementText: 'J\'ai lu et j\'accepte les dispositions de l\'Accord Utilisateur et de la', userAgreement: '', diff --git a/src/i18nKeys/it.ts b/src/i18nKeys/it.ts index f2c17c6..a6b5db8 100644 --- a/src/i18nKeys/it.ts +++ b/src/i18nKeys/it.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Partecipa come partecipante', rapport: 'Rapporto', invite: 'Invita', - save: 'Salva sala' + save: 'Salva sala', + rate: 'Valuta', + tellAboutReason: 'Descrivi cosa è successo', + rating_raport: 'Rapporto', + rating_position_and_presence: 'Posizione di coaching o presenza di coaching', + rating_balance_and_frustration: 'Equilibrio tra sostegno e frustrazione', + rating_agreement: 'Creazione di un accordo di coaching (contratto di sessione)', + rating_planning_and_goals: 'Pianificazione e definizione di obiettivi', + rating_reality: 'Chiarimento della realtà', + rating_opportunities: 'Nuove opportunità trovate', + rating_action_plan: 'È stato elaborato un piano d\'azione', + rating_motivation: 'Fonti di motivazione trovate', + rating_next_session_stretch: 'Esiste un\'estensione per la prossima sessione', + rating_relationship: 'Instaurazione di un rapporto di fiducia con il cliente', + rating_listening: 'Ascolto profondo e attivo', + rating_questions: 'Utilizzo di domande "forti"', + rating_communication: 'Comunicazione diretta', + rating_awareness: 'Sviluppo e stimolo della consapevolezza', + rating_progress: 'Gestione dei progressi e delle responsabilità' }, agreementText: 'Ho letto e accetto i termini dell\'Accordo con l\'utente,', userAgreement: '', diff --git a/src/i18nKeys/ru.ts b/src/i18nKeys/ru.ts index 7ef3681..cf1ed68 100644 --- a/src/i18nKeys/ru.ts +++ b/src/i18nKeys/ru.ts @@ -65,7 +65,25 @@ export default { joinParticipant: 'Присоединиться как участник', rapport: 'Раппорт', invite: 'Пригласить', - save: 'Сохранить комнату' + save: 'Сохранить комнату', + rate: 'Оценить', + tellAboutReason: 'Расскажите, что произошло', + rating_raport: 'Раппорт', + rating_position_and_presence: 'Коуч-позиция или коучинговое присутствие', + rating_balance_and_frustration: 'Баланс поддержки и фрустрации', + rating_agreement: 'Создание коучингового соглашения (контракт на сессию)', + rating_planning_and_goals: 'Планирование и постановка целей', + rating_reality: 'Прояснение реальности', + rating_opportunities: 'Найдены новые возможности', + rating_action_plan: 'Составлен план действий', + rating_motivation: 'Найдены источники мотивации', + rating_next_session_stretch: 'Есть "протяжка" на следующую сессию', + rating_relationship: 'Установление доверительных отношений с клиентом', + rating_listening: 'Глубокое активное слушание', + rating_questions: 'Использование сильных вопросов', + rating_communication: 'Прямая коммуникация', + rating_awareness: 'Развитие и стимулирование осознанности', + rating_progress: 'Управление прогрессом и ответственностью' }, agreementText: 'Я прочитал и согласен с условиями Пользовательского соглашения,', userAgreement: 'Пользовательского соглашения', diff --git a/src/styles/_modal.scss b/src/styles/_modal.scss index f72e0a1..0b9c91a 100644 --- a/src/styles/_modal.scss +++ b/src/styles/_modal.scss @@ -26,7 +26,7 @@ } } - &__comment__content { + &__comment__content, &__report__content { display: flex; flex-direction: column; padding: 44px 40px; diff --git a/src/styles/sessions/_details.scss b/src/styles/sessions/_details.scss index c421f92..dcbc525 100644 --- a/src/styles/sessions/_details.scss +++ b/src/styles/sessions/_details.scss @@ -57,6 +57,30 @@ line-height: 120%; } + &__report-list { + display: flex; + width: 100%; + flex-direction: column; + gap: 8px; + + & > div { + width: 100%; + color: #4E7C86; + @include rem(13); + font-weight: 500; + line-height: 120%; + display: flex; + gap: 8px; + justify-content: space-between; + align-items: flex-end; + } + + &_divider { + flex: 1; + border-bottom: 1px solid #E4F5FA; + } + } + &__comments { display: flex; flex-direction: column; diff --git a/src/styles/view/_rate.scss b/src/styles/view/_rate.scss index e194d35..f312b09 100644 --- a/src/styles/view/_rate.scss +++ b/src/styles/view/_rate.scss @@ -14,4 +14,25 @@ color: #c4dfe6 !important; } } + + &-list { + display: flex; + flex-direction: column; + gap: 24px; + + &__item { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + + &_title { + color: #2C7873; + @include rem(13); + font-weight: 500; + line-height: 120%; + text-align: center; + } + } + } } diff --git a/src/types/rooms.ts b/src/types/rooms.ts index 95f4bf3..c191e3d 100644 --- a/src/types/rooms.ts +++ b/src/types/rooms.ts @@ -42,3 +42,16 @@ export type RoomEditDTO = { tags?: Tag[]; availableSlots: Slot[]; }; + +export type Report = { + evaluationCriteriaId: number, + evaluationCriteriaName?: string, + score?: number, + key?: string +}; + +export type ReportData = { + sessionId: number, + sessionSupervisorScores: Report[], + supervisorComment?: string +}; From a13eac0ac4db9d4772102ae4c3eff6b017f666aa Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 28 Nov 2024 18:27:48 +0400 Subject: [PATCH 18/20] 0.2.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fbecb8..5c8b79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 1d22567..39888d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.2.3", + "version": "0.2.4", "private": true, "scripts": { "dev": "next dev -p 4200", From 3ab06523ccb6ce22f2b2fcdb9b6fa3e1ebe30315 Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 5 Dec 2024 20:31:55 +0400 Subject: [PATCH 19/20] feat: update mobile assetlinks --- public/.well-known/assetlinks.json | 1 + 1 file changed, 1 insertion(+) diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json index 6733add..ddaf1a5 100644 --- a/public/.well-known/assetlinks.json +++ b/public/.well-known/assetlinks.json @@ -6,6 +6,7 @@ "package_name": "com.bbuddy.whistle", "sha256_cert_fingerprints": [ "87:A2:49:9A:F4:05:9C:06:3C:3D:F3:10:88:F5:49:6D:5F:F2:BC:1E:90:0D:F2:37:A5:BA:37:19:5C:A3:75:C2", + "D0:28:97:E7:64:5D:ED:8D:7F:F1:41:B2:E8:F6:AB:7B:EE:FB:A3:1A:A2:D7:92:D4:C5:41:9A:3C:47:CE:EB:43", "86:42:FE:EA:44:22:9D:16:7F:FC:70:92:A6:39:9D:B1:C3:F1:DE:21:32:4A:45:8C:07:98:39:55:AF:47:32:66" ] } From dbd5eaa014529a5ad10518def5341e724e24f096 Mon Sep 17 00:00:00 2001 From: SD Date: Thu, 5 Dec 2024 20:50:54 +0400 Subject: [PATCH 20/20] 0.2.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c8b79e..18bb505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bbuddy-ui", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbuddy-ui", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", diff --git a/package.json b/package.json index 39888d8..0b31f54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbuddy-ui", - "version": "0.2.4", + "version": "0.2.5", "private": true, "scripts": { "dev": "next dev -p 4200",