snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { IStore } from '../app/types';
import { openDialog } from '../base/dialog/actions';
import { hideNotification, showNotification } from '../notifications/actions';
import {
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TYPE,
SALESFORCE_LINK_NOTIFICATION_ID
} from '../notifications/constants';
import { SalesforceLinkDialog } from './components';
import { isSalesforceEnabled } from './functions';
/**
* Displays the notification for linking the meeting to Salesforce.
*
* @returns {void}
*/
export function showSalesforceNotification() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!isSalesforceEnabled(getState())) {
return;
}
dispatch(showNotification({
descriptionKey: 'notify.linkToSalesforceDescription',
titleKey: 'notify.linkToSalesforce',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
customActionNameKey: [ 'notify.linkToSalesforceKey' ],
customActionHandler: [ () => {
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
dispatch(openDialog('SalesforceLinkDialog', SalesforceLinkDialog));
} ],
appearance: NOTIFICATION_TYPE.NORMAL
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
};
}

View File

@@ -0,0 +1,2 @@
// @ts-ignore
export { default as SalesforceLinkDialog } from './native/SalesforceLinkDialog';

View File

@@ -0,0 +1 @@
export { default as SalesforceLinkDialog } from './web/SalesforceLinkDialog';

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import {
IconRecordAccount,
IconRecordContact,
IconRecordLead,
IconRecordOpportunity
} from '../../../base/icons/svg';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { formatAmount } from '../../functions';
import { ISalesforceData, SalesforceObjectType } from '../../types';
import { RecordListItem } from './RecordListItem';
import styles from './styles';
interface IProps {
onUnlink: (type: SalesforceObjectType, id: string) => void;
salesforceData: ISalesforceData | null;
unlinkingId: string | null;
}
/**
* Component for displaying currently linked Salesforce objects with unlink buttons.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const CurrentLinksSection = ({ salesforceData, unlinkingId, onUnlink }: IProps) => {
const { t } = useTranslation();
const hasAccount = Boolean(salesforceData?.account);
const hasLeads = salesforceData?.leads?.length;
const hasContacts = salesforceData?.contacts?.length;
const hasDeal = Boolean(salesforceData?.deal);
const hasAnyLinks = hasAccount || hasLeads || hasContacts || hasDeal;
if (!hasAnyLinks) {
return null;
}
return (
<View style = { styles.currentLinksSection as ViewStyle }>
<Text style = { styles.sectionTitle }>{t('dialog.currentLinks')}</Text>
{salesforceData?.account && (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
compact = { true }
disabled = { unlinkingId !== null }
icon = { IconRecordAccount }
isLoading = { unlinkingId === salesforceData.account.accountId }
metadata = { t('record.type.account') }
name = { salesforceData.account.accountName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onUnlink('Account', salesforceData.account!.accountId) } />
)}
{salesforceData?.leads?.map(lead => (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
compact = { true }
disabled = { unlinkingId !== null }
icon = { IconRecordLead }
isLoading = { unlinkingId === lead.leadId }
key = { lead.leadId }
metadata = { `${t('record.type.lead')}${lead.leadCompany ? ` \u2022 ${lead.leadCompany}` : ''}` }
name = { lead.leadName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onUnlink('Lead', lead.leadId) } />
))}
{salesforceData?.contacts?.map(contact => (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
compact = { true }
disabled = { unlinkingId !== null }
icon = { IconRecordContact }
isLoading = { unlinkingId === contact.contactId }
key = { contact.contactId }
metadata = { t('record.type.contact') }
name = { contact.contactName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onUnlink('Contact', contact.contactId) } />
))}
{salesforceData?.deal && (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
compact = { true }
disabled = { unlinkingId !== null }
icon = { IconRecordOpportunity }
isLoading = { unlinkingId === salesforceData.deal.opportunityId }
metadata = { `${salesforceData.deal.opportunityStage}${
salesforceData.deal.amount !== undefined
? ` \u2022 $${formatAmount(salesforceData.deal.amount)}`
: ''
}` }
name = { salesforceData.deal.opportunityName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onUnlink('Opportunity', salesforceData.deal!.opportunityId) } />
)}
</View>
);
};
export default CurrentLinksSection;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { formatAmount } from '../../functions';
import { IPendingAccount, IPendingDeal } from '../../types';
import styles from './styles';
interface IPendingAccountsSectionProps {
confirmingId: string | null;
items: IPendingAccount[];
onConfirm: (accountId: string) => void;
onRejectAll: () => void;
rejecting: boolean;
type: 'accounts';
}
interface IPendingDealsSectionProps {
confirmingId: string | null;
items: IPendingDeal[];
onConfirm: (opportunityId: string) => void;
onRejectAll: () => void;
rejecting: boolean;
type: 'deals';
}
type IProps = IPendingAccountsSectionProps | IPendingDealsSectionProps;
/**
* Component for displaying pending Salesforce suggestions (accounts or deals).
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const PendingSection = (props: IProps) => {
const { t } = useTranslation();
const { items, confirmingId, rejecting, onConfirm, onRejectAll, type } = props;
if (items.length === 0) {
return null;
}
const isAccounts = type === 'accounts';
const title = isAccounts
? t('dialog.suggestedAccounts')
: t('dialog.suggestedDeals');
const description = isAccounts
? t('dialog.pendingDescription')
: t('dialog.pendingDealsDescription');
return (
<View style = { styles.section as ViewStyle }>
<View style = { styles.pendingHeader as ViewStyle }>
<Text style = { styles.sectionTitle }>{title}</Text>
<Button
disabled = { rejecting }
labelKey = 'dialog.dismissAll'
onClick = { onRejectAll }
type = { BUTTON_TYPES.TERTIARY } />
</View>
<Text style = { styles.sectionDescription }>{description}</Text>
{isAccounts ? (
(items as IPendingAccount[]).map(account => (
<View
key = { account.accountId }
style = { styles.listItem as ViewStyle }>
<View style = { styles.listItemInfo as ViewStyle }>
<Text
numberOfLines = { 1 }
style = { styles.listItemName }>
{account.accountName}
</Text>
{account.matchedEmailDomain && (
<Text style = { styles.listItemMeta }>
{account.matchedEmailDomain}
</Text>
)}
</View>
<View style = { styles.listItemButtonContainer as ViewStyle }>
{confirmingId === account.accountId ? (
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
<LoadingIndicator size = 'small' />
</View>
) : (
<Button
disabled = { confirmingId !== null }
labelKey = 'dialog.confirm'
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => onConfirm(account.accountId) }
type = { BUTTON_TYPES.PRIMARY } />
)}
</View>
</View>
))
) : (
(items as IPendingDeal[]).map(deal => (
<View
key = { deal.opportunityId }
style = { styles.listItem as ViewStyle }>
<View style = { styles.listItemInfo as ViewStyle }>
<Text
numberOfLines = { 1 }
style = { styles.listItemName }>
{deal.opportunityName}
</Text>
<Text style = { styles.listItemMeta }>
{deal.opportunityStage}
{deal.amount !== undefined && ` \u2022 $${formatAmount(deal.amount)}`}
</Text>
</View>
<View style = { styles.listItemButtonContainer as ViewStyle }>
{confirmingId === deal.opportunityId ? (
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
<LoadingIndicator size = 'small' />
</View>
) : (
<Button
disabled = { confirmingId !== null }
labelKey = 'dialog.confirm'
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => onConfirm(deal.opportunityId) }
type = { BUTTON_TYPES.PRIMARY } />
)}
</View>
</View>
))
)}
</View>
);
};
export default PendingSection;

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Text, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import styles from './styles';
interface IProps {
/**
* Translation key for the action button.
*/
actionLabelKey: string;
/**
* Button type for the action button.
*/
actionType?: BUTTON_TYPES;
/**
* Whether to use compact styling (no background, less padding).
*/
compact?: boolean;
/**
* Whether the action button should be disabled.
*/
disabled?: boolean;
/**
* The icon component to display.
*/
icon: Function;
/**
* Whether this item's action is in progress.
*/
isLoading?: boolean;
/**
* Secondary text (type label, company, stage, etc.).
*/
metadata?: string;
/**
* The primary display name.
*/
name: string;
/**
* Click handler for the action button.
*/
onAction: () => void;
}
/**
* Reusable list item component for Salesforce records with an action button.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const RecordListItem = ({
actionLabelKey,
actionType = BUTTON_TYPES.PRIMARY,
compact = false,
disabled = false,
icon,
isLoading = false,
metadata,
name,
onAction
}: IProps) => {
const itemStyle = compact ? styles.listItemCompact : styles.listItem;
return (
<View style = { itemStyle as ViewStyle }>
<View style = { styles.listItemIcon as ViewStyle }>
<Icon
size = { 24 }
src = { icon } />
</View>
<View style = { styles.listItemInfo as ViewStyle }>
<Text
numberOfLines = { 1 }
style = { styles.listItemName }>
{name}
</Text>
{metadata && (
<Text style = { styles.listItemMeta }>
{metadata}
</Text>
)}
</View>
<View style = { styles.listItemButtonContainer as ViewStyle }>
{isLoading ? (
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
<LoadingIndicator size = 'small' />
</View>
) : (
<Button
disabled = { disabled }
labelKey = { actionLabelKey }
onClick = { onAction }
type = { actionType } />
)}
</View>
</View>
);
};
export default RecordListItem;

View File

@@ -0,0 +1,138 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, View, ViewStyle } from 'react-native';
import { IconSearch } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import { StyleType } from '../../../base/styles/functions.any';
import Input from '../../../base/ui/components/native/Input';
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
import { CurrentLinksSection } from './CurrentLinksSection';
import { PendingSection } from './PendingSection';
import { SearchResultsSection } from './SearchResultsSection';
import styles from './styles';
/**
* Component that renders the Salesforce link dialog.
*
* @returns {React$Element<any>}
*/
const SalesforceLinkDialog = () => {
const { t } = useTranslation();
const {
salesforceData,
searchResults,
hasSearchResults,
hasPendingAccounts,
hasPendingDeals,
searchTerm,
isLoading,
isSearching,
error,
confirmingAccountId,
confirmingDealId,
rejectingAccounts,
rejectingDeals,
linkingId,
unlinkingId,
setSearchTerm,
handleConfirmAccount,
handleRejectAllAccounts,
handleConfirmDeal,
handleRejectAllDeals,
handleLinkRecord,
handleUnlinkRecord
} = useSalesforceLinkDialog();
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
}, [ setSearchTerm ]);
const renderSearch = () => (
<View style = { styles.searchContainer as ViewStyle }>
<Input
icon = { IconSearch }
onChange = { handleSearchChange }
placeholder = { t('dialog.searchInSalesforce') }
value = { searchTerm } />
</View>
);
const renderContent = () => {
if (isLoading) {
return (
<View style = { styles.spinnerContainer as ViewStyle }>
<LoadingIndicator />
</View>
);
}
return (
<ScrollView
bounces = { false }
contentContainerStyle = { styles.scrollContent }>
{/* Search spinner */}
{isSearching && (
<View style = { styles.spinnerContainer as ViewStyle }>
<LoadingIndicator />
</View>
)}
{/* Search results */}
{!isSearching && hasSearchResults && searchResults && (
<SearchResultsSection
linkingId = { linkingId }
onLink = { handleLinkRecord }
results = { searchResults } />
)}
{/* Error (only show when not searching and no results) */}
{!isSearching && !hasSearchResults && error && (
<Text style = { styles.errorText }>
{error}
</Text>
)}
{/* Pending accounts (when not searching) */}
{!searchTerm && hasPendingAccounts && salesforceData?.pendingAccounts && (
<PendingSection
confirmingId = { confirmingAccountId }
items = { salesforceData.pendingAccounts }
onConfirm = { handleConfirmAccount }
onRejectAll = { handleRejectAllAccounts }
rejecting = { rejectingAccounts }
type = 'accounts' />
)}
{/* Pending deals (when not searching) */}
{!searchTerm && hasPendingDeals && salesforceData?.pendingDeals && (
<PendingSection
confirmingId = { confirmingDealId }
items = { salesforceData.pendingDeals }
onConfirm = { handleConfirmDeal }
onRejectAll = { handleRejectAllDeals }
rejecting = { rejectingDeals }
type = 'deals' />
)}
{/* Current links */}
<CurrentLinksSection
onUnlink = { handleUnlinkRecord }
salesforceData = { salesforceData }
unlinkingId = { unlinkingId } />
</ScrollView>
);
};
return (
<JitsiScreen style = { styles.salesforceDialogContainer as StyleType }>
{renderSearch()}
{renderContent()}
</JitsiScreen>
);
};
export default SalesforceLinkDialog;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import {
IconRecordAccount,
IconRecordContact,
IconRecordLead,
IconRecordOpportunity
} from '../../../base/icons/svg';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { formatAmount } from '../../functions';
import {
IAccountMatch,
IContactMatch,
ILeadMatch,
IOpportunityMatch,
ISearchResults,
SalesforceObjectType
} from '../../types';
import { RecordListItem } from './RecordListItem';
import styles from './styles';
interface IProps {
linkingId: string | null;
onLink: (type: SalesforceObjectType, data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch) => void;
results: ISearchResults;
}
/**
* Component for displaying Salesforce search results with link buttons.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const SearchResultsSection = ({ results, linkingId, onLink }: IProps) => {
const { t } = useTranslation();
const hasResults = results.accounts.length
|| results.leads.length
|| results.contacts.length
|| results.opportunities.length;
if (!hasResults) {
return (
<View style = { styles.noResultsContainer as ViewStyle }>
<Text style = { styles.noResultsText }>
{t('dialog.searchResultsNotFound')}
</Text>
</View>
);
}
return (
<View>
{!!results.accounts.length && (
<>
<Text style = { styles.groupTitle }>{t('record.type.account')}</Text>
{results.accounts.map(account => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordAccount }
isLoading = { linkingId === account.accountId }
key = { account.accountId }
name = { account.accountName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onLink('Account', account) } />
))}
</>
)}
{!!results.leads.length && (
<>
<Text style = { styles.groupTitle }>{t('record.type.lead')}</Text>
{results.leads.map(lead => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordLead }
isLoading = { linkingId === lead.leadId }
key = { lead.leadId }
metadata = { lead.leadCompany }
name = { lead.leadName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onLink('Lead', lead) } />
))}
</>
)}
{!!results.contacts.length && (
<>
<Text style = { styles.groupTitle }>{t('record.type.contact')}</Text>
{results.contacts.map(contact => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordContact }
isLoading = { linkingId === contact.contactId }
key = { contact.contactId }
metadata = { contact.contactEmail }
name = { contact.contactName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onLink('Contact', contact) } />
))}
</>
)}
{!!results.opportunities.length && (
<>
<Text style = { styles.groupTitle }>{t('record.type.opportunity')}</Text>
{results.opportunities.map(opp => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordOpportunity }
isLoading = { linkingId === opp.opportunityId }
key = { opp.opportunityId }
metadata = { `${opp.opportunityStage}${
opp.amount !== undefined ? ` \u2022 $${formatAmount(opp.amount)}` : ''
}` }
name = { opp.opportunityName }
/* eslint-disable-next-line react/jsx-no-bind */
onAction = { () => onLink('Opportunity', opp) } />
))}
</>
)}
</View>
);
};
export default SearchResultsSection;

View File

@@ -0,0 +1,164 @@
import { TextStyle, ViewStyle } from 'react-native';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
interface IStyles {
currentLinksSection: ViewStyle;
errorText: TextStyle;
groupTitle: TextStyle;
listItem: ViewStyle;
listItemButtonContainer: ViewStyle;
listItemCompact: ViewStyle;
listItemIcon: ViewStyle;
listItemInfo: ViewStyle;
listItemMeta: TextStyle;
listItemName: TextStyle;
listItemSpinnerContainer: ViewStyle;
noResultsContainer: ViewStyle;
noResultsText: TextStyle;
pendingHeader: ViewStyle;
salesforceDialogContainer: ViewStyle;
scrollContent: ViewStyle;
searchContainer: ViewStyle;
section: ViewStyle;
sectionDescription: TextStyle;
sectionTitle: TextStyle;
spinnerContainer: ViewStyle;
}
const styles: IStyles = {
// Main container
salesforceDialogContainer: {
position: 'relative',
flexDirection: 'column',
flex: 1,
backgroundColor: BaseTheme.palette.ui01
},
// Search
searchContainer: {
alignSelf: 'stretch',
backgroundColor: BaseTheme.palette.ui01,
paddingHorizontal: BaseTheme.spacing[3],
paddingTop: BaseTheme.spacing[2],
paddingBottom: BaseTheme.spacing[3]
},
// Loading/spinner
spinnerContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: BaseTheme.spacing[6]
},
// Error
errorText: {
color: BaseTheme.palette.textError,
textAlign: 'center',
padding: BaseTheme.spacing[4]
},
// No results
noResultsContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: BaseTheme.spacing[4]
},
noResultsText: {
color: BaseTheme.palette.text03,
textAlign: 'center'
},
// Section styles
section: {
marginBottom: BaseTheme.spacing[4],
paddingHorizontal: BaseTheme.spacing[3]
},
sectionTitle: {
fontSize: 15,
fontWeight: '600',
color: BaseTheme.palette.text01,
marginBottom: BaseTheme.spacing[2]
},
sectionDescription: {
fontSize: 13,
color: BaseTheme.palette.text03,
marginBottom: BaseTheme.spacing[3]
},
// Pending section header
pendingHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: BaseTheme.spacing[2]
},
// Current links section
currentLinksSection: {
marginTop: BaseTheme.spacing[4],
paddingTop: BaseTheme.spacing[3],
paddingHorizontal: BaseTheme.spacing[3],
borderTopWidth: 1,
borderTopColor: BaseTheme.palette.ui05
},
// Group title (for search results)
groupTitle: {
fontSize: 12,
fontWeight: '600',
color: BaseTheme.palette.text03,
textTransform: 'uppercase',
marginTop: BaseTheme.spacing[3],
marginBottom: BaseTheme.spacing[2],
paddingHorizontal: BaseTheme.spacing[3]
},
// RecordListItem styles
listItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.shape.borderRadius,
padding: BaseTheme.spacing[3],
marginBottom: BaseTheme.spacing[2],
marginHorizontal: BaseTheme.spacing[3]
},
listItemCompact: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: BaseTheme.spacing[2]
},
listItemIcon: {
width: 24,
height: 24,
marginRight: BaseTheme.spacing[3]
},
listItemInfo: {
flex: 1
},
listItemName: {
fontSize: 15,
color: BaseTheme.palette.text01
},
listItemMeta: {
fontSize: 13,
color: BaseTheme.palette.text03,
marginTop: 2
},
listItemButtonContainer: {
marginLeft: BaseTheme.spacing[3]
},
listItemSpinnerContainer: {
width: 70,
alignItems: 'center',
justifyContent: 'center'
},
// Scroll container
scrollContent: {
flexGrow: 1
}
};
export default styles;

View File

@@ -0,0 +1,146 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import {
IconRecordAccount,
IconRecordContact,
IconRecordLead,
IconRecordOpportunity
} from '../../../base/icons/svg';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { ISalesforceData, SalesforceObjectType } from '../../types';
import { RecordListItem } from './RecordListItem';
interface IProps {
onUnlink: (type: SalesforceObjectType, id: string) => void;
salesforceData: ISalesforceData | null;
unlinkingId: string | null;
}
const useStyles = makeStyles()(theme => {
return {
section: {
marginTop: '24px',
paddingTop: '16px',
borderTop: `1px solid ${theme.palette.ui05}`
},
title: {
fontSize: '0.875rem',
fontWeight: 600,
margin: '0 0 12px 0'
},
list: {
listStyle: 'none',
margin: 0,
padding: 0
}
};
});
/**
* Component for displaying currently linked Salesforce objects with unlink buttons.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const CurrentLinksSection = ({ salesforceData, unlinkingId, onUnlink }: IProps) => {
const { t } = useTranslation();
const { classes } = useStyles();
const hasAccount = Boolean(salesforceData?.account);
const hasLeads = salesforceData?.leads?.length;
const hasContacts = salesforceData?.contacts?.length;
const hasDeal = Boolean(salesforceData?.deal);
const hasAnyLinks = hasAccount || hasLeads || hasContacts || hasDeal;
const handleUnlinkAccount = useCallback((id: string) => {
onUnlink('Account', id);
}, [ onUnlink ]);
const handleUnlinkLead = useCallback((id: string) => {
onUnlink('Lead', id);
}, [ onUnlink ]);
const handleUnlinkContact = useCallback((id: string) => {
onUnlink('Contact', id);
}, [ onUnlink ]);
const handleUnlinkOpportunity = useCallback((id: string) => {
onUnlink('Opportunity', id);
}, [ onUnlink ]);
if (!hasAnyLinks) {
return null;
}
return (
<div className = { classes.section }>
<h3 className = { classes.title }>{t('dialog.currentLinks')}</h3>
<ul className = { classes.list }>
{salesforceData?.account && (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
disabled = { unlinkingId !== null }
icon = { IconRecordAccount }
isLoading = { unlinkingId === salesforceData.account.accountId }
key = { salesforceData.account.accountId }
metadata = { t('record.type.account') }
name = { salesforceData.account.accountName }
onAction = { handleUnlinkAccount }
record = { salesforceData.account.accountId } />
)}
{salesforceData?.leads?.map(lead => (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
disabled = { unlinkingId !== null }
icon = { IconRecordLead }
isLoading = { unlinkingId === lead.leadId }
key = { lead.leadId }
metadata = { `${t('record.type.lead')}${lead.leadCompany ? ` \u2022 ${lead.leadCompany}` : ''}` }
name = { lead.leadName }
onAction = { handleUnlinkLead }
record = { lead.leadId } />
))}
{salesforceData?.contacts?.map(contact => (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
disabled = { unlinkingId !== null }
icon = { IconRecordContact }
isLoading = { unlinkingId === contact.contactId }
key = { contact.contactId }
metadata = { t('record.type.contact') }
name = { contact.contactName }
onAction = { handleUnlinkContact }
record = { contact.contactId } />
))}
{salesforceData?.deal && (
<RecordListItem
actionLabelKey = 'dialog.unlink'
actionType = { BUTTON_TYPES.TERTIARY }
disabled = { unlinkingId !== null }
icon = { IconRecordOpportunity }
isLoading = { unlinkingId === salesforceData.deal.opportunityId }
key = { salesforceData.deal.opportunityId }
metadata = { `${salesforceData.deal.opportunityStage}${
salesforceData.deal.amount !== undefined
? ` \u2022 $${salesforceData.deal.amount.toLocaleString()}`
: ''
}` }
name = { salesforceData.deal.opportunityName }
onAction = { handleUnlinkOpportunity }
record = { salesforceData.deal.opportunityId } />
)}
</ul>
</div>
);
};
export default CurrentLinksSection;

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import Spinner from '../../../base/ui/components/web/Spinner';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { IPendingAccount, IPendingDeal } from '../../types';
interface IPendingAccountsSectionProps {
confirmingId: string | null;
items: IPendingAccount[];
onConfirm: (accountId: string) => void;
onRejectAll: () => void;
rejecting: boolean;
type: 'accounts';
}
interface IPendingDealsSectionProps {
confirmingId: string | null;
items: IPendingDeal[];
onConfirm: (opportunityId: string) => void;
onRejectAll: () => void;
rejecting: boolean;
type: 'deals';
}
type IProps = IPendingAccountsSectionProps | IPendingDealsSectionProps;
const useStyles = makeStyles()(theme => {
return {
section: {
marginBottom: '24px'
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
},
title: {
fontSize: '0.875rem',
fontWeight: 600,
margin: 0
},
description: {
fontSize: '0.75rem',
color: theme.palette.text03,
marginBottom: '12px'
},
list: {
listStyle: 'none',
margin: 0,
padding: 0
},
item: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px',
backgroundColor: theme.palette.inputFieldBackground,
borderRadius: theme.shape.borderRadius,
marginBottom: '8px',
'&:last-child': {
marginBottom: 0
}
},
itemInfo: {
flex: 1,
minWidth: 0
},
itemName: {
fontSize: '0.875rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
itemMeta: {
fontSize: '0.75rem',
color: theme.palette.text03,
marginTop: '2px'
},
buttonContainer: {
marginLeft: '12px',
flexShrink: 0
},
spinnerContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '70px'
}
};
});
/**
* Component for displaying pending Salesforce suggestions (accounts or deals).
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const PendingSection = (props: IProps) => {
const { t } = useTranslation();
const { classes } = useStyles();
const { items, confirmingId, rejecting, onConfirm, onRejectAll, type } = props;
if (items.length === 0) {
return null;
}
const isAccounts = type === 'accounts';
const title = isAccounts
? t('dialog.suggestedAccounts')
: t('dialog.suggestedDeals');
const description = isAccounts
? t('dialog.pendingDescription')
: t('dialog.pendingDealsDescription');
return (
<div className = { classes.section }>
<div className = { classes.header }>
<h3 className = { classes.title }>{title}</h3>
<Button
disabled = { rejecting }
labelKey = 'dialog.dismissAll'
onClick = { onRejectAll }
size = 'small'
type = { BUTTON_TYPES.TERTIARY } />
</div>
<p className = { classes.description }>{description}</p>
<ul className = { classes.list }>
{isAccounts ? (
(items as IPendingAccount[]).map(account => (
<li
className = { classes.item }
key = { account.accountId }>
<div className = { classes.itemInfo }>
<div className = { classes.itemName }>{account.accountName}</div>
{account.matchedEmailDomain && (
<div className = { classes.itemMeta }>{account.matchedEmailDomain}</div>
)}
</div>
<div className = { classes.buttonContainer }>
{confirmingId === account.accountId ? (
<div className = { classes.spinnerContainer }>
<Spinner size = 'small' />
</div>
) : (
<Button
disabled = { confirmingId !== null }
labelKey = 'dialog.confirm'
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => onConfirm(account.accountId) }
size = 'small'
type = { BUTTON_TYPES.PRIMARY } />
)}
</div>
</li>
))
) : (
(items as IPendingDeal[]).map(deal => (
<li
className = { classes.item }
key = { deal.opportunityId }>
<div className = { classes.itemInfo }>
<div className = { classes.itemName }>{deal.opportunityName}</div>
<div className = { classes.itemMeta }>
{deal.opportunityStage}
{deal.amount !== undefined && ` \u2022 $${deal.amount.toLocaleString()}`}
</div>
</div>
<div className = { classes.buttonContainer }>
{confirmingId === deal.opportunityId ? (
<div className = { classes.spinnerContainer }>
<Spinner size = 'small' />
</div>
) : (
<Button
disabled = { confirmingId !== null }
labelKey = 'dialog.confirm'
/* eslint-disable-next-line react/jsx-no-bind */
onClick = { () => onConfirm(deal.opportunityId) }
size = 'small'
type = { BUTTON_TYPES.PRIMARY } />
)}
</div>
</li>
))
)}
</ul>
</div>
);
};
export default PendingSection;

View File

@@ -0,0 +1,160 @@
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import Spinner from '../../../base/ui/components/web/Spinner';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
interface IProps<T> {
/**
* Translation key for the action button.
*/
actionLabelKey: string;
/**
* Button type for the action button.
*/
actionType?: BUTTON_TYPES;
/**
* Whether the action button should be disabled.
*/
disabled?: boolean;
/**
* The icon component to display.
*/
icon: React.ComponentType;
/**
* Whether this item's action is in progress.
*/
isLoading?: boolean;
/**
* Secondary text (type label, company, stage, etc.).
*/
metadata?: string;
/**
* The primary display name.
*/
name: string;
/**
* Click handler for the action button.
*/
onAction: (record: T) => void;
/**
* The record data to pass back when action is triggered.
*/
record: T;
}
const useStyles = makeStyles()(theme => {
return {
item: {
display: 'flex',
alignItems: 'center',
padding: '10px 12px',
backgroundColor: theme.palette.ui03,
borderRadius: theme.shape.borderRadius,
marginBottom: '6px',
'&:last-child': {
marginBottom: 0
}
},
iconContainer: {
width: '24px',
height: '24px',
marginRight: '12px',
flexShrink: 0,
'& svg': {
width: '24px',
height: '24px',
borderRadius: theme.shape.borderRadius
}
},
itemInfo: {
flex: 1,
minWidth: 0
},
itemName: {
fontSize: '0.875rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
itemMeta: {
fontSize: '0.75rem',
color: theme.palette.text03,
marginTop: '2px'
},
buttonContainer: {
marginLeft: '12px',
flexShrink: 0
},
spinnerContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '70px'
}
};
});
/**
* Reusable list item component for Salesforce records with an action button.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const RecordListItem = <T, >({
actionLabelKey,
actionType = BUTTON_TYPES.PRIMARY,
disabled = false,
icon: Icon,
isLoading = false,
metadata,
name,
onAction,
record
}: IProps<T>) => {
const { classes } = useStyles();
const handleAction = useCallback(() => {
onAction(record);
}, [ onAction, record ]);
return (
<div className = { classes.item }>
<div className = { classes.iconContainer }>
<Icon />
</div>
<div className = { classes.itemInfo }>
<div className = { classes.itemName }>{name}</div>
{metadata && <div className = { classes.itemMeta }>{metadata}</div>}
</div>
<div className = { classes.buttonContainer }>
{isLoading ? (
<div className = { classes.spinnerContainer }>
<Spinner size = 'small' />
</div>
) : (
<Button
disabled = { disabled }
labelKey = { actionLabelKey }
onClick = { handleAction }
size = 'small'
type = { actionType } />
)}
</div>
</div>
);
};
export default RecordListItem;

View File

@@ -0,0 +1,218 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { hideDialog } from '../../../base/dialog/actions';
import Icon from '../../../base/icons/components/Icon';
import { IconSearch } from '../../../base/icons/svg';
import { getFieldValue } from '../../../base/react/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Spinner from '../../../base/ui/components/web/Spinner';
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
import { CurrentLinksSection } from './CurrentLinksSection';
import { PendingSection } from './PendingSection';
import { SearchResultsSection } from './SearchResultsSection';
const useStyles = makeStyles()(theme => {
return {
container: {
minHeight: '400px',
maxHeight: '500px',
overflowY: 'auto',
position: 'relative'
},
searchContainer: {
position: 'relative',
padding: '1px',
marginBottom: '16px'
},
searchIcon: {
display: 'block',
position: 'absolute',
color: theme.palette.salesforceSearchIcon,
left: 16,
top: 10,
width: 20,
height: 20
},
searchInput: {
backgroundColor: theme.palette.salesforceSearchBackground,
border: '1px solid',
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.salesforceSearchBorder,
color: theme.palette.salesforceSearchText,
padding: '10px 16px 10px 44px',
width: '100%',
height: 40,
'&::placeholder': {
color: theme.palette.salesforceSearchPlaceholder,
...theme.typography.bodyShortRegular
}
},
spinner: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 0',
boxSizing: 'border-box'
},
error: {
textAlign: 'center',
color: theme.palette.textError,
padding: '24px'
}
};
});
/**
* Component that renders the Salesforce link dialog.
*
* @returns {React$Element<any>}
*/
function SalesforceLinkDialog() {
const { t } = useTranslation();
const { classes, theme } = useStyles();
const dispatch = useDispatch();
const {
salesforceData,
searchResults,
hasSearchResults,
hasPendingAccounts,
hasPendingDeals,
searchTerm,
isLoading,
isSearching,
error,
confirmingAccountId,
confirmingDealId,
rejectingAccounts,
rejectingDeals,
linkingId,
unlinkingId,
setSearchTerm,
handleConfirmAccount,
handleRejectAllAccounts,
handleConfirmDeal,
handleRejectAllDeals,
handleLinkRecord,
handleUnlinkRecord
} = useSalesforceLinkDialog();
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = getFieldValue(event);
setSearchTerm(value);
}, [ setSearchTerm ]);
const handleSearchKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
}
}, []);
const handleClose = useCallback(() => {
dispatch(hideDialog());
}, [ dispatch ]);
const renderSearch = () => (
<div className = { classes.searchContainer }>
<Icon
className = { classes.searchIcon }
color = { theme.palette.icon03 }
src = { IconSearch } />
<input
autoComplete = 'off'
autoFocus = { true }
className = { classes.searchInput }
name = 'salesforceSearch'
onChange = { handleSearchChange }
onKeyDown = { handleSearchKeyDown }
placeholder = { t('dialog.searchInSalesforce') }
tabIndex = { 0 }
value = { searchTerm } />
</div>
);
const renderContent = () => {
if (isLoading) {
return (
<div className = { classes.spinner }>
<Spinner />
</div>
);
}
return (
<>
{/* Search spinner */}
{isSearching && (
<div className = { classes.spinner }>
<Spinner />
</div>
)}
{/* Search results */}
{!isSearching && hasSearchResults && searchResults && (
<SearchResultsSection
linkingId = { linkingId }
onLink = { handleLinkRecord }
results = { searchResults } />
)}
{/* Error (only show when not searching and no results) */}
{!isSearching && !hasSearchResults && error && (
<div className = { classes.error }>
{error}
</div>
)}
{/* Pending suggestions (when not searching) */}
{!searchTerm && hasPendingAccounts && salesforceData?.pendingAccounts && (
<PendingSection
confirmingId = { confirmingAccountId }
items = { salesforceData.pendingAccounts }
onConfirm = { handleConfirmAccount }
onRejectAll = { handleRejectAllAccounts }
rejecting = { rejectingAccounts }
type = 'accounts' />
)}
{!searchTerm && hasPendingDeals && salesforceData?.pendingDeals && (
<PendingSection
confirmingId = { confirmingDealId }
items = { salesforceData.pendingDeals }
onConfirm = { handleConfirmDeal }
onRejectAll = { handleRejectAllDeals }
rejecting = { rejectingDeals }
type = 'deals' />
)}
{/* Current links */}
<CurrentLinksSection
onUnlink = { handleUnlinkRecord }
salesforceData = { salesforceData }
unlinkingId = { unlinkingId } />
</>
);
};
return (
<Dialog
cancel = {{ hidden: true }}
disableEnter = { true }
ok = {{ hidden: true }}
onCancel = { handleClose }
titleKey = 'dialog.linkMeetingTitle'>
<div className = { classes.container }>
{renderSearch()}
{renderContent()}
</div>
</Dialog>
);
}
export default SalesforceLinkDialog;

View File

@@ -0,0 +1,183 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import {
IconRecordAccount,
IconRecordContact,
IconRecordLead,
IconRecordOpportunity
} from '../../../base/icons/svg';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import {
IAccountMatch,
IContactMatch,
ILeadMatch,
IOpportunityMatch,
ISearchResults,
SalesforceObjectType
} from '../../types';
import { RecordListItem } from './RecordListItem';
interface IProps {
linkingId: string | null;
onLink: (type: SalesforceObjectType, data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch) => void;
results: ISearchResults;
}
const useStyles = makeStyles()(theme => {
return {
section: {
marginBottom: '16px'
},
groupTitle: {
fontSize: '0.75rem',
fontWeight: 600,
color: theme.palette.text03,
textTransform: 'uppercase',
margin: '16px 0 8px 0'
},
list: {
listStyle: 'none',
margin: 0,
padding: 0
},
noResults: {
textAlign: 'center',
color: theme.palette.text03,
padding: '24px'
}
};
});
/**
* Component for displaying Salesforce search results with link buttons.
*
* @param {IProps} props - Component props.
* @returns {React.ReactElement}
*/
export const SearchResultsSection = ({ results, linkingId, onLink }: IProps) => {
const { t } = useTranslation();
const { classes } = useStyles();
const hasResults = results.accounts.length
|| results.leads.length
|| results.contacts.length
|| results.opportunities.length;
const handleLinkAccount = useCallback((account: IAccountMatch) => {
onLink('Account', account);
}, [ onLink ]);
const handleLinkLead = useCallback((lead: ILeadMatch) => {
onLink('Lead', lead);
}, [ onLink ]);
const handleLinkContact = useCallback((contact: IContactMatch) => {
onLink('Contact', contact);
}, [ onLink ]);
const handleLinkOpportunity = useCallback((opportunity: IOpportunityMatch) => {
onLink('Opportunity', opportunity);
}, [ onLink ]);
if (!hasResults) {
return (
<div className = { classes.noResults }>
{t('dialog.searchResultsNotFound')}
</div>
);
}
return (
<div className = { classes.section }>
{!!results.accounts.length && (
<>
<h4 className = { classes.groupTitle }>{t('record.type.account')}</h4>
<ul className = { classes.list }>
{results.accounts.map(account => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordAccount }
isLoading = { linkingId === account.accountId }
key = { account.accountId }
name = { account.accountName }
onAction = { handleLinkAccount }
record = { account } />
))}
</ul>
</>
)}
{!!results.leads.length && (
<>
<h4 className = { classes.groupTitle }>{t('record.type.lead')}</h4>
<ul className = { classes.list }>
{results.leads.map(lead => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordLead }
isLoading = { linkingId === lead.leadId }
key = { lead.leadId }
metadata = { lead.leadCompany }
name = { lead.leadName }
onAction = { handleLinkLead }
record = { lead } />
))}
</ul>
</>
)}
{!!results.contacts.length && (
<>
<h4 className = { classes.groupTitle }>{t('record.type.contact')}</h4>
<ul className = { classes.list }>
{results.contacts.map(contact => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordContact }
isLoading = { linkingId === contact.contactId }
key = { contact.contactId }
metadata = { contact.contactEmail }
name = { contact.contactName }
onAction = { handleLinkContact }
record = { contact } />
))}
</ul>
</>
)}
{!!results.opportunities.length && (
<>
<h4 className = { classes.groupTitle }>{t('record.type.opportunity')}</h4>
<ul className = { classes.list }>
{results.opportunities.map(opp => (
<RecordListItem
actionLabelKey = 'dialog.link'
actionType = { BUTTON_TYPES.PRIMARY }
disabled = { linkingId !== null }
icon = { IconRecordOpportunity }
isLoading = { linkingId === opp.opportunityId }
key = { opp.opportunityId }
metadata = { `${opp.opportunityStage}${
opp.amount !== undefined ? ` \u2022 $${opp.amount.toLocaleString()}` : ''
}` }
name = { opp.opportunityName }
onAction = { handleLinkOpportunity }
record = { opp } />
))}
</ul>
</>
)}
</div>
);
};
export default SearchResultsSection;

View File

@@ -0,0 +1,41 @@
import {
IconRecordAccount,
IconRecordContact,
IconRecordLead,
IconRecordOpportunity
} from '../base/icons/svg';
export const NOTES_MAX_LENGTH = 255;
export const NOTES_LINES = 4;
export const CONTENT_HEIGHT_OFFSET = 200;
export const LIST_HEIGHT_OFFSET = 250;
export const RECORD_TYPE: {
[key: string]: {
icon?: Function;
label: string;
};
} = {
ACCOUNT: {
label: 'record.type.account',
icon: IconRecordAccount
},
CONTACT: {
label: 'record.type.contact',
icon: IconRecordContact
},
LEAD: {
label: 'record.type.lead',
icon: IconRecordLead
},
OPPORTUNITY: {
label: 'record.type.opportunity',
icon: IconRecordOpportunity
},
OWNER: {
label: 'record.type.owner'
}
};

View File

@@ -0,0 +1,228 @@
import { IReduxState } from '../app/types';
import { isInBreakoutRoom } from '../breakout-rooms/functions';
import {
IAccountMatch,
IConfirmAccountResult,
IConfirmDealResult,
IContactMatch,
ILeadMatch,
ILinkResult,
IOpportunityMatch,
ISalesforceData,
ISearchResults,
SalesforceObjectType
} from './types';
/**
* Formats a number with thousand separators.
* Uses a regex approach that works reliably in React Native
* (toLocaleString has known issues on Android and iOS with Hermes).
*
* @param {number} amount - The number to format.
* @returns {string} The formatted number string.
*/
export function formatAmount(amount: number): string {
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Determines whether Salesforce is enabled for the current conference.
*
* @param {IReduxState} state - The redux store, the redux
* {@code getState} function, or the redux state itself.
* @returns {boolean}
*/
export const isSalesforceEnabled = (state: IReduxState) => {
const { salesforceUrl } = state['features/base/config'];
const isBreakoutRoom = isInBreakoutRoom(state);
return Boolean(salesforceUrl) && !isBreakoutRoom;
};
/**
* Helper to make authenticated requests to the meet-metrics API.
*
* @param {string} url - The full URL to fetch.
* @param {string} jwt - The JWT token for authentication.
* @param {RequestInit} options - Additional fetch options.
* @returns {Promise<any>}
*/
async function fetchWithAuth(url: string, jwt: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`,
...options.headers
}
});
const json = await res.json();
return res.ok ? json : Promise.reject(json);
}
/**
* Fetches the Salesforce data for a session including current links and pending suggestions.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @returns {Promise<ISalesforceData>}
*/
export async function getSessionSalesforceData(
url: string,
jwt: string,
sessionId: string
): Promise<ISalesforceData> {
const response = await fetchWithAuth(`${url}/api/sessions/crm`, jwt, {
method: 'POST',
body: JSON.stringify({ sessionIds: [ sessionId ] })
});
// Extract data for this specific session from the batch response
const sessionData = response?.crmDataBySession?.[sessionId]?.salesforce;
return sessionData || null;
}
/**
* Searches Salesforce objects (Accounts, Leads, Contacts, Opportunities).
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} query - The search query (minimum 2 characters).
* @returns {Promise<ISearchResults>}
*/
export async function searchSalesforce(
url: string,
jwt: string,
query: string
): Promise<ISearchResults> {
return fetchWithAuth(`${url}/api/salesforce/search?q=${encodeURIComponent(query)}`, jwt);
}
/**
* Links a session to a Salesforce object.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @param {SalesforceObjectType} type - The type of Salesforce object.
* @param {Object} data - The match data for the object.
* @returns {Promise<ILinkResult>}
*/
export async function linkSession(
url: string,
jwt: string,
sessionId: string,
type: SalesforceObjectType,
data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch
): Promise<ILinkResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/link`, jwt, {
method: 'POST',
body: JSON.stringify({ type, data })
});
}
/**
* Confirms a pending account match and auto-links the best opportunity.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @param {string} accountId - The Salesforce Account ID to confirm.
* @returns {Promise<IConfirmAccountResult>}
*/
export async function confirmPendingAccount(
url: string,
jwt: string,
sessionId: string,
accountId: string
): Promise<IConfirmAccountResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/confirm-account`, jwt, {
method: 'POST',
body: JSON.stringify({ accountId })
});
}
/**
* Rejects all pending account matches for a session.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @returns {Promise<ILinkResult>}
*/
export async function rejectPendingAccounts(
url: string,
jwt: string,
sessionId: string
): Promise<ILinkResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/pending-accounts`, jwt, {
method: 'DELETE'
});
}
/**
* Confirms a pending deal match.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @param {string} opportunityId - The Salesforce Opportunity ID to confirm.
* @returns {Promise<IConfirmDealResult>}
*/
export async function confirmPendingDeal(
url: string,
jwt: string,
sessionId: string,
opportunityId: string
): Promise<IConfirmDealResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/confirm-deal`, jwt, {
method: 'POST',
body: JSON.stringify({ opportunityId })
});
}
/**
* Rejects all pending deal matches for a session.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @returns {Promise<ILinkResult>}
*/
export async function rejectPendingDeals(
url: string,
jwt: string,
sessionId: string
): Promise<ILinkResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/pending-deals`, jwt, {
method: 'DELETE'
});
}
/**
* Unlinks a Salesforce object from a session.
*
* @param {string} url - The base URL for the meet-metrics API.
* @param {string} jwt - The JWT needed for authentication.
* @param {string} sessionId - The meeting session ID.
* @param {SalesforceObjectType} type - The type of Salesforce object to unlink.
* @param {string} id - The Salesforce object ID to unlink.
* @returns {Promise<ILinkResult>}
*/
export async function unlinkSession(
url: string,
jwt: string,
sessionId: string,
type: SalesforceObjectType,
id: string
): Promise<ILinkResult> {
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/link`, jwt, {
method: 'DELETE',
body: JSON.stringify({ type, id })
});
}

View File

@@ -0,0 +1,166 @@
/**
* Pending account suggestion awaiting user confirmation.
*/
export interface IPendingAccount {
accountId: string;
accountName: string;
matchConfidence: number;
matchedEmailDomain?: string;
}
/**
* Pending deal/opportunity suggestion awaiting user confirmation.
*/
export interface IPendingDeal {
accountId: string;
accountName: string;
amount?: number;
closeDate?: string;
isClosed?: boolean;
matchConfidence: number;
opportunityId: string;
opportunityName: string;
opportunityStage: string;
}
/**
* Linked account information.
*/
export interface ILinkedAccount {
accountId: string;
accountName: string;
}
/**
* Linked lead information.
*/
export interface ILinkedLead {
leadCompany?: string;
leadId: string;
leadName: string;
}
/**
* Linked contact information.
*/
export interface ILinkedContact {
contactId: string;
contactName: string;
}
/**
* Linked deal/opportunity information.
*/
export interface ILinkedDeal {
amount?: number;
closeDate?: string;
isClosed?: boolean;
isWon?: boolean;
opportunityId: string;
opportunityName: string;
opportunityStage: string;
probability?: number;
}
/**
* Complete Salesforce data for a session including current links and pending suggestions.
*/
export interface ISalesforceData {
account?: ILinkedAccount;
contacts?: ILinkedContact[];
deal?: ILinkedDeal;
leads?: ILinkedLead[];
pendingAccounts?: IPendingAccount[];
pendingDeals?: IPendingDeal[];
}
/**
* Account match from search results.
*/
export interface IAccountMatch {
accountId: string;
accountName: string;
matchConfidence: number;
matchedEmailDomain?: string;
}
/**
* Lead match from search results.
*/
export interface ILeadMatch {
leadCompany?: string;
leadEmail: string;
leadId: string;
leadName: string;
matchConfidence: number;
}
/**
* Contact match from search results.
*/
export interface IContactMatch {
accountId?: string;
accountName?: string;
contactEmail: string;
contactId: string;
contactName: string;
matchConfidence: number;
}
/**
* Opportunity match from search results.
*/
export interface IOpportunityMatch {
accountId?: string;
accountName?: string;
amount?: number;
closeDate?: string;
isClosed?: boolean;
matchConfidence: number;
opportunityId: string;
opportunityName: string;
opportunityStage: string;
}
/**
* Unified search results from Salesforce.
*/
export interface ISearchResults {
accounts: IAccountMatch[];
contacts: IContactMatch[];
leads: ILeadMatch[];
opportunities: IOpportunityMatch[];
}
/**
* Salesforce object type for API calls.
*/
export type SalesforceObjectType = 'Account' | 'Lead' | 'Contact' | 'Opportunity';
/**
* Link result from API.
*/
export interface ILinkResult {
error?: string;
success: boolean;
}
/**
* Confirm pending account result.
*/
export interface IConfirmAccountResult {
error?: string;
linkedOpportunity?: {
opportunityId: string;
opportunityName: string;
};
success: boolean;
}
/**
* Confirm pending deal result.
*/
export interface IConfirmDealResult {
error?: string;
success: boolean;
}

View File

@@ -0,0 +1,424 @@
import { debounce } from 'lodash-es';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { hideNotification, showNotification } from '../notifications/actions';
import {
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TYPE,
SALESFORCE_LINK_NOTIFICATION_ID
} from '../notifications/constants';
import {
confirmPendingAccount,
confirmPendingDeal,
getSessionSalesforceData,
linkSession,
rejectPendingAccounts,
rejectPendingDeals,
searchSalesforce,
unlinkSession
} from './functions';
import {
IAccountMatch,
IContactMatch,
ILeadMatch,
IOpportunityMatch,
ISalesforceData,
ISearchResults,
SalesforceObjectType
} from './types';
export const useSalesforceLinkDialog = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
// Redux state
const conference = useSelector(getCurrentConference);
const sessionId = conference?.getMeetingUniqueId();
const { salesforceUrl = '' } = useSelector((state: IReduxState) => state['features/base/config']);
const { jwt = '' } = useSelector((state: IReduxState) => state['features/base/jwt']);
// Component state
const [ salesforceData, setSalesforceData ] = useState<ISalesforceData | null>(null);
const [ searchResults, setSearchResults ] = useState<ISearchResults | null>(null);
const [ searchTerm, setSearchTerm ] = useState('');
const [ isLoading, setIsLoading ] = useState(true);
const [ isSearching, setIsSearching ] = useState(false);
const [ error, setError ] = useState<string | null>(null);
// Loading states for actions
const [ confirmingAccountId, setConfirmingAccountId ] = useState<string | null>(null);
const [ confirmingDealId, setConfirmingDealId ] = useState<string | null>(null);
const [ rejectingAccounts, setRejectingAccounts ] = useState(false);
const [ rejectingDeals, setRejectingDeals ] = useState(false);
const [ linkingId, setLinkingId ] = useState<string | null>(null);
const [ unlinkingId, setUnlinkingId ] = useState<string | null>(null);
/**
* Refreshes the Salesforce data for the current session.
*/
const refreshSalesforceData = useCallback(async () => {
if (!sessionId) {
return;
}
try {
const data = await getSessionSalesforceData(salesforceUrl, jwt, sessionId);
setSalesforceData(data);
setError(null);
} catch (err: any) {
console.error('Failed to fetch Salesforce data:', err);
setError(err?.error || t('dialog.salesforceDataError'));
}
}, [ salesforceUrl, jwt, sessionId, t ]);
/**
* Performs a search in Salesforce.
*/
const performSearch = useCallback(async (query: string) => {
if (query.length < 2) {
setSearchResults(null);
return;
}
setIsSearching(true);
try {
const results = await searchSalesforce(salesforceUrl, jwt, query);
setSearchResults(results);
setError(null);
} catch (err: any) {
console.error('Salesforce search failed:', err);
setError(err?.error || t('dialog.searchResultsError'));
setSearchResults(null);
} finally {
setIsSearching(false);
}
}, [ salesforceUrl, jwt, t ]);
// Debounced search
const debouncedSearch = useMemo(
() => debounce(performSearch, 500),
[ performSearch ]
);
// Track if component is mounted
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
// Fetch Salesforce data on mount
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await refreshSalesforceData();
if (isMounted.current) {
setIsLoading(false);
}
};
fetchData();
}, [ refreshSalesforceData ]);
// Trigger search when search term changes
useEffect(() => {
if (searchTerm.length >= 2) {
debouncedSearch(searchTerm);
} else {
setSearchResults(null);
}
}, [ searchTerm, debouncedSearch ]);
/**
* Handles confirming a pending account.
*/
const handleConfirmAccount = useCallback(async (accountId: string) => {
if (!sessionId) {
return;
}
setConfirmingAccountId(accountId);
try {
const result = await confirmPendingAccount(salesforceUrl, jwt, sessionId, accountId);
if (result.success) {
dispatch(showNotification({
titleKey: 'notify.confirmAccountSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
await refreshSalesforceData();
}
} catch (err: any) {
dispatch(showNotification({
titleKey: 'notify.confirmAccountError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setConfirmingAccountId(null);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
/**
* Handles rejecting all pending accounts.
*/
const handleRejectAllAccounts = useCallback(async () => {
if (!sessionId) {
return;
}
setRejectingAccounts(true);
try {
await rejectPendingAccounts(salesforceUrl, jwt, sessionId);
dispatch(showNotification({
titleKey: 'notify.dismissSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
await refreshSalesforceData();
} catch (err: any) {
dispatch(showNotification({
titleKey: 'notify.dismissError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setRejectingAccounts(false);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
/**
* Handles confirming a pending deal.
*/
const handleConfirmDeal = useCallback(async (opportunityId: string) => {
if (!sessionId) {
return;
}
setConfirmingDealId(opportunityId);
try {
const result = await confirmPendingDeal(salesforceUrl, jwt, sessionId, opportunityId);
if (result.success) {
dispatch(showNotification({
titleKey: 'notify.confirmDealSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
await refreshSalesforceData();
}
} catch (err: any) {
dispatch(showNotification({
titleKey: 'notify.confirmDealError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setConfirmingDealId(null);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
/**
* Handles rejecting all pending deals.
*/
const handleRejectAllDeals = useCallback(async () => {
if (!sessionId) {
return;
}
setRejectingDeals(true);
try {
await rejectPendingDeals(salesforceUrl, jwt, sessionId);
dispatch(showNotification({
titleKey: 'notify.dismissSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
await refreshSalesforceData();
} catch (err: any) {
dispatch(showNotification({
titleKey: 'notify.dismissError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setRejectingDeals(false);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
/**
* Handles linking a record from search results.
*/
const handleLinkRecord = useCallback(async (
type: SalesforceObjectType,
data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch
) => {
if (!sessionId) {
return;
}
const id = type === 'Account' ? (data as IAccountMatch).accountId
: type === 'Lead' ? (data as ILeadMatch).leadId
: type === 'Contact' ? (data as IContactMatch).contactId
: (data as IOpportunityMatch).opportunityId;
setLinkingId(id);
dispatch(showNotification({
titleKey: 'notify.linkToSalesforceProgress',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.NORMAL
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
try {
await linkSession(salesforceUrl, jwt, sessionId, type, data);
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
dispatch(showNotification({
titleKey: 'notify.linkToSalesforceSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
// Clear search and refresh data
setSearchTerm('');
setSearchResults(null);
await refreshSalesforceData();
} catch (err: any) {
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
dispatch(showNotification({
titleKey: 'notify.linkToSalesforceError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setLinkingId(null);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
/**
* Handles unlinking a record from the session.
*/
const handleUnlinkRecord = useCallback(async (type: SalesforceObjectType, id: string) => {
if (!sessionId) {
return;
}
setUnlinkingId(id);
try {
await unlinkSession(salesforceUrl, jwt, sessionId, type, id);
dispatch(showNotification({
titleKey: 'notify.unlinkFromSalesforceSuccess',
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.SUCCESS
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
await refreshSalesforceData();
} catch (err: any) {
dispatch(showNotification({
titleKey: 'notify.unlinkFromSalesforceError',
descriptionKey: err?.error,
uid: SALESFORCE_LINK_NOTIFICATION_ID,
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
} finally {
setUnlinkingId(null);
}
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
// Filter out already-linked items from search results
const filteredSearchResults = useMemo(() => {
if (!searchResults || !salesforceData) {
return searchResults;
}
return {
accounts: searchResults.accounts.filter(
account => salesforceData.account?.accountId !== account.accountId
),
leads: searchResults.leads.filter(
lead => !salesforceData.leads?.some(l => l.leadId === lead.leadId)
),
contacts: searchResults.contacts.filter(
contact => !salesforceData.contacts?.some(c => c.contactId === contact.contactId)
),
opportunities: searchResults.opportunities.filter(
opp => salesforceData.deal?.opportunityId !== opp.opportunityId
)
};
}, [ searchResults, salesforceData ]);
const hasSearchResults = filteredSearchResults
&& (filteredSearchResults.accounts.length > 0
|| filteredSearchResults.leads.length > 0
|| filteredSearchResults.contacts.length > 0
|| filteredSearchResults.opportunities.length > 0);
const hasPendingAccounts = (salesforceData?.pendingAccounts?.length ?? 0) > 0;
const hasPendingDeals = (salesforceData?.pendingDeals?.length ?? 0) > 0;
return {
// Data
salesforceData,
searchResults: filteredSearchResults,
hasSearchResults,
hasPendingAccounts,
hasPendingDeals,
// State
searchTerm,
isLoading,
isSearching,
error,
// Action loading states
confirmingAccountId,
confirmingDealId,
rejectingAccounts,
rejectingDeals,
linkingId,
unlinkingId,
// Setters
setSearchTerm,
// Actions
handleConfirmAccount,
handleRejectAllAccounts,
handleConfirmDeal,
handleRejectAllDeals,
handleLinkRecord,
handleUnlinkRecord,
refreshSalesforceData
};
};