blog #1
			
				
			
		
		
		
	|  | @ -1,5 +1,7 @@ | ||||||
| import { apiClient } from '../lib/apiClient'; | 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) => { | export const getExpertsList = async (locale: string, filter?: GeneralFilter) => { | ||||||
|     const response = await apiClient.post( |     const response = await apiClient.post( | ||||||
|  | @ -28,3 +30,33 @@ export const getExpertById = async (id: string, locale: string) => { | ||||||
| 
 | 
 | ||||||
|     return response.data as ExpertDetails || null; |     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, |     children: ReactNode, | ||||||
|     news: ReactNode, |     news: ReactNode, | ||||||
|     directions: ReactNode, |     directions: ReactNode, | ||||||
|     experts: ReactNode |     experts: ReactNode, | ||||||
|  |     payment: ReactNode | ||||||
| }) { | }) { | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|  | @ -26,4 +27,4 @@ export default function MainLayout({ children, news, directions, experts }: { | ||||||
|             {experts} |             {experts} | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| }; | } | ||||||
|  |  | ||||||
|  | @ -11,6 +11,7 @@ import { | ||||||
| } from '../../../../components/Experts/ExpertDetails'; | } from '../../../../components/Experts/ExpertDetails'; | ||||||
| import { Details } from '../../../../types/experts'; | import { Details } from '../../../../types/experts'; | ||||||
| import { BackButton } from '../../../../components/view/BackButton'; | import { BackButton } from '../../../../components/view/BackButton'; | ||||||
|  | import {SchedulerModal} from "../../../../components/Modals/SchedulerModal"; | ||||||
| 
 | 
 | ||||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||||
|     title: 'Bbuddy - Experts item', |     title: 'Bbuddy - Experts item', | ||||||
|  | @ -81,7 +82,7 @@ export default async function ExpertItem({ params: { expertId = '', locale} }: { | ||||||
|                         </BackButton> |                         </BackButton> | ||||||
|                     </Suspense> |                     </Suspense> | ||||||
|                 </div> |                 </div> | ||||||
|                 <ExpertCard expert={expert} /> |                 <ExpertCard expert={expert} locale={locale} expertId={expertId}/> | ||||||
|                 <ExpertInformation expert={expert} locale={locale} /> |                 <ExpertInformation expert={expert} locale={locale} /> | ||||||
| 
 | 
 | ||||||
|                 <h2 className="title-h2">Expert Background</h2> |                 <h2 className="title-h2">Expert Background</h2> | ||||||
|  |  | ||||||
|  | @ -39,4 +39,4 @@ export default function LocaleLayout({ children, params: { locale } }: LayoutPro | ||||||
|             </ConfigProvider> |             </ConfigProvider> | ||||||
|         </AntdRegistry> |         </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'; | 'use client'; | ||||||
| 
 | 
 | ||||||
| import React, { FC } from 'react'; | import React, {FC, useEffect, useState} from 'react'; | ||||||
| import Image from 'next/image'; | import Image from 'next/image'; | ||||||
| import { Tag, Image as AntdImage, Space } from 'antd'; | import { Tag, Image as AntdImage, Space } from 'antd'; | ||||||
| import { ZoomInOutlined, ZoomOutOutlined, StarFilled } from '@ant-design/icons'; | 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 { Locale } from '../../types/locale'; | ||||||
| import { CustomRate } from '../view/CustomRate'; | 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 = { | type ExpertDetailsProps = { | ||||||
|     expert: ExpertDetails; |     expert: ExpertDetails; | ||||||
|     locale?: string; |     locale?: string; | ||||||
|  |     expertId?: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId }) => { | ||||||
|     const { publicCoachDetails } = expert || {}; |     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 ( |     return ( | ||||||
|         <div className="expert-card"> |         <div className="expert-card"> | ||||||
|  | @ -36,7 +52,7 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|             <div className="expert-card__wrap-btn"> |             <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="" /> |                     <img src="/images/calendar-outline.svg" className="" alt="" /> | ||||||
|                     Schedule |                     Schedule | ||||||
|                 </a> |                 </a> | ||||||
|  | @ -45,6 +61,15 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => { | ||||||
|                     Video |                     Video | ||||||
|                 </a> |                 </a> | ||||||
|             </div> |             </div> | ||||||
|  |             <SchedulerModal | ||||||
|  |                 open={showSchedulerModal} | ||||||
|  |                 handleCancel={() => setShowSchedulerModal(false)} | ||||||
|  |                 updateMode={setMode} | ||||||
|  |                 mode={mode} | ||||||
|  |                 expertId={expertId as string} | ||||||
|  |                 locale={locale as string} | ||||||
|  |                 sessionCost={sessionCost} | ||||||
|  |             /> | ||||||
|         </div> |         </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[]; |     associations?: Association[]; | ||||||
|     associationLevels?: AssociationLevel[]; |     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