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}
/>
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx
new file mode 100644
index 000000000..ad7e64689
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
+
+export const SharePaymentLinkDialog = compose(withDialogRedux())(
+ SharePaymentLinkDialogRoot,
+);
+
+SharePaymentLinkDialog.displayName = 'SharePaymentLinkDialog';
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts
new file mode 100644
index 000000000..c6c8fdeed
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.schema.ts
@@ -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'),
+});
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx
new file mode 100644
index 000000000..18127dcb9
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkForm.tsx
@@ -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,
+ ) => {
+ 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 (
+
+ initialValues={formInitialValues}
+ validationSchema={SharePaymentLinkFormSchema}
+ onSubmit={handleFormSubmit}
+ >
+
+
+ );
+};
+SharePaymentLinkForm.displayName = 'SharePaymentLinkForm';
diff --git a/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx
new file mode 100644
index 000000000..0c93ba9c1
--- /dev/null
+++ b/packages/webapp/src/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkFormContent.tsx
@@ -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 (
+ <>
+
+
+
+ date.toLocaleDateString()}
+ parseDate={(str) => new Date(str)}
+ inputProps={{
+ fill: true,
+ leftElement: ,
+ }}
+ fastField
+ />
+
+
+ {url && (
+
+
+ Copy
+
+ }
+ />
+
+ )}
+
+
+
+ {url ? (
+
+ Copy Link
+
+ ) : (
+ <>
+
+ Generate
+
+ Cancel
+ >
+ )}
+
+ >
+ );
+}
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 };
+}