From 1ba1a279d418fed15237c11b7216039da4aaec59 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:37:07 +0100 Subject: [PATCH 1/3] Feat: support improvements (#989) * feat: added missing translations * feat: contact data change hint + document required * feat: verification call settings * feat: updated notification of changes support issue reasons * feat: recommendation anchor * fix: tests --- package-lock.json | 16 ++--- package.json | 4 +- src/__tests__/labels.test.ts | 10 +++ src/config/labels.ts | 14 +++++ src/hooks/anchor.hook.ts | 17 ++++++ src/screens/account.screen.tsx | 57 +++++++++++------ src/screens/settings.screen.tsx | 91 +++++++++++++++++++++++++++- src/screens/support-issue.screen.tsx | 42 ++++++++----- src/translations/languages/de.json | 20 +++++- src/translations/languages/fr.json | 20 +++++- src/translations/languages/it.json | 20 +++++- 11 files changed, 260 insertions(+), 51 deletions(-) create mode 100644 src/hooks/anchor.hook.ts diff --git a/package-lock.json b/package-lock.json index 75243f29..039d67e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.247", - "@dfx.swiss/react-components": "^1.3.0-beta.247", + "@dfx.swiss/react": "^1.3.0-beta.249", + "@dfx.swiss/react-components": "^1.3.0-beta.249", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", @@ -2631,9 +2631,9 @@ } }, "node_modules/@dfx.swiss/react": { - "version": "1.3.0-beta.247", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.247.tgz", - "integrity": "sha512-tKQKYVoBCiqtNFG5CkGpai17fzMVyLss2JjsT7cGlFcjFIBph9u//WOJFceuZBXCny3vLi3RMxaji3p5zdQ0Pw==", + "version": "1.3.0-beta.249", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.249.tgz", + "integrity": "sha512-/1KirTNfCjeYZ10H07AcPSI0XDR2suAhqcgMxcv99UmC29A+DO7oGjWgiAM6HsWexLSwxdXwX/xkF45VtZYNJg==", "license": "MIT", "dependencies": { "ibantools": "^4.2.1", @@ -2644,9 +2644,9 @@ } }, "node_modules/@dfx.swiss/react-components": { - "version": "1.3.0-beta.247", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.247.tgz", - "integrity": "sha512-P2rRi/b8qXWYHhPRfsu3/+7Xsc6feke2wqGCVch/UG7X8v0ThPwah8T0gNGi1AbrQiUXT//snImhCehoWPwunQ==", + "version": "1.3.0-beta.249", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.249.tgz", + "integrity": "sha512-vvLjHWO+xgHVoETLT+QporGU+/1ZGw1rMyU+0Ep9sornxuazmAYUbZd56Wme+KgL3OCZZPmb5lXeOpbcQm7EUw==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.18.1", diff --git a/package.json b/package.json index 7bb20034..94eaf0cd 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "access": "public" }, "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.247", - "@dfx.swiss/react-components": "^1.3.0-beta.247", + "@dfx.swiss/react": "^1.3.0-beta.249", + "@dfx.swiss/react-components": "^1.3.0-beta.249", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", diff --git a/src/__tests__/labels.test.ts b/src/__tests__/labels.test.ts index 3934f6c6..36a121eb 100644 --- a/src/__tests__/labels.test.ts +++ b/src/__tests__/labels.test.ts @@ -64,6 +64,16 @@ jest.mock('@dfx.swiss/react', () => ({ TransactionFailureReason: {}, PaymentQuoteStatus: {}, FileType: {}, + PhoneCallTime: { + H_9_TO_10: 'H9To10', + H_10_TO_11: 'H10To11', + H_11_TO_12: 'H11To12', + H_12_TO_13: 'H12To13', + H_13_TO_14: 'H13To14', + H_14_TO_15: 'H14To15', + H_15_TO_16: 'H15To16', + H_9_TO_16: 'H9To16', + }, })); import { toPaymentStateLabel, PaymentMethodLabels, LimitLabels, addressLabel } from '../config/labels'; diff --git a/src/config/labels.ts b/src/config/labels.ts index 25e745cb..ecba26a8 100644 --- a/src/config/labels.ts +++ b/src/config/labels.ts @@ -5,6 +5,7 @@ import { InvestmentDate, Limit, PaymentQuoteStatus, + PhoneCallTime, Session, SupportIssueReason, SupportIssueType, @@ -172,3 +173,16 @@ export function addressLabel(wallet: UserAddress | Session): string { ? custodyLabel : wallet.address ?? ''; } + +// --- VERIFICATION CALL --- // +export const PhoneCallTimeLabels = { + [PhoneCallTime.H_9_TO_10]: '09:00 - 10:00', + [PhoneCallTime.H_10_TO_11]: '10:00 - 11:00', + [PhoneCallTime.H_11_TO_12]: '11:00 - 12:00', + [PhoneCallTime.H_12_TO_13]: '12:00 - 13:00', + [PhoneCallTime.H_13_TO_14]: '13:00 - 14:00', + [PhoneCallTime.H_14_TO_15]: '14:00 - 15:00', + [PhoneCallTime.H_15_TO_16]: '15:00 - 16:00', + [PhoneCallTime.H_9_TO_16]: '09:00 - 16:00', +}; + diff --git a/src/hooks/anchor.hook.ts b/src/hooks/anchor.hook.ts new file mode 100644 index 00000000..655477ab --- /dev/null +++ b/src/hooks/anchor.hook.ts @@ -0,0 +1,17 @@ +import { RefObject, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export function useAnchor(anchorName: string, ref: RefObject, isReady = true): void { + const [searchParams, setSearchParams] = useSearchParams(); + const anchor = searchParams.get('a'); + + useEffect(() => { + if (anchor === anchorName && ref.current && isReady) { + setTimeout(() => { + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + searchParams.delete('a'); + setSearchParams(searchParams, { replace: true }); + }, 100); + } + }, [anchor, anchorName, isReady]); +} diff --git a/src/screens/account.screen.tsx b/src/screens/account.screen.tsx index 21deb361..b8c10d76 100644 --- a/src/screens/account.screen.tsx +++ b/src/screens/account.screen.tsx @@ -27,13 +27,13 @@ import { StyledDataTableExpandableRow, StyledDataTableRow, StyledDropdown, + StyledIconButton, StyledInput, StyledLoadingSpinner, - StyledIconButton, StyledVerticalStack, } from '@dfx.swiss/react-components'; import copy from 'copy-to-clipboard'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { RecommendationsSection } from 'src/components/account/recommendations-section'; import { KycStatus } from 'src/components/kyc-status'; @@ -42,8 +42,9 @@ import { addressLabel } from 'src/config/labels'; import { Urls } from 'src/config/urls'; import { useLayoutContext } from 'src/contexts/layout.context'; import { useWindowContext } from 'src/contexts/window.context'; -import { useKycHelper } from 'src/hooks/kyc-helper.hook'; +import { useAnchor } from 'src/hooks/anchor.hook'; import { useUserGuard } from 'src/hooks/guard.hook'; +import { useKycHelper } from 'src/hooks/kyc-helper.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { blankedAddress, downloadPdfFromString, sortAddressesByBlockchain, url } from 'src/util/utils'; @@ -99,10 +100,12 @@ export default function AccountScreen(): JSX.Element { const [isPdfLoading, setIsPdfLoading] = useState(false); const [pdfError, setPdfError] = useState(); const [showRecommendationModal, setShowRecommendationModal] = useState(false); - const [isDataLoading, setIsDataLoading] = useState(true); const [pdfBlockchains, setPdfBlockchains] = useState([]); + const recommendationsRef = useRef(null); + useAnchor('recommendation', recommendationsRef, !isUserLoading && !isDataLoading); + const isKycLevel50 = user && user.kyc.level >= 50; useUserGuard('/login'); @@ -302,18 +305,18 @@ export default function AccountScreen(): JSX.Element { const annualVolumeSum = annualVolumeItems?.reduce((acc, item) => acc + item.value, 0); const title = showPdfModal - ? translate('screens/home', 'PDF Download Address Report') - : showRecommendationModal - ? translate('screens/recommendation', 'Create Invitation') - : isEmbedded - ? translate('screens/home', 'DFX services') - : translate('screens/home', 'Account'); + ? translate('screens/home', 'PDF Download Address Report') + : showRecommendationModal + ? translate('screens/recommendation', 'Create Invitation') + : isEmbedded + ? translate('screens/home', 'DFX services') + : translate('screens/home', 'Account'); const hasBackButton = (canClose && !isEmbedded) || showPdfModal || showRecommendationModal; const onBack = showPdfModal - ? closePdfModal - : showRecommendationModal - ? () => setShowRecommendationModal(false) - : undefined; + ? closePdfModal + : showRecommendationModal + ? () => setShowRecommendationModal(false) + : undefined; const image = 'https://dfx.swiss/images/app/berge.jpg'; useLayoutOptions({ title, backButton: hasBackButton, onBack }); @@ -338,7 +341,11 @@ export default function AccountScreen(): JSX.Element {
{profile.mail} - navigate('/account/mail', { setRedirect: true })} inline /> + navigate('/account/mail', { setRedirect: true })} + inline + />
)} @@ -346,7 +353,11 @@ export default function AccountScreen(): JSX.Element {
{profile.phone} - startStep(KycStepName.PHONE_CHANGE)} inline /> + startStep(KycStepName.PHONE_CHANGE)} + inline + />
)} @@ -354,7 +365,11 @@ export default function AccountScreen(): JSX.Element {
{[profile.firstName, profile.lastName].filter(Boolean).join(' ')} - startStep(KycStepName.NAME_CHANGE)} inline /> + startStep(KycStepName.NAME_CHANGE)} + inline + />
)} @@ -362,7 +377,11 @@ export default function AccountScreen(): JSX.Element {
{formatAddress(profile.address)} - startStep(KycStepName.ADDRESS_CHANGE)} inline /> + startStep(KycStepName.ADDRESS_CHANGE)} + inline + />
)} @@ -473,7 +492,7 @@ export default function AccountScreen(): JSX.Element { <>
-

+

{translate('screens/recommendation', 'Recommendations')}

diff --git a/src/screens/settings.screen.tsx b/src/screens/settings.screen.tsx index 7648c4fa..d5ea76da 100644 --- a/src/screens/settings.screen.tsx +++ b/src/screens/settings.screen.tsx @@ -2,6 +2,8 @@ import { BankAccount, Fiat, Language, + PhoneCallStatus, + PhoneCallTime, useBankAccountContext, useFiatContext, UserAddress, @@ -14,11 +16,12 @@ import { StyledButton, StyledButtonWidth, StyledDropdown, + StyledDropdownMultiChoice, StyledLoadingSpinner, StyledVerticalStack, } from '@dfx.swiss/react-components'; import copy from 'copy-to-clipboard'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { Trans } from 'react-i18next'; import ActionableList from 'src/components/actionable-list'; @@ -26,11 +29,12 @@ import { ConfirmationOverlay } from 'src/components/overlay/confirmation-overlay import { EditBankAccount } from 'src/components/overlay/edit-bank-overlay'; import { EditOverlay } from 'src/components/overlay/edit-overlay'; import { AddBankAccount } from 'src/components/payment/add-bank-account'; -import { addressLabel } from 'src/config/labels'; +import { addressLabel, PhoneCallTimeLabels } from 'src/config/labels'; import { useLayoutContext } from 'src/contexts/layout.context'; import { useSettingsContext } from 'src/contexts/settings.context'; import { useWalletContext } from 'src/contexts/wallet.context'; import { useWindowContext } from 'src/contexts/window.context'; +import { useAnchor } from 'src/hooks/anchor.hook'; import { useUserGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; @@ -39,6 +43,8 @@ import { blankedAddress, sortAddressesByBlockchain } from 'src/util/utils'; interface FormData { language: Language; currency: Fiat; + preferredPhoneTimes: PhoneCallTime[]; + acceptCall: boolean; } enum OverlayType { @@ -64,12 +70,15 @@ const OverlayHeader: { [key in OverlayType]: string } = { export default function SettingsScreen(): JSX.Element { const { translate, language, currency, availableLanguages, changeLanguage, changeCurrency } = useSettingsContext(); const { currencies } = useFiatContext(); - const { user, isUserLoading, userAddresses } = useUserContext(); + const { user, isUserLoading, userAddresses, updateCallSettings } = useUserContext(); const { width } = useWindowContext(); const { navigate } = useNavigation(); const { rootRef } = useLayoutContext(); const { bankAccounts, updateAccount, isLoading: isLoadingBankAccounts } = useBankAccountContext(); + const verificationCallRef = useRef(null); + useAnchor('call', verificationCallRef, !isUserLoading); + const [overlayData, setOverlayData] = useState(); const [overlayType, setOverlayType] = useState(OverlayType.NONE); @@ -82,6 +91,8 @@ export default function SettingsScreen(): JSX.Element { } = useForm(); const selectedLanguage = useWatch({ control, name: 'language' }); const selectedCurrency = useWatch({ control, name: 'currency' }); + const selectedPreferredPhoneTimes = useWatch({ control, name: 'preferredPhoneTimes' }); + const acceptCall = useWatch({ control, name: 'acceptCall' }); useEffect(() => { if (language && !selectedLanguage) setValue('language', language); @@ -91,6 +102,22 @@ export default function SettingsScreen(): JSX.Element { if (currency && !selectedCurrency) setValue('currency', currency); }, [currency]); + useEffect(() => { + if (user?.preferredPhoneTimes && !selectedPreferredPhoneTimes) { + setValue('preferredPhoneTimes', user.preferredPhoneTimes); + } + }, [user?.preferredPhoneTimes]); + + useEffect(() => { + if (user?.phoneCallStatus && acceptCall === undefined) { + if (user.phoneCallStatus === PhoneCallStatus.ACCEPTED) { + setValue('acceptCall', true); + } else if (user.phoneCallStatus === PhoneCallStatus.REJECTED) { + setValue('acceptCall', false); + } + } + }, [user?.phoneCallStatus]); + useEffect(() => { if (selectedLanguage && selectedLanguage?.id !== language?.id) { changeLanguage(selectedLanguage); @@ -103,6 +130,18 @@ export default function SettingsScreen(): JSX.Element { } }, [selectedCurrency]); + useEffect(() => { + if (selectedPreferredPhoneTimes) { + updateCallSettings(selectedPreferredPhoneTimes); + } + }, [selectedPreferredPhoneTimes]); + + useEffect(() => { + if (acceptCall !== undefined) { + updateCallSettings(undefined, acceptCall); + } + }, [acceptCall]); + function onCloseOverlay(): void { setOverlayType(OverlayType.NONE); setOverlayData(undefined); @@ -261,6 +300,52 @@ export default function SettingsScreen(): JSX.Element { })} /> )} + + {(!user?.phoneCallStatus || + ![PhoneCallStatus.COMPLETED, PhoneCallStatus.FAILED].includes(user.phoneCallStatus)) && ( + +

+ {translate('screens/settings', 'Verification Call')} +

+ + +

+ {translate('screens/settings', 'Verification may require a phone call. Should we call you?')} +

+ +
+ + rootRef={rootRef} + name="acceptCall" + label={translate('screens/settings', 'Phone verification')} + smallLabel={true} + placeholder={translate('general/actions', 'Select') + '...'} + items={[true, false]} + labelFunc={(item) => + item + ? translate('screens/settings', 'Yes, call me') + : translate('screens/settings', "No, don't call me") + } + /> + + + {acceptCall && ( +
+ + rootRef={rootRef} + name="preferredPhoneTimes" + label={translate('screens/settings', 'Preferred call time')} + smallLabel={true} + placeholder={translate('general/actions', 'Select') + '...'} + items={Object.values(PhoneCallTime)} + labelFunc={(item) => translate('screens/settings', PhoneCallTimeLabels[item])} + /> + + )} +
+
+ )} + (!file || DefaultFileTypes.includes(file.type) ? true : 'file_type')), + file: [ + selectedType === SupportIssueType.NOTIFICATION_OF_CHANGES && Validations.Required, + Validations.Custom((file) => (!file || DefaultFileTypes.includes(file.type) ? true : 'file_type')), + ], }); useLayoutOptions({ @@ -366,15 +366,25 @@ export default function SupportIssueScreen(): JSX.Element { /> {reasons.length > 1 && ( - - rootRef={rootRef} - label={translate('screens/support', 'Reason')} - items={reasons.filter((r) => r !== SupportIssueReason.FUNDS_NOT_RECEIVED || !orderParam)} - labelFunc={(item) => translate('screens/support', IssueReasonLabels[item])} - name="reason" - placeholder={translate('general/actions', 'Select') + '...'} - full - /> + + + rootRef={rootRef} + label={translate('screens/support', 'Reason')} + items={reasons.filter((r) => r !== SupportIssueReason.FUNDS_NOT_RECEIVED || !orderParam)} + labelFunc={(item) => translate('screens/support', IssueReasonLabels[item])} + name="reason" + placeholder={translate('general/actions', 'Select') + '...'} + full + /> + {selectedType === SupportIssueType.NOTIFICATION_OF_CHANGES && ( +

+ + Name, address, phone number and email address can be changed directly in your{' '} + . + +

+ )} +
)} {selectedType === SupportIssueType.TRANSACTION_ISSUE && diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index f8431856..6ca925bc 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -896,12 +896,20 @@ "Partnership request": "Anfrage zu einer Partnerschaft", "Notification of changes": "Mitteilung von Änderungen", "Bug report": "Fehlermeldung", + "Verification call": "Verifizierungsanruf", "Reason": "Grund", "Other": "Andere", "Data request": "Datenanfrage", "Funds not received": "Zahlung nicht erhalten", "Transaction missing": "Transaktion fehlt", + "Reject call": "Anruf ablehnen", + "Repeat call": "Anruf wiederholen", + "Name changed": "Name geändert", + "Address changed": "Adresse geändert", + "Civil status changed": "Zivilstand geändert", + + "contactDataChangeHint": "Name, Adresse, Telefonnummer und E-Mail-Adresse können direkt in Deinem <2> geändert werden.", "Transaction ID": "Transaktions-ID", "Select a transaction to proceed with": "Wähle eine Transaktion aus, um fortzufahren", @@ -968,7 +976,17 @@ "delete": "Bist Du sicher, dass Du die Adresse <1>{{address}} von Deinem DFX-Konto löschen möchtest? Diese Aktion ist nicht rückgängig zu machen.", "delete_iban": "Bist Du sicher, dass Du das Bankkonto <1>{{address}} von Deinem DFX-Konto löschen möchtest?", - "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "Deine Daten verbleiben vorübergehend auf unseren Servern, bevor sie dauerhaft gelöscht werden. Wenn Du Fragen hast, wende Dich bitte an unser Support-Team." + "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "Deine Daten verbleiben vorübergehend auf unseren Servern, bevor sie dauerhaft gelöscht werden. Wenn Du Fragen hast, wende Dich bitte an unser Support-Team.", + + "Verification Call": "Verifizierungsanruf", + "Preferred call time": "Bevorzugte Anrufzeit", + "Morning": "Vormittag", + "Afternoon": "Nachmittag", + "Evening": "Abend", + "Yes, call me": "Ja, ruft mich an", + "No, don't call me": "Nein, ruft mich nicht an", + "Verification may require a phone call. Should we call you?": "Die Verifizierung kann einen Anruf erfordern. Sollen wir Dich anrufen?", + "Phone verification": "Telefonische Verifizierung" }, "screens/safe": { "My DFX Safe": "Mein DFX Safe", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index f72e59f2..861430a8 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -895,12 +895,20 @@ "Partnership request": "Demande de partenariat", "Notification of changes": "Notification de changements", "Bug report": "Rapport de bug", + "Verification call": "Appel de vérification", "Reason": "Motif", "Other": "Autres", "Data request": "Demande de données", "Funds not received": "Fonds non reçus", "Transaction missing": "Transaction manquante", + "Reject call": "Refuser l'appel", + "Repeat call": "Répéter l'appel", + "Name changed": "Nom modifié", + "Address changed": "Adresse modifiée", + "Civil status changed": "État civil modifié", + + "contactDataChangeHint": "Le nom, l'adresse, le numéro de téléphone et l'adresse e-mail peuvent être modifiés directement dans votre <2>.", "Transaction ID": "ID de transaction", "Select a transaction to proceed with": "Sélectionnez une transaction pour continuer", @@ -967,7 +975,17 @@ "delete": "Êtes-vous sûr de vouloir supprimer l'adresse <1>{{address}} de votre compte DFX ? Cette action est irréversible.", "delete_iban": "Êtes-vous sûr de vouloir supprimer le compte bancaire <1>{{address}} de votre compte DFX ?", - "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "Vos données resteront temporairement sur nos serveurs avant la suppression définitive. Si vous avez des questions, veuillez contacter notre équipe de support." + "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "Vos données resteront temporairement sur nos serveurs avant la suppression définitive. Si vous avez des questions, veuillez contacter notre équipe de support.", + + "Verification Call": "Appel de vérification", + "Preferred call time": "Heure d'appel préférée", + "Morning": "Matin", + "Afternoon": "Après-midi", + "Evening": "Soir", + "Yes, call me": "Oui, appelez-moi", + "No, don't call me": "Non, ne m'appelez pas", + "Verification may require a phone call. Should we call you?": "La vérification peut nécessiter un appel téléphonique. Devons-nous vous appeler?", + "Phone verification": "Vérification téléphonique" }, "screens/safe": { "My DFX Safe": "Mon DFX Safe", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 39ad82c5..e6306727 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -895,12 +895,20 @@ "Partnership request": "Richiesta di partnership", "Notification of changes": "Notifica di cambiamenti", "Bug report": "Segnalazione di un errore", + "Verification call": "Chiamata di verifica", "Reason": "Motivo", "Other": "Altro", "Data request": "Richiesta di dati", "Funds not received": "Fondi non ricevuti", "Transaction missing": "Transazione mancante", + "Reject call": "Rifiuta chiamata", + "Repeat call": "Ripeti chiamata", + "Name changed": "Nome cambiato", + "Address changed": "Indirizzo cambiato", + "Civil status changed": "Stato civile cambiato", + + "contactDataChangeHint": "Nome, indirizzo, numero di telefono e indirizzo e-mail possono essere modificati direttamente nel tuo <2>.", "Transaction ID": "ID della transazione", "Select a transaction to proceed with": "Selezionare una transazione per procedere", @@ -967,7 +975,17 @@ "delete": "Siete sicuri di voler eliminare l'indirizzo <1>{{address}} dal vostro account DFX? Questa azione è irreversibile.", "delete_iban": "Siete sicuri di voler eliminare il conto bancario <1>{{address}} dal vostro account DFX?", - "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "I tuoi dati rimarranno sui nostri server temporaneamente prima della cancellazione definitiva. Se hai domande, contatta il nostro team di supporto." + "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "I tuoi dati rimarranno sui nostri server temporaneamente prima della cancellazione definitiva. Se hai domande, contatta il nostro team di supporto.", + + "Verification Call": "Chiamata di verifica", + "Preferred call time": "Orario di chiamata preferito", + "Morning": "Mattina", + "Afternoon": "Pomeriggio", + "Evening": "Sera", + "Yes, call me": "Sì, chiamatemi", + "No, don't call me": "No, non chiamatemi", + "Verification may require a phone call. Should we call you?": "La verifica potrebbe richiedere una telefonata. Dobbiamo chiamarti?", + "Phone verification": "Verifica telefonica" }, "screens/safe": { "My DFX Safe": "Il mio DFX Safe", From 64c81fc71326d0ac8fea3737b91b459d720bb36e Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:54:48 +0100 Subject: [PATCH 2/3] fix: user DTO (#996) --- package-lock.json | 16 +++---- package.json | 4 +- src/screens/settings.screen.tsx | 79 +++++++++++++++++---------------- 3 files changed, 51 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 039d67e7..11bce08c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.4", "license": "MIT", "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.249", - "@dfx.swiss/react-components": "^1.3.0-beta.249", + "@dfx.swiss/react": "^1.3.0-beta.250", + "@dfx.swiss/react-components": "^1.3.0-beta.250", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", @@ -2631,9 +2631,9 @@ } }, "node_modules/@dfx.swiss/react": { - "version": "1.3.0-beta.249", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.249.tgz", - "integrity": "sha512-/1KirTNfCjeYZ10H07AcPSI0XDR2suAhqcgMxcv99UmC29A+DO7oGjWgiAM6HsWexLSwxdXwX/xkF45VtZYNJg==", + "version": "1.3.0-beta.250", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react/-/react-1.3.0-beta.250.tgz", + "integrity": "sha512-tsquDZI+rnZLzYKFXMVPBjby5j2K5JLtKKZ+H42qAeGYmdGvpWzjmizg7mOFDGKgNjJyLEqgihX9zuzsdE8XdQ==", "license": "MIT", "dependencies": { "ibantools": "^4.2.1", @@ -2644,9 +2644,9 @@ } }, "node_modules/@dfx.swiss/react-components": { - "version": "1.3.0-beta.249", - "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.249.tgz", - "integrity": "sha512-vvLjHWO+xgHVoETLT+QporGU+/1ZGw1rMyU+0Ep9sornxuazmAYUbZd56Wme+KgL3OCZZPmb5lXeOpbcQm7EUw==", + "version": "1.3.0-beta.250", + "resolved": "https://registry.npmjs.org/@dfx.swiss/react-components/-/react-components-1.3.0-beta.250.tgz", + "integrity": "sha512-hdEJCAiJdW3X5XKozhVCzgFxtKFHRJzrnsbPjxL3mJea4pOhPUchxG3OeBGatmYv1EoDBbpQL0kUjFnuKkN+Ow==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.18.1", diff --git a/package.json b/package.json index 94eaf0cd..1307fa2e 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "access": "public" }, "dependencies": { - "@dfx.swiss/react": "^1.3.0-beta.249", - "@dfx.swiss/react-components": "^1.3.0-beta.249", + "@dfx.swiss/react": "^1.3.0-beta.250", + "@dfx.swiss/react-components": "^1.3.0-beta.250", "@ledgerhq/hw-app-btc": "^6.24.1", "@ledgerhq/hw-app-eth": "^6.33.7", "@ledgerhq/hw-transport-webhid": "^6.27.19", diff --git a/src/screens/settings.screen.tsx b/src/screens/settings.screen.tsx index d5ea76da..5c4ed3f4 100644 --- a/src/screens/settings.screen.tsx +++ b/src/screens/settings.screen.tsx @@ -103,20 +103,20 @@ export default function SettingsScreen(): JSX.Element { }, [currency]); useEffect(() => { - if (user?.preferredPhoneTimes && !selectedPreferredPhoneTimes) { - setValue('preferredPhoneTimes', user.preferredPhoneTimes); + if (user?.kyc.preferredPhoneTimes && !selectedPreferredPhoneTimes) { + setValue('preferredPhoneTimes', user.kyc.preferredPhoneTimes); } - }, [user?.preferredPhoneTimes]); + }, [user?.kyc.preferredPhoneTimes]); useEffect(() => { - if (user?.phoneCallStatus && acceptCall === undefined) { - if (user.phoneCallStatus === PhoneCallStatus.ACCEPTED) { + if (user?.kyc.phoneCallStatus && acceptCall === undefined) { + if (user.kyc.phoneCallStatus === PhoneCallStatus.ACCEPTED) { setValue('acceptCall', true); - } else if (user.phoneCallStatus === PhoneCallStatus.REJECTED) { + } else if (user.kyc.phoneCallStatus === PhoneCallStatus.REJECTED) { setValue('acceptCall', false); } } - }, [user?.phoneCallStatus]); + }, [user?.kyc.phoneCallStatus]); useEffect(() => { if (selectedLanguage && selectedLanguage?.id !== language?.id) { @@ -301,47 +301,50 @@ export default function SettingsScreen(): JSX.Element { /> )} - {(!user?.phoneCallStatus || - ![PhoneCallStatus.COMPLETED, PhoneCallStatus.FAILED].includes(user.phoneCallStatus)) && ( + {(!user?.kyc.phoneCallStatus || + ![PhoneCallStatus.COMPLETED, PhoneCallStatus.FAILED].includes(user.kyc.phoneCallStatus)) && ( -

+

{translate('screens/settings', 'Verification Call')}

- -

- {translate('screens/settings', 'Verification may require a phone call. Should we call you?')} -

- + +

+ {translate('screens/settings', 'Verification may require a phone call. Should we call you?')} +

+ +
+ + rootRef={rootRef} + name="acceptCall" + label={translate('screens/settings', 'Phone verification')} + smallLabel={true} + placeholder={translate('general/actions', 'Select') + '...'} + items={[true, false]} + labelFunc={(item) => + item + ? translate('screens/settings', 'Yes, call me') + : translate('screens/settings', "No, don't call me") + } + /> + + + {acceptCall && (
- + rootRef={rootRef} - name="acceptCall" - label={translate('screens/settings', 'Phone verification')} + name="preferredPhoneTimes" + label={translate('screens/settings', 'Preferred call time')} smallLabel={true} placeholder={translate('general/actions', 'Select') + '...'} - items={[true, false]} - labelFunc={(item) => - item - ? translate('screens/settings', 'Yes, call me') - : translate('screens/settings', "No, don't call me") - } + items={Object.values(PhoneCallTime)} + labelFunc={(item) => translate('screens/settings', PhoneCallTimeLabels[item])} /> - - {acceptCall && ( -
- - rootRef={rootRef} - name="preferredPhoneTimes" - label={translate('screens/settings', 'Preferred call time')} - smallLabel={true} - placeholder={translate('general/actions', 'Select') + '...'} - items={Object.values(PhoneCallTime)} - labelFunc={(item) => translate('screens/settings', PhoneCallTimeLabels[item])} - /> - - )} + )}
)} From 7d7fc854959d77748bff51e569e8eda7ad1c6faa Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:59:23 +0100 Subject: [PATCH 3/3] Add recommendation network graph and KYC step detail (#997) --- package-lock.json | 491 ++++++++++++++++++ package.json | 1 + src/App.tsx | 10 + src/hooks/compliance.hook.ts | 53 ++ src/screens/compliance-kyc-step.screen.tsx | 218 ++++++++ ...compliance-recommendation-graph.screen.tsx | 245 +++++++++ src/screens/compliance-user.screen.tsx | 72 ++- 7 files changed, 1083 insertions(+), 7 deletions(-) create mode 100644 src/screens/compliance-kyc-step.screen.tsx create mode 100644 src/screens/compliance-recommendation-graph.screen.tsx diff --git a/package-lock.json b/package-lock.json index 11bce08c..e38436f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "react-qr-code": "^2.0.11", "react-router-dom": "^6.10.0", "react-scripts": "5.0.1", + "reactflow": "^11.11.4", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "tronweb": "^6.1.1", @@ -6032,6 +6033,108 @@ "license": "MIT", "peer": true }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", @@ -11010,6 +11113,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debounce": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", @@ -11089,6 +11445,12 @@ "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -15501,6 +15863,12 @@ "integrity": "sha512-rhjH9AG1fvabIDoGRVH587413LPjTZgmDF9fOFCbFJQV4yuocX1mHxxvXI4g3cGwbVY9wAYIoKlg1N79frJKQw==", "license": "MIT" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -16517,6 +16885,111 @@ "node": ">=0.12" } }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -29438,6 +29911,24 @@ "node": ">= 10.0.0" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 1307fa2e..6cb4833b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "react-qr-code": "^2.0.11", "react-router-dom": "^6.10.0", "react-scripts": "5.0.1", + "reactflow": "^11.11.4", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "tronweb": "^6.1.1", diff --git a/src/App.tsx b/src/App.tsx index 5dcd1e2d..8a20840b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,8 @@ const ComplianceKycFilesScreen = lazy(() => import('./screens/compliance-kyc-fil const ComplianceKycFilesDetailsScreen = lazy(() => import('./screens/compliance-kyc-files-details.screen')); const ComplianceKycStatsScreen = lazy(() => import('./screens/compliance-kyc-stats.screen')); const ComplianceTransactionListScreen = lazy(() => import('./screens/compliance-transaction-list.screen')); +const ComplianceKycStepScreen = lazy(() => import('./screens/compliance-kyc-step.screen')); +const ComplianceRecommendationGraphScreen = lazy(() => import('./screens/compliance-recommendation-graph.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); const RealunitHoldersScreen = lazy(() => import('./screens/realunit-holders.screen')); const RealunitQuotesScreen = lazy(() => import('./screens/realunit-quotes.screen')); @@ -333,6 +335,14 @@ export const Routes = [ path: 'compliance/user/:id', element: withSuspense(), }, + { + path: 'compliance/user/:id/kyc-step/:stepId', + element: withSuspense(), + }, + { + path: 'compliance/recommendations/:id', + element: withSuspense(), + }, { path: 'compliance/bank-tx/:id/return', element: withSuspense(), diff --git a/src/hooks/compliance.hook.ts b/src/hooks/compliance.hook.ts index c23bee08..05c5dc19 100644 --- a/src/hooks/compliance.hook.ts +++ b/src/hooks/compliance.hook.ts @@ -92,12 +92,57 @@ export interface ComplianceUserData { sellRoutes: SellRouteInfo[]; } +export interface RecommendationGraphNode { + id: number; + firstname?: string; + surname?: string; + kycStatus?: string; + kycLevel?: number; + tradeApprovalDate?: Date; +} + +export interface RecommendationGraphEdge { + id: number; + recommenderId: number; + recommendedId: number; + method: string; + type: string; + isConfirmed?: boolean; + confirmationDate?: Date; + created: Date; +} + +export interface RecommendationGraph { + nodes: RecommendationGraphNode[]; + edges: RecommendationGraphEdge[]; + rootId: number; +} + +export interface RecommendationUserInfo { + id: number; + firstname?: string; + surname?: string; +} + +export interface RecommendationEntry { + id: number; + recommended: RecommendationUserInfo; + isConfirmed?: boolean; + confirmationDate?: Date; + created: Date; +} + export interface KycStepInfo { id: number; name: string; type?: string; status: string; sequenceNumber: number; + result?: string; + comment?: string; + recommender?: RecommendationUserInfo; + recommended?: RecommendationUserInfo; + allRecommendations?: RecommendationEntry[]; created: Date; } @@ -291,6 +336,13 @@ export function useCompliance() { }); } + async function getRecommendationGraph(userDataId: number): Promise { + return call({ + url: `support/recommendation-graph/${userDataId}`, + method: 'GET', + }); + } + return useMemo( () => ({ search, @@ -302,6 +354,7 @@ export function useCompliance() { getKycFileList, getKycFileStats, getTransactionList, + getRecommendationGraph, }), [call], ); diff --git a/src/screens/compliance-kyc-step.screen.tsx b/src/screens/compliance-kyc-step.screen.tsx new file mode 100644 index 00000000..ce842c08 --- /dev/null +++ b/src/screens/compliance-kyc-step.screen.tsx @@ -0,0 +1,218 @@ +import { ApiError } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ErrorHint } from 'src/components/error-hint'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { KycStepInfo, RecommendationUserInfo, useCompliance } from 'src/hooks/compliance.hook'; +import { useComplianceGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +function UserCard({ label, user }: { label: string; user: RecommendationUserInfo }): JSX.Element { + return ( +
+
{label}
+
+ {[user.firstname, user.surname].filter(Boolean).join(' ') || '-'} +
+
UserData #{user.id}
+
+ ); +} + +export default function ComplianceKycStepScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const navigate = useNavigate(); + const { id: userDataId, stepId } = useParams(); + const { getUserData } = useCompliance(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [step, setStep] = useState(); + + useLayoutOptions({ title: translate('screens/compliance', 'KYC Step'), backButton: true, noMaxWidth: true }); + + useEffect(() => { + let cancelled = false; + if (userDataId && stepId) { + setIsLoading(true); + getUserData(+userDataId) + .then((data) => { + if (cancelled) return; + const found = data.kycSteps?.find((s) => s.id === +stepId); + if (found) setStep(found); + else setError('KYC Step not found'); + }) + .catch((e: ApiError) => !cancelled && setError(e.message ?? 'Unknown error')) + .finally(() => !cancelled && setIsLoading(false)); + } else { + setError('Missing parameters'); + setIsLoading(false); + } + return () => { cancelled = true; }; + }, [userDataId, stepId]); + + if (error) return ; + if (isLoading || !step) return ; + + let parsedResult: object | string | undefined; + try { + parsedResult = step.result ? JSON.parse(step.result) : undefined; + } catch { + parsedResult = step.result; + } + + return ( +
+ {/* Header */} +
+

+ {step.name} #{step.id} +

+ + {step.status} + + +
+ + {/* Recommender -> Recommended */} + {(step.recommender || step.recommended) && ( +
+ {step.recommender && } + {step.recommender && step.recommended && ( +
+ )} + {step.recommended && } +
+ )} + + {/* Info Table */} +
+ + + {[ + ['ID', step.id], + ['Name', step.name], + ['Type', step.type || '-'], + ['Status', step.status], + ['Sequence', step.sequenceNumber], + ['Created', new Date(step.created).toLocaleString()], + ].map(([key, value]) => ( + + + + + ))} + +
{String(key)}{String(value)}
+
+ + {/* Comment */} + {step.comment && ( +
+

Comment

+
+ {step.comment} +
+
+ )} + + {/* All Recommendations by this Recommender */} + {step.allRecommendations && step.allRecommendations.length > 0 && step.recommender && ( +
+

+ All Recommendations by {[step.recommender.firstname, step.recommender.surname].filter(Boolean).join(' ') || `#${step.recommender.id}`} ({step.allRecommendations.length}) +

+
+ + + + + + + + + + + {step.allRecommendations.map((rec) => ( + navigate(`/compliance/user/${rec.recommended.id}`)} + > + + + + + + ))} + +
UserDataNameConfirmedDate
+ #{rec.recommended.id} + + {[rec.recommended.firstname, rec.recommended.surname].filter(Boolean).join(' ') || '-'} + + + {rec.isConfirmed === true ? 'Yes' : rec.isConfirmed === false ? 'Denied' : 'Pending'} + + + {new Date(rec.created).toLocaleDateString()} +
+
+
+ )} + + {/* Result */} + {parsedResult && ( +
+

Result

+
+ {typeof parsedResult === 'object' ? ( + + + {Object.entries(parsedResult).map(([key, value]) => ( + + + + + ))} + +
{key} + {typeof value === 'object' && value !== null + ? JSON.stringify(value, null, 2) + : String(value ?? '-')} +
+ ) : ( +
{String(parsedResult)}
+ )} +
+
+ )} +
+ ); +} diff --git a/src/screens/compliance-recommendation-graph.screen.tsx b/src/screens/compliance-recommendation-graph.screen.tsx new file mode 100644 index 00000000..a6d9cd94 --- /dev/null +++ b/src/screens/compliance-recommendation-graph.screen.tsx @@ -0,0 +1,245 @@ +import { ApiError } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import ReactFlow, { + Background, + Controls, + Edge, + Handle, + MarkerType, + MiniMap, + Node, + Position, + useEdgesState, + useNodesState, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { ErrorHint } from 'src/components/error-hint'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RecommendationGraph, RecommendationGraphNode, useCompliance } from 'src/hooks/compliance.hook'; +import { useComplianceGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; + +function UserNode({ data }: { data: RecommendationGraphNode & { isRoot: boolean; childCount: number } }) { + const navigate = useNavigate(); + const name = [data.firstname, data.surname].filter(Boolean).join(' ') || '-'; + const hasApproval = !!data.tradeApprovalDate; + + return ( +
navigate(`/compliance/user/${data.id}`)} + > + +
#{data.id}
+
{name}
+
+ {data.kycStatus && ( + {data.kycStatus} + )} + {data.kycLevel != null && ( + L{data.kycLevel} + )} +
+ {data.childCount > 0 && ( +
{data.childCount} recommendations
+ )} + +
+ ); +} + +const nodeTypes = { user: UserNode }; + +function layoutGraph(graph: RecommendationGraph): { nodes: Node[]; edges: Edge[] } { + // Build adjacency: recommender -> recommended[] + const children = new Map(); + const parents = new Map(); + + for (const edge of graph.edges) { + const cList = children.get(edge.recommenderId) ?? []; + cList.push(edge.recommendedId); + children.set(edge.recommenderId, cList); + const pList = parents.get(edge.recommendedId) ?? []; + pList.push(edge.recommenderId); + parents.set(edge.recommendedId, pList); + } + + // Find root nodes (no parent) or use the provided rootId's top ancestor + const findRoot = (id: number, visited = new Set()): number => { + visited.add(id); + const parentList = parents.get(id) || []; + for (const p of parentList) { + if (!visited.has(p)) return findRoot(p, visited); + } + return id; + }; + + const topRoot = findRoot(graph.rootId); + + // BFS to assign levels and positions + const levels = new Map(); + const queue: number[] = [topRoot]; + levels.set(topRoot, 0); + const ordered: number[] = []; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift() as number; + if (visited.has(current)) continue; + visited.add(current); + ordered.push(current); + const childList = children.get(current) || []; + for (const child of childList) { + if (!levels.has(child)) { + levels.set(child, (levels.get(current) || 0) + 1); + queue.push(child); + } + } + } + + // Add any nodes not reached by BFS (disconnected) + for (const node of graph.nodes) { + if (!levels.has(node.id)) { + levels.set(node.id, 0); + ordered.push(node.id); + } + } + + // Group by level for x positioning + const byLevel = new Map(); + for (const id of ordered) { + const level = levels.get(id) || 0; + const lvl = byLevel.get(level) ?? []; + lvl.push(id); + byLevel.set(level, lvl); + } + + const NODE_WIDTH = 220; + const NODE_HEIGHT = 120; + const nodeMap = new Map(graph.nodes.map((n) => [n.id, n])); + + const nodes: Node[] = []; + for (const [level, ids] of byLevel.entries()) { + const totalWidth = ids.length * NODE_WIDTH; + const startX = -totalWidth / 2; + + ids.forEach((id, index) => { + const nodeData = nodeMap.get(id); + if (!nodeData) return; + nodes.push({ + id: String(id), + type: 'user', + position: { x: startX + index * NODE_WIDTH, y: level * NODE_HEIGHT }, + data: { + ...nodeData, + isRoot: id === graph.rootId, + childCount: (children.get(id) || []).length, + }, + }); + }); + } + + const edges: Edge[] = graph.edges.map((e) => ({ + id: `e-${e.id}`, + source: String(e.recommenderId), + target: String(e.recommendedId), + markerEnd: { type: MarkerType.ArrowClosed }, + style: { stroke: e.isConfirmed ? '#22c55e' : e.isConfirmed === false ? '#ef4444' : '#9ca3af' }, + label: e.method, + labelStyle: { fontSize: 10, fill: '#6b7280' }, + })); + + return { nodes, edges }; +} + +export default function ComplianceRecommendationGraphScreen(): JSX.Element { + useComplianceGuard(); + + const { translate } = useSettingsContext(); + const { id: userDataId } = useParams(); + const { getRecommendationGraph } = useCompliance(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [graph, setGraph] = useState(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + useLayoutOptions({ + title: translate('screens/compliance', 'Recommendation Network'), + backButton: true, + noMaxWidth: true, + }); + + useEffect(() => { + let cancelled = false; + if (userDataId) { + setIsLoading(true); + getRecommendationGraph(+userDataId) + .then((data) => { + if (cancelled) return; + setGraph(data); + const layout = layoutGraph(data); + setNodes(layout.nodes); + setEdges(layout.edges); + }) + .catch((e: ApiError) => !cancelled && setError(e.message ?? 'Unknown error')) + .finally(() => !cancelled && setIsLoading(false)); + } + return () => { cancelled = true; }; + }, [userDataId]); + + const memoNodeTypes = useMemo(() => nodeTypes, []); + + if (error) return ; + if (isLoading) return ; + + return ( +
+ {/* Stats bar */} +
+ Nodes: {graph?.nodes.length || 0} + Edges: {graph?.edges.length || 0} + + Current user + + + Trade approved + + + No approval + +
+ + + + + (n.data?.isRoot ? '#1e40af' : n.data?.tradeApprovalDate ? '#22c55e' : '#d1d5db')} + zoomable + pannable + /> + +
+ ); +} diff --git a/src/screens/compliance-user.screen.tsx b/src/screens/compliance-user.screen.tsx index 6e736352..33a49dff 100644 --- a/src/screens/compliance-user.screen.tsx +++ b/src/screens/compliance-user.screen.tsx @@ -1,7 +1,7 @@ import { ApiError, useKyc } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { ErrorHint } from 'src/components/error-hint'; import { useSettingsContext } from 'src/contexts/settings.context'; import { @@ -69,6 +69,7 @@ export default function ComplianceUserScreen(): JSX.Element { const { id: userDataId } = useParams(); const { getUserData } = useCompliance(); const { getFile } = useKyc(); + const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); @@ -98,15 +99,17 @@ export default function ComplianceUserScreen(): JSX.Element { } useEffect(() => { + let cancelled = false; if (userDataId) { setIsLoading(true); getUserData(+userDataId) - .then(setData) - .catch((e: ApiError) => setError(e.message ?? 'Unknown error')) - .finally(() => setIsLoading(false)); + .then((d) => !cancelled && setData(d)) + .catch((e: ApiError) => !cancelled && setError(e.message ?? 'Unknown error')) + .finally(() => !cancelled && setIsLoading(false)); } else { setError('No ID provided'); } + return () => { cancelled = true; }; }, [userDataId]); useEffect(() => { @@ -205,12 +208,66 @@ export default function ComplianceUserScreen(): JSX.Element { - {/* Middle: KYC Files */} -
+ {/* Middle: KYC Steps + KYC Files */} +
+ {/* Recommendation Steps */} + {(() => { + const recommendations = data.kycSteps?.filter((s) => s.name === 'Recommendation') || []; + return ( +
+

+ Recommendation ({recommendations.length}) +

+
+ {recommendations.length > 0 ? ( + + + + + + + + + {recommendations.map((step: KycStepInfo) => ( + navigate(`/compliance/user/${userDataId}/kyc-step/${step.id}`, { state: { step } })} + > + + + + ))} + +
StatusCreated
+ + {step.status} + + + {new Date(step.created).toLocaleDateString()} +
+ ) : ( +
No recommendation
+ )} +
+
+ ); + })()} + + {/* KYC Files */} +

{translate('screens/compliance', 'KYC Files')} ({data.kycFiles?.length || 0})

-
+
{data.kycFiles?.length > 0 ? ( @@ -240,6 +297,7 @@ export default function ComplianceUserScreen(): JSX.Element {
No KYC files
)} + {/* Right: File Preview */}