blog #1
|
@ -1,7 +1,5 @@
|
||||||
import { apiClient } from '../lib/apiClient';
|
import { apiClient } from '../lib/apiClient';
|
||||||
import {GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession} from '../types/experts';
|
import { GeneralFilter, ExpertsData, ExpertDetails } 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(
|
||||||
|
@ -30,33 +28,3 @@ 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;
|
|
||||||
};
|
|
|
@ -1,78 +0,0 @@
|
||||||
"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,8 +16,7 @@ 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 (
|
||||||
<>
|
<>
|
||||||
|
@ -27,4 +26,4 @@ export default function MainLayout({ children, news, directions, experts }: {
|
||||||
{experts}
|
{experts}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
@ -11,7 +11,6 @@ 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',
|
||||||
|
@ -82,7 +81,7 @@ export default async function ExpertItem({ params: { expertId = '', locale} }: {
|
||||||
</BackButton>
|
</BackButton>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<ExpertCard expert={expert} locale={locale} expertId={expertId}/>
|
<ExpertCard expert={expert} />
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
import {ElementsForm} from "../../../../components/stripe/ElementsForm";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Payment",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PaymentElementPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams?: { payment_intent_client_secret?: string };
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<h1>Pay</h1>
|
|
||||||
<ElementsForm />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { unstable_setRequestLocale } from 'next-intl/server';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { GeneralTopSection } from '../../../components/Page';
|
|
||||||
import PaymentElementPage from "./@payment/page";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Bbuddy - Take the lead with BB',
|
|
||||||
description: 'Bbuddy desc Take the lead with BB'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BbClientPage({ params: { locale } }: { params: { locale: string } }) {
|
|
||||||
unstable_setRequestLocale(locale);
|
|
||||||
const t = useTranslations('BbClient');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GeneralTopSection
|
|
||||||
title={t('header')}
|
|
||||||
mainImage="banner-phone.png"
|
|
||||||
/>
|
|
||||||
<div className="main-articles bb-client">
|
|
||||||
<PaymentElementPage />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,19 +0,0 @@
|
||||||
import type { Metadata } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Payment Intent Result",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResultLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="page-container">
|
|
||||||
<h1>Payment Intent Result</h1>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
|
|
||||||
import { stripe} from "../../../../lib/stripe";
|
|
||||||
import PrintObject from "../../../../components/stripe/PrintObject";
|
|
||||||
|
|
||||||
export default async function ResultPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: { payment_intent: string };
|
|
||||||
}) {
|
|
||||||
if (!searchParams.payment_intent)
|
|
||||||
throw new Error("Please provide a valid payment_intent (`pi_...`)");
|
|
||||||
|
|
||||||
const paymentIntent: Stripe.PaymentIntent =
|
|
||||||
await stripe.paymentIntents.retrieve(searchParams.payment_intent);
|
|
||||||
|
|
||||||
// Тут под идее тыкнуться в бек на тему того - прошла ли оплата. в зависимости от этого показать что все ок или нет
|
|
||||||
// также стоит расшить ссылкой КУДА переходить после того как показали что все ок.
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Status: {paymentIntent.status}</h2>
|
|
||||||
<h3>Payment Intent response:</h3>
|
|
||||||
<PrintObject content={paymentIntent} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
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,36 +1,20 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, {FC, useEffect, useState} from 'react';
|
import React, { FC } 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, ExpertScheduler} from '../../types/experts';
|
import { ExpertDetails, ExpertDocument } 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, locale, expertId }) => {
|
export const ExpertCard: FC<ExpertDetailsProps> = ({ expert }) => {
|
||||||
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">
|
||||||
|
@ -52,7 +36,7 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="expert-card__wrap-btn">
|
<div className="expert-card__wrap-btn">
|
||||||
<a href="#" className="btn-apply" onClick={onSchedulerHandle}>
|
<a href="#" className="btn-apply">
|
||||||
<img src="/images/calendar-outline.svg" className="" alt="" />
|
<img src="/images/calendar-outline.svg" className="" alt="" />
|
||||||
Schedule
|
Schedule
|
||||||
</a>
|
</a>
|
||||||
|
@ -61,15 +45,6 @@ export const ExpertCard: FC<ExpertDetailsProps> = ({ expert, locale, expertId })
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,290 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, {Dispatch, FC, SetStateAction, useEffect, useState} from 'react';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {Modal, Form, Calendar, Radio } from 'antd';
|
|
||||||
import type { CalendarProps, RadioChangeEvent } from 'antd';
|
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
|
||||||
import { RegisterContent, ResetContent, FinishContent, EnterContent } from './authModalContent';
|
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
|
||||||
import {ExpertDetails, ExpertScheduler, Tags} from "../../types/experts";
|
|
||||||
import { createStyles } from 'antd-style';
|
|
||||||
import {useLocalStorage} from "../../hooks/useLocalStorage";
|
|
||||||
import {AUTH_TOKEN_KEY} from "../../constants/common";
|
|
||||||
import {getSchedulerByExpertId, getSchedulerSession} from "../../actions/experts";
|
|
||||||
import {ElementsForm} from "../stripe/ElementsForm";
|
|
||||||
|
|
||||||
type SchedulerModalProps = {
|
|
||||||
open: boolean;
|
|
||||||
handleCancel: () => void;
|
|
||||||
mode: 'data' | 'time' | 'pay' | 'finish';
|
|
||||||
updateMode: (mode: 'data' | 'time' | 'pay' | 'finish') => void;
|
|
||||||
sessionCost: number;
|
|
||||||
expertId: string;
|
|
||||||
locale: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useStyle = createStyles(({ token, css, cx }) => {
|
|
||||||
const lunar = css`
|
|
||||||
color: ${token.colorTextTertiary};
|
|
||||||
font-size: ${token.fontSizeSM}px;
|
|
||||||
`;
|
|
||||||
return {
|
|
||||||
wrapper: css`
|
|
||||||
width: 450px;
|
|
||||||
border: 1px solid ${token.colorBorderSecondary};
|
|
||||||
border-radius: ${token.borderRadiusOuter};
|
|
||||||
padding: 5px;
|
|
||||||
`,
|
|
||||||
dateCell: css`
|
|
||||||
position: relative;
|
|
||||||
&:before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: auto;
|
|
||||||
max-width: 40px;
|
|
||||||
max-height: 40px;
|
|
||||||
background: transparent;
|
|
||||||
transition: background 300ms;
|
|
||||||
border-radius: ${token.borderRadiusOuter}px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
&:hover:before {
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
today: css`
|
|
||||||
&:before {
|
|
||||||
border: 1px solid ${token.colorPrimary};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
text: css`
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
`,
|
|
||||||
lunar,
|
|
||||||
current: css`
|
|
||||||
color: ${token.colorTextLightSolid};
|
|
||||||
&:before {
|
|
||||||
background: ${token.colorPrimary};
|
|
||||||
}
|
|
||||||
&:hover:before {
|
|
||||||
background: ${token.colorPrimary};
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.${cx(lunar)} {
|
|
||||||
color: ${token.colorTextLightSolid};
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
monthCell: css`
|
|
||||||
width: 120px;
|
|
||||||
color: ${token.colorTextBase};
|
|
||||||
border-radius: ${token.borderRadiusOuter}px;
|
|
||||||
padding: 5px 0;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
monthCellCurrent: css`
|
|
||||||
color: ${token.colorTextLightSolid};
|
|
||||||
background: ${token.colorPrimary};
|
|
||||||
&:hover {
|
|
||||||
background: ${token.colorPrimary};
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
weekend: css`
|
|
||||||
color: ${token.colorError};
|
|
||||||
&.gray {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SchedulerModal: FC<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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,195 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { StripeError } from "@stripe/stripe-js";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import {
|
|
||||||
useStripe,
|
|
||||||
useElements,
|
|
||||||
PaymentElement,
|
|
||||||
Elements,
|
|
||||||
} from "@stripe/react-stripe-js";
|
|
||||||
|
|
||||||
import StripeTestCards from "./StripeTestCards";
|
|
||||||
|
|
||||||
import getStripe from "../../utils/get-stripe";
|
|
||||||
import { createPaymentIntent} from "../../actions/stripe";
|
|
||||||
import {Form} from "antd";
|
|
||||||
import {Payment} from "../../types/payment";
|
|
||||||
import {CustomInput} from "../view/CustomInput";
|
|
||||||
import {i18nText} from "../../i18nKeys";
|
|
||||||
import {FC, useEffect} from "react";
|
|
||||||
import {getPersonalData} from "../../actions/profile";
|
|
||||||
|
|
||||||
type PaymentFormProps = {
|
|
||||||
amount: number,
|
|
||||||
sessionId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const CheckoutForm: FC<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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
|
|
||||||
export default function PrintObject({
|
|
||||||
content,
|
|
||||||
}: {
|
|
||||||
content: Stripe.PaymentIntent | Stripe.Checkout.Session;
|
|
||||||
}): JSX.Element {
|
|
||||||
const formattedContent: string = JSON.stringify(content, null, 2);
|
|
||||||
return <pre>{formattedContent}</pre>;
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
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,29 +112,3 @@ 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
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export type Payment = {
|
|
||||||
amount: number;
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
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