From 3a6c7bd88c358f050c0edbbf2ec290714644f4c9 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Sat, 13 Jul 2024 13:13:58 +0300 Subject: [PATCH] stripe payment --- src/actions/experts.ts | 34 ++- 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 | 33 ++- 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 ++ 20 files changed, 905 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 91e0d73..5ddb524 100644 --- a/src/actions/experts.ts +++ b/src/actions/experts.ts @@ -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; +}; \ No newline at end of file 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 daa1e7d..7558955 100644 --- a/src/app/[locale]/(main)/layout.tsx +++ b/src/app/[locale]/(main)/layout.tsx @@ -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} ); -}; +} diff --git a/src/app/[locale]/experts/[expertId]/page.tsx b/src/app/[locale]/experts/[expertId]/page.tsx index 6d1a701..23dd24e 100644 --- a/src/app/[locale]/experts/[expertId]/page.tsx +++ b/src/app/[locale]/experts/[expertId]/page.tsx @@ -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} }: { - +

Expert Background

diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 520e6ae..8eee097 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -39,4 +39,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 0c06c74..773a73a 100644 --- a/src/components/Experts/ExpertDetails.tsx +++ b/src/components/Experts/ExpertDetails.tsx @@ -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 = ({ expert }) => { +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 (
@@ -36,7 +52,7 @@ export const ExpertCard: FC = ({ expert }) => {
- + Schedule @@ -45,6 +61,15 @@ export const ExpertCard: FC = ({ expert }) => { Video
+ setShowSchedulerModal(false)} + updateMode={setMode} + mode={mode} + expertId={expertId as string} + locale={locale as string} + sessionCost={sessionCost} + /> ); }; 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 160db87..3df393d 100644 --- a/src/types/experts.ts +++ b/src/types/experts.ts @@ -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 +} \ 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