From 347ac0113c4d128d5fe888f3e58c9ed08495ac25 Mon Sep 17 00:00:00 2001 From: SD Date: Fri, 5 Jul 2024 17:03:11 +0400 Subject: [PATCH 1/5] feat: add .env file --- .env | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..91a7cbb --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +NEXT_PUBLIC_SERVER_BASE_URL=https://api.bbuddy.expert/api +NEXT_PUBLIC_AGORA_APPID=ed90c9dc42634e5687d4e2e0766b363f + From f3d2cfd55af51e2c3acdf03730c2e621d2d68a8f Mon Sep 17 00:00:00 2001 From: SD Date: Mon, 8 Jul 2024 17:55:36 +0400 Subject: [PATCH 2/5] fix: fix agora audioTrack --- src/components/Account/agora/Agora.tsx | 2 +- .../agora/components/CameraVideoTrack.tsx | 30 +++++ .../Account/agora/components/LocalUser.tsx | 108 ++++++++++++++++++ .../agora/components/LocalUserPanel.tsx | 26 +---- .../agora/components/MicrophoneAudioTrack.tsx | 32 ++++++ .../agora/components/RemoteVideoPlayer.tsx | 69 ++++++----- src/components/Account/agora/index.tsx | 2 - 7 files changed, 209 insertions(+), 60 deletions(-) create mode 100644 src/components/Account/agora/components/CameraVideoTrack.tsx create mode 100644 src/components/Account/agora/components/LocalUser.tsx create mode 100644 src/components/Account/agora/components/MicrophoneAudioTrack.tsx diff --git a/src/components/Account/agora/Agora.tsx b/src/components/Account/agora/Agora.tsx index f63be12..6be491d 100644 --- a/src/components/Account/agora/Agora.tsx +++ b/src/components/Account/agora/Agora.tsx @@ -24,7 +24,7 @@ export const Agora = ({ sessionId, secret, stopCalling, remoteUser }: AgoraProps useJoin( { - appid: 'ed90c9dc42634e5687d4e2e0766b363f', + appid: process.env.NEXT_PUBLIC_AGORA_APPID, channel: `${sessionId}-${secret}`, token: null, }, diff --git a/src/components/Account/agora/components/CameraVideoTrack.tsx b/src/components/Account/agora/components/CameraVideoTrack.tsx new file mode 100644 index 0000000..668f51b --- /dev/null +++ b/src/components/Account/agora/components/CameraVideoTrack.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import { ICameraVideoTrack, LocalVideoTrack, LocalVideoTrackProps, MaybePromiseOrNull } from 'agora-rtc-react'; +import { useAwaited } from '../../../../utils/agora/tools'; + +interface CameraVideoTrackProps extends LocalVideoTrackProps { + /** + * A camera video track which can be created by `createCameraVideoTrack()`. + */ + readonly track?: MaybePromiseOrNull; + /** + * Device ID, which can be retrieved by calling `getDevices()`. + */ + readonly deviceId?: string; +} + +export const CameraVideoTrack = ({ + track: maybeTrack, + deviceId, + ...props +}: CameraVideoTrackProps) => { + const track = useAwaited(maybeTrack); + + useEffect(() => { + if (track && deviceId != null) { + track.setDevice(deviceId).catch(console.warn); + } + }, [deviceId, track]); + + return ; +}; diff --git a/src/components/Account/agora/components/LocalUser.tsx b/src/components/Account/agora/components/LocalUser.tsx new file mode 100644 index 0000000..5d5bf01 --- /dev/null +++ b/src/components/Account/agora/components/LocalUser.tsx @@ -0,0 +1,108 @@ +import { HTMLProps, ReactNode } from 'react'; +import { ICameraVideoTrack, IMicrophoneAudioTrack, MaybePromiseOrNull } from 'agora-rtc-react'; +import { UserCover } from '../components'; +import { MicrophoneAudioTrack } from './MicrophoneAudioTrack'; +import { CameraVideoTrack } from './CameraVideoTrack'; + +interface LocalUserProps extends HTMLProps { + /** + * Whether to turn on the local user's microphone. Default false. + */ + readonly micOn?: boolean; + /** + * Whether to turn on the local user's camera. Default false. + */ + readonly cameraOn?: boolean; + /** + * A microphone audio track which can be created by `createMicrophoneAudioTrack()`. + */ + readonly audioTrack?: MaybePromiseOrNull; + /** + * A camera video track which can be created by `createCameraVideoTrack()`. + */ + readonly videoTrack?: MaybePromiseOrNull; + /** + * Whether to play the local user's audio track. Default follows `micOn`. + */ + readonly playAudio?: boolean; + /** + * Whether to play the local user's video track. Default follows `cameraOn`. + */ + readonly playVideo?: boolean; + /** + * Device ID, which can be retrieved by calling `getDevices()`. + */ + readonly micDeviceId?: string; + /** + * Device ID, which can be retrieved by calling `getDevices()`. + */ + readonly cameraDeviceId?: string; + /** + * The volume. The value ranges from 0 (mute) to 1000 (maximum). A value of 100 is the current volume. + */ + readonly volume?: number; + /** + * Render cover image if playVideo is off. + */ + readonly cover?: string; + /** + * Children is rendered on top of the video canvas. + */ + readonly children?: ReactNode; +} + +/** + * Play/Stop local user camera and microphone track. + */ +export function LocalUser({ + micOn, + cameraOn, + audioTrack, + videoTrack, + playAudio = false, + playVideo, + micDeviceId, + cameraDeviceId, + volume, + cover, + children, + style, + ...props +}: LocalUserProps) { + playVideo = playVideo ?? !!cameraOn; + playAudio = playAudio ?? !!micOn; + return ( +
+ + + {cover && !cameraOn && } +
{children}
+
+ ); +}; diff --git a/src/components/Account/agora/components/LocalUserPanel.tsx b/src/components/Account/agora/components/LocalUserPanel.tsx index d76c908..87587bd 100644 --- a/src/components/Account/agora/components/LocalUserPanel.tsx +++ b/src/components/Account/agora/components/LocalUserPanel.tsx @@ -1,14 +1,14 @@ -import { LocalUser, useLocalMicrophoneTrack, useLocalCameraTrack, usePublish, useIsConnected } from 'agora-rtc-react'; -import { useState, useEffect } from 'react'; +import { useLocalMicrophoneTrack, useLocalCameraTrack, usePublish, useIsConnected } from 'agora-rtc-react'; import { UserOutlined } from '@ant-design/icons'; import { useLocalStorage } from '../../../../hooks/useLocalStorage'; import { AUTH_USER } from '../../../../constants/common'; +import { LocalUser } from './LocalUser'; type LocalUserPanelProps = { calling: boolean; micOn: boolean; cameraOn: boolean; -} +}; export const LocalUserPanel = ({ calling, @@ -18,26 +18,11 @@ export const LocalUserPanel = ({ const isConnected = useIsConnected(); const [userData] = useLocalStorage(AUTH_USER, ''); const { faceImageUrl: userImage = '' } = userData ? JSON.parse(userData) : {}; - - const [playVideo, setPlayVideo] = useState(false); - const [playAudio, setPlayAudio] = useState(false); - const { localMicrophoneTrack } = useLocalMicrophoneTrack(micOn); const { localCameraTrack } = useLocalCameraTrack(cameraOn); + usePublish([localMicrophoneTrack, localCameraTrack]); - useEffect(() => { - if (calling) { - setPlayVideo(cameraOn) - } - }, [cameraOn]); - - useEffect(() => { - if (calling) { - setPlayAudio(micOn) - } - }, [micOn]); - return calling && isConnected ? (
{!cameraOn && ( @@ -51,9 +36,6 @@ export const LocalUserPanel = ({ audioTrack={localMicrophoneTrack} cameraOn={cameraOn} micOn={micOn} - playAudio={playAudio} - playVideo={playVideo} - style={{ width: '100%', height: '100%' }} videoTrack={localCameraTrack} />
diff --git a/src/components/Account/agora/components/MicrophoneAudioTrack.tsx b/src/components/Account/agora/components/MicrophoneAudioTrack.tsx new file mode 100644 index 0000000..3876449 --- /dev/null +++ b/src/components/Account/agora/components/MicrophoneAudioTrack.tsx @@ -0,0 +1,32 @@ +import { ReactNode, useEffect } from 'react'; +import { IMicrophoneAudioTrack, LocalAudioTrack, LocalAudioTrackProps, MaybePromiseOrNull } from 'agora-rtc-react'; +import { useAwaited } from '../../../../utils/agora/tools'; + +interface MicrophoneAudioTrackProps extends LocalAudioTrackProps { + /** + * A microphone audio track which can be created by `createMicrophoneAudioTrack()`. + */ + readonly track?: MaybePromiseOrNull; + /** + * Device ID, which can be retrieved by calling `getDevices()`. + */ + readonly deviceId?: string; + + readonly children?: ReactNode; +} + +export const MicrophoneAudioTrack = ({ + track: maybeTrack, + deviceId, + ...props +}: MicrophoneAudioTrackProps) => { + const track = useAwaited(maybeTrack); + + useEffect(() => { + if (track && deviceId != null) { + track.setDevice(deviceId).catch(console.warn); + } + }, [deviceId, track]); + + return ; +}; diff --git a/src/components/Account/agora/components/RemoteVideoPlayer.tsx b/src/components/Account/agora/components/RemoteVideoPlayer.tsx index b5014f5..bb61164 100644 --- a/src/components/Account/agora/components/RemoteVideoPlayer.tsx +++ b/src/components/Account/agora/components/RemoteVideoPlayer.tsx @@ -31,39 +31,38 @@ export interface RemoteVideoPlayerProps extends HTMLProps { * An `IRemoteVideoTrack` can only be own by one `RemoteVideoPlayer`. */ export function RemoteVideoPlayer({ - track, - playVideo, - cover, - client, - style, - children, - ...props + track, + playVideo, + cover, + client, + style, + children, + ...props }: RemoteVideoPlayerProps) { - const resolvedClient = useRTCClient(client); - const hasVideo = resolvedClient.remoteUsers?.find( - user => user.uid === track?.getUserId(), - )?.hasVideo; - playVideo = playVideo ?? hasVideo; - return ( -
- - {cover && !playVideo && } -
{children}
-
- ); -} + const resolvedClient = useRTCClient(client); + const hasVideo = resolvedClient.remoteUsers?.find(user => user.uid === track?.getUserId())?.hasVideo; + playVideo = playVideo ?? hasVideo; + + return ( +
+ + {cover && !playVideo && } +
{children}
+
+ ); +}; diff --git a/src/components/Account/agora/index.tsx b/src/components/Account/agora/index.tsx index d4d23f9..cbca252 100644 --- a/src/components/Account/agora/index.tsx +++ b/src/components/Account/agora/index.tsx @@ -4,8 +4,6 @@ import AgoraRTC, { AgoraRTCProvider } from 'agora-rtc-react'; import { Session } from '../../../types/sessions'; import { Agora } from './Agora'; -AgoraRTC.setLogLevel(0); - export const AgoraClient = ({ session, stopCalling, isCoach }: { session?: Session, stopCalling: () => void, isCoach: boolean }) => { const remoteUser = isCoach ? (session?.clients?.length ? session?.clients[0] : undefined) : session?.coach; From 3a6c7bd88c358f050c0edbbf2ec290714644f4c9 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Sat, 13 Jul 2024 13:13:58 +0300 Subject: [PATCH 3/5] 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 From bdbe4f4b04eb82c770682770e84802fc64d8bc61 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Sun, 14 Jul 2024 11:36:23 +0300 Subject: [PATCH 4/5] Revert "stripe payment" This reverts commit 3a6c7bd88c358f050c0edbbf2ec290714644f4c9. --- 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, 9 insertions(+), 905 deletions(-) delete mode 100644 src/actions/stripe.ts delete mode 100644 src/app/[locale]/payment/@payment/page.tsx delete mode 100644 src/app/[locale]/payment/page.tsx delete mode 100644 src/app/[locale]/payment/result/layout.tsx delete mode 100644 src/app/[locale]/payment/result/page.tsx delete mode 100644 src/app/api/webhooks/route.ts delete mode 100644 src/components/Modals/SchedulerModal.tsx delete mode 100644 src/components/stripe/ElementsForm.tsx delete mode 100644 src/components/stripe/PrintObject.tsx delete mode 100644 src/components/stripe/StripeTestCards.tsx delete mode 100644 src/lib/stripe.ts delete mode 100644 src/types/payment.ts delete mode 100644 src/utils/get-stripe.ts delete mode 100644 src/utils/stripe-helpers.ts diff --git a/src/actions/experts.ts b/src/actions/experts.ts index 5ddb524..91e0d73 100644 --- a/src/actions/experts.ts +++ b/src/actions/experts.ts @@ -1,7 +1,5 @@ import { apiClient } from '../lib/apiClient'; -import {GeneralFilter, ExpertsData, ExpertDetails, ExpertScheduler, ExpertSchedulerSession} from '../types/experts'; -import {useLocalStorage} from "../hooks/useLocalStorage"; -import {AUTH_TOKEN_KEY} from "../constants/common"; +import { GeneralFilter, ExpertsData, ExpertDetails } from '../types/experts'; export const getExpertsList = async (locale: string, filter?: GeneralFilter) => { const response = await apiClient.post( @@ -30,33 +28,3 @@ 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 deleted file mode 100644 index c0d2254..0000000 --- a/src/actions/stripe.ts +++ /dev/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 }; -} \ No newline at end of file diff --git a/src/app/[locale]/(main)/layout.tsx b/src/app/[locale]/(main)/layout.tsx index 7558955..daa1e7d 100644 --- a/src/app/[locale]/(main)/layout.tsx +++ b/src/app/[locale]/(main)/layout.tsx @@ -16,8 +16,7 @@ export default function MainLayout({ children, news, directions, experts }: { children: ReactNode, news: ReactNode, directions: ReactNode, - experts: ReactNode, - payment: ReactNode + experts: ReactNode }) { return ( <> @@ -27,4 +26,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 23dd24e..6d1a701 100644 --- a/src/app/[locale]/experts/[expertId]/page.tsx +++ b/src/app/[locale]/experts/[expertId]/page.tsx @@ -11,7 +11,6 @@ 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', @@ -82,7 +81,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 8eee097..520e6ae 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 deleted file mode 100644 index c93d91d..0000000 --- a/src/app/[locale]/payment/@payment/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Metadata } from "next"; - -import {ElementsForm} from "../../../../components/stripe/ElementsForm"; - -export const metadata: Metadata = { - title: "Payment", -}; - -export default function PaymentElementPage({ - searchParams, - }: { - searchParams?: { payment_intent_client_secret?: string }; -}) { - return ( -
-

Pay

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

Payment Intent Result

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

Status: {paymentIntent.status}

-

Payment Intent response:

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

Processing...

; - - case "requires_action": - return

Authenticating...

; - - case "succeeded": - return

Payment Succeeded 🥳

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

Error 😭

-

{errorMessage}

- - ); - - default: - return null; - } - }; - - useEffect(() => { - elements?.update({ amount: formAmount * 100 }); - }, [formAmount]); - - const handleInputChange: React.ChangeEventHandler = (e) => { - setInput({ - ...input, - [e.currentTarget.name]: e.currentTarget.value, - }); - }; - - const onSubmit = async (data) => { - try { - if (!elements || !stripe) return; - - setPayment({ status: "processing" }); - - const { error: submitError } = await elements.submit(); - - if (submitError) { - setPayment({ status: "error" }); - setErrorMessage(submitError.message ?? "An unknown error occurred"); - - return; - } - - // Create a PaymentIntent with the specified amount. - console.log('DATA', data); - const { client_secret: clientSecret } = await createPaymentIntent( - {amount: amount}, - ); - - // Use your card Element with other Stripe.js APIs - const { error: confirmError } = await stripe!.confirmPayment({ - elements, - clientSecret, - confirmParams: { - return_url: `${window.location.origin}/ru/payment/result`, - payment_method_data: { - allow_redisplay: 'limited', - billing_details: { - name: input.cardholderName, - }, - }, - }, - }); - - if (confirmError) { - setPayment({ status: "error" }); - setErrorMessage(confirmError.message ?? "An unknown error occurred"); - } - } catch (err) { - const { message } = err as StripeError; - - setPayment({ status: "error" }); - setErrorMessage(message ?? "An unknown error occurred"); - } - }; - - - - return ( - <> -
-
- - Your payment details: - {paymentType === "card" ? ( - - ) : null} -
- { - setPaymentType(e.value.type); - }} - /> -
-
- -
- - - ); -} - -export const ElementsForm: FC = ({amount, sessionId}) => { - return ( - - - - ) -} \ No newline at end of file diff --git a/src/components/stripe/PrintObject.tsx b/src/components/stripe/PrintObject.tsx deleted file mode 100644 index b7cd424..0000000 --- a/src/components/stripe/PrintObject.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Stripe } from "stripe"; - -export default function PrintObject({ - content, - }: { - content: Stripe.PaymentIntent | Stripe.Checkout.Session; -}): JSX.Element { - const formattedContent: string = JSON.stringify(content, null, 2); - return
{formattedContent}
; -} \ No newline at end of file diff --git a/src/components/stripe/StripeTestCards.tsx b/src/components/stripe/StripeTestCards.tsx deleted file mode 100644 index cf40281..0000000 --- a/src/components/stripe/StripeTestCards.tsx +++ /dev/null @@ -1,19 +0,0 @@ -export default function StripeTestCards(): JSX.Element { - return ( -
- Use any of the{" "} - - Stripe test cards - {" "} - for demo, e.g.{" "} -
- 4242424242424242 -
- . -
- ); -} \ No newline at end of file diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts deleted file mode 100644 index 08773a7..0000000 --- a/src/lib/stripe.ts +++ /dev/null @@ -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: "", - }, -}); \ No newline at end of file diff --git a/src/types/experts.ts b/src/types/experts.ts index 3df393d..160db87 100644 --- a/src/types/experts.ts +++ b/src/types/experts.ts @@ -112,29 +112,3 @@ 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 deleted file mode 100644 index cdadd42..0000000 --- a/src/types/payment.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 6930d5b..0000000 --- a/src/utils/get-stripe.ts +++ /dev/null @@ -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; - -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 deleted file mode 100644 index 09144bd..0000000 --- a/src/utils/stripe-helpers.ts +++ /dev/null @@ -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); -} \ No newline at end of file From ed756d0646e74073419fcce9bf769f1a078451a5 Mon Sep 17 00:00:00 2001 From: dzfelix Date: Fri, 16 Aug 2024 14:43:07 +0300 Subject: [PATCH 5/5] blog contentful --- .env | 3 + package.json | 2 + src/app/[locale]/blog/[blogId]/page.tsx | 150 ------------ src/app/[locale]/blog/[slug]/page.tsx | 87 +++++++ .../[locale]/blog/category/[slug]/page.tsx | 101 ++++++++ src/app/[locale]/blog/page.tsx | 228 +++++------------- src/lib/contentful/RichText.tsx | 16 ++ src/lib/contentful/authors.ts | 16 ++ src/lib/contentful/blogPosts.ts | 99 ++++++++ src/lib/contentful/blogPostsCategories.ts | 36 +++ src/lib/contentful/contentImage.ts | 27 +++ src/lib/contentful/contentfulClient.ts | 28 +++ src/types/author.ts | 20 ++ src/types/blogPost.ts | 39 +++ src/types/blogPostCategory.ts | 20 ++ src/types/blogWidgets/widgetMedia.ts | 20 ++ src/types/blogWidgets/widgetParagraph.ts | 20 ++ 17 files changed, 589 insertions(+), 323 deletions(-) delete mode 100644 src/app/[locale]/blog/[blogId]/page.tsx create mode 100644 src/app/[locale]/blog/[slug]/page.tsx create mode 100644 src/app/[locale]/blog/category/[slug]/page.tsx create mode 100644 src/lib/contentful/RichText.tsx create mode 100644 src/lib/contentful/authors.ts create mode 100644 src/lib/contentful/blogPosts.ts create mode 100644 src/lib/contentful/blogPostsCategories.ts create mode 100644 src/lib/contentful/contentImage.ts create mode 100644 src/lib/contentful/contentfulClient.ts create mode 100644 src/types/author.ts create mode 100644 src/types/blogPost.ts create mode 100644 src/types/blogPostCategory.ts create mode 100644 src/types/blogWidgets/widgetMedia.ts create mode 100644 src/types/blogWidgets/widgetParagraph.ts diff --git a/.env b/.env index 91a7cbb..509d982 100644 --- a/.env +++ b/.env @@ -1,3 +1,6 @@ NEXT_PUBLIC_SERVER_BASE_URL=https://api.bbuddy.expert/api NEXT_PUBLIC_AGORA_APPID=ed90c9dc42634e5687d4e2e0766b363f +CONTENTFUL_SPACE_ID = voxpxjq7y7vf +CONTENTFUL_ACCESS_TOKEN = s99GWKfpDKkNwiEJ3pN7US_tmqsGvDlaex-sOJwpzuc +CONTENTFUL_PREVIEW_ACCESS_TOKEN = Z9WOKpLDbKNj7xVOmT_VXYNLH0AZwISFvQsq0PQlHfE \ No newline at end of file diff --git a/package.json b/package.json index fdfb4c7..4b6b281 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "@ant-design/cssinjs": "^1.18.1", "@ant-design/icons": "^5.2.6", "@ant-design/nextjs-registry": "^1.0.0", + "@contentful/rich-text-react-renderer": "^15.22.9", "agora-rtc-react": "^2.1.0", "agora-rtc-sdk-ng": "^4.20.2", "antd": "^5.12.1", "antd-img-crop": "^4.21.0", "axios": "^1.6.5", + "contentful": "^10.13.3", "dayjs": "^1.11.10", "lodash": "^4.17.21", "next": "14.0.3", diff --git a/src/app/[locale]/blog/[blogId]/page.tsx b/src/app/[locale]/blog/[blogId]/page.tsx deleted file mode 100644 index 131333a..0000000 --- a/src/app/[locale]/blog/[blogId]/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react'; -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; - -export const metadata: Metadata = { - title: 'Bbuddy - Blog item', - description: 'Bbuddy desc blog item' -}; - -export function generateStaticParams() { - return [{ blogId: 'news-1' }, { blogId: 'news-2' }]; -} - -export default function BlogItem({ params }: { params: { blogId: string } }) { - if (!params?.blogId) notFound(); - - return ( -
-
-

6 learnings from Shivpuri to Silicon Valley

-
Leadership & Management
-
- {`news id ${params.blogId}`}
- I’m excited to kick off this series of newsletters where I’ll be sharing my experiences, learnings, - and best practices which helped me to grow both in my personal and professional life. My hope is to - give back to the community and help anyone connect directly with me who may have got impacted with - recent layoffs, dealing with immigration challenges. -
-
- -
-
-
- -
-
Sonali Garg
-
February 6th, 2023
-
-
-
-
- - 165 -
-
- - Share -
-
-
-
-

- This is not about layoffs, it's about living with whatever life throws at you.. -

-

- Over the past few months, as the macro-economic events have unfolded, I have heard voices filled - with anxiety, helplessness and general lack of confidence to deal with this ambiguity from my - mentees, colleagues, friends and family. I was laid off from Meta last November and I firmly - believe this is nothing but a bump in the road that might seem like a steep climb in the - short-term. I may not have all the answers but this has inspired me to share my story. If you - are looking for a sob story, you can stop reading now. Ever wondered what it takes for a girl - born into a conservative family in a small sleepy town in India, who lost one of her parents at - age 17, earned her living while pursuing engineering, moved to the UK by herself and ended up - working in big tech in Silicon valley? My goal with this series of posts is to inspire and share - my mental models that helped me throughout my professional and personal life. -

-

- After completing my engineering, I started my career at a small software company in Bhopal and - then worked for TCS(Tata Consultancy Services), one of the largest IT-outsourcing companies in - the world for almost 5 years. Over the past 14 years, I have worked for big tech companies like - Meta (Facebook) and Google, wore multiple hats, led strategic programs, scaled multi - billion-dollar businesses, built teams and helped achieve business operational excellence. - Throughout my career, I’ve dealt with several challenges from execution to scale to building a - high performance team. A lot of my early struggles were about how to assimilate in a new - culture, create a network in a new environment, earn trust, create and nurture work - relationships into fruitful friendships and so on. -

-

- I was born in a conservative family in a small town called ‘Shivpuri’, also known as ‘Mini - Kashmir’ because of its natural beauty. My father was a civil engineer working on Madikheda Dam - on Sindh river and was a strict disciplinarian. He was gone from dawn to dusk and was always - focused. My mother was a teacher in a school that was about 30 kms from our home. We (me and my - sister) would often be left with neighbors to be taken care of and this led us to become - independent at an early age. Our otherwise slow paced, simple life with only a few families - around in the government quarters that were set up to support construction of the dam was filled - with natural beauty, wildlife and a community of close friends. Our lives were balanced and - while my parents worked hard to provide basic needs, we were satisfied. There were only a few - schools with Hindi being the prevalent language as the medium of teaching. There were no - colleges for advanced studies and most girls did not go to college often married off by their - 18th birthday. Generally speaking, we had a joyous childhood with just the basics. While most - folks we interacted with were not highly educated nor ambitious, earned lower middle class - salaries and lacked exposure to the outside world but there was plenty to learn from them. - People had learnt to stick together in good and bad times. They embodied the old school - qualities of hard work, dedication and commitment. Be willing to give it all- hard work, - dedication and commitment. -

-

- In 2003, my father passed away suddenly and we found ourselves in crisis. My mother was a - teacher and she did not have time to deal with her grief. Rather, she was struggling to garner - support to get transferred to a school in Bhopal, capital of Madhya Pradesh to be closer to our - maternal grandparents. As we uprooted ourselves from Shivpuri to Bhopal, one of my father’s - loyal friends came to help load the moving truck. While he had nothing to gain out of us, he - continued to serve us until the last day in Shivpuri. Remember, in crisis your team matters more - than any other time. Advocate for them ruthlessly in good and bad times, they will come through - in crisis. -

-

- Eventually we found our footing, my mother’s job was transferred to a local school in Bhopal and - I got admission in a government engineering college. My sister was still attending high school - and both of us were teaching tuition classes to middle school students in the evenings to make - ends meet. I also started a tiffin service for a few out of town students while attending - college to pay for my transportation and cost of supplies. We refused to give up. Persevere when - all else fails. -

-

- Our 5 years went by quickly in Bhopal as we worked towards improving our financial situation and - I completed my Bachelors in Computer Science. This was the time I first stepped out to live in a - metropolitan city, Mumbai for my job at TCS. This was a paradigm shift from Bhopal and I was - blown away to meet so many talented folks in Mumbai. In my head, I did not belong in this place. - I had imposter syndrome and felt like an outsider trying to make it in a new city. Most people I - met were fluent in more than 1 language, well-dressed, communicated openly and with confidence, - and presented themselves well. I was always in a dilemma when it came to adopting values. It - took me a while to adjust to it but I was still not confident about my work and communication - while my hard skills that I learnt in engineering were top notch. I kept questioning my - abilities but persisted. This was not the first time I was out of my comfort zone. Persist, when - in discomfort. -

-

- I worked with multiple global companies who were clients of TCS and was presented an opportunity - to move to Scotland, UK for an year to work for GE, who was also a client. This was my first - opportunity to explore a different culture, food, music, languages etc. I remember working on my - english when in Mumbai, in preparation for my UK trip. It was really difficult to understand the - accent in the UK, even though language was not a barrier. I still remember certain words would - just not get across no matter how hard some of my colleagues tried and they would end up using - signs to convey. Be prepared, opportunities come to those who are prepared. -

-

- In 2013, I came to the US on a dependent visa after marriage and quickly realized the curse of - H4 visa. I paved my path by going back to school at UC Berkeley and then jumped back into - building my career from scratch. While working in the US over the past years, I realized college - degrees with good grades and certifications definitely help you to get your foot in the door but - are not enough to be successful in your career. As I was again starting from scratch in a new - culture, determined to do whatever it takes, having done this a few times before, it doesn’t - scare me as much. Never be afraid to start from zero again! -

-
-
-
- ); -}; diff --git a/src/app/[locale]/blog/[slug]/page.tsx b/src/app/[locale]/blog/[slug]/page.tsx new file mode 100644 index 0000000..d1573dc --- /dev/null +++ b/src/app/[locale]/blog/[slug]/page.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import { draftMode } from 'next/headers' +import { notFound } from 'next/navigation'; +import {fetchBlogPost, fetchBlogPosts, Widget} from "../../../../lib/contentful/blogPosts"; +import Util from "node:util"; +import RichText from "../../../../lib/contentful/RichText"; + +export const metadata: Metadata = { + title: 'Bbuddy - Blog item', + description: 'Bbuddy desc blog item' +}; + +interface BlogPostPageParams { + slug: string +} + +interface BlogPostPageProps { + params: BlogPostPageParams +} + +export async function generateStaticParams(): Promise { + const blogPosts = await fetchBlogPosts({ preview: false }) + + return blogPosts.map((post) => ({ slug: post.slug })) +} +function renderWidget (widget: Widget) { + switch (widget.type){ + case 'widgetParagraph': + return ( + <> +

+ {widget.widget.subTitle} +

+ + + ) + case 'widgetMedia': + return ( + + ) + } +} + +export default async function BlogItem({params}: { params: BlogPostPageParams }) { + const item = await fetchBlogPost({slug: params.slug, preview: draftMode().isEnabled }) + console.log('BLOG POST') + console.log(Util.inspect(item, {showHidden: false, depth: null, colors: true})) + if (!item) notFound(); + + return ( +
+
+

{item.title}

+
{item.category}
+
+ +
+
+ +
+
+
+ +
+
{item.author?.name}
+
{item.createdAt}
+
+
+
+
+ + 165 +
+
+ + Share +
+
+
+
+ {item.body.map(renderWidget)} +
+
+
+ ); +}; diff --git a/src/app/[locale]/blog/category/[slug]/page.tsx b/src/app/[locale]/blog/category/[slug]/page.tsx new file mode 100644 index 0000000..696247c --- /dev/null +++ b/src/app/[locale]/blog/category/[slug]/page.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import { draftMode } from 'next/headers' +import {unstable_setRequestLocale} from "next-intl/server"; +import Link from "next/link"; +import {fetchBlogPosts} from "../../../../../lib/contentful/blogPosts"; +import {fetchBlogPostCategories} from "../../../../../lib/contentful/blogPostsCategories"; + +export const metadata: Metadata = { + title: 'Bbuddy - Blog', + description: 'Bbuddy desc blog' +}; + +interface BlogPostPageParams { + slug: string + locale: string +} +interface BlogPostPageProps { + params: BlogPostPageParams +} + +export default async function Blog({params}: { params: BlogPostPageParams }) { + unstable_setRequestLocale(params.locale); + const data = await fetchBlogPosts({ preview: draftMode().isEnabled, locale: params.locale, category: params.slug }) + const cats = await fetchBlogPostCategories(false) + return ( +
+
+
+

+ Mentorship, Career
+ Development & Coaching. +

+
+

The ins-and-outs of building a career in tech, gaining
experience

+

from a mentor, and getting your feet wet with coaching.

+
+
+ +
+
+
+
+
+
+ { + cats.map((cat, i)=>( + {cat.title} + )) + } +
+
+
+
+
+
+ {data.map((item, i) => ( +
  • + +
    + {item.listImage?.alt}/ +
    +
    +
    +
    + {item.title} +
    +
    {item.category}
    +
    + {item.excerpt} +
    +
    +
    +
    + +
    +
    {item.author.name}
    +
    {item.createdAt}
    +
    +
    +
    +
    + + 165 +
    +
    + + Share +
    +
    +
    +
    + +
  • + ))} +
    +
    +
    +
    + ); +} diff --git a/src/app/[locale]/blog/page.tsx b/src/app/[locale]/blog/page.tsx index 20f2989..3ce264d 100644 --- a/src/app/[locale]/blog/page.tsx +++ b/src/app/[locale]/blog/page.tsx @@ -1,210 +1,92 @@ import React from 'react'; import type { Metadata } from 'next'; +import * as Util from "node:util"; +import {fetchBlogPosts} from "../../../lib/contentful/blogPosts"; +import {unstable_setRequestLocale} from "next-intl/server"; +import Link from "next/link"; +import {fetchBlogPostCategories} from "../../../lib/contentful/blogPostsCategories"; export const metadata: Metadata = { title: 'Bbuddy - Blog', description: 'Bbuddy desc blog' }; -export default function Blog() { + + +export default async function Blog({ params: { locale } }: { params: { locale: string } }) { + unstable_setRequestLocale(locale); + const data = await fetchBlogPosts(false, locale) + const cats = await fetchBlogPostCategories(false) return (

    - Mentorship, Career
    + Mentorship, Career
    Development & Coaching.

    -

    The ins-and-outs of building a career in tech, gaining
    experience

    +

    The ins-and-outs of building a career in tech, gaining
    experience

    from a mentor, and getting your feet wet with coaching.

    - +
    - -
    - -
    -
    -
    -
    - 6 learnings from Shivpuri to Silicon Valley + {data.map((item, i) => ( +
  • + +
    + {item.listImage?.alt}/
    -
    Leadership & Management
    -
    - I’m excited to kick off this series of newsletters where I’ll be sharing my - experiences, - learnings, and best practices which helped me to grow both in my personal and - professional life. My hope is to give back to the community and help anyone - connect directly with me who may have got impacted with recent layoffs, - dealing with immigration challenges. -
    -
  • -
    -
    - -
    -
    Sonali Garg
    -
    February 6th, 2023
    +
    +
    +
    + {item.title} +
    +
    {item.category}
    +
    + {item.excerpt} +
    +
    +
    +
    + +
    +
    {item.author.name}
    +
    {item.createdAt}
    +
    +
    +
    +
    + + 165 +
    +
    + + Share +
    +
    -
    -
    - - 165 -
    -
    - - Share -
    -
    -
    -
    -
    - -
    - -
    -
    -
    -
    - 6 learnings from Shivpuri to Silicon Valley -
    -
    Leadership & Management
    -
    - I’m excited to kick off this series of newsletters where I’ll be sharing my - experiences, - learnings, and best practices which helped me to grow both in my personal and - professional life. My hope is to give back to the community and help anyone - connect directly with me who may have got impacted with recent layoffs, - dealing with immigration challenges. -
    -
    -
    -
    - -
    -
    Sonali Garg
    -
    February 6th, 2023
    -
    -
    -
    -
    - - 165 -
    -
    - - Share -
    -
    -
    -
    -
    - -
    - -
    -
    -
    -
    - 6 learnings from Shivpuri to Silicon Valley -
    -
    Leadership & Management
    -
    - I’m excited to kick off this series of newsletters where I’ll be sharing my - experiences, - learnings, and best practices which helped me to grow both in my personal and - professional life. My hope is to give back to the community and help anyone - connect directly with me who may have got impacted with recent layoffs, - dealing with immigration challenges. -
    -
    -
    -
    - -
    -
    Sonali Garg
    -
    February 6th, 2023
    -
    -
    -
    -
    - - 165 -
    -
    - - Share -
    -
    -
    -
    -
    - -
    - -
    -
    -
    -
    - 6 learnings from Shivpuri to Silicon Valley -
    -
    Leadership & Management
    -
    - I’m excited to kick off this series of newsletters where I’ll be sharing my - experiences, - learnings, and best practices which helped me to grow both in my personal and - professional life. My hope is to give back to the community and help anyone - connect directly with me who may have got impacted with recent layoffs, - dealing with immigration challenges. -
    -
    -
    -
    - -
    -
    Sonali Garg
    -
    February 6th, 2023
    -
    -
    -
    -
    - - 165 -
    -
    - - Share -
    -
    -
    -
    -
    + + + ))}
    diff --git a/src/lib/contentful/RichText.tsx b/src/lib/contentful/RichText.tsx new file mode 100644 index 0000000..597d70b --- /dev/null +++ b/src/lib/contentful/RichText.tsx @@ -0,0 +1,16 @@ +import { Document as RichTextDocument } from '@contentful/rich-text-types' +import { documentToReactComponents } from '@contentful/rich-text-react-renderer' + +type RichTextProps = { + document: RichTextDocument | null +} + +function RichText({ document }: RichTextProps) { + if (!document) { + return null + } + + return <>{documentToReactComponents(document)} +} + +export default RichText \ No newline at end of file diff --git a/src/lib/contentful/authors.ts b/src/lib/contentful/authors.ts new file mode 100644 index 0000000..57a2f30 --- /dev/null +++ b/src/lib/contentful/authors.ts @@ -0,0 +1,16 @@ +import { parseContentfulContentImage } from './contentImage' +import {Author, AuthorEntry} from "../../types/author"; + + + + +export function parseContentfulAuthor(authorEntry?: AuthorEntry): Author | null { + if (!authorEntry) { + return null + } + + return { + name: authorEntry.fields.name || '', + avatar: parseContentfulContentImage(authorEntry.fields.avatar), + } +} \ No newline at end of file diff --git a/src/lib/contentful/blogPosts.ts b/src/lib/contentful/blogPosts.ts new file mode 100644 index 0000000..6fa939b --- /dev/null +++ b/src/lib/contentful/blogPosts.ts @@ -0,0 +1,99 @@ +import { Entry } from 'contentful' +import contentfulClient from './contentfulClient' +import { parseContentfulContentImage } from './contentImage' +import {BlogPost, BlogPostEntry, BlogPostSkeleton} from "../../types/blogPost"; +import {parseContentfulAuthor} from "./authors"; +import dayjs from "dayjs"; +import {WidgetMedia, WidgetMediaEntry} from "../../types/blogWidgets/widgetMedia"; +import {WidgetParagraph} from "../../types/blogWidgets/widgetParagraph"; +import entry from "next/dist/server/typescript/rules/entry"; +import Util from "node:util"; + +type PostEntry = BlogPostEntry//Entry +type widgetEnum = WidgetParagraph | WidgetMedia +export type Widget = { + widget: widgetEnum + type: string +} +type WidgetEntry = WidgetMediaEntry | WidgetParagraph +function parseWidgets(entries?: Array): Array | null { + if (!entries || !entries.length) { + return null + } + return entries.map((entry: WidgetEntry) => { + const wType = entry.sys.contentType.sys.id + let wObj = {} as widgetEnum + switch (wType){ + case 'widgetParagraph': + wObj = { + subTitle: entry.fields.subTitle, + body: entry.fields.body + } + break + case 'widgetMedia': + wObj = { + decription: entry.fields.decription || '', + file: parseContentfulContentImage(entry.fields.file) + } + break + } + return { + type: wType, + widget: wObj + } + }) +} + +export function parseContentfulBlogPost(entry?: PostEntry): BlogPost | null { + if (!entry) { + return null + } + + return { + title: entry.fields.title || '', + slug: entry.fields.slug, + excerpt: entry.fields.excerpt || '', + listImage: parseContentfulContentImage(entry.fields.listImage), + author: parseContentfulAuthor(entry.fields.author), + createdAt: dayjs(entry.sys.createdAt).format('MMM DD, YYYY'), + category: entry.fields.category.fields.title, + body: parseWidgets(entry.fields.body) || [] + } +} + +interface FetchBlogPostsOptions { + preview: boolean + local?: string + category?: string +} +export async function fetchBlogPosts({ preview, category }: FetchBlogPostsOptions): Promise { + const contentful = contentfulClient({ preview }) + const query = { + content_type: 'blogPost', + select: ['fields.title', 'fields.excerpt', 'fields.author', 'fields.listImage', 'fields.author', 'fields.category', 'sys.createdAt', 'fields.slug'], + order: ['sys.createdAt'], + } + if (category){ + query['fields.category.fields.slug'] = category + query['fields.category.sys.contentType.sys.id']='blogPostCategory' + } + const blogPostsResult = await contentful.getEntries(query) + + return blogPostsResult.items.map((blogPostEntry) => parseContentfulBlogPost(blogPostEntry) as BlogPost) +} + +interface FetchBlogPostOptions { + slug: string + preview: boolean +} +export async function fetchBlogPost({ slug, preview }: FetchBlogPostOptions): Promise { + const contentful = contentfulClient({ preview }) + + const blogPostsResult = await contentful.getEntries({ + content_type: 'blogPost', + 'fields.slug': slug, + include: 2, + }) + + return parseContentfulBlogPost(blogPostsResult.items[0]) +} \ No newline at end of file diff --git a/src/lib/contentful/blogPostsCategories.ts b/src/lib/contentful/blogPostsCategories.ts new file mode 100644 index 0000000..84006a3 --- /dev/null +++ b/src/lib/contentful/blogPostsCategories.ts @@ -0,0 +1,36 @@ +import { Entry } from 'contentful' +import contentfulClient from './contentfulClient' +import { parseContentfulContentImage } from './contentImage' +import {BlogPost, BlogPostEntry, BlogPostSkeleton} from "../../types/blogPost"; +import {parseContentfulAuthor} from "./authors"; +import dayjs from "dayjs"; +import {BlogPostCategory, BlogPostCategoryEntry, BlogPostCategorySkeleton} from "../../types/blogPostCategory"; + +type ListEntry = BlogPostCategoryEntry + + + +export function parseContentfulBlogPostCategory(entry?: ListEntry): BlogPostCategory | null { + if (!entry) { + return null + } + return { + title: entry.fields.title || '', + slug: entry.fields.slug || '' + } +} + +interface FetchListOptions { + preview: boolean + local?: string +} +export async function fetchBlogPostCategories({ preview }: FetchListOptions): Promise { + const contentful = contentfulClient({ preview }) + + const results = await contentful.getEntries({ + content_type: 'blogPostCategory', + order: ['fields.title'], + }) + + return results.items.map((entry) => parseContentfulBlogPostCategory(entry) as BlogPostCategory) +} diff --git a/src/lib/contentful/contentImage.ts b/src/lib/contentful/contentImage.ts new file mode 100644 index 0000000..3048548 --- /dev/null +++ b/src/lib/contentful/contentImage.ts @@ -0,0 +1,27 @@ +import { Asset, AssetLink } from 'contentful' + +export interface ContentImage { + src: string + alt: string + width: number + height: number +} + +export function parseContentfulContentImage( + asset?: Asset | { sys: AssetLink } +): ContentImage | null { + if (!asset) { + return null + } + + if (!('fields' in asset)) { + return null + } + + return { + src: asset.fields.file?.url || '', + alt: asset.fields.description || '', + width: asset.fields.file?.details.image?.width || 0, + height: asset.fields.file?.details.image?.height || 0, + } +} \ No newline at end of file diff --git a/src/lib/contentful/contentfulClient.ts b/src/lib/contentful/contentfulClient.ts new file mode 100644 index 0000000..61fcb2e --- /dev/null +++ b/src/lib/contentful/contentfulClient.ts @@ -0,0 +1,28 @@ +import { createClient } from 'contentful' + +const { CONTENTFUL_SPACE_ID, CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_PREVIEW_ACCESS_TOKEN } = process.env + +// This is the standard Contentful client. It fetches +// content that has been published. +const client = createClient({ + space: CONTENTFUL_SPACE_ID!, + accessToken: CONTENTFUL_ACCESS_TOKEN!, +}) + +// This is a Contentful client that's been configured +// to fetch drafts and unpublished content. +const previewClient = createClient({ + space: CONTENTFUL_SPACE_ID!, + accessToken: CONTENTFUL_PREVIEW_ACCESS_TOKEN!, + host: 'preview.contentful.com', +}) + +// This little helper will let us switch between the two +// clients easily: +export default function contentfulClient({ preview = false }) { + if (preview) { + return previewClient + } + + return client +} \ No newline at end of file diff --git a/src/types/author.ts b/src/types/author.ts new file mode 100644 index 0000000..05c7eac --- /dev/null +++ b/src/types/author.ts @@ -0,0 +1,20 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful' +import {BlogPostFields} from "./blogPost"; +import {ContentImage} from "../lib/contentful/contentImage"; + +export interface AuthorFields { + name: EntryFieldTypes.Symbol + avatar: EntryFieldTypes.AssetLink +} + +export interface Author { + name: string + avatar: ContentImage | null +} + +export type AuthorSkeleton = EntrySkeletonType +export type AuthorEntry = Entry< + AuthorSkeleton, + Modifiers, + Locales +> \ No newline at end of file diff --git a/src/types/blogPost.ts b/src/types/blogPost.ts new file mode 100644 index 0000000..c4776c4 --- /dev/null +++ b/src/types/blogPost.ts @@ -0,0 +1,39 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful' +import {Author, AuthorSkeleton} from "./author"; +import {ContentImage} from "../lib/contentful/contentImage"; +import {BlogPostCategorySkeleton} from "./blogPostCategory"; +import {WidgetMedia, WidgetMediaSkeleton} from "./blogWidgets/widgetMedia"; +import {WidgetParagraph, WidgetParagraphSkeleton} from "./blogWidgets/widgetParagraph"; + +export interface BlogPostFields { + title?: EntryFieldTypes.Symbol + slug: EntryFieldTypes.Symbol + excerpt: EntryFieldTypes.Symbol + listImage?: EntryFieldTypes.AssetLink + author?: AuthorSkeleton + category: BlogPostCategorySkeleton + createdAt?: EntryFieldTypes.Date + body?: Array +} + +export interface BlogPostFields extends BlogPostFields{ + body: Array +} + +export interface BlogPost { + title: string + slug: string + excerpt: string + listImage: ContentImage | null + author: Author | null + category: string + createdAt: string + body: Array +} + +export type BlogPostSkeleton = EntrySkeletonType +export type BlogPostEntry = Entry< + BlogPostSkeleton, + Modifiers, + Locales +> \ No newline at end of file diff --git a/src/types/blogPostCategory.ts b/src/types/blogPostCategory.ts new file mode 100644 index 0000000..65567df --- /dev/null +++ b/src/types/blogPostCategory.ts @@ -0,0 +1,20 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful' +import {BlogPostFields} from "./blogPost"; +import {ContentImage} from "../lib/contentful/contentImage"; + +export interface BlogPostCategoryFields { + title: EntryFieldTypes.Symbol + slug: EntryFieldTypes.Symbol +} + +export interface BlogPostCategory { + title: string + slug: string +} + +export type BlogPostCategorySkeleton = EntrySkeletonType +export type BlogPostCategoryEntry = Entry< + BlogPostCategorySkeleton, + Modifiers, + Locales +> \ No newline at end of file diff --git a/src/types/blogWidgets/widgetMedia.ts b/src/types/blogWidgets/widgetMedia.ts new file mode 100644 index 0000000..172ae23 --- /dev/null +++ b/src/types/blogWidgets/widgetMedia.ts @@ -0,0 +1,20 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful' +import {BlogPostFields} from "./blogPost"; +import {ContentImage} from "../lib/contentful/contentImage"; + +export interface WidgetMediaFields { + decription?: EntryFieldTypes.Symbol + file?: EntryFieldTypes.AssetLink +} + +export interface WidgetMedia { + file: ContentImage | null + decription: string | null +} + +export type WidgetMediaSkeleton = EntrySkeletonType +export type WidgetMediaEntry = Entry< + WidgetMediaSkeleton, + Modifiers, + Locales +> \ No newline at end of file diff --git a/src/types/blogWidgets/widgetParagraph.ts b/src/types/blogWidgets/widgetParagraph.ts new file mode 100644 index 0000000..0d363cd --- /dev/null +++ b/src/types/blogWidgets/widgetParagraph.ts @@ -0,0 +1,20 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from 'contentful' +import {BlogPostFields} from "./blogPost"; +import {ContentImage} from "../lib/contentful/contentImage"; +import { Document as RichTextDocument } from '@contentful/rich-text-types' +export interface WidgetParagraphFields { + subTitle?: EntryFieldTypes.Symbol + body?: EntryFieldTypes.RichText +} + +export interface WidgetParagraph { + subTitle: string + body: RichTextDocument | null +} + +export type WidgetParagraphSkeleton = EntrySkeletonType +export type WidgetParagraphEntry = Entry< + WidgetParagraphSkeleton, + Modifiers, + Locales +> \ No newline at end of file