blog #1
			
				
			
		
		
		
	|  | @ -1,5 +1,7 @@ | |||
| import { apiClient } from '../lib/apiClient'; | ||||
| import { GeneralFilter, ExpertsData, ExpertDetails } from '../types/experts'; | ||||
| import {GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession} from '../types/experts'; | ||||
| import {useLocalStorage} from "../hooks/useLocalStorage"; | ||||
| import {AUTH_TOKEN_KEY} from "../constants/common"; | ||||
| 
 | ||||
| export const getExpertsList = async (locale: string, filter?: GeneralFilter) => { | ||||
|     const response = await apiClient.post( | ||||
|  | @ -28,3 +30,33 @@ export const getExpertById = async (id: string, locale: string) => { | |||
| 
 | ||||
|     return response.data as ExpertDetails || null; | ||||
| }; | ||||
| 
 | ||||
| export const getSchedulerByExpertId = async (expertId: string, locale: string, jwt: string) => { | ||||
|     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; | ||||
| }; | ||||
|  | @ -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 }; | ||||
| } | ||||
|  | @ -16,7 +16,8 @@ export default function MainLayout({ children, news, directions, experts }: { | |||
|     children: ReactNode, | ||||
|     news: ReactNode, | ||||
|     directions: ReactNode, | ||||
|     experts: ReactNode | ||||
|     experts: ReactNode, | ||||
|     payment: ReactNode | ||||
| }) { | ||||
|     return ( | ||||
|         <> | ||||
|  | @ -26,4 +27,4 @@ export default function MainLayout({ children, news, directions, experts }: { | |||
|             {experts} | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| } | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import { | |||
| } from '../../../../components/Experts/ExpertDetails'; | ||||
| import { Details } from '../../../../types/experts'; | ||||
| import { BackButton } from '../../../../components/view/BackButton'; | ||||
| import {SchedulerModal} from "../../../../components/Modals/SchedulerModal"; | ||||
| 
 | ||||
| export const metadata: Metadata = { | ||||
|     title: 'Bbuddy - Experts item', | ||||
|  | @ -81,7 +82,7 @@ export default async function ExpertItem({ params: { expertId = '', locale} }: { | |||
|                         </BackButton> | ||||
|                     </Suspense> | ||||
|                 </div> | ||||
|                 <ExpertCard expert={expert} /> | ||||
|                 <ExpertCard expert={expert} locale={locale} expertId={expertId}/> | ||||
|                 <ExpertInformation expert={expert} locale={locale} /> | ||||
| 
 | ||||
|                 <h2 className="title-h2">Expert Background</h2> | ||||
|  |  | |||
|  | @ -39,4 +39,4 @@ export default function LocaleLayout({ children, params: { locale } }: LayoutPro | |||
|             </ConfigProvider> | ||||
|         </AntdRegistry> | ||||
|     ); | ||||
| }; | ||||
| } | ||||
|  |  | |||
|  | @ -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 ( | ||||
|         <div className="page-container"> | ||||
|             <h1>Pay</h1> | ||||
|             <ElementsForm /> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -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 ( | ||||
|         <> | ||||
|             <GeneralTopSection | ||||
|                 title={t('header')} | ||||
|                 mainImage="banner-phone.png" | ||||
|             /> | ||||
|             <div className="main-articles bb-client"> | ||||
|                 <PaymentElementPage /> | ||||
|             </div> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|  | @ -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 ( | ||||
|         <div className="page-container"> | ||||
|             <h1>Payment Intent Result</h1> | ||||
|             {children} | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -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 ( | ||||
|         <> | ||||
|             <h2>Status: {paymentIntent.status}</h2> | ||||
|             <h3>Payment Intent response:</h3> | ||||
|             <PrintObject content={paymentIntent} /> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
|  | @ -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 }); | ||||
| } | ||||
|  | @ -1,20 +1,36 @@ | |||
| '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 { ExpertDetails, ExpertDocument } from '../../types/experts'; | ||||
| import {ExpertDetails, ExpertDocument, ExpertScheduler} from '../../types/experts'; | ||||
| import { Locale } from '../../types/locale'; | ||||
| import { CustomRate } from '../view/CustomRate'; | ||||
| 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; | ||||
| }; | ||||
| 
 | ||||
| export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | ||||
| export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId }) => { | ||||
|     const { publicCoachDetails } = expert || {}; | ||||
|     const [showSchedulerModal, setShowSchedulerModal] = useState<boolean>(false); | ||||
|     const [mode, setMode] = useState<'data' | 'time' | 'pay' | 'finish'>('data'); | ||||
|     const { publicCoachDetails: { tags = [], sessionCost = 0, sessionDuration = 0 } } = expert || {}; | ||||
| 
 | ||||
|     const onSchedulerHandle = async () => { | ||||
|         console.log('sessionCost', sessionCost); | ||||
|         setMode('data'); | ||||
|         setShowSchedulerModal(true) | ||||
|         // отмаппим.
 | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|         <div className="expert-card"> | ||||
|  | @ -36,7 +52,7 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | |||
|                 </div> | ||||
|             </div> | ||||
|             <div className="expert-card__wrap-btn"> | ||||
|                 <a href="#" className="btn-apply"> | ||||
|                 <a href="#" className="btn-apply" onClick={onSchedulerHandle}> | ||||
|                     <img src="/images/calendar-outline.svg" className="" alt="" /> | ||||
|                     Schedule | ||||
|                 </a> | ||||
|  | @ -45,6 +61,15 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | |||
|                     Video | ||||
|                 </a> | ||||
|             </div> | ||||
|             <SchedulerModal | ||||
|                 open={showSchedulerModal} | ||||
|                 handleCancel={() => setShowSchedulerModal(false)} | ||||
|                 updateMode={setMode} | ||||
|                 mode={mode} | ||||
|                 expertId={expertId as string} | ||||
|                 locale={locale as string} | ||||
|                 sessionCost={sessionCost} | ||||
|             /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|  |  | |||
|  | @ -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<SchedulerModalProps> = ({ | ||||
|    open, | ||||
|    handleCancel, | ||||
|    mode, | ||||
|    updateMode, | ||||
|     sessionCost, | ||||
|     locale, | ||||
|     expertId, | ||||
| }) => { | ||||
|     const { styles } = useStyle({ test: true }); | ||||
|     const [selectDate, setSelectDate] = React.useState<Dayjs>(dayjs()); | ||||
|     const [dates, setDates] = React.useState<any>(); | ||||
|     const [tags, setTags] = React.useState<Tags[]>([]); | ||||
|     const [tag, setTag] = React.useState<number>(-1); | ||||
|     const [slot, setSlot] = React.useState<string>(''); | ||||
|     const [sessionId, setSessionId] = React.useState<number>(-1); | ||||
|     const [rawScheduler, setRawScheduler] = useState<ExpertScheduler | null>(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<Dayjs>['mode']) => { | ||||
|         console.log(value.format('YYYY-MM-DD'), mode); | ||||
|     }; | ||||
| 
 | ||||
|     const onDateChange: CalendarProps<Dayjs>['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<Dayjs>['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: ( | ||||
|                 <div className={styles.text}> | ||||
|             <span | ||||
|                 className={classNames({ | ||||
|                     [styles.weekend]: isWeekend, | ||||
|                 })} | ||||
|             > | ||||
|               {date.get('date')} | ||||
|             </span> | ||||
|                 </div> | ||||
|             ), | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     return ( | ||||
|         <Modal | ||||
|             className="b-modal" | ||||
|             open={open} | ||||
|             title={undefined} | ||||
|             onOk={undefined} | ||||
|             onCancel={handleCancel} | ||||
|             footer={false} | ||||
|             width={498} | ||||
|             closeIcon={<CloseOutlined style={{ fontSize: 20, color: '#000' }}/>} | ||||
|         > | ||||
|             <div> | ||||
|                 {tags && ( | ||||
|                     <Radio.Group name="radiogroupTags" onChange={handleTag}> | ||||
|                     {tags?.map((tag)=>( | ||||
|                         <Radio key={tag.id} value={tag.id}>{tag.name}</Radio> | ||||
|                         ) | ||||
|                     )} | ||||
|                     </Radio.Group> | ||||
|                     ) | ||||
|                 } | ||||
|             </div> | ||||
|             {mode === 'data' && ( | ||||
|                 <Calendar | ||||
|                     fullscreen={false} | ||||
|                     onPanelChange={onPanelChange} | ||||
|                     fullCellRender={cellRender} | ||||
|                     onSelect={onDateChange} | ||||
|                     disabledDate={disabledDate} | ||||
|                     headerRender={({ value, type, onChange, onTypeChange }) => { | ||||
|                         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)=>( | ||||
|                                 <button key={'SchedulerMonth'+i} onClick={()=> onChange(m)}> | ||||
|                                     {m.month()} | ||||
|                                 </button> | ||||
|                             ))} | ||||
|                         </>) | ||||
|                     }} | ||||
| 
 | ||||
|                 /> | ||||
|             )} | ||||
|             {mode === 'time' && ( | ||||
|                 <> | ||||
|                     <div> | ||||
|                         <button onClick={()=>{updateMode('data')}}>back</button> | ||||
|                     </div> | ||||
| 
 | ||||
|                     <Radio.Group name="radiogroupSlots" onChange={handleTimeslot}> | ||||
|                         {dates[selectDate.format('YYYY-MM-DD')].map( (el) => { | ||||
|                             return (<Radio key={dayjs(el.startTime).format()} value={el}>{dayjs(el.startTime).format('hh-mm')} - {dayjs(el.endTime).format('hh-mm')}</Radio>) | ||||
|                         })} | ||||
|                     </Radio.Group> | ||||
|                     <button onClick={handleSingupSession}>Записаться</button> | ||||
|                 </> | ||||
|             )} | ||||
|             {mode === 'pay' && ( | ||||
|                 <ElementsForm amount={sessionCost}/> | ||||
|             )} | ||||
|         </Modal> | ||||
|     ); | ||||
| }; | ||||
|  | @ -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<PaymentFormProps>  = ({amount, sessionId}) => { | ||||
|     const [input, setInput] = React.useState<{ | ||||
|         paySumm: number; | ||||
|         cardholderName: string; | ||||
|     }>({ | ||||
|         paySumm: 1, | ||||
|         cardholderName: "", | ||||
|     }); | ||||
|     const [form, ] = Form.useForm<Payment>(); | ||||
|     const formAmount = Form.useWatch('amount', form); | ||||
|     const [paymentType, setPaymentType] = React.useState<string>(""); | ||||
|     const [payment, setPayment] = React.useState<{ | ||||
|         status: "initial" | "processing" | "error"; | ||||
|     }>({ status: "initial" }); | ||||
|     const [errorMessage, setErrorMessage] = React.useState<string>(""); | ||||
| 
 | ||||
|     const stripe = useStripe(); | ||||
|     const elements = useElements(); | ||||
| 
 | ||||
|     const PaymentStatus = ({ status }: { status: string }) => { | ||||
|         switch (status) { | ||||
|             case "processing": | ||||
|             case "requires_payment_method": | ||||
|             case "requires_confirmation": | ||||
|                 return <h2>Processing...</h2>; | ||||
| 
 | ||||
|             case "requires_action": | ||||
|                 return <h2>Authenticating...</h2>; | ||||
| 
 | ||||
|             case "succeeded": | ||||
|                 return <h2>Payment Succeeded 🥳</h2>; | ||||
| 
 | ||||
|             case "error": | ||||
|                 return ( | ||||
|                     <> | ||||
|                         <h2>Error 😭</h2> | ||||
|                         <p className="error-message">{errorMessage}</p> | ||||
|                     </> | ||||
|                 ); | ||||
| 
 | ||||
|             default: | ||||
|                 return null; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         elements?.update({ amount: formAmount * 100 }); | ||||
|     }, [formAmount]); | ||||
| 
 | ||||
|     const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (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 ( | ||||
|         <> | ||||
|             <Form form={form} onFinish={onSubmit}> | ||||
|                 <fieldset className="elements-style"> | ||||
|                     <StripeTestCards/> | ||||
|                     <legend>Your payment details:</legend> | ||||
|                     {paymentType === "card" ? ( | ||||
|                         <input | ||||
|                             placeholder="Cardholder name" | ||||
|                             className="elements-style" | ||||
|                             type="Text" | ||||
|                             name="cardholderName" | ||||
|                             onChange={handleInputChange} | ||||
|                             required | ||||
|                         /> | ||||
|                     ) : null} | ||||
|                     <div className="FormRow elements-style"> | ||||
|                         <PaymentElement | ||||
|                             onChange={(e) => { | ||||
|                                 setPaymentType(e.value.type); | ||||
|                             }} | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <button | ||||
|                     className="elements-style-background" | ||||
|                     type="submit" | ||||
|                     disabled={ | ||||
|                         !["initial", "succeeded", "error"].includes(payment.status) || | ||||
|                         !stripe | ||||
|                     } | ||||
|                 > | ||||
|                     Pay | ||||
|                 </button> | ||||
|             </Form> | ||||
|             <PaymentStatus status={payment.status}/> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| export const ElementsForm: FC<PaymentFormProps> = ({amount, sessionId}) => { | ||||
|     return ( | ||||
|     <Elements | ||||
|         stripe={getStripe()} | ||||
|         options={{ | ||||
|             appearance: { | ||||
|                     variables: { | ||||
|                         colorIcon: "#6772e5", | ||||
|                         fontFamily: "Roboto, Open Sans, Segoe UI, sans-serif", | ||||
|                     }, | ||||
|                 }, | ||||
|                 currency: 'eur', | ||||
|                 mode: "payment", | ||||
|                 amount: amount*100, | ||||
|             }} | ||||
|         > | ||||
|             <CheckoutForm amount={amount} sessionId={sessionId}/> | ||||
|         </Elements> | ||||
|     ) | ||||
| } | ||||
|  | @ -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 <pre>{formattedContent}</pre>; | ||||
| } | ||||
|  | @ -0,0 +1,19 @@ | |||
| export default function StripeTestCards(): JSX.Element { | ||||
|     return ( | ||||
|         <div className="test-card-notice"> | ||||
|             Use any of the{" "} | ||||
|             <a | ||||
|                 href="https://stripe.com/docs/testing#cards" | ||||
|                 target="_blank" | ||||
|                 rel="noopener noreferrer" | ||||
|             > | ||||
|                 Stripe test cards | ||||
|             </a>{" "} | ||||
|             for demo, e.g.{" "} | ||||
|             <div className="card-number"> | ||||
|                 4242<span></span>4242<span></span>4242<span></span>4242 | ||||
|             </div> | ||||
|             . | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
|  | @ -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: "", | ||||
|     }, | ||||
| }); | ||||
|  | @ -112,3 +112,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 | ||||
| } | ||||
|  | @ -0,0 +1,3 @@ | |||
| export type Payment = { | ||||
|     amount: number; | ||||
| } | ||||
|  | @ -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<Stripe | null>; | ||||
| 
 | ||||
| export default function getStripe(): Promise<Stripe | null> { | ||||
|     if (!stripePromise) | ||||
|         stripePromise = loadStripe( | ||||
|             process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string, | ||||
|         ); | ||||
| 
 | ||||
|     return stripePromise; | ||||
| } | ||||
|  | @ -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); | ||||
| } | ||||
		Loading…
	
		Reference in New Issue