feat: sharable payment link dialog

This commit is contained in:
Ahmed Bouhuolia
2024-09-15 19:28:43 +02:00
parent 9517b4e279
commit 542e61dbfc
17 changed files with 476 additions and 19 deletions

View File

@@ -59,7 +59,7 @@ function AppInsider({ history }) {
children={<EmailConfirmation />}
/>
<Route path={'/auth'} children={<AuthenticationPage />} />
<Route path={'/payment'} children={<PaymentPortalPage />} />
<Route path={'/payment/:linkId'} children={<PaymentPortalPage />} />
<Route path={'/'} children={<DashboardPrivatePages />} />
</Switch>
</Router>

View File

@@ -5,6 +5,7 @@ import withDialogActions from '@/containers/Dialog/withDialogActions';
import { compose } from '@/utils';
import '@/style/components/Dialog/Dialog.scss';
import { DialogProvider } from './DialogProvider';
function DialogComponent(props) {
const { name, children, closeDialog, onClose } = props;
@@ -15,7 +16,7 @@ function DialogComponent(props) {
};
return (
<Dialog {...props} onClose={handleClose}>
{children}
<DialogProvider value={props}>{children}</DialogProvider>
</Dialog>
);
}

View File

@@ -0,0 +1,20 @@
import React, { createContext, useContext, ReactNode } from 'react';
const DialogContext = createContext<any>(null);
export const useDialogContext = () => {
return useContext(DialogContext);
};
interface DialogProviderProps {
value: any;
children: ReactNode;
}
export const DialogProvider: React.FC<DialogProviderProps> = ({ value, children }) => {
return (
<DialogContext.Provider value={value}>
{children}
</DialogContext.Provider>
);
};

View File

@@ -52,6 +52,7 @@ import PaymentMailDialog from '@/containers/Sales/PaymentsReceived/PaymentMailDi
import { ExportDialog } from '@/containers/Dialogs/ExportDialog';
import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog';
import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransactions/dialogs/DisconnectBankAccountDialog/DisconnectBankAccountDialog';
import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog';
/**
* Dialogs container.
@@ -151,6 +152,7 @@ export default function DialogsContainer() {
<DisconnectBankAccountDialog
dialogName={DialogsName.DisconnectBankAccountConfirmation}
/>
<SharePaymentLinkDialog dialogName={DialogsName.SharePaymentLink} />
</div>
);
}

View File

@@ -77,4 +77,5 @@ export enum DialogsName {
Export = 'Export',
BankRuleForm = 'BankRuleForm',
DisconnectBankAccountConfirmation = 'DisconnectBankAccountConfirmation',
SharePaymentLink = 'SharePaymentLink'
}

View File

@@ -103,6 +103,13 @@ function InvoiceDetailActionsBar({
openDialog(DialogsName.InvoiceMail, { invoiceId });
};
const handleShareButtonClick = () => {
openDialog(DialogsName.SharePaymentLink, {
transactionId: invoiceId,
transactionType: 'SaleInvoice',
});
};
return (
<DrawerActionsBar>
<NavbarGroup>
@@ -150,6 +157,11 @@ function InvoiceDetailActionsBar({
onClick={handleDeleteInvoice}
/>
</Can>
<Button
className={Classes.MINIMAL}
text={'Share'}
onClick={handleShareButtonClick}
/>
<Can I={SaleInvoiceAction.Writeoff} a={AbilitySubject.Invoice}>
<NavbarDivider />
<BadDebtMenuItem

View File

@@ -0,0 +1,16 @@
import { DialogBody } from '@blueprintjs/core';
import { SharePaymentLinkForm } from './SharePaymentLinkForm';
import { SharePaymentLinkFormContent } from './SharePaymentLinkFormContent';
import { SharePaymentLinkProvider } from './SharePaymentLinkProvider';
export function SharePaymentLinkContent() {
return (
<DialogBody>
<SharePaymentLinkProvider>
<SharePaymentLinkForm>
<SharePaymentLinkFormContent />
</SharePaymentLinkForm>
</SharePaymentLinkProvider>
</DialogBody>
);
}

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const SharePaymentLinkContent = React.lazy(() =>
import('./SharePaymentLinkContent').then((module) => ({
default: module.SharePaymentLinkContent,
})),
);
/**
*
*/
function SharePaymentLinkDialogRoot({ dialogName, payload, isOpen }) {
return (
<Dialog
name={dialogName}
isOpen={isOpen}
payload={payload}
title={'Share Link'}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 400 }}
>
<DialogSuspense>
<SharePaymentLinkContent />
</DialogSuspense>
</Dialog>
);
}
export const SharePaymentLinkDialog = compose(withDialogRedux())(
SharePaymentLinkDialogRoot,
);
SharePaymentLinkDialog.displayName = 'SharePaymentLinkDialog';

View File

@@ -0,0 +1,15 @@
import * as Yup from 'yup';
export const SharePaymentLinkFormSchema = Yup.object().shape({
publicity: Yup.string()
.oneOf(['private', 'public'], 'Invalid publicity type')
.required('Publicity is required'),
expiryDate: Yup.date()
.nullable()
.required('Expiration date is required')
.min(new Date(), 'Expiration date must be in the future'),
transactionId: Yup.string()
.required('Transaction ID is required'),
transactionType: Yup.string()
.required('Transaction type is required'),
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Intent } from '@blueprintjs/core';
import { Formik, Form, FormikHelpers } from 'formik';
import { useCreatePaymentLink } from '@/hooks/query/payment-link';
import { AppToaster } from '@/components';
import { SharePaymentLinkFormSchema } from './SharePaymentLinkForm.schema';
import { useDialogContext } from '@/components/Dialog/DialogProvider';
import { useDialogActions } from '@/hooks/state';
import { useSharePaymentLink } from './SharePaymentLinkProvider';
interface SharePaymentLinkFormProps {
children: React.ReactNode;
}
interface SharePaymentLinkFormValues {
publicity: string;
expiryDate: string;
transactionId: string;
transactionType: string;
}
const initialValues = {
publicity: '',
expiryDate: '',
transactionId: '',
transactionType: '',
};
export const SharePaymentLinkForm = ({
children,
}: SharePaymentLinkFormProps) => {
const { mutateAsync: generateShareLink } = useCreatePaymentLink();
const { payload } = useDialogContext();
const { setUrl } = useSharePaymentLink();
const transactionId = payload?.transactionId;
const transactionType = payload?.transactionType;
const formInitialValues = {
...initialValues,
transactionType,
transactionId,
};
const handleFormSubmit = (
values: SharePaymentLinkFormValues,
{ setSubmitting }: FormikHelpers<SharePaymentLinkFormValues>,
) => {
setSubmitting(true);
generateShareLink(values)
.then((res) => {
setSubmitting(false);
setUrl(res.link?.link);
})
.catch(() => {
setSubmitting(false);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<Formik<SharePaymentLinkFormValues>
initialValues={formInitialValues}
validationSchema={SharePaymentLinkFormSchema}
onSubmit={handleFormSubmit}
>
<Form>{children}</Form>
</Formik>
);
};
SharePaymentLinkForm.displayName = 'SharePaymentLinkForm';

View File

@@ -0,0 +1,86 @@
// @ts-nocheck
import {
Button,
FormGroup,
InputGroup,
Intent,
Position,
} from '@blueprintjs/core';
import {
DialogFooter,
FDateInput,
FFormGroup,
FSelect,
Icon,
Stack,
} from '@/components';
import { useSharePaymentLink } from './SharePaymentLinkProvider';
import { useClipboard } from '@/hooks/utils/useClipboard';
export function SharePaymentLinkFormContent() {
const { url } = useSharePaymentLink();
const clipboard = useClipboard();
const handleCopyBtnClick = () => {
clipboard.copy(url);
};
return (
<>
<Stack>
<FSelect
name={'publicity'}
items={[
{ value: 'private', text: 'Private' },
{ value: 'public', text: 'Public' },
]}
fastField
/>
<FFormGroup name={'expiryDate'} label={'Expiration Date'} fastField>
<FDateInput
name={'expiryDate'}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{
fill: true,
leftElement: <Icon icon={'date-range'} />,
}}
fastField
/>
</FFormGroup>
{url && (
<FormGroup name={'link'} label={'Payment Link'}>
<InputGroup
name={'link'}
value={url}
disabled
leftElement={
<Button onClick={handleCopyBtnClick} minimal>
Copy
</Button>
}
/>
</FormGroup>
)}
</Stack>
<DialogFooter>
{url ? (
<Button intent={Intent.PRIMARY} onClick={handleCopyBtnClick}>
Copy Link
</Button>
) : (
<>
<Button type={'submit'} intent={Intent.PRIMARY}>
Generate
</Button>
<Button>Cancel</Button>
</>
)}
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,35 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface SharePaymentLinkContextType {
url: string;
setUrl: React.Dispatch<React.SetStateAction<string>>;
}
const SharePaymentLinkContext =
createContext<SharePaymentLinkContextType | null>(null);
interface SharePaymentLinkProviderProps {
children: ReactNode;
}
export const SharePaymentLinkProvider: React.FC<
SharePaymentLinkProviderProps
> = ({ children }) => {
const [url, setUrl] = useState<string>('');
return (
<SharePaymentLinkContext.Provider value={{ url, setUrl }}>
{children}
</SharePaymentLinkContext.Provider>
);
};
export const useSharePaymentLink = () => {
const context = useContext(SharePaymentLinkContext);
if (!context) {
throw new Error(
'useSharePaymentLink must be used within a SharePaymentLinkProvider',
);
}
return context;
};

View File

@@ -2,27 +2,31 @@ import { Text, Classes, Button, Intent } from '@blueprintjs/core';
import clsx from 'classnames';
import { Box, Group, Stack } from '@/components';
import styles from './PaymentPortal.module.scss';
import { usePaymentPortalBoot } from './PaymentPortalBoot';
export function PaymentPortal() {
const { sharableLinkMeta } = usePaymentPortalBoot();
return (
<Box className={styles.root}>
<Stack className={styles.body}>
<Group spacing={8}>
<Box className={styles.companyLogoWrap}></Box>
<Text>Bigcapital Technology, Inc.</Text>
<Text>{sharableLinkMeta?.companyName}</Text>
</Group>
<Stack spacing={6}>
<h1 className={styles.bigTitle}>
Bigcapital Technology, Inc. Sent an Invoice for $1000.00
{sharableLinkMeta?.companyName} Sent an Invoice for{' '}
{sharableLinkMeta?.totalFormatted}
</h1>
<Text className={clsx(Classes.TEXT_MUTED, styles.invoiceDueDate)}>
Invoice due September 13, 2024
Invoice due {sharableLinkMeta?.dueDateFormatted}
</Text>
</Stack>
<Stack spacing={2}>
<Box className={styles.customerName}>Ahmed Bouhuolia</Box>
<Box className={styles.customerName}>{sharableLinkMeta?.customerName}</Box>
<Box>Bigcapital Technology, Inc.</Box>
<Box>131 Continental Dr Suite 305 Newark,</Box>
<Box>Delaware 19713</Box>
@@ -30,7 +34,9 @@ export function PaymentPortal() {
<Box>ahmed@bigcapital.app</Box>
</Stack>
<h2 className={styles.invoiceNumber}>Invoice INV-000001</h2>
<h2 className={styles.invoiceNumber}>
Invoice {sharableLinkMeta?.invoiceNo}
</h2>
<Stack spacing={0} className={styles.totals}>
<Group
@@ -38,12 +44,14 @@ export function PaymentPortal() {
className={clsx(styles.totalItem, styles.borderBottomGray)}
>
<Text>Sub Total</Text>
<Text>11.00</Text>
<Text>{sharableLinkMeta?.subtotalFormatted}</Text>
</Group>
<Group position={'apart'} className={styles.totalItem}>
<Text>Total</Text>
<Text style={{ fontWeight: 600 }}>11.00</Text>
<Text style={{ fontWeight: 600 }}>
{sharableLinkMeta?.totalFormatted}
</Text>
</Group>
<Group
@@ -51,7 +59,7 @@ export function PaymentPortal() {
className={clsx(styles.totalItem, styles.borderBottomDark)}
>
<Text>Paid Amount (-)</Text>
<Text>11.00</Text>
<Text>{sharableLinkMeta?.paymentAmountFormatted}</Text>
</Group>
<Group
@@ -59,7 +67,9 @@ export function PaymentPortal() {
className={clsx(styles.totalItem, styles.borderBottomDark)}
>
<Text>Due Amount</Text>
<Text style={{ fontWeight: 600 }}>11.00</Text>
<Text style={{ fontWeight: 600 }}>
{sharableLinkMeta?.dueAmountFormatted}
</Text>
</Group>
</Stack>
@@ -79,7 +89,7 @@ export function PaymentPortal() {
intent={Intent.PRIMARY}
className={clsx(styles.footerButton, styles.buyButton)}
>
Pay $10,000.00
Pay {sharableLinkMeta?.totalFormatted}
</Button>
</Stack>

View File

@@ -1,22 +1,37 @@
import React, { createContext, useContext, ReactNode } from 'react';
import {
GetSharableLinkMetaResponse,
useGetSharableLinkMeta,
} from '@/hooks/query/payment-link';
interface PaymentPortalContextType {
// Define the context type here
paymentAmount: number;
setPaymentAmount: (amount: number) => void;
sharableLinkMeta: GetSharableLinkMetaResponse | undefined;
isSharableLinkMetaLoading: boolean;
}
const PaymentPortalContext = createContext<PaymentPortalContextType>(
{} as PaymentPortalContextType,
);
export const PaymentPortalBoot: React.FC<{ children: ReactNode }> = ({
interface PaymentPortalBootProps {
linkId: string;
children: ReactNode;
}
export const PaymentPortalBoot: React.FC<PaymentPortalBootProps> = ({
linkId,
children,
}) => {
const [paymentAmount, setPaymentAmount] = React.useState<number>(0);
const { data: sharableLinkMeta, isLoading: isSharableLinkMetaLoading } =
useGetSharableLinkMeta(linkId);
const value = {
sharableLinkMeta,
isSharableLinkMetaLoading,
};
return (
<PaymentPortalContext.Provider value={{ paymentAmount, setPaymentAmount }}>
<PaymentPortalContext.Provider value={value}>
{children}
</PaymentPortalContext.Provider>
);

View File

@@ -1,5 +1,13 @@
import { useParams } from 'react-router-dom';
import { PaymentPortal } from './PaymentPortal';
import { PaymentPortalBoot } from './PaymentPortalBoot';
export default function PaymentPortalPage() {
return <PaymentPortal />;
const { linkId } = useParams<{ linkId: string}>();
return (
<PaymentPortalBoot linkId={linkId}>
<PaymentPortal />
</PaymentPortalBoot>
);
}

View File

@@ -0,0 +1,94 @@
// @ts-nocheck
import {
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
useMutation,
useQuery,
} from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase, transfromToSnakeCase } from '@/utils';
interface CreatePaymentLinkValues {
publicity: string;
transactionType: string;
transactionId: number | string;
expiryDate: string;
}
interface CreatePaymentLinkResponse {
link: string;
}
/**
* Creates a new payment link.
* @param {UseMutationOptions<CreatePaymentLinkResponse, Error, CreatePaymentLinkValues>} options
* @returns {UseMutationResult<CreatePaymentLinkResponse, Error, CreatePaymentLinkValues>}
*/
export function useCreatePaymentLink(
options?: UseMutationOptions<
CreatePaymentLinkResponse,
Error,
CreatePaymentLinkValues
>,
): UseMutationResult<
CreatePaymentLinkResponse,
Error,
CreatePaymentLinkValues
> {
const apiRequest = useApiRequest();
return useMutation<CreatePaymentLinkResponse, Error, CreatePaymentLinkValues>(
(values) =>
apiRequest
.post('/payment-links/generate', transfromToSnakeCase(values))
.then((res) => res.data),
{
...options,
},
);
}
export interface GetSharableLinkMetaResponse {
dueAmount: number;
dueAmountFormatted: string;
dueDate: string;
dueDateFormatted: string;
invoiceDateFormatted: string;
invoiceNo: string;
paymentAmount: number;
paymentAmountFormatted: string;
subtotal: number;
subtotalFormatted: string;
subtotalLocalFormatted: string;
total: number;
totalFormatted: string;
totalLocalFormatted: string;
customerName: string;
companyName: string;
}
/**
* Fetches the sharable link metadata for a given link ID.
* @param {string} linkId - The ID of the link to fetch metadata for.
* @param {UseQueryOptions<GetSharableLinkMetaResponse, Error>} options - Optional query options.
* @returns {UseQueryResult<GetSharableLinkMetaResponse, Error>} The query result.
*/
export function useGetSharableLinkMeta(
linkId: string,
options?: UseQueryOptions<GetSharableLinkMetaResponse, Error>,
): UseQueryResult<GetSharableLinkMetaResponse, Error> {
const apiRequest = useApiRequest();
return useQuery<GetSharableLinkMetaResponse, Error>(
['sharable-link-meta', linkId],
() =>
apiRequest
.get(`/sharable-links/meta/invoice/${linkId}`)
.then((res) => transformToCamelCase(res.data?.data)),
{
...options,
},
);
}

View File

@@ -0,0 +1,32 @@
import { useState } from 'react';
export function useClipboard({ timeout = 2000 } = {}) {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), timeout));
setCopied(value);
};
const copy = (valueToCopy: any) => {
if ('clipboard' in navigator) {
navigator.clipboard
.writeText(valueToCopy)
.then(() => handleCopyResult(true))
.catch((err) => setError(err));
} else {
setError(new Error('useClipboard: navigator.clipboard is not supported'));
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}