diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 2aa9e0f3b..5c62605f9 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -59,7 +59,7 @@ function AppInsider({ history }) { children={} /> } /> - } /> + } /> } /> diff --git a/packages/webapp/src/components/Dialog/Dialog.tsx b/packages/webapp/src/components/Dialog/Dialog.tsx index 6e32a99af..7446db4c3 100644 --- a/packages/webapp/src/components/Dialog/Dialog.tsx +++ b/packages/webapp/src/components/Dialog/Dialog.tsx @@ -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 ( - {children} + {children} ); } diff --git a/packages/webapp/src/components/Dialog/DialogProvider.tsx b/packages/webapp/src/components/Dialog/DialogProvider.tsx new file mode 100644 index 000000000..c8348a930 --- /dev/null +++ b/packages/webapp/src/components/Dialog/DialogProvider.tsx @@ -0,0 +1,20 @@ +import React, { createContext, useContext, ReactNode } from 'react'; + +const DialogContext = createContext(null); + +export const useDialogContext = () => { + return useContext(DialogContext); +}; + +interface DialogProviderProps { + value: any; + children: ReactNode; +} + +export const DialogProvider: React.FC = ({ value, children }) => { + return ( + + {children} + + ); +}; diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index 8da29ab37..c1884d55f 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -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() { + ); } diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index b86755cfb..e4d1be7b7 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -77,4 +77,5 @@ export enum DialogsName { Export = 'Export', BankRuleForm = 'BankRuleForm', DisconnectBankAccountConfirmation = 'DisconnectBankAccountConfirmation', + SharePaymentLink = 'SharePaymentLink' } diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx index 678517ef4..bad1c097c 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx @@ -103,6 +103,13 @@ function InvoiceDetailActionsBar({ openDialog(DialogsName.InvoiceMail, { invoiceId }); }; + const handleShareButtonClick = () => { + openDialog(DialogsName.SharePaymentLink, { + transactionId: invoiceId, + transactionType: 'SaleInvoice', + }); + }; + return ( @@ -150,6 +157,11 @@ function InvoiceDetailActionsBar({ onClick={handleDeleteInvoice} /> + + } + /> + + )} + + + + {url ? ( + + ) : ( + <> + + + + )} + + + ); +} diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx new file mode 100644 index 000000000..64deaec57 --- /dev/null +++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkProvider.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface SharePaymentLinkContextType { + url: string; + setUrl: React.Dispatch>; +} + +const SharePaymentLinkContext = + createContext(null); + +interface SharePaymentLinkProviderProps { + children: ReactNode; +} + +export const SharePaymentLinkProvider: React.FC< + SharePaymentLinkProviderProps +> = ({ children }) => { + const [url, setUrl] = useState(''); + + return ( + + {children} + + ); +}; + +export const useSharePaymentLink = () => { + const context = useContext(SharePaymentLinkContext); + if (!context) { + throw new Error( + 'useSharePaymentLink must be used within a SharePaymentLinkProvider', + ); + } + return context; +}; diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx index 875909bc6..650bb8136 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx @@ -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 ( - Bigcapital Technology, Inc. + {sharableLinkMeta?.companyName}

- Bigcapital Technology, Inc. Sent an Invoice for $1000.00 + {sharableLinkMeta?.companyName} Sent an Invoice for{' '} + {sharableLinkMeta?.totalFormatted}

- Invoice due September 13, 2024 + Invoice due {sharableLinkMeta?.dueDateFormatted}
- Ahmed Bouhuolia + {sharableLinkMeta?.customerName} Bigcapital Technology, Inc. 131 Continental Dr Suite 305 Newark, Delaware 19713 @@ -30,7 +34,9 @@ export function PaymentPortal() { ahmed@bigcapital.app -

Invoice INV-000001

+

+ Invoice {sharableLinkMeta?.invoiceNo} +

Sub Total - 11.00 + {sharableLinkMeta?.subtotalFormatted} Total - 11.00 + + {sharableLinkMeta?.totalFormatted} + Paid Amount (-) - 11.00 + {sharableLinkMeta?.paymentAmountFormatted} Due Amount - 11.00 + + {sharableLinkMeta?.dueAmountFormatted} + @@ -79,7 +89,7 @@ export function PaymentPortal() { intent={Intent.PRIMARY} className={clsx(styles.footerButton, styles.buyButton)} > - Pay $10,000.00 + Pay {sharableLinkMeta?.totalFormatted}
diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx index c9e3d1bb6..360abcc18 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx @@ -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( {} as PaymentPortalContextType, ); -export const PaymentPortalBoot: React.FC<{ children: ReactNode }> = ({ +interface PaymentPortalBootProps { + linkId: string; + children: ReactNode; +} + +export const PaymentPortalBoot: React.FC = ({ + linkId, children, }) => { - const [paymentAmount, setPaymentAmount] = React.useState(0); + const { data: sharableLinkMeta, isLoading: isSharableLinkMetaLoading } = + useGetSharableLinkMeta(linkId); + + const value = { + sharableLinkMeta, + isSharableLinkMetaLoading, + }; return ( - + {children} ); diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx index cdadb8df3..b671d7137 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortalPage.tsx @@ -1,5 +1,13 @@ +import { useParams } from 'react-router-dom'; import { PaymentPortal } from './PaymentPortal'; +import { PaymentPortalBoot } from './PaymentPortalBoot'; export default function PaymentPortalPage() { - return ; + const { linkId } = useParams<{ linkId: string}>(); + + return ( + + + + ); } diff --git a/packages/webapp/src/hooks/query/payment-link.ts b/packages/webapp/src/hooks/query/payment-link.ts new file mode 100644 index 000000000..1f12df834 --- /dev/null +++ b/packages/webapp/src/hooks/query/payment-link.ts @@ -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} options + * @returns {UseMutationResult} + */ +export function useCreatePaymentLink( + options?: UseMutationOptions< + CreatePaymentLinkResponse, + Error, + CreatePaymentLinkValues + >, +): UseMutationResult< + CreatePaymentLinkResponse, + Error, + CreatePaymentLinkValues +> { + const apiRequest = useApiRequest(); + + return useMutation( + (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} options - Optional query options. + * @returns {UseQueryResult} The query result. + */ +export function useGetSharableLinkMeta( + linkId: string, + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + ['sharable-link-meta', linkId], + () => + apiRequest + .get(`/sharable-links/meta/invoice/${linkId}`) + .then((res) => transformToCamelCase(res.data?.data)), + { + ...options, + }, + ); +} diff --git a/packages/webapp/src/hooks/utils/useClipboard.ts b/packages/webapp/src/hooks/utils/useClipboard.ts new file mode 100644 index 000000000..46fcf1098 --- /dev/null +++ b/packages/webapp/src/hooks/utils/useClipboard.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; + +export function useClipboard({ timeout = 2000 } = {}) { + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [copyTimeout, setCopyTimeout] = useState(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 }; +}