diff --git a/package.json b/package.json index 814c4fd66..8a9917fd8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@react-pdf/renderer": "^3.1.3", "@sentry/nextjs": "^7.80.0", "@stripe/react-stripe-js": "^1.16.1", - "@stripe/stripe-js": "^1.46.0", + "@stripe/stripe-js": "^3.1.0", "@tanstack/react-query": "^4.16.1", "@tryghost/content-api": "^1.11.4", "axios": "^1.6.8", @@ -62,6 +62,7 @@ "next-auth": "^4.24.5", "next-i18next": "^14.0.3", "nookies": "^2.5.2", + "papaparse": "^5.4.1", "quill-blot-formatter": "^1.0.5", "quill-html-edit-button": "^2.2.12", "react": "18.2.0", @@ -98,6 +99,7 @@ "@types/lodash.truncate": "^4.4.7", "@types/lru-cache": "^5.1.1", "@types/node": "14.14.37", + "@types/papaparse": "^5", "@types/react": "18.2.14", "@types/react-dom": "^18.0.0", "@types/react-gtm-module": "2.0.0", diff --git a/src/common/hooks/bank-transactions.ts b/src/common/hooks/bank-transactions.ts index 028acce03..c2c8a7cd0 100644 --- a/src/common/hooks/bank-transactions.ts +++ b/src/common/hooks/bank-transactions.ts @@ -1,4 +1,4 @@ -import { BankTransactionsHistoryResponse } from 'gql/bank-transactions' +import { BankTransactionsHistoryResponse, BankTransactionsInput } from 'gql/bank-transactions' import { useSession } from 'next-auth/react' import { useQuery } from '@tanstack/react-query' @@ -21,3 +21,11 @@ export function useBankTransactionsList( }, ) } + +export function useFindBankTransaction(id: string) { + const { data: session } = useSession() + return useQuery( + [endpoints.bankTransactions.getTransactionById(id).url], + authQueryFnFactory(session?.accessToken), + ) +} diff --git a/src/common/hooks/donation.ts b/src/common/hooks/donation.ts index 5c7d175dd..2232fbb07 100644 --- a/src/common/hooks/donation.ts +++ b/src/common/hooks/donation.ts @@ -13,6 +13,7 @@ import { DonationResponse, DonorsCountResult, PaymentAdminResponse, + StripeChargeResponse, TPaymentResponse, TotalDonatedMoneyResponse, UserDonationResult, @@ -114,3 +115,11 @@ export function getTotalDonatedMoney() { export function useDonatedUsersCount() { return useQuery([endpoints.donation.getDonorsCount.url]) } + +export function useGetStripeChargeFromPID(stripeId: string) { + const { data: session } = useSession() + return useQuery( + [endpoints.payments.referenceStripeWithInternal(stripeId).url], + { queryFn: authQueryFnFactory(session?.accessToken) }, + ) +} diff --git a/src/common/util/lazyWithPreload.ts b/src/common/util/lazyWithPreload.ts new file mode 100644 index 000000000..42a3d332a --- /dev/null +++ b/src/common/util/lazyWithPreload.ts @@ -0,0 +1,21 @@ +import React, { ComponentProps, ComponentType, LazyExoticComponent } from 'react' + +export type PreloadableComponent>> = + LazyExoticComponent & { + preload(): Promise + } + +function lazyWithPreload>>( + factory: () => Promise<{ default: T }>, +): PreloadableComponent { + const Component: Partial> = React.lazy(factory) + + Component.preload = async () => { + const LoadableComponent = await factory() + return LoadableComponent.default + } + + return Component as PreloadableComponent +} + +export default lazyWithPreload diff --git a/src/components/admin/payments/PaymentsPage.tsx b/src/components/admin/payments/PaymentsPage.tsx index 50e2c6d21..8083c4892 100644 --- a/src/components/admin/payments/PaymentsPage.tsx +++ b/src/components/admin/payments/PaymentsPage.tsx @@ -11,7 +11,7 @@ export const ModalStore = new ModalStoreImpl() export const RefundStore = new RefundStoreImpl() export const InvalidateStore = new ModalStoreImpl() -export default function DocumentsPage() { +export default function PaymentsPage() { const { t } = useTranslation() return ( diff --git a/src/components/admin/payments/create-payment/CreatePaymentDialog.tsx b/src/components/admin/payments/create-payment/CreatePaymentDialog.tsx new file mode 100644 index 000000000..738685d13 --- /dev/null +++ b/src/components/admin/payments/create-payment/CreatePaymentDialog.tsx @@ -0,0 +1,25 @@ +import React, { createContext } from 'react' +import { + Actions, + CreatePayment, + createPaymentStepReducer, +} from './helpers/createPaymentStepReducer' +import CreatePaymentStepper from './CreatePaymentStepper' +import { observer } from 'mobx-react' + +export type PaymentContext = { + payment: CreatePayment + dispatch: React.Dispatch +} +export const PaymentContext = createContext({} as PaymentContext) + +function CreatePaymentDialog() { + const [payment, dispatch] = createPaymentStepReducer() + return ( + + + + ) +} + +export default observer(CreatePaymentDialog) diff --git a/src/components/admin/payments/create-payment/CreatePaymentStepper.tsx b/src/components/admin/payments/create-payment/CreatePaymentStepper.tsx new file mode 100644 index 000000000..8f714568b --- /dev/null +++ b/src/components/admin/payments/create-payment/CreatePaymentStepper.tsx @@ -0,0 +1,113 @@ +import { Button, Card, CardContent, Dialog, Grid, IconButton } from '@mui/material' + +import { Form, Formik } from 'formik' +import React, { useContext } from 'react' +import * as yup from 'yup' +import { FileImportDialog } from './benevity/FileImportDialog' +import { benevityInputValidation, benevityValidation } from './benevity/helpers/validation' +import BenevityImportTypeSelector from './benevity/BenevityImportTypeSelector' +import CreatePaymentFromBenevityRecord from './benevity/CreatePaymentFromBenevityRecord' +import { PaymentContext } from './CreatePaymentDialog' +import PaymentTypeSelector from './PaymentTypeSelector' +import { BenevityManualImport } from './benevity/BenevityManualImport' +import { stripeInputValidation } from './stripe/helpers/validations' +import { StripeChargeLookupForm } from './stripe/StripeChargeLookupForm' +import { SelectedPaymentSource } from './helpers/createPaymentStepReducer' +import { CreatePaymentFromStripeCharge } from './stripe/CreatePaymentFromStripeCharge' +import { ModalStore } from '../PaymentsPage' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { Close } from '@mui/icons-material' +type Validation = yup.InferType< + typeof stripeInputValidation | typeof benevityValidation | typeof benevityInputValidation +> + +type Steps = { + [key in SelectedPaymentSource]: { + component: React.JSX.Element + validation?: yup.SchemaOf | null + }[] +} + +function CreatePaymentStepper() { + const paymentContext = useContext(PaymentContext) + const { isPaymentImportOpen, hideImport } = ModalStore + const { payment, dispatch } = paymentContext + + const steps: Steps = { + none: [{ component: }], + stripe: [ + { + component: , + validation: stripeInputValidation, + }, + { + component: , + }, + ], + benevity: [ + { + component: , + validation: null, + }, + { + component: + payment.benevityImportType === 'file' ? : , + validation: payment.benevityImportType === 'file' ? null : benevityInputValidation, + }, + { + component: , + validation: benevityValidation, + }, + ], + } + const onClose = () => { + dispatch({ type: 'RESET_MODAL' }) + hideImport() + } + + const handleOnBackClick = () => { + dispatch({ type: 'DECREMENT_STEP' }) + } + return ( + + + + + + + + + + + + { + if (payment.step < steps[payment.paymentSource].length - 1) { + dispatch({ type: 'INCREMENT_STEP' }) + } + }} + initialValues={{}} + validationSchema={steps[payment.paymentSource][payment.step].validation}> +
{steps[payment.paymentSource][payment.step].component}
+
+
+
+
+
+
+ ) +} + +export default CreatePaymentStepper diff --git a/src/components/admin/payments/create-payment/PaymentTypeSelector.tsx b/src/components/admin/payments/create-payment/PaymentTypeSelector.tsx new file mode 100644 index 000000000..50d84bfc2 --- /dev/null +++ b/src/components/admin/payments/create-payment/PaymentTypeSelector.tsx @@ -0,0 +1,31 @@ +import { Box, Button, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' +import React, { useContext } from 'react' +import { SelectedPaymentSource } from './helpers/createPaymentStepReducer' +import { PaymentContext } from './CreatePaymentDialog' + +export default function PaymentTypeSelector() { + const paymentContext = useContext(PaymentContext) + const { t } = useTranslation('') + const handleSubmit = (source: SelectedPaymentSource) => { + paymentContext.dispatch({ type: 'UPDATE_PAYMENT_SOURCE', payload: source }) + } + return ( + <> + + Ръчно добавяне на плащане + + + {t('')} + + + + + + + ) +} diff --git a/src/components/admin/payments/create-payment/benevity/BenevityEditableInput.tsx b/src/components/admin/payments/create-payment/benevity/BenevityEditableInput.tsx new file mode 100644 index 000000000..c5dc374a5 --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/BenevityEditableInput.tsx @@ -0,0 +1,76 @@ +import { IconButton, TextField } from '@mui/material' +import { TranslatableField, translateError } from 'common/form/validation' +import { useField } from 'formik' +import { useTranslation } from 'next-i18next' +import { useEffect, useRef, useState } from 'react' +import EditIcon from '@mui/icons-material/Edit' + +export const BenevityInput = ({ + name, + suffix, + canEdit = false, +}: { + name: string + suffix?: string + canEdit?: boolean +}) => { + const { t } = useTranslation() + const [editable, setEditable] = useState(false) + const [field, meta] = useField(name) + const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' + + const ref = useRef(null) + + useEffect(() => { + if (!editable) return + const child = ref.current + child?.focus() + }, [editable]) + + const toggleEdit = () => { + setEditable((prev) => !prev) + } + const onBlur = ( + formikBlur: typeof field.onBlur, + e: React.FocusEvent, + ) => { + formikBlur(e) + toggleEdit() + } + + return ( + onBlur(field.onBlur, e)} + InputProps={{ + disableUnderline: !editable, + inputRef: ref, + disabled: !editable, + sx: { + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: '#000000', + }, + '& .MuiInputBase-input': { + width: `${String(field.value).length + 1}ch`, + maxWidth: !editable ? `12ch` : 'auto', + }, + }, + endAdornment: ( + <> + {suffix && {suffix}} + {canEdit && ( + + + + )} + + ), + }} + /> + ) +} diff --git a/src/components/admin/payments/create-payment/benevity/BenevityImportTypeSelector.tsx b/src/components/admin/payments/create-payment/benevity/BenevityImportTypeSelector.tsx new file mode 100644 index 000000000..b0f3b1bfc --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/BenevityImportTypeSelector.tsx @@ -0,0 +1,36 @@ +import { Box, Button, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' +import { observer } from 'mobx-react' +import { useContext } from 'react' +import { PaymentContext } from '../CreatePaymentDialog' +import { BenevityImportType } from '../helpers/createPaymentStepReducer' + +function BenevityImportFirstStep() { + const { t } = useTranslation() + const paymentContext = useContext(PaymentContext) + + const handleImportTypeChange = (importType: BenevityImportType) => { + paymentContext.dispatch({ type: 'SET_BENEVITY_IMPORT_TYPE', payload: importType }) + } + + return ( + <> + + Прикачване на CSV файл + + + {t('Създаване на дарения от Benevity, чрез CSV файл')} + + + + + + + ) +} + +export default observer(BenevityImportFirstStep) diff --git a/src/components/admin/payments/create-payment/benevity/BenevityManualImport.tsx b/src/components/admin/payments/create-payment/benevity/BenevityManualImport.tsx new file mode 100644 index 000000000..adf61776e --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/BenevityManualImport.tsx @@ -0,0 +1,25 @@ +import { Box, Button, TextField, Typography } from '@mui/material' +import { TranslatableField, translateError } from 'common/form/validation' +import { useField } from 'formik' +import { useTranslation } from 'next-i18next' + +export function BenevityManualImport() { + const { t } = useTranslation('') + const [field, meta] = useField('transactionId') + const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' + return ( + <> + + {t('Въведете ID от Benevity или ID на банкова транзация')} + + + + + + + ) +} diff --git a/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityForm.tsx b/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityForm.tsx new file mode 100644 index 000000000..3d6cba0b0 --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityForm.tsx @@ -0,0 +1,97 @@ +import { Button, Grid, Typography } from '@mui/material' +import { useMutation } from '@tanstack/react-query' +import { useImportBenevityDonation } from 'service/donation' +import { benevityInitialValues } from './helpers/form/initialValues' +import { useFormikContext } from 'formik' +import { AxiosError, AxiosResponse } from 'axios' +import { TPaymentResponse } from 'gql/donations' +import { BenevityRequest } from './helpers/benevity.types' +import { useEffect } from 'react' +import { fromMoney } from 'common/util/money' +import SubmitButton from 'components/common/form/SubmitButton' +import { BenevityImportInput } from './helpers/benevity.types' +import BenevityTransactionData from './helpers/form/BenevityTransactionData' +import { BenevityDonationsTable } from './helpers/form/BenevityDonationTable' +import { BankTransactionsInput } from 'gql/bank-transactions' +import { AlertStore } from 'stores/AlertStore' +import { ApiError } from 'service/apiErrors' +import { useTranslation } from 'next-i18next' +import { ModalStore } from '../../PaymentsPage' + +type CreatePaymentFromBenevityForm = { + data: BankTransactionsInput +} +export function CreatePaymentFromBenevityForm({ data }: CreatePaymentFromBenevityForm) { + const { hideImport } = ModalStore + const { t } = useTranslation() + + const benevityMutation = useMutation< + AxiosResponse, + AxiosError, + BenevityRequest + >({ + mutationFn: useImportBenevityDonation(), + onSuccess: () => { + AlertStore.show(t('common:alerts.success'), 'success') + hideImport() + }, + onError: (error: AxiosError) => { + AlertStore.show(error.message, 'error') + }, + }) + const { values, setFieldValue } = useFormikContext() + + useEffect(() => { + if (!data) return + setFieldValue('amount', fromMoney(data.amount ?? 0)) + setFieldValue('transactionId', data.id) + setFieldValue('currency', values.benevityData?.currency) + if (!values.benevityData) { + setFieldValue('benevityData', benevityInitialValues) + } + }, []) + + useEffect(() => { + setFieldValue('exchangeRate', values.amount / values.benevityData?.netTotalPayment) + }, [values.amount, values.benevityData?.netTotalPayment]) + + const handleSubmit = () => { + const bodyReq: BenevityRequest = { + amount: values.amount, + exchangeRate: values.exchangeRate, + benevityData: values.benevityData, + extPaymentIntentId: values.transactionId, + } + benevityMutation.mutate(bodyReq) + } + return ( + + + + + + + Дарения по кампании: + + + + + + + + + + + ) +} diff --git a/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityRecord.tsx b/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityRecord.tsx new file mode 100644 index 000000000..06c4f5fe2 --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/CreatePaymentFromBenevityRecord.tsx @@ -0,0 +1,21 @@ +import { useFindBankTransaction } from 'common/hooks/bank-transactions' +import { useFormikContext } from 'formik' +import React from 'react' +import { BenevityImportInput } from './helpers/benevity.types' +import { CircularProgress } from '@mui/material' +import { CreatePaymentFromBenevityForm } from './CreatePaymentFromBenevityForm' + +export default function CreatePaymentFromBenevityRecord() { + const { values } = useFormikContext() + const { data, isLoading, isError } = useFindBankTransaction( + values.transactionId ?? values.benevityData?.disbursementId, + ) + if (isLoading) return + if (isError) return + if (!isLoading && !data) + return `Bank donation with ${ + values.transactionId ?? values.benevityData.disbursementId + } not found` + + return +} diff --git a/src/components/admin/payments/create-payment/benevity/FileImportDialog.tsx b/src/components/admin/payments/create-payment/benevity/FileImportDialog.tsx new file mode 100644 index 000000000..f98bd873b --- /dev/null +++ b/src/components/admin/payments/create-payment/benevity/FileImportDialog.tsx @@ -0,0 +1,91 @@ +import { BenevityCSVParser } from './helpers/benevityCsvParser' +import { asyncFileReader } from './helpers/readFile' +import { useField } from 'formik' +import { useRef, useState } from 'react' + +export function FileImportDialog() { + const [, setIsDragging] = useState(false) + const inputFile = useRef(null) + const submitButtonRef = useRef(null) + const [, , { setValue }] = useField('benevityData') + + const onDragOver = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(true) + event.dataTransfer.dropEffect = 'copy' + } + const onDragLeave = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(false) + } + const onDrop = async (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(false) + const file = event.dataTransfer.files[0] + const fileAsText = await asyncFileReader(file) + const csvToJSON = BenevityCSVParser(fileAsText as string) + setValue(csvToJSON) + submitButtonRef.current?.click() + } + const onClick = () => { + inputFile.current?.click() + } + + const onChange = async (event: React.ChangeEvent) => { + event.preventDefault() + setIsDragging(false) + + const filelist = event.target.files + if (!filelist) return + const file = filelist[0] + if (file.type !== 'text/csv') + throw new Error('Unsupported file format. Only csv files are allowed') + const fileAsText = await asyncFileReader(file) + const csvToJSON = BenevityCSVParser(fileAsText as string) + setValue(csvToJSON) + submitButtonRef.current?.click() + } + + return ( + <> +
+
+ Провлачете файла в квадрата +
+
+ + + + + ) +} diff --git a/src/components/admin/payments/create-payment/stripe/StripeCreatePaymentDialog.tsx b/src/components/admin/payments/create-payment/stripe/StripeCreatePaymentDialog.tsx new file mode 100644 index 000000000..42a454af2 --- /dev/null +++ b/src/components/admin/payments/create-payment/stripe/StripeCreatePaymentDialog.tsx @@ -0,0 +1,117 @@ +import { + Button, + Grid, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material' +import { useMutation } from '@tanstack/react-query' +import { AxiosError, AxiosResponse } from 'axios' +import { money } from 'common/util/money' +import { stripeFeeCalculator } from 'components/client/one-time-donation/helpers/stripe-fee-calculator' + +import { StripeChargeResponse, TPaymentResponse } from 'gql/donations' +import React from 'react' +import { useTranslation } from 'next-i18next' +import { useCreatePaymentFromStripeMutation } from 'service/donation' +import Stripe from 'stripe' +import { AlertStore } from 'stores/AlertStore' +import { ApiError } from 'service/apiErrors' +import { ModalStore } from '../../PaymentsPage' + +type CreateUpdatePaymentFromStripeChargeProps = { + data: StripeChargeResponse + id: string +} + +export function CreatePaymentFromStripeChargeTable({ + data, + id, +}: CreateUpdatePaymentFromStripeChargeProps) { + const { t } = useTranslation() + const { hideImport } = ModalStore + const stripeMutation = useMutation< + AxiosResponse, + AxiosError, + Stripe.Charge + >({ + mutationFn: useCreatePaymentFromStripeMutation(), + onSuccess: () => { + AlertStore.show(t('common:alerts.success'), 'success') + hideImport() + }, + onError: (error: AxiosError) => { + AlertStore.show(error.message, 'error') + }, + }) + + const handleSubmit = () => { + stripeMutation.mutate(data.stripe) + } + + const stripeStatus = data.stripe.refunded ? 'refund' : data.stripe.status + + return ( + + + + Намерено плащане + + + + + Плащане с номер{' '} + + {id} + {' '} + беше намерено. Желате ли да синхронизирате вътрешните данни със Страйп? + + + + + + + База данни на Страйп + Вътрена база данни + + + Статус + {t('profile:donations.status.' + stripeStatus)} + + {data.internal?.status + ? t('profile:donations.status.' + data.internal?.status) + : 'N/A'} + + + + Сума(бруто) + {money(data.stripe.amount)} + {money(data.internal?.chargedAmount ?? 0)} + + + Сума(нето) + + {money(data.stripe.amount - stripeFeeCalculator(data.stripe.amount, data.region))} + + {money(data.internal?.amount ?? 0)} + + + Дарител(име) + {data.stripe.billing_details.name} + {data.internal?.billingName ?? 'N/A'} + + + Дарител(емайл) + {data.stripe.billing_details.email} + {data.internal?.billingEmail ?? 'N/A'} + + + + + + ) +} diff --git a/src/components/admin/payments/create-payment/stripe/helpers/validations.ts b/src/components/admin/payments/create-payment/stripe/helpers/validations.ts new file mode 100644 index 000000000..e4fe60fd3 --- /dev/null +++ b/src/components/admin/payments/create-payment/stripe/helpers/validations.ts @@ -0,0 +1,4 @@ +import * as yup from 'yup' +export const stripeInputValidation = yup.object({ + extPaymentIntentId: yup.string().required().matches(/^ch_/, 'Невалиден номер на Страйп'), +}) diff --git a/src/components/admin/payments/grid/Grid.tsx b/src/components/admin/payments/grid/Grid.tsx index f7ba32b0b..790881774 100644 --- a/src/components/admin/payments/grid/Grid.tsx +++ b/src/components/admin/payments/grid/Grid.tsx @@ -30,6 +30,7 @@ import { PaymentStatus, PaymentProvider } from '../../../../gql/donations.enums' import { useSession } from 'next-auth/react' import { PaymentAdminResponse } from 'gql/donations' import Link from 'next/link' +import CreatePaymentDialog from '../create-payment/CreatePaymentDialog' interface RenderCellProps { params: GridRenderCellParams @@ -51,7 +52,7 @@ export default observer(function Grid() { const [focusedRowId, setFocusedRowId] = useState(null as string | null) const { t } = useTranslation() const router = useRouter() - const { isDetailsOpen } = ModalStore + const { isDetailsOpen, isPaymentImportOpen } = ModalStore const { isRefundOpen } = RefundStore const { isDeleteOpen, @@ -274,6 +275,7 @@ export default observer(function Grid() { {isDetailsOpen && } {isRefundOpen && } {isDeleteOpen && } + {isPaymentImportOpen && } ) }) diff --git a/src/components/admin/payments/grid/GridAppbar.tsx b/src/components/admin/payments/grid/GridAppbar.tsx index 7388944ec..6b1d80a93 100644 --- a/src/components/admin/payments/grid/GridAppbar.tsx +++ b/src/components/admin/payments/grid/GridAppbar.tsx @@ -2,8 +2,8 @@ import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' import { Box, - Button, FormControl, + IconButton, InputLabel, MenuItem, Select, @@ -26,6 +26,9 @@ import theme from 'common/theme' import debounce from 'lodash/debounce' import { useCampaignList } from 'common/hooks/campaigns' +import AddIcon from '@mui/icons-material/Add' +import { ModalStore } from '../PaymentsPage' +import { observer } from 'mobx-react' const addIconStyles = { background: theme.palette.primary.light, @@ -35,7 +38,7 @@ const addIconStyles = { boxShadow: 3, } -export default function GridAppbar() { +function GridAppbar() { const router = useRouter() const { donationStore } = useStores() const { t } = useTranslation() @@ -50,7 +53,7 @@ export default function GridAppbar() { const [searchValue, setSearchValue] = useState('') const [selectedCampaign, setSelectedCampaign] = useState('') const { data: campaigns } = useCampaignList() - + const { showImport } = ModalStore const debounceSearch = useMemo( () => debounce( @@ -130,9 +133,23 @@ export default function GridAppbar() { - + + showImport()}> + + + + + + + exportToExcel.mutate()} + /> + + {/* button is disabled because we have a bug(conflicting bank donations) https://github.com/podkrepi-bg/frontend/issues/1649 */} - - - exportToExcel.mutate()} - /> - + ) } + +export default observer(GridAppbar) diff --git a/src/components/common/Carousel.tsx b/src/components/common/Carousel.tsx new file mode 100644 index 000000000..0f79733b1 --- /dev/null +++ b/src/components/common/Carousel.tsx @@ -0,0 +1,91 @@ +import 'slick-carousel/slick/slick-theme.css' +import 'slick-carousel/slick/slick.css' +import { useEffect, useRef } from 'react' +import Slider from 'react-slick' +import { styled } from '@mui/material/styles' + +import type { InnerSlider, Settings } from 'react-slick' + +type Props = Settings & { + children: React.JSX.Element[] | React.JSX.Element | undefined +} + +type TInnerSlider = InnerSlider & { + props: Settings +} + +interface InternalSlider extends Slider { + innerSlider: TInnerSlider +} + +const SliderStyled = styled(Slider)(({ theme }) => ({ + '.slick-list': { + paddingBottom: theme.spacing(3), + transition: 'visibility 2000ms', + }, + + '.slick-track': { + transition: 'visibility 3000ms', + }, + + '.slick-dots li button::before': { + fontSize: theme.typography.pxToRem(10), + color: '#D9D9D9', + opacity: 1, + }, + + '.slick-dots li.slick-active button::before': { + fontSize: theme.typography.pxToRem(10), + color: '#B0E5FF', + opacity: 1, + }, + + '.slick-slide[aria-hidden=false]': { + visibility: 'visible', + }, + + '.slick-slide[aria-hidden=true]': { + visibility: 'hidden', + animation: 'fadeIn 400ms', + }, + + '@keyframes fadeIn': { + from: { visibility: 'visible' }, + to: { visibility: 'hidden' }, + }, +})) + +export default function Carousel({ children, ...props }: Props) { + const sliderRef = useRef(null) + const timerRef = useRef(null) + + // Clear setTimeout instance if created + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + const afterChange = (currentSlide: number) => { + const totalChilds = (sliderRef.current?.props?.children as []).length + const isLastSlide = + totalChilds - (sliderRef.current?.innerSlider.props.slidesToShow as number) === currentSlide + if ( + sliderRef.current?.props.infinite === false && + sliderRef.current?.props.autoplay === true && + isLastSlide + ) { + timerRef.current = setTimeout(() => { + sliderRef.current?.slickGoTo(0) + }, sliderRef.current?.innerSlider?.props.autoplaySpeed) + } + } + + return ( + + {children} + + ) +} diff --git a/src/components/common/withLazyloadPreload.tsx b/src/components/common/withLazyloadPreload.tsx new file mode 100644 index 000000000..af80955b4 --- /dev/null +++ b/src/components/common/withLazyloadPreload.tsx @@ -0,0 +1,23 @@ +import lazyWithPreload from 'common/util/lazyWithPreload' +import { Suspense, useState } from 'react' + +const RenderNotificationModal = lazyWithPreload( + () => import('components/client/notifications/GeneralSubscribeModal'), +) +export default function withLazyloadSubscribtionModal( + WrappedComponent: React.ComponentType, +) { + const [open, setOpen] = useState(false) + const onClick = () => { + setOpen((prev) => !prev) + } + const onMouseOver = () => { + RenderNotificationModal.preload() + } + return (props: T) => ( + <> + + {open && } + + ) +} diff --git a/src/gql/donations.d.ts b/src/gql/donations.d.ts index 4bf58e80e..b2f1ea658 100644 --- a/src/gql/donations.d.ts +++ b/src/gql/donations.d.ts @@ -40,6 +40,7 @@ export type TPaymentResponse = { provider: PaymentProvider extCustomerId: string amount: number + chargedAmount: number currency: Currency extPaymentIntentId: string extPaymentMethodId: string @@ -248,3 +249,9 @@ export type PaymentAdminResponse = { items: PaymentAdminResponse[] total: number } + +export type StripeChargeResponse = { + stripe: Stripe.Charge + internal?: TPaymentResponse + region: CardRegion +} diff --git a/src/pages/admin/payments/index.tsx b/src/pages/admin/payments/index.tsx index 9ac7e137d..46ff81af4 100644 --- a/src/pages/admin/payments/index.tsx +++ b/src/pages/admin/payments/index.tsx @@ -3,7 +3,7 @@ import { securedAdminProps } from 'middleware/auth/securedProps' import { endpoints } from 'service/apiEndpoints' export const getServerSideProps = securedAdminProps( - ['common', 'auth', 'admin', 'donations', 'validation'], + ['common', 'auth', 'admin', 'donations', 'validation', 'profile'], () => endpoints.payments.list(undefined, undefined, { pageIndex: 0, pageSize: 20 }).url, ) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 224f6550f..7b7fcf4b4 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -30,6 +30,7 @@ export const getServerSideProps: GetServerSideProps<{ 'campaigns', 'validation', 'auth', + 'profile', ])), session, dehydratedState: dehydrate(client), diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index 629235b41..18ad94645 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -145,6 +145,14 @@ export const endpoints = { getPayment: (id: string) => { return { url: `/donation/payments/${id}`, method: 'GET' } }, + referenceStripeWithInternal: (id: string) => { + return { url: `/donation/stripe/${id}`, method: 'GET' } + }, + synchronizeWithStripe: { + url: `/donation/create-update-stripe-payment`, + method: 'PUT', + }, + createFromBeneivty: { url: `/donation/import/benevity`, method: 'POST' }, }, donation: { prices: { url: '/donation/prices', method: 'GET' }, @@ -242,6 +250,7 @@ export const endpoints = { editPaymentRef: (id: string) => { url: `/bank-transaction/${id}/edit-ref`, method: 'PUT' }, rerunDates: { url: '/bank-transaction/rerun-dates', method: 'POST' }, + getTransactionById: (id: string) => { url: `/bank-transaction/${id}`, method: 'GET' }, }, documents: { documentsList: { url: '/document', method: 'GET' }, diff --git a/src/service/donation.ts b/src/service/donation.ts index f1dba2b4f..1d7d289f4 100644 --- a/src/service/donation.ts +++ b/src/service/donation.ts @@ -17,6 +17,7 @@ import { authConfig } from 'service/restRequests' import { UploadBankTransactionsFiles } from 'components/admin/bank-transactions-file/types' import { useMutation } from '@tanstack/react-query' import { FilterData } from 'gql/types' +import { BenevityRequest } from 'components/admin/payments/create-payment/benevity/helpers/benevity.types' export const createCheckoutSession = async (data: CheckoutSessionInput) => { return await apiClient.post>( @@ -125,3 +126,25 @@ export const useExportToExcel = (filterData?: FilterData, searchData?: string) = }) } } + +export const useImportBenevityDonation = () => { + const { data: session } = useSession() + return async (data: BenevityRequest) => { + return await apiClient.post( + endpoints.payments.createFromBeneivty.url, + data, + authConfig(session?.accessToken), + ) + } +} + +export function useCreatePaymentFromStripeMutation() { + const { data: session } = useSession() + return async (data: Stripe.Charge) => { + return await apiClient.put( + endpoints.payments.synchronizeWithStripe.url, + data, + authConfig(session?.accessToken), + ) + } +} diff --git a/src/stores/dashboard/ModalStore.ts b/src/stores/dashboard/ModalStore.ts index 898d46ee8..e3ae35e40 100644 --- a/src/stores/dashboard/ModalStore.ts +++ b/src/stores/dashboard/ModalStore.ts @@ -10,6 +10,7 @@ type Record = { export class ModalStoreImpl { isDetailsOpen = false isDeleteOpen = false + isPaymentImportOpen = false selectedRecord: Record = { id: '', name: '', @@ -20,8 +21,10 @@ export class ModalStoreImpl { isDetailsOpen: observable, isDeleteOpen: observable, selectedRecord: observable, + isPaymentImportOpen: observable, setSelectedRecord: action, showDetails: action, + showImport: action, hideDetails: action, showDelete: action, hideDelete: action, @@ -44,6 +47,13 @@ export class ModalStoreImpl { this.isDeleteOpen = false } + showImport = () => { + this.isPaymentImportOpen = true + } + + hideImport = () => { + this.isPaymentImportOpen = false + } setSelectedRecord = (record: Record) => { this.selectedRecord = record } diff --git a/yarn.lock b/yarn.lock index 24dba84f4..f8f782a67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3799,10 +3799,10 @@ __metadata: languageName: node linkType: hard -"@stripe/stripe-js@npm:^1.46.0": - version: 1.54.1 - resolution: "@stripe/stripe-js@npm:1.54.1" - checksum: eb54054edea3d3f9a8c18e8442cc05b46f7afbe5bd85863121737f268875d30aab6de16ee84d467853ad9d983bf69f922f45daeee6f13134ca138f7e862c9eb4 +"@stripe/stripe-js@npm:^3.1.0": + version: 3.1.0 + resolution: "@stripe/stripe-js@npm:3.1.0" + checksum: a32e5d3d6cd7bd7130e0d2f15312bdae83f568125407e461c033d726c44503bd00aabe8d6d593a6329d4a10b8a325440ce947cec21b685f260ee3f01f4597ec8 languageName: node linkType: hard @@ -4173,6 +4173,15 @@ __metadata: languageName: node linkType: hard +"@types/papaparse@npm:^5": + version: 5.3.14 + resolution: "@types/papaparse@npm:5.3.14" + dependencies: + "@types/node": "*" + checksum: fbf942ed92179eeb824d4e544cc701468157a4ce3f6f668f8b17692d9886fea92ccff5e56965615ff64f049efa01ff95ddb7d30c67e0186bc802a6cc8ef26e63 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -11707,6 +11716,13 @@ __metadata: languageName: node linkType: hard +"papaparse@npm:^5.4.1": + version: 5.4.1 + resolution: "papaparse@npm:5.4.1" + checksum: fc9e52f7158dca3517c229e3309065b1ab5da6c7194572fba4f31ff138bc43e3c91182cc40365cc828f97fe10d0aca416068fd731661058bea0f69ddb84a411a + languageName: node + linkType: hard + "parchment@npm:^1.1.2, parchment@npm:^1.1.4": version: 1.1.4 resolution: "parchment@npm:1.1.4" @@ -11909,7 +11925,7 @@ __metadata: "@react-pdf/renderer": ^3.1.3 "@sentry/nextjs": ^7.80.0 "@stripe/react-stripe-js": ^1.16.1 - "@stripe/stripe-js": ^1.46.0 + "@stripe/stripe-js": ^3.1.0 "@tanstack/react-query": ^4.16.1 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^13.4.0 @@ -11919,6 +11935,7 @@ __metadata: "@types/lodash.truncate": ^4.4.7 "@types/lru-cache": ^5.1.1 "@types/node": 14.14.37 + "@types/papaparse": ^5 "@types/react": 18.2.14 "@types/react-dom": ^18.0.0 "@types/react-gtm-module": 2.0.0 @@ -11960,6 +11977,7 @@ __metadata: next-i18next: ^14.0.3 next-sitemap: ^3.1.52 nookies: ^2.5.2 + papaparse: ^5.4.1 prettier: 2.3.0 quill-blot-formatter: ^1.0.5 quill-html-edit-button: ^2.2.12