diff --git a/clients/banking/package.json b/clients/banking/package.json index 347a4ccbc..849d2178b 100644 --- a/clients/banking/package.json +++ b/clients/banking/package.json @@ -11,18 +11,16 @@ "clean": "tsc --clean" }, "dependencies": { - "@0no-co/graphql.web": "1.0.6", "@formatjs/intl": "2.10.1", "@juggle/resize-observer": "3.4.0", "@sentry/react": "7.109.0", "@swan-io/boxed": "2.1.1", "@swan-io/chicane": "2.0.0", + "@swan-io/graphql-client": "0.1.0-beta4", "@swan-io/lake": "7.3.3", "@swan-io/request": "1.0.4", "@swan-io/shared-business": "7.3.3", "@swan-io/use-form": "2.0.0", - "@urql/devtools": "2.0.3", - "@urql/exchange-graphcache": "7.0.0", "core-js": "3.36.1", "dayjs": "1.11.10", "iban": "0.0.14", @@ -35,9 +33,7 @@ "react-ux-form": "1.5.0", "rifm": "0.12.1", "tggl-client": "1.13.2", - "ts-pattern": "5.1.0", - "urql": "4.0.7", - "wonka": "6.3.4" + "ts-pattern": "5.1.0" }, "devDependencies": { "@types/iban": "0.0.35", diff --git a/clients/banking/src/App.tsx b/clients/banking/src/App.tsx index 826d198c8..e1a668467 100644 --- a/clients/banking/src/App.tsx +++ b/clients/banking/src/App.tsx @@ -1,22 +1,23 @@ +import { AsyncData } from "@swan-io/boxed"; +import { ClientContext, useQuery } from "@swan-io/graphql-client"; import { ErrorBoundary } from "@swan-io/lake/src/components/ErrorBoundary"; -import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { ToastStack } from "@swan-io/lake/src/components/ToastStack"; import { colors } from "@swan-io/lake/src/constants/design"; import { isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; -import { Suspense } from "react"; import { StyleSheet } from "react-native"; import { P, match } from "ts-pattern"; -import { Provider as ClientProvider } from "urql"; -import { AccountArea } from "./components/AccountArea"; +import { AccountMembershipArea } from "./components/AccountMembershipArea"; import { ErrorView } from "./components/ErrorView"; import { ProjectRootRedirect } from "./components/ProjectRootRedirect"; +import { Redirect } from "./components/Redirect"; +import { AuthStatusDocument } from "./graphql/partner"; import { NotFoundPage } from "./pages/NotFoundPage"; import { PopupCallbackPage } from "./pages/PopupCallbackPage"; import { ProjectLoginPage } from "./pages/ProjectLoginPage"; +import { partnerClient, unauthenticatedClient } from "./utils/gql"; import { logFrontendError } from "./utils/logger"; import { projectConfiguration } from "./utils/projectId"; import { Router } from "./utils/routes"; -import { isUnauthorizedError, partnerClient } from "./utils/urql"; const styles = StyleSheet.create({ base: { @@ -25,51 +26,76 @@ const styles = StyleSheet.create({ }, }); +const AppContainer = () => { + const route = Router.useRoute(["ProjectLogin", "ProjectRootRedirect", "AccountArea"]); + const [authStatus] = useQuery(AuthStatusDocument, {}); + + const loginInfo = authStatus + .mapOk(data => data.user?.id != null) + .map(result => ({ isLoggedIn: result.getWithDefault(false) })); + + return match(loginInfo) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => null) + .with(AsyncData.P.Done(P.select()), ({ isLoggedIn }) => { + return match(route) + .with({ name: "ProjectLogin" }, ({ params: { sessionExpired } }) => + projectConfiguration.match({ + None: () => , + Some: ({ projectId }) => + isLoggedIn ? ( + // Skip login and redirect to the root URL + + ) : ( + + + + ), + }), + ) + .with({ name: "AccountArea" }, { name: "ProjectRootRedirect" }, route => + isLoggedIn ? ( + match(route) + .with({ name: "AccountArea" }, ({ params: { accountMembershipId } }) => ( + + )) + .with({ name: "ProjectRootRedirect" }, ({ params: { to, source } }) => ( + + )) + .with(P.nullish, () => ) + .exhaustive() + ) : ( + + ), + ) + .with(P.nullish, () => ) + .exhaustive(); + }) + .exhaustive(); +}; + export const App = () => { - const route = Router.useRoute([ - "PopupCallback", - "ProjectLogin", - "ProjectRootRedirect", - "AccountArea", - ]); + const route = Router.useRoute(["PopupCallback"]); return ( logFrontendError(error)} - fallback={({ error }) => - isUnauthorizedError(error) ? <> : - } + fallback={() => } > - - }> - {match(route) - .with({ name: "PopupCallback" }, () => ) - .with({ name: "ProjectLogin" }, ({ params: { sessionExpired } }) => - projectConfiguration.match({ - None: () => , - Some: ({ projectId }) => ( - - ), - }), - ) - .with({ name: "AccountArea" }, { name: "ProjectRootRedirect" }, route => - match(route) - .with({ name: "AccountArea" }, ({ params: { accountMembershipId } }) => ( - - )) - .with({ name: "ProjectRootRedirect" }, ({ params: { to, source } }) => ( - - )) - .exhaustive(), - ) - .with(P.nullish, () => ) - .exhaustive()} - - + {match(route) + // The callback page is agnostic as to the current authentication, + // meaning we don't check if the user is logged in when on this path + .with({ name: "PopupCallback" }, () => ) + .otherwise(() => ( + // The auth check requires a GraphQL client + + + + + ))} diff --git a/clients/banking/src/components/AccountArea.tsx b/clients/banking/src/components/AccountArea.tsx index 793177db3..49126a7af 100644 --- a/clients/banking/src/components/AccountArea.tsx +++ b/clients/banking/src/components/AccountArea.tsx @@ -1,4 +1,4 @@ -import { Option, Result } from "@swan-io/boxed"; +import { Array, Dict, Option } from "@swan-io/boxed"; import { AutoWidthImage } from "@swan-io/lake/src/components/AutoWidthImage"; import { Box } from "@swan-io/lake/src/components/Box"; import { ErrorBoundary } from "@swan-io/lake/src/components/ErrorBoundary"; @@ -23,10 +23,7 @@ import { insets } from "@swan-io/lake/src/constants/insets"; import { useBoolean } from "@swan-io/lake/src/hooks/useBoolean"; import { usePersistedState } from "@swan-io/lake/src/hooks/usePersistedState"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; -import { isEmpty, isNotEmpty, isNullish } from "@swan-io/lake/src/utils/nullish"; -import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql"; -import { Request } from "@swan-io/request"; +import { isNotEmpty, isNullish } from "@swan-io/lake/src/utils/nullish"; import { CONTENT_ID, SkipToContent } from "@swan-io/shared-business/src/components/SkipToContent"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -38,13 +35,8 @@ import { View, } from "react-native"; import { P, match } from "ts-pattern"; -import { useQuery } from "urql"; import logoSwan from "../assets/images/logo-swan.svg"; -import { - AccountAreaDocument, - LastRelevantIdentificationDocument, - UpdateAccountLanguageDocument, -} from "../graphql/partner"; +import { AccountAreaQuery, IdentificationFragment } from "../graphql/partner"; import { AccountActivationPage } from "../pages/AccountActivationPage"; import { AccountNotFoundPage, NotFoundPage } from "../pages/NotFoundPage"; import { ProfilePage } from "../pages/ProfilePage"; @@ -54,7 +46,6 @@ import { getIdentificationLevelStatusInfo } from "../utils/identification"; import { logFrontendError, setSentryUser } from "../utils/logger"; import { projectConfiguration } from "../utils/projectId"; import { - RouteName, Router, accountMinimalRoutes, historyMenuRoutes, @@ -62,7 +53,6 @@ import { } from "../utils/routes"; import { signout } from "../utils/signout"; import { updateTgglContext } from "../utils/tggl"; -import { isUnauthorizedError } from "../utils/urql"; import { AccountDetailsArea } from "./AccountDetailsArea"; import { AccountNavigation, Menu } from "./AccountNavigation"; import { AccountActivationTag, AccountPicker, AccountPickerButton } from "./AccountPicker"; @@ -150,11 +140,55 @@ const styles = StyleSheet.create({ type Props = { accountMembershipId: string; + accountMembership: NonNullable; + user: NonNullable; + projectInfo: NonNullable; + lastRelevantIdentification: Option; + shouldDisplayIdVerification: boolean; + requireFirstTransfer: boolean; + permissions: { + canInitiatePayments: boolean; + canManageBeneficiaries: boolean; + canManageCards: boolean; + canViewAccount: boolean; + canManageAccountMembership: boolean; + }; + features: { + accountStatementsVisible: boolean; + accountVisible: boolean; + transferCreationVisible: boolean; + paymentListVisible: boolean; + virtualIbansVisible: boolean; + memberCreationVisible: boolean; + memberListVisible: boolean; + physicalCardOrderVisible: boolean; + virtualCardOrderVisible: boolean; + }; + activationTag: AccountActivationTag; + sections: { + history: boolean; + account: boolean; + transfer: boolean; + cards: boolean; + members: boolean; + }; + reload: () => void; }; -const COOKIE_REFRESH_INTERVAL = 30000; // 30s - -export const AccountArea = ({ accountMembershipId }: Props) => { +export const AccountArea = ({ + accountMembershipId, + accountMembership, + projectInfo, + user, + sections, + features, + activationTag, + lastRelevantIdentification, + shouldDisplayIdVerification, + requireFirstTransfer, + permissions, + reload, +}: Props) => { const { desktop } = useResponsive(); const [isScrolled, setIsScrolled] = useState(false); @@ -167,169 +201,21 @@ export const AccountArea = ({ accountMembershipId }: Props) => { setIsScrolled(event.nativeEvent.contentOffset.y > 0); }, []); - // Call API to extend cookie TTL - useEffect(() => { - const intervalId = setInterval(() => { - Request.make({ url: "/api/ping", method: "POST", withCredentials: true }); - }, COOKIE_REFRESH_INTERVAL); - - return () => clearInterval(intervalId); - }, []); - - const [ - { - data: { accountMembership, projectInfo, user }, - }, - reexecuteQuery, - ] = useQueryWithErrorBoundary({ - query: AccountAreaDocument, - variables: { accountMembershipId }, - }); - - const hasRequiredIdentificationLevel = - accountMembership?.hasRequiredIdentificationLevel ?? undefined; - - // We have to perform a cascading request here because the `recommendedIdentificationLevel` - // is only available once the account membership is loaded. - // This is acceptable because it only triggers the query if necessary. - const [{ data: lastRelevantIdentificationData }] = useQuery({ - query: LastRelevantIdentificationDocument, - pause: hasRequiredIdentificationLevel !== false, - variables: { - accountMembershipId, - identificationProcess: accountMembership?.recommendedIdentificationLevel, - }, - }); - - const lastRelevantIdentification = Option.fromNullable( - lastRelevantIdentificationData?.accountMembership?.user?.identifications?.edges?.[0]?.node, - ); - - const [, updateAccountLanguage] = useUrqlMutation(UpdateAccountLanguageDocument); - - const accountId = accountMembership?.account?.id; - const language = accountMembership?.account?.language; - const iban = accountMembership?.account?.IBAN; - const bankDetails = accountMembership?.account?.bankDetails; - - useEffect(() => { - // Triggers `bankDetails` generation if not yet available - if (accountId != null && language != null && iban != null && bankDetails == null) { - const future = updateAccountLanguage({ id: accountId, language }); - return () => future.cancel(); - } - }, [accountId, language, iban, bankDetails, updateAccountLanguage]); - - const currentAccountMembership = useMemo( - () => Option.fromNullable(accountMembership).toResult(new Error("NoAccountMembership")), - [accountMembership], - ); - - const hasMultipleMemberships = currentAccountMembership - .toOption() - .flatMap(data => Option.fromNullable(data.user)) + const hasMultipleMemberships = Option.fromNullable(accountMembership.user) .map(({ accountMemberships: { totalCount } }) => totalCount > 1) .getWithDefault(false); - const account = accountMembership?.account; + const account = accountMembership.account; const accountCountry = account?.country ?? undefined; const holder = account?.holder; const isIndividual = holder?.info.__typename === "AccountHolderIndividualInfo"; - const hasTransactions = (account?.transactions?.totalCount ?? 0) >= 1; - - const requireFirstTransfer = match({ account, user }) - .with( - { account: { country: "FRA" }, user: { identificationLevels: { PVID: false, QES: false } } }, - () => true, - ) - .with( - { account: { country: "ESP" }, user: { identificationLevels: { QES: false } } }, - () => true, - ) - .with({ account: { country: "DEU" } }, () => true) - .otherwise(() => false); - - const { supportingDocumentSettings } = projectInfo; - const documentCollectMode = supportingDocumentSettings?.collectMode; - - const documentCollection = holder?.supportingDocumentCollections.edges[0]?.node; - const documentCollectionStatus = documentCollection?.statusInfo.status; const userId = user?.id ?? ""; const firstName = user?.firstName ?? ""; const lastName = user?.lastName ?? ""; const phoneNumber = user?.mobilePhoneNumber ?? ""; - const activationTag = match({ - documentCollectionStatus, - documentCollectMode, - hasTransactions, - identificationStatusInfo: lastRelevantIdentification.map(getIdentificationLevelStatusInfo), - accountHolderType: account?.holder.info.__typename, - verificationStatus: account?.holder.verificationStatus, - isIndividual, - requireFirstTransfer, - isLegalRepresentative: accountMembership?.legalRepresentative ?? false, - account, - }) - .returnType() - // if payment level limitations have been lifted, no need for activation - .with({ verificationStatus: "Refused", isLegalRepresentative: true }, () => "refused") - .with( - { account: { paymentLevel: "Unlimited", paymentAccountType: "PaymentService" } }, - () => "none", - ) - // never show to non-legal rep memberships - .with({ isLegalRepresentative: false }, () => "none") - .with({ identificationStatusInfo: Option.P.Some({ status: "Pending" }) }, () => "pending") - .with( - { identificationStatusInfo: Option.P.Some({ status: P.not("Valid") }) }, - () => "actionRequired", - ) - .with( - { documentCollectionStatus: "PendingReview", accountHolderType: "AccountHolderCompanyInfo" }, - () => "pending", - ) - .with( - { - documentCollectionStatus: P.not("Approved"), - accountHolderType: "AccountHolderCompanyInfo", - }, - () => "actionRequired", - ) - .with( - { - isIndividual: true, - requireFirstTransfer: false, - account: { - holder: { verificationStatus: P.union("NotStarted", "Pending") }, - }, - }, - { - isIndividual: true, - requireFirstTransfer: false, - documentCollectMode: "API", - account: { - holder: { verificationStatus: P.union("Pending", "WaitingForInformation") }, - }, - }, - () => "pending", - ) - .with( - { - isIndividual: true, - requireFirstTransfer: false, - account: { holder: { verificationStatus: "Verified" } }, - }, - () => "none", - ) - .with( - { isIndividual: true, requireFirstTransfer: true, hasTransactions: false }, - () => "actionRequired", - ) - .otherwise(() => "none"); - const [, setAccountMembershipState] = usePersistedState( `swan_session_webBankingAccountMembershipState${projectConfiguration .map(({ projectId }) => `_${projectId}`) @@ -338,203 +224,89 @@ export const AccountArea = ({ accountMembershipId }: Props) => { ); useEffect(() => { - match(currentAccountMembership) - .with(Result.P.Ok({ id: P.select(), user: { id: user?.id } }), accountMembershipId => - setAccountMembershipState({ accountMembershipId }), - ) - .otherwise(() => setAccountMembershipState({})); - }, [setAccountMembershipState, currentAccountMembership, user]); - - const refetchAccountAreaQuery = useCallback(() => { - reexecuteQuery({ requestPolicy: "network-only" }); - }, [reexecuteQuery]); + setAccountMembershipState({ accountMembershipId }); + }, [setAccountMembershipState, accountMembershipId]); useEffect(() => { - if (userId) { - const sentryUser: Record = { id: userId }; - - firstName && (sentryUser["firstName"] = firstName); - lastName && (sentryUser["lastName"] = lastName); - phoneNumber && (sentryUser["phoneNumber"] = phoneNumber); - - setSentryUser(sentryUser); - } else { - setSentryUser(null); - } - }, [firstName, lastName, phoneNumber, userId]); - - const settings = projectInfo.webBankingSettings; - - const accountStatementsVisible = Boolean(settings?.accountStatementsVisible); - const accountVisible = Boolean(settings?.accountVisible); - const transferCreationVisible = Boolean(settings?.transferCreationVisible); - const paymentListVisible = Boolean(settings?.paymentListVisible); - const virtualIbansVisible = Boolean(settings?.virtualIbansVisible); - - const memberCreationVisible = Boolean(settings?.memberCreationVisible); - const memberListVisible = Boolean(settings?.memberListVisible); - - const physicalCardOrderVisible = Boolean(settings?.physicalCardOrderVisible); - const virtualCardOrderVisible = Boolean(settings?.virtualCardOrderVisible); - const cardOrderVisible = physicalCardOrderVisible || virtualCardOrderVisible; - - const B2BMembershipIDVerification = projectInfo.B2BMembershipIDVerification; - - const membership = useMemo( - () => - currentAccountMembership.map(accountMembership => { - const { canInitiatePayments, canManageBeneficiaries, canManageCards, canViewAccount } = - accountMembership; - - const membershipEnabled = accountMembership.statusInfo.status === "Enabled"; - const canManageAccountMembership = - accountMembership.canManageAccountMembership && membershipEnabled; - const canAddCard = canViewAccount && canManageCards; - - // identity verication is removed for permission-less membership - // if the project has a disabled `B2BMembershipIDVerification` - const shouldDisplayIdVerification = !( - B2BMembershipIDVerification === false && - accountMembership.canManageAccountMembership === false && - accountMembership.canInitiatePayments === false && - accountMembership.canManageBeneficiaries === false && - accountMembership.canManageCards === false - ); - - return { - accountMembership, - canManageAccountMembership, - canInitiatePayments, - canManageBeneficiaries, - canAddCard, - canManageCards, - - shouldDisplayIdVerification, - - historyMenuIsVisible: canViewAccount, - detailsMenuIsVisible: canViewAccount && accountVisible, - - paymentMenuIsVisible: - canViewAccount && - canInitiatePayments && - membershipEnabled && - (transferCreationVisible || paymentListVisible), - - // In case the user doesn't have the right to manage cards - // but has one attached to the current membership - cardMenuIsVisible: - accountMembership.allCards.totalCount > 0 || (canAddCard && cardOrderVisible), - - memberMenuIsVisible: canViewAccount && canManageAccountMembership && memberListVisible, - }; - }), - [ - currentAccountMembership, - accountVisible, - paymentListVisible, - cardOrderVisible, - memberListVisible, - transferCreationVisible, - B2BMembershipIDVerification, - ], - ); + setSentryUser({ + id: user.id, + firstName: user.firstName ?? undefined, + lastName: user.lastName ?? undefined, + phoneNumber: user.mobilePhoneNumber ?? undefined, + }); + }, [user]); const accentColor = projectInfo.accentColor ?? invariantColors.defaultAccentColor; const projectName = projectInfo.name; const projectLogo = projectInfo.logoUri ?? undefined; - const menu = membership - .map( - ({ - accountMembership, - historyMenuIsVisible, - detailsMenuIsVisible, - paymentMenuIsVisible, - cardMenuIsVisible, - memberMenuIsVisible, - }) => - holder?.verificationStatus === "Refused" - ? [] - : [ - { - matchRoutes: ["AccountTransactionsArea"], - iconActive: "apps-list-filled", - icon: "apps-list-regular", - name: t("navigation.history"), - to: Router.AccountTransactionsListRoot({ accountMembershipId }), - hidden: !historyMenuIsVisible, - }, - { - matchRoutes: ["AccountDetailsArea"], - iconActive: "building-bank-filled", - icon: "building-bank-regular", - name: t("navigation.account"), - to: Router.AccountDetailsIban({ accountMembershipId }), - hidden: !detailsMenuIsVisible, - }, - { - matchRoutes: ["AccountPaymentsArea"], - iconActive: "arrow-swap-filled", - icon: "arrow-swap-regular", - name: t("navigation.transfer"), - to: Router.AccountPaymentsRoot({ accountMembershipId }), - hidden: !paymentMenuIsVisible, - }, - { - matchRoutes: ["AccountCardsArea"], - iconActive: "payment-filled", - icon: "payment-regular", - name: t("navigation.cards"), - to: Router.AccountCardsList({ accountMembershipId }), - hidden: !cardMenuIsVisible, - }, - { - matchRoutes: ["AccountMembersArea"], - iconActive: "people-filled", - icon: "people-regular", - name: t("navigation.members"), - to: Router.AccountMembersList({ accountMembershipId }), - hidden: !memberMenuIsVisible, - hasNotifications: Option.fromNullable(accountMembership.account) - .map( - ({ accountMembershipsWithBindingUserError }) => - accountMembershipsWithBindingUserError.totalCount > 0, - ) - .getWithDefault(false), - }, - ], - ) - .getWithDefault([]); + const menu: Menu = + holder?.verificationStatus === "Refused" + ? [] + : [ + { + matchRoutes: ["AccountTransactionsArea"], + iconActive: "apps-list-filled", + icon: "apps-list-regular", + name: t("navigation.history"), + to: Router.AccountTransactionsListRoot({ accountMembershipId }), + hidden: !sections.history, + }, + { + matchRoutes: ["AccountDetailsArea"], + iconActive: "building-bank-filled", + icon: "building-bank-regular", + name: t("navigation.account"), + to: Router.AccountDetailsIban({ accountMembershipId }), + hidden: !sections.account, + }, + { + matchRoutes: ["AccountPaymentsArea"], + iconActive: "arrow-swap-filled", + icon: "arrow-swap-regular", + name: t("navigation.transfer"), + to: Router.AccountPaymentsRoot({ accountMembershipId }), + hidden: !sections.transfer, + }, + { + matchRoutes: ["AccountCardsArea"], + iconActive: "payment-filled", + icon: "payment-regular", + name: t("navigation.cards"), + to: Router.AccountCardsList({ accountMembershipId }), + hidden: !sections.cards, + }, + { + matchRoutes: ["AccountMembersArea"], + iconActive: "people-filled", + icon: "people-regular", + name: t("navigation.members"), + to: Router.AccountMembersList({ accountMembershipId }), + hidden: !sections.members, + hasNotifications: Option.fromNullable(accountMembership.account) + .map( + ({ accountMembershipsWithBindingUserError }) => + accountMembershipsWithBindingUserError.totalCount > 0, + ) + .getWithDefault(false), + }, + ]; const routes = useMemo(() => { - const routes: RouteName[] = [...accountMinimalRoutes]; - - membership.toOption().match({ - None: () => {}, - Some: ({ - historyMenuIsVisible, - detailsMenuIsVisible, - paymentMenuIsVisible, - cardMenuIsVisible, - memberMenuIsVisible, - }) => { - historyMenuIsVisible && routes.push(...historyMenuRoutes); - detailsMenuIsVisible && routes.push("AccountDetailsArea"); - paymentMenuIsVisible && routes.push(...paymentMenuRoutes); - cardMenuIsVisible && routes.push("AccountCardsArea"); - memberMenuIsVisible && routes.push("AccountMembersArea"); - }, - }); - - return routes; - }, [membership]); + return [ + ...accountMinimalRoutes, + ...(sections.history ? historyMenuRoutes : []), + ...(sections.account ? (["AccountDetailsArea"] as const) : []), + ...(sections.transfer ? paymentMenuRoutes : []), + ...(sections.cards ? (["AccountCardsArea"] as const) : []), + ...(sections.members ? (["AccountMembersArea"] as const) : []), + ]; + }, [sections]); const route = Router.useRoute(routes); - const email = currentAccountMembership - .map(accountMembership => accountMembership.email) - .toOption() - .toUndefined(); + const email = accountMembership.email; + const hasRequiredIdentificationLevel = accountMembership.hasRequiredIdentificationLevel ?? false; useEffect(() => { updateTgglContext({ accountCountry, userId, email }); @@ -555,24 +327,26 @@ export const AccountArea = ({ accountMembershipId }: Props) => { const accountPickerButtonRef = useRef(null); const [isAccountPickerOpen, setAccountPickerOpen] = useBoolean(false); - const [availableBalance, setAvailableBalance] = useState(() => - membership - .map(({ accountMembership }) => accountMembership.account?.balances?.available) - .toOption() - .toUndefined(), + const accountId = accountMembership.account?.id; + + const roots = { + history: Router.AccountTransactionsListRoot({ accountMembershipId }), + account: Router.AccountDetailsIban({ accountMembershipId }), + transfer: Router.AccountPaymentsRoot({ accountMembershipId }), + cards: Router.AccountCardsList({ accountMembershipId }), + members: Router.AccountMembersList({ accountMembershipId }), + }; + + const firstAccesibleRoute = Array.findMap(Dict.entries(roots), ([key, route]) => + sections[key] ? Option.Some(route) : Option.None(), ); - useEffect(() => { - setAvailableBalance( - membership - .map(({ accountMembership }) => accountMembership.account?.balances?.available) - .toOption() - .toUndefined(), - ); - }, [membership]); - - if (membership.isError()) { - return ; + const canQueryCardOnTransaction = + accountMembership.statusInfo.status !== "BindingUserError" && + accountMembership.canManageAccountMembership; + + if (accountMembership.user?.id !== user?.id) { + return ; } return ( @@ -608,69 +382,59 @@ export const AccountArea = ({ accountMembershipId }: Props) => { )} - {match(membership) - .with( - Result.P.Ok(P.select()), - ({ accountMembership, shouldDisplayIdVerification }) => ( - <> - - - - - - - { - // TODO: Prevent full reload by tweaking layout + Suspense - window.location.assign(Router.AccountRoot({ accountMembershipId })); - }} - /> - - - - - - - - - - - {t("login.signout")} - - - - - - - ), - ) - .otherwise(() => null)} + + + + + + + { + // TODO: Prevent full reload by tweaking layout + Suspense + window.location.assign(Router.AccountRoot({ accountMembershipId })); + }} + /> + + + + + + + + + + + {t("login.signout")} + + + + + )} @@ -682,7 +446,7 @@ export const AccountArea = ({ accountMembershipId }: Props) => { desktop ? styles.desktopContentContainer : styles.mobileContentContainer } > - {!desktop && ( + {desktop ? null : ( <> { logFrontendError(error)} - fallback={({ error }) => - isUnauthorizedError(error) ? <> : - } + fallback={() => } > - {match(membership) - .with( - Result.P.Ok(P.select()), - ({ - accountMembership, - canAddCard, - canManageCards, - canManageAccountMembership, - cardMenuIsVisible, - historyMenuIsVisible, - detailsMenuIsVisible, - memberMenuIsVisible, - paymentMenuIsVisible, - shouldDisplayIdVerification, - }) => { - const accountId = accountMembership.account?.id; - - const indexUrl: string = historyMenuIsVisible - ? Router.AccountTransactionsListRoot({ accountMembershipId }) - : detailsMenuIsVisible - ? Router.AccountDetailsIban({ accountMembershipId }) - : paymentMenuIsVisible - ? Router.AccountPaymentsRoot({ accountMembershipId }) - : cardMenuIsVisible - ? Router.AccountCardsList({ accountMembershipId }) - : memberMenuIsVisible - ? Router.AccountMembersList({ accountMembershipId }) - : ""; - - if (accountMembership.user?.id !== user?.id) { - return ; - } - - const canQueryCardOnTransaction = - accountMembership.statusInfo.status !== "BindingUserError" && - accountMembership.canManageAccountMembership; - - if (holder?.verificationStatus === "Refused") { - return ( - + ) : ( + }> + {match(route) + .with({ name: "AccountRoot" }, () => + firstAccesibleRoute.match({ + Some: route => , + None: () => , + }), + ) + .with({ name: "AccountProfile" }, () => ( + + )) + .with({ name: "AccountDetailsArea" }, () => + isNullish(accountId) || !features.accountVisible ? ( + + ) : ( + - ); - } - return ( - }> - {match(route) - .with({ name: "AccountRoot" }, () => - isNotEmpty(indexUrl) ? ( - - ) : ( - - ), - ) - .with({ name: "AccountProfile" }, () => ( - - )) - .with({ name: "AccountDetailsArea" }, () => - isNullish(accountId) || !detailsMenuIsVisible ? ( - - ) : ( - - ), - ) - .with( - { name: "AccountTransactionsArea" }, - ({ params: { accountMembershipId } }) => - isNullish(accountId) ? ( - - ) : ( - - ), - ) - - .with( - { name: "AccountPaymentsArea" }, - ({ params: { consentId, standingOrder, status: consentStatus } }) => - isNullish(accountId) || isNullish(accountCountry) ? ( - - ) : ( - - ), - ) - .with({ name: "AccountCardsArea" }, () => ( - + isNullish(accountId) ? ( + + ) : ( + + ), + ) + + .with( + { name: "AccountPaymentsArea" }, + ({ params: { consentId, standingOrder, status: consentStatus } }) => + isNullish(accountId) || isNullish(accountCountry) ? ( + + ) : ( + + ), + ) + .with({ name: "AccountCardsArea" }, () => ( + + )) + .with({ name: "AccountMembersArea" }, ({ params }) => + match({ accountId, accountMembership }) + .with( + { + accountId: P.string, + accountMembership: { account: { country: P.string } }, + }, + ({ accountId, accountMembership: currentUserAccountMembership }) => ( + - )) - .with({ name: "AccountMembersArea" }, ({ params }) => - match({ accountId, accountMembership }) - .with( - { - accountId: P.string, - accountMembership: { account: { country: P.string } }, - }, - ({ - accountId, - accountMembership: currentUserAccountMembership, - }) => ( - - ), - ) - .otherwise(() => ), - ) - .with({ name: "AccountActivation" }, () => ( - - )) - .otherwise(() => ( - - ))} - - ); - }, - ) - .otherwise(() => ( - - ))} + ), + ) + .otherwise(() => ), + ) + .with({ name: "AccountActivation" }, () => ( + + )) + .otherwise(() => ( + + ))} + + )} - {!desktop && - membership.match({ - Error: () => null, - Ok: ({ accountMembership, shouldDisplayIdVerification }) => ( - - ), - })} + {desktop ? null : ( + + )} diff --git a/clients/banking/src/components/AccountMembersDetailsCardList.tsx b/clients/banking/src/components/AccountMembersDetailsCardList.tsx index 6d94ec71e..7658cbdfc 100644 --- a/clients/banking/src/components/AccountMembersDetailsCardList.tsx +++ b/clients/banking/src/components/AccountMembersDetailsCardList.tsx @@ -1,5 +1,6 @@ import { Array, Option } from "@swan-io/boxed"; import { Link } from "@swan-io/chicane"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty, @@ -10,7 +11,6 @@ import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeBu import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { useMemo } from "react"; import { StyleSheet, View } from "react-native"; @@ -21,6 +21,7 @@ import { Router } from "../utils/routes"; import { CardList } from "./CardList"; import { CardFilters, CardListFilter } from "./CardListFilter"; import { CardWizard } from "./CardWizard"; +import { Connection } from "./Connection"; import { ErrorView } from "./ErrorView"; const styles = StyleSheet.create({ @@ -95,17 +96,11 @@ export const AccountMembersDetailsCardList = ({ .with("Canceled", () => CANCELED_STATUSES) .exhaustive(); - const { data, nextData, reload, setAfter } = useUrqlPaginatedQuery( - { - query: CardListPageWithoutAccountDocument, - variables: { - first: PER_PAGE, - filters: { statuses, types: filters.type, search: filters.search }, - accountMembershipId: editingAccountMembershipId, - }, - }, - [filters], - ); + const [data, { isLoading, reload, setVariables }] = useQuery(CardListPageWithoutAccountDocument, { + first: PER_PAGE, + filters: { statuses, types: filters.type, search: filters.search }, + accountMembershipId: editingAccountMembershipId, + }); const empty = ( { + reload(); + }} large={large} > {canAddCard ? ( @@ -194,39 +191,47 @@ export const AccountMembersDetailsCardList = ({ result.match({ Error: error => , Ok: ({ accountMembership }) => ( - ( - + {cards => ( + ( + + )} + loading={{ + isLoading, + count: 20, + }} + onRefreshRequest={() => { + reload(); + }} + onEndReached={() => { + if (cards?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: cards?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => + hasFilters ? ( + + ) : ( + empty + ) + } /> )} - loading={{ - isLoading: nextData.isLoading(), - count: 20, - }} - onRefreshRequest={reload} - onEndReached={() => { - if (accountMembership?.cards.pageInfo.hasNextPage ?? false) { - setAfter(accountMembership?.cards.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => - hasFilters ? ( - - ) : ( - empty - ) - } - /> + ), }), })} diff --git a/clients/banking/src/components/AccountMembershipArea.tsx b/clients/banking/src/components/AccountMembershipArea.tsx new file mode 100644 index 000000000..0bf291ac7 --- /dev/null +++ b/clients/banking/src/components/AccountMembershipArea.tsx @@ -0,0 +1,333 @@ +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useDeferredQuery, useMutation } from "@swan-io/graphql-client"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; +import { Request } from "@swan-io/request"; +import { useCallback, useEffect, useMemo } from "react"; +import { P, match } from "ts-pattern"; +import { + AccountAreaDocument, + LastRelevantIdentificationDocument, + UpdateAccountLanguageDocument, +} from "../graphql/partner"; +import { getIdentificationLevelStatusInfo } from "../utils/identification"; +import { AccountArea } from "./AccountArea"; +import { AccountActivationTag } from "./AccountPicker"; +import { ErrorView } from "./ErrorView"; + +type Props = { + accountMembershipId: string; +}; + +const COOKIE_REFRESH_INTERVAL = 30000; // 30s + +export const AccountMembershipArea = ({ accountMembershipId }: Props) => { + const [data, { query }] = useDeferredQuery(AccountAreaDocument); + const [lastRelevantIdentification, { query: queryLastRelevantIdentification }] = useDeferredQuery( + LastRelevantIdentificationDocument, + ); + const [updateAccountLanguage] = useMutation(UpdateAccountLanguageDocument); + + useEffect(() => { + const request = query({ accountMembershipId }).tapOk(({ accountMembership }) => { + const hasRequiredIdentificationLevel = + accountMembership?.hasRequiredIdentificationLevel ?? undefined; + const recommendedIdentificationLevel = accountMembership?.recommendedIdentificationLevel; + + const accountId = accountMembership?.account?.id; + const language = accountMembership?.account?.language; + const iban = accountMembership?.account?.IBAN; + const bankDetails = accountMembership?.account?.bankDetails; + + if (accountId != null && language != null && iban != null && bankDetails == null) { + void updateAccountLanguage({ id: accountId, language }); + } + + if (hasRequiredIdentificationLevel === false) { + return queryLastRelevantIdentification({ + accountMembershipId, + identificationProcess: recommendedIdentificationLevel, + }); + } + }); + return () => request.cancel(); + }, [accountMembershipId, query, queryLastRelevantIdentification, updateAccountLanguage]); + + const reload = useCallback(() => { + query({ accountMembershipId }).tapOk(({ accountMembership }) => { + const hasRequiredIdentificationLevel = + accountMembership?.hasRequiredIdentificationLevel ?? undefined; + const recommendedIdentificationLevel = accountMembership?.recommendedIdentificationLevel; + + const accountId = accountMembership?.account?.id; + const language = accountMembership?.account?.language; + const iban = accountMembership?.account?.IBAN; + const bankDetails = accountMembership?.account?.bankDetails; + + if (accountId != null && language != null && iban != null && bankDetails == null) { + void updateAccountLanguage({ id: accountId, language }); + } + + if (hasRequiredIdentificationLevel === false) { + queryLastRelevantIdentification({ + accountMembershipId, + identificationProcess: recommendedIdentificationLevel, + }); + } + }); + }, [accountMembershipId, query, queryLastRelevantIdentification, updateAccountLanguage]); + + // Call API to extend cookie TTL + useEffect(() => { + const intervalId = setInterval(() => { + Request.make({ url: "/api/ping", method: "POST", withCredentials: true }); + }, COOKIE_REFRESH_INTERVAL); + + return () => clearInterval(intervalId); + }, []); + + const info = useMemo( + () => + data + .flatMapOk(data => + match(lastRelevantIdentification) + .with(AsyncData.P.Loading, () => AsyncData.Loading()) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => + AsyncData.Done(Result.Error(error)), + ) + .otherwise(() => + AsyncData.Done( + Result.Ok({ + data, + lastRelevantIdentification: lastRelevantIdentification + .toOption() + .flatMap(result => result.toOption()) + .flatMap(value => + Option.fromNullable( + value.accountMembership?.user?.identifications?.edges?.[0]?.node, + ), + ), + }), + ), + ), + ) + .mapOkToResult(({ data, lastRelevantIdentification }) => { + return match(data) + .with( + { + user: P.not(P.nullish), + accountMembership: { user: P.not(P.nullish) }, + }, + ({ accountMembership, projectInfo, user }) => { + const { + canInitiatePayments, + canManageBeneficiaries, + canManageCards, + canViewAccount, + canManageAccountMembership, + } = accountMembership; + + // ID verification should be hidden from users when the project + // has a `B2BMembershipIDVerification` set to `false` and that + // no sensitive permission is one + const shouldDisplayIdVerification = !( + projectInfo.B2BMembershipIDVerification === false && + canManageAccountMembership === false && + canInitiatePayments === false && + canManageBeneficiaries === false && + canManageCards === false + ); + + const webBankingSettings = projectInfo.webBankingSettings; + + const features = { + accountStatementsVisible: webBankingSettings?.accountStatementsVisible ?? false, + accountVisible: webBankingSettings?.accountVisible ?? false, + transferCreationVisible: webBankingSettings?.transferCreationVisible ?? false, + paymentListVisible: webBankingSettings?.paymentListVisible ?? false, + virtualIbansVisible: webBankingSettings?.virtualIbansVisible ?? false, + memberCreationVisible: webBankingSettings?.memberCreationVisible ?? false, + memberListVisible: webBankingSettings?.memberListVisible ?? false, + physicalCardOrderVisible: webBankingSettings?.physicalCardOrderVisible ?? false, + virtualCardOrderVisible: webBankingSettings?.virtualCardOrderVisible ?? false, + }; + + const account = accountMembership.account; + const documentCollection = + account?.holder?.supportingDocumentCollections.edges[0]?.node; + const documentCollectionStatus = documentCollection?.statusInfo.status; + const documentCollectMode = projectInfo.supportingDocumentSettings?.collectMode; + + const isIndividual = + account?.holder?.info.__typename === "AccountHolderIndividualInfo"; + const hasTransactions = (account?.transactions?.totalCount ?? 0) >= 1; + + const requireFirstTransfer = match({ account, user }) + .with( + { + account: { country: "FRA" }, + user: { identificationLevels: { PVID: false, QES: false } }, + }, + () => true, + ) + .with( + { account: { country: "ESP" }, user: { identificationLevels: { QES: false } } }, + () => true, + ) + .with({ account: { country: "DEU" } }, () => true) + .otherwise(() => false); + + const activationTag = match({ + documentCollectionStatus, + documentCollectMode, + hasTransactions, + identificationStatusInfo: lastRelevantIdentification.map( + getIdentificationLevelStatusInfo, + ), + accountHolderType: account?.holder.info.__typename, + verificationStatus: account?.holder.verificationStatus, + isIndividual, + requireFirstTransfer, + isLegalRepresentative: accountMembership?.legalRepresentative ?? false, + account, + }) + .returnType() + // if payment level limitations have been lifted, no need for activation + .with( + { verificationStatus: "Refused", isLegalRepresentative: true }, + () => "refused", + ) + .with( + { + account: { paymentLevel: "Unlimited", paymentAccountType: "PaymentService" }, + }, + () => "none", + ) + // never show to non-legal rep memberships + .with({ isLegalRepresentative: false }, () => "none") + .with( + { identificationStatusInfo: Option.P.Some({ status: "Pending" }) }, + () => "pending", + ) + .with( + { identificationStatusInfo: Option.P.Some({ status: P.not("Valid") }) }, + () => "actionRequired", + ) + .with( + { + documentCollectionStatus: "PendingReview", + accountHolderType: "AccountHolderCompanyInfo", + }, + () => "pending", + ) + .with( + { + documentCollectionStatus: P.not("Approved"), + accountHolderType: "AccountHolderCompanyInfo", + }, + () => "actionRequired", + ) + .with( + { + isIndividual: true, + requireFirstTransfer: false, + account: { + holder: { verificationStatus: P.union("NotStarted", "Pending") }, + }, + }, + { + isIndividual: true, + requireFirstTransfer: false, + documentCollectMode: "API", + account: { + holder: { verificationStatus: P.union("Pending", "WaitingForInformation") }, + }, + }, + () => "pending", + ) + .with( + { + isIndividual: true, + requireFirstTransfer: false, + account: { holder: { verificationStatus: "Verified" } }, + }, + () => "none", + ) + .with( + { isIndividual: true, requireFirstTransfer: true, hasTransactions: false }, + () => "actionRequired", + ) + .otherwise(() => "none"); + + return Result.Ok({ + accountMembership, + user, + projectInfo, + lastRelevantIdentification, + shouldDisplayIdVerification, + requireFirstTransfer, + permissions: { + canInitiatePayments, + canManageBeneficiaries, + canManageCards, + canViewAccount, + canManageAccountMembership, + }, + features, + activationTag, + sections: { + history: canViewAccount, + account: canViewAccount && features.accountVisible, + transfer: + canViewAccount && + canInitiatePayments && + accountMembership.statusInfo.status === "Enabled" && + (features.transferCreationVisible || features.paymentListVisible), + cards: + accountMembership.allCards.totalCount > 0 || + (canViewAccount && canManageCards && features.virtualCardOrderVisible), + members: + canViewAccount && canManageAccountMembership && features.memberListVisible, + }, + }); + }, + ) + .otherwise(() => Result.Error(undefined)); + }), + [data, lastRelevantIdentification], + ); + + return match(info) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with( + AsyncData.P.Done(Result.P.Ok(P.select())), + ({ + user, + accountMembership, + projectInfo, + shouldDisplayIdVerification, + permissions, + requireFirstTransfer, + features, + lastRelevantIdentification, + activationTag, + sections, + }) => ( + + ), + ) + .exhaustive(); +}; diff --git a/clients/banking/src/components/AccountPicker.tsx b/clients/banking/src/components/AccountPicker.tsx index 5a3d57f71..0be21081c 100644 --- a/clients/banking/src/components/AccountPicker.tsx +++ b/clients/banking/src/components/AccountPicker.tsx @@ -1,3 +1,4 @@ +import { useQuery } from "@swan-io/graphql-client"; import { Icon } from "@swan-io/lake/src/components/Icon"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; @@ -13,7 +14,6 @@ import { radii, spacings, } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { GetNode } from "@swan-io/lake/src/utils/types"; import { forwardRef, useCallback, useEffect, useState } from "react"; @@ -27,6 +27,7 @@ import { } from "../graphql/partner"; import { formatCurrency, t } from "../utils/i18n"; import { Router } from "../utils/routes"; +import { Connection } from "./Connection"; const styles = StyleSheet.create({ container: { @@ -165,21 +166,14 @@ const Item = ({ onPress, isActive, membership }: ItemProps) => { type Props = { accountMembershipId: string; - availableBalance?: Amount; onPressItem: (accountMembershipId: string) => void; }; export const AccountPicker = ({ accountMembershipId, onPressItem }: Props) => { - const { data: accountMemberships, setAfter } = useUrqlPaginatedQuery( - { - query: GetAccountMembershipsDocument, - variables: { - first: 10, - filters: { status: ["BindingUserError", "ConsentPending", "Enabled", "InvitationSent"] }, - }, - }, - [], - ); + const [accountMemberships, { setVariables }] = useQuery(GetAccountMembershipsDocument, { + first: 10, + filters: { status: ["BindingUserError", "ConsentPending", "Enabled", "InvitationSent"] }, + }); const [showScrollAid, setShowScrollAid] = useState(false); @@ -208,29 +202,33 @@ export const AccountPicker = ({ accountMembershipId, onPressItem }: Props) => { Ok: ({ user }) => user == null ? null : ( - `AccountSelector${item.node.id}`} - onScroll={handleScroll} - renderItem={({ item }) => ( - { - onPressItem(item.node.id); + + {accountMemberships => ( + `AccountSelector${item.node.id}`} + onScroll={handleScroll} + renderItem={({ item }) => ( + { + onPressItem(item.node.id); + }} + /> + )} + onEndReached={() => { + const endCursor = accountMemberships.pageInfo.endCursor; + if (endCursor != null) { + setVariables({ after: endCursor }); + } }} /> )} - onEndReached={() => { - const endCursor = user.accountMemberships.pageInfo.endCursor; - if (endCursor != null) { - setAfter(endCursor); - } - }} - /> + [] = useMemo( () => @@ -457,19 +457,13 @@ export const AccountStatementCustom = ({ accountId, large }: Props) => { const [newWasOpened, setNewWasOpened] = useState(false); const [displayedView, setDisplayedView] = useState<"list" | "new">("list"); - const { data, nextData, setAfter, reload } = useUrqlPaginatedQuery( - { - query: AccountStatementsPageDocument, - variables: { - first: PER_PAGE, - accountId, - filters: { - period: "Custom", - }, - }, + const [data, { isLoading, reload, setVariables }] = useQuery(AccountStatementsPageDocument, { + first: PER_PAGE, + accountId, + filters: { + period: "Custom", }, - [accountId], - ); + }); return ( <> @@ -496,7 +490,9 @@ export const AccountStatementCustom = ({ accountId, large }: Props) => { large={large} accountId={accountId} onCancel={() => setDisplayedView("list")} - onSuccess={reload} + onSuccess={() => { + reload(); + }} /> ) : null} @@ -534,62 +530,70 @@ export const AccountStatementCustom = ({ accountId, large }: Props) => { )} - - large ? styles.containerRowLarge : styles.containerRow - } - breakpoint={breakpoints.tiny} - data={account?.statements?.edges?.map(({ node }) => node) ?? []} - keyExtractor={item => item.id} - headerHeight={48} - rowHeight={48} - groupHeaderHeight={48} - extraInfo={{ large }} - columns={columns} - getRowLink={({ item }) => { - const availableItem = - item.status === "Available" ? Option.Some(item) : Option.None(); - return availableItem - .flatMap(item => - Array.findMap(item.type, item => Option.fromNullable(item?.url)), - ) - .map(url => ) - .getWithDefault(); - }} - loading={{ - isLoading: nextData.isLoading(), - count: NUM_TO_RENDER, - }} - onEndReached={() => { - if (account?.statements?.pageInfo.hasNextPage ?? false) { - setAfter(account?.statements?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => ( - - - - { - setNewWasOpened(true); - setDisplayedView("new"); - }} - color="current" - > - {t("common.new")} - - + + {statements => ( + + large ? styles.containerRowLarge : styles.containerRow + } + breakpoint={breakpoints.tiny} + data={statements?.edges?.map(({ node }) => node) ?? []} + keyExtractor={item => item.id} + headerHeight={48} + rowHeight={48} + groupHeaderHeight={48} + extraInfo={{ large }} + columns={columns} + getRowLink={({ item }) => { + const availableItem = + item.status === "Available" ? Option.Some(item) : Option.None(); + return availableItem + .flatMap(item => + Array.findMap(item.type, item => + Option.fromNullable(item?.url), + ), + ) + .map(url => ) + .getWithDefault(); + }} + loading={{ + isLoading, + count: NUM_TO_RENDER, + }} + onEndReached={() => { + if (statements?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: statements?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => ( + + + + { + setNewWasOpened(true); + setDisplayedView("new"); + }} + color="current" + > + {t("common.new")} + + + )} + smallColumns={smallColumns} + /> )} - smallColumns={smallColumns} - /> + ) : null} diff --git a/clients/banking/src/components/AccountStatementMonthly.tsx b/clients/banking/src/components/AccountStatementMonthly.tsx index 679b38283..3212bca6a 100644 --- a/clients/banking/src/components/AccountStatementMonthly.tsx +++ b/clients/banking/src/components/AccountStatementMonthly.tsx @@ -1,5 +1,6 @@ import { Array, Option } from "@swan-io/boxed"; import { Link } from "@swan-io/chicane"; +import { useQuery } from "@swan-io/graphql-client"; import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; import { FixedListViewEmpty, @@ -19,13 +20,13 @@ import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveCont import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, colors, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { GetNode } from "@swan-io/lake/src/utils/types"; import dayjs from "dayjs"; import { StyleSheet, View } from "react-native"; import { ErrorView } from "../components/ErrorView"; import { AccountStatementsPageDocument, AccountStatementsPageQuery } from "../graphql/partner"; import { t } from "../utils/i18n"; +import { Connection } from "./Connection"; const styles = StyleSheet.create({ columnHeaders: { @@ -150,17 +151,11 @@ const smallColumns: ColumnConfig[] = [ const PER_PAGE = 20; export const AccountStatementMonthly = ({ accountId, large }: Props) => { - const { data, nextData, setAfter } = useUrqlPaginatedQuery( - { - query: AccountStatementsPageDocument, - variables: { - first: PER_PAGE, - accountId, - filters: { period: "Monthly" }, - }, - }, - [accountId], - ); + const [data, { isLoading, setVariables }] = useQuery(AccountStatementsPageDocument, { + first: PER_PAGE, + accountId, + filters: { period: "Monthly" }, + }); return ( <> @@ -183,44 +178,50 @@ export const AccountStatementMonthly = ({ accountId, large }: Props) => { {() => ( - (large ? styles.containerRowLarge : styles.containerRow)} - breakpoint={breakpoints.tiny} - data={account?.statements?.edges?.map(({ node }) => node) ?? []} - keyExtractor={item => item.id} - headerHeight={48} - rowHeight={48} - groupHeaderHeight={48} - extraInfo={{ large }} - columns={columns} - getRowLink={({ item }) => { - const availableItem = - item.status === "Available" ? Option.Some(item) : Option.None(); - return availableItem - .flatMap(item => - Array.findMap(item.type, item => Option.fromNullable(item?.url)), - ) - .map(url => ) - .getWithDefault(); - }} - loading={{ - isLoading: nextData.isLoading(), - count: NUM_TO_RENDER, - }} - onEndReached={() => { - if (account?.statements?.pageInfo.hasNextPage ?? false) { - setAfter(account?.statements?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => ( - + {statements => ( + (large ? styles.containerRowLarge : styles.containerRow)} + breakpoint={breakpoints.tiny} + data={statements?.edges?.map(({ node }) => node) ?? []} + keyExtractor={item => item.id} + headerHeight={48} + rowHeight={48} + groupHeaderHeight={48} + extraInfo={{ large }} + columns={columns} + getRowLink={({ item }) => { + const availableItem = + item.status === "Available" ? Option.Some(item) : Option.None(); + return availableItem + .flatMap(item => + Array.findMap(item.type, item => Option.fromNullable(item?.url)), + ) + .map(url => ) + .getWithDefault(); + }} + loading={{ + isLoading, + count: NUM_TO_RENDER, + }} + onEndReached={() => { + if (statements?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: statements?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => ( + + )} + smallColumns={smallColumns} /> )} - smallColumns={smallColumns} - /> + )} diff --git a/clients/banking/src/components/CardCancelConfirmationModal.tsx b/clients/banking/src/components/CardCancelConfirmationModal.tsx index 24a663362..8ff54c7d4 100644 --- a/clients/banking/src/components/CardCancelConfirmationModal.tsx +++ b/clients/banking/src/components/CardCancelConfirmationModal.tsx @@ -1,8 +1,8 @@ +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Space } from "@swan-io/lake/src/components/Space"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; @@ -23,7 +23,7 @@ export const CardCancelConfirmationModal = ({ onSuccess, onPressClose, }: Props) => { - const [cardCancelation, cancelCard] = useUrqlMutation(CancelCardDocument); + const [cancelCard, cardCancelation] = useMutation(CancelCardDocument); const onPressConfirm = () => { if (cardId != null) { diff --git a/clients/banking/src/components/CardItemArea.tsx b/clients/banking/src/components/CardItemArea.tsx index d8047b00f..93c9d7fe3 100644 --- a/clients/banking/src/components/CardItemArea.tsx +++ b/clients/banking/src/components/CardItemArea.tsx @@ -1,4 +1,5 @@ -import { Option } from "@swan-io/boxed"; +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useDeferredQuery } from "@swan-io/graphql-client"; import { useCrumb } from "@swan-io/lake/src/components/Breadcrumbs"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; @@ -8,11 +9,9 @@ import { TabView } from "@swan-io/lake/src/components/TabView"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors, spacings } from "@swan-io/lake/src/constants/design"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; -import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql"; -import { Suspense, useMemo } from "react"; +import { Suspense, useCallback, useEffect, useMemo } from "react"; import { ScrollView, StyleSheet } from "react-native"; import { P, match } from "ts-pattern"; -import { useQuery } from "urql"; import { CardPageDocument, LastRelevantIdentificationDocument } from "../graphql/partner"; import { getMemberName } from "../utils/accountMembership"; import { t } from "../utils/i18n"; @@ -74,286 +73,340 @@ export const CardItemArea = ({ "AccountCardsItemOrderAddress", ]); - const [ - { - data: { - card, - projectInfo: { id: projectId, B2BMembershipIDVerification }, - }, - }, - reexecuteQuery, - ] = useQueryWithErrorBoundary({ - query: CardPageDocument, - variables: { cardId }, - }); + const [data, { query }] = useDeferredQuery(CardPageDocument); + const [lastRelevantIdentification, { query: queryLastRelevantIdentification }] = useDeferredQuery( + LastRelevantIdentificationDocument, + ); - const cardAccountMembershipId = card?.accountMembership?.id ?? undefined; - const hasRequiredIdentificationLevel = - card?.accountMembership?.hasRequiredIdentificationLevel ?? undefined; - // We have to perform a cascading request here because the `recommendedIdentificationLevel` - // is only available once the card is loaded. - // This is acceptable because it only triggers the query if necessary. - const [{ data: lastRelevantIdentificationData }] = useQuery({ - query: LastRelevantIdentificationDocument, - pause: cardAccountMembershipId == undefined || hasRequiredIdentificationLevel !== false, - variables: { - // we can case given the query is paused otherwise - accountMembershipId: cardAccountMembershipId as string, - identificationProcess: card?.accountMembership?.recommendedIdentificationLevel, - }, - }); + const reload = useCallback(() => { + query({ cardId }).tapOk(({ card }) => { + const cardAccountMembershipId = card?.accountMembership?.id ?? undefined; + const hasRequiredIdentificationLevel = + card?.accountMembership?.hasRequiredIdentificationLevel ?? undefined; - const lastRelevantIdentification = Option.fromNullable( - lastRelevantIdentificationData?.accountMembership?.user?.identifications?.edges?.[0]?.node, - ); + if (cardAccountMembershipId != null && hasRequiredIdentificationLevel === false) { + return queryLastRelevantIdentification({ + accountMembershipId: cardAccountMembershipId, + identificationProcess: card?.accountMembership?.recommendedIdentificationLevel, + }); + } + }); + }, [cardId, query, queryLastRelevantIdentification]); + + useEffect(() => { + const request = query({ cardId }).tapOk(({ card }) => { + const cardAccountMembershipId = card?.accountMembership?.id ?? undefined; + const hasRequiredIdentificationLevel = + card?.accountMembership?.hasRequiredIdentificationLevel ?? undefined; + + if (cardAccountMembershipId != null && hasRequiredIdentificationLevel === false) { + return queryLastRelevantIdentification({ + accountMembershipId: cardAccountMembershipId, + identificationProcess: card?.accountMembership?.recommendedIdentificationLevel, + }); + } + }); + return () => request.cancel(); + }, [cardId, query, queryLastRelevantIdentification]); useCrumb( useMemo(() => { - if (card == null) { - return undefined; - } - const accountMembership = card.accountMembership; - const cardHolderName = getMemberName({ accountMembership }); - return { - label: card.name != null ? `${cardHolderName} - ${card.name}` : cardHolderName, - link: Router.AccountCardsItem({ accountMembershipId, cardId }), - }; - }, [card, accountMembershipId, cardId]), + return data + .toOption() + .flatMap(result => result.toOption()) + .flatMap(({ card }) => Option.fromNullable(card)) + .map(card => { + const accountMembership = card.accountMembership; + const cardHolderName = getMemberName({ accountMembership }); + return { + label: card.name != null ? `${cardHolderName} - ${card.name}` : cardHolderName, + link: Router.AccountCardsItem({ accountMembershipId, cardId }), + }; + }) + .toUndefined(); + }, [data, accountMembershipId, cardId]), ); - const shouldShowPhysicalCardTab = match({ physicalCardOrderVisible, card }) + return match({ data, lastRelevantIdentification }) + .with( + { data: P.union(AsyncData.P.NotAsked, AsyncData.P.Loading) }, + { lastRelevantIdentification: AsyncData.P.Loading }, + () => , + ) + .with({ data: AsyncData.P.Done(Result.P.Error(P.select())) }, error => ( + + )) + .with({ lastRelevantIdentification: AsyncData.P.Done(Result.P.Error(P.select())) }, error => ( + + )) .with( { - physicalCardOrderVisible: true, - card: { cardProduct: { applicableToPhysicalCards: true }, type: P.not("SingleUseVirtual") }, + data: AsyncData.Done(Result.P.Ok(P.select("data"))), + lastRelevantIdentification: P.select("lastRelevantIdentificationData"), }, - () => true, - ) - .with({ card: { type: "VirtualAndPhysical" } }, () => true) - .otherwise(() => false); + ({ + data: { + card, + projectInfo: { id: projectId, B2BMembershipIDVerification }, + }, + lastRelevantIdentificationData, + }) => { + const lastRelevantIdentification = lastRelevantIdentificationData + .toOption() + .flatMap(result => result.toOption()) + .flatMap(lastRelevantIdentification => + Option.fromNullable( + lastRelevantIdentification.accountMembership?.user?.identifications?.edges?.[0]?.node, + ), + ); - const isCurrentUserCardOwner = userId === card?.accountMembership.user?.id; + const shouldShowPhysicalCardTab = match({ physicalCardOrderVisible, card }) + .with( + { + physicalCardOrderVisible: true, + card: { + cardProduct: { applicableToPhysicalCards: true }, + type: P.not("SingleUseVirtual"), + }, + }, + () => true, + ) + .with({ card: { type: "VirtualAndPhysical" } }, () => true) + .otherwise(() => false); - const membershipStatus = card?.accountMembership.statusInfo; + const isCurrentUserCardOwner = userId === card?.accountMembership.user?.id; - const hasStrictlyNoPermission = - card?.accountMembership?.canManageAccountMembership === false && - card?.accountMembership?.canInitiatePayments === false && - card?.accountMembership?.canManageBeneficiaries === false && - card?.accountMembership?.canManageCards === false; + const membershipStatus = card?.accountMembership.statusInfo; - const cardRequiresIdentityVerification = - B2BMembershipIDVerification === false && hasStrictlyNoPermission - ? false - : card?.accountMembership.hasRequiredIdentificationLevel === false; + const hasStrictlyNoPermission = + card?.accountMembership?.canManageAccountMembership === false && + card?.accountMembership?.canInitiatePayments === false && + card?.accountMembership?.canManageBeneficiaries === false && + card?.accountMembership?.canManageCards === false; - const hasBindingUserError = - membershipStatus?.__typename === "AccountMembershipBindingUserErrorStatusInfo" && - (membershipStatus.birthDateMatchError || - membershipStatus.firstNameMatchError || - membershipStatus.lastNameMatchError || - membershipStatus.phoneNumberMatchError); + const cardRequiresIdentityVerification = + B2BMembershipIDVerification === false && hasStrictlyNoPermission + ? false + : card?.accountMembership.hasRequiredIdentificationLevel === false; - if (card == null) { - return ; - } + const hasBindingUserError = + membershipStatus?.__typename === "AccountMembershipBindingUserErrorStatusInfo" && + (membershipStatus.birthDateMatchError || + membershipStatus.firstNameMatchError || + membershipStatus.lastNameMatchError || + membershipStatus.phoneNumberMatchError); - return ( - <> - [ + if (card == null) { + return ; + } + + return ( + <> + []), - { - label: t("cardDetail.transactions"), - url: Router.AccountCardsItemTransactions({ accountMembershipId, cardId }), - }, - ...match(card) - .with( - { - statusInfo: { - __typename: P.not(P.union("CardCanceledStatusInfo", "CardCancelingStatusInfo")), + label: t("cardDetail.virtualCard"), + url: Router.AccountCardsItem({ accountMembershipId, cardId }), }, - }, - () => [ + ...(shouldShowPhysicalCardTab + ? [ + { + label: t("cardDetail.physicalCard"), + url: Router.AccountCardsItemPhysicalCard({ + accountMembershipId, + cardId, + }), + }, + ] + : []), + ...match({ isCurrentUserCardOwner, card }) + .with( + { isCurrentUserCardOwner: true, card: { type: P.not("SingleUseVirtual") } }, + () => [ + { + label: t("cardDetail.mobilePayment"), + url: Router.AccountCardsItemMobilePayment({ + accountMembershipId, + cardId, + }), + }, + ], + ) + .otherwise(() => []), { - label: t("cardDetail.settings"), - url: Router.AccountCardsItemSettings({ - accountMembershipId, - cardId, - }), + label: t("cardDetail.transactions"), + url: Router.AccountCardsItemTransactions({ accountMembershipId, cardId }), }, - ], - ) - .otherwise(() => []), - ]} - otherLabel={t("common.tabs.other")} - /> + ...match(card) + .with( + { + statusInfo: { + __typename: P.not( + P.union("CardCanceledStatusInfo", "CardCancelingStatusInfo"), + ), + }, + }, + () => [ + { + label: t("cardDetail.settings"), + url: Router.AccountCardsItemSettings({ + accountMembershipId, + cardId, + }), + }, + ], + ) + .otherwise(() => []), + ]} + otherLabel={t("common.tabs.other")} + /> - }> - {match(route) - .with({ name: "AccountCardsItem" }, ({ params: { cardId } }) => ( - - {hasBindingUserError && ( - <> - + }> + {match(route) + .with({ name: "AccountCardsItem" }, ({ params: { cardId } }) => ( + + {hasBindingUserError && ( + <> + - - - {t("card.alert.informationConflict")} - - - - )} + + + {t("card.alert.informationConflict")} + + + + )} - + - - - )) - .with( - { name: "AccountCardsItemPhysicalCard" }, - ({ params: { cardId, accountMembershipId } }) => ( - - {hasBindingUserError && ( - <> + + )) + .with( + { name: "AccountCardsItemPhysicalCard" }, + ({ params: { cardId, accountMembershipId } }) => ( + + {hasBindingUserError && ( + <> + - - - {t("card.alert.informationConflict")} - - - - )} + + + {t("card.alert.informationConflict")} + + + + )} - + - - - ), - ) - .with({ name: "AccountCardsItemMobilePayment" }, () => ( - - + + + ), + ) + .with({ name: "AccountCardsItemMobilePayment" }, () => ( + + - + - - - )) - .with( - { name: "AccountCardsItemTransactions" }, - ({ params: { cardId, accountMembershipId, ...params } }) => ( - - ), - ) - .with({ name: "AccountCardsItemSettings" }, ({ params: { cardId } }) => ( - - + + + )) + .with( + { name: "AccountCardsItemTransactions" }, + ({ params: { cardId, accountMembershipId, ...params } }) => ( + + ), + ) + .with({ name: "AccountCardsItemSettings" }, ({ params: { cardId } }) => ( + + - + - - - )) - .otherwise(() => ( - - ))} - - - ); + + + )) + .otherwise(() => ( + + ))} + + + ); + }, + ) + .otherwise(() => ); }; diff --git a/clients/banking/src/components/CardItemMobilePayment.tsx b/clients/banking/src/components/CardItemMobilePayment.tsx index db60e0afe..c16a27f45 100644 --- a/clients/banking/src/components/CardItemMobilePayment.tsx +++ b/clients/banking/src/components/CardItemMobilePayment.tsx @@ -1,4 +1,5 @@ import { Array, Option } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty } from "@swan-io/lake/src/components/FixedListView"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; @@ -9,7 +10,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; @@ -126,7 +126,7 @@ export const CardItemMobilePayment = ({ const [cancelConfirmationModalModal, setCancelConfirmationModalModal] = useState< Option >(Option.None()); - const [digitalCardCancelation, cancelDigitalCard] = useUrqlMutation(CancelDigitalCardDocument); + const [cancelDigitalCard, digitalCardCancelation] = useMutation(CancelDigitalCardDocument); const onPressCancel = ({ digitalCardId }: { digitalCardId: string }) => { cancelDigitalCard({ digitalCardId }) diff --git a/clients/banking/src/components/CardItemPhysicalDetails.tsx b/clients/banking/src/components/CardItemPhysicalDetails.tsx index dbea57feb..4089c9aa6 100644 --- a/clients/banking/src/components/CardItemPhysicalDetails.tsx +++ b/clients/banking/src/components/CardItemPhysicalDetails.tsx @@ -1,4 +1,5 @@ import { Option } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; import { Icon } from "@swan-io/lake/src/components/Icon"; @@ -16,7 +17,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { nullishOrEmptyToUndefined } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -459,15 +459,13 @@ export const CardItemPhysicalDetails = ({ const initialShippingAddress = card.accountMembership.account?.holder.residencyAddress ?? undefined; - const [physicalCardPrinting, printPhysicalCard] = useUrqlMutation(PrintPhysicalCardDocument); - const [permanentBlocking, permanentlyBlockCard] = useUrqlMutation(CancelPhysicalCardDocument); - const [cardSuspension, suspendPhysicalCard] = useUrqlMutation(SuspendPhysicalCardDocument); - const [cardUnsuspension, unsuspendPhysicalCard] = useUrqlMutation(ResumePhysicalCardDocument); - const [pinCardViewing, viewPhysicalCardPin] = useUrqlMutation(ViewPhysicalCardPinDocument); - const [physicalCardActivation, activatePhysicalCard] = useUrqlMutation( - ActivatePhysicalCardDocument, - ); - const [physicalCardNumberViewing, viewPhysicalCardNumbers] = useUrqlMutation( + const [printPhysicalCard, physicalCardPrinting] = useMutation(PrintPhysicalCardDocument); + const [permanentlyBlockCard, permanentBlocking] = useMutation(CancelPhysicalCardDocument); + const [suspendPhysicalCard, cardSuspension] = useMutation(SuspendPhysicalCardDocument); + const [unsuspendPhysicalCard, cardUnsuspension] = useMutation(ResumePhysicalCardDocument); + const [viewPhysicalCardPin, pinCardViewing] = useMutation(ViewPhysicalCardPinDocument); + const [activatePhysicalCard, physicalCardActivation] = useMutation(ActivatePhysicalCardDocument); + const [viewPhysicalCardNumbers, physicalCardNumberViewing] = useMutation( ViewPhysicalCardNumbersDocument, ); diff --git a/clients/banking/src/components/CardItemSettings.tsx b/clients/banking/src/components/CardItemSettings.tsx index 9caa97516..33d442340 100644 --- a/clients/banking/src/components/CardItemSettings.tsx +++ b/clients/banking/src/components/CardItemSettings.tsx @@ -1,4 +1,5 @@ import { Option, Result } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty } from "@swan-io/lake/src/components/FixedListView"; import { Icon } from "@swan-io/lake/src/components/Icon"; @@ -8,7 +9,6 @@ import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Link } from "@swan-io/lake/src/components/Link"; import { Space } from "@swan-io/lake/src/components/Space"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -60,7 +60,7 @@ export const CardItemSettings = ({ lastRelevantIdentification, canManageCards, }: Props) => { - const [cardUpdate, updateCard] = useUrqlMutation(UpdateCardDocument); + const [updateCard, cardUpdate] = useMutation(UpdateCardDocument); const [isCancelConfirmationModalVisible, setIsCancelConfirmationModalVisible] = useState(false); const accountHolder = card.accountMembership.account?.holder; const settingsRef = useRef(null); diff --git a/clients/banking/src/components/CardItemTransactionList.tsx b/clients/banking/src/components/CardItemTransactionList.tsx index ddb9a7801..390d96036 100644 --- a/clients/banking/src/components/CardItemTransactionList.tsx +++ b/clients/banking/src/components/CardItemTransactionList.tsx @@ -1,4 +1,5 @@ import { Array, AsyncData, Option, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty, @@ -10,7 +11,6 @@ import { Pressable } from "@swan-io/lake/src/components/Pressable"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { Space } from "@swan-io/lake/src/components/Space"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { useCallback, useMemo, useRef, useState } from "react"; import { StyleSheet } from "react-native"; @@ -24,6 +24,7 @@ import { getMemberName } from "../utils/accountMembership"; import { t } from "../utils/i18n"; import { Router } from "../utils/routes"; import { CardItemIdentityVerificationGate } from "./CardItemIdentityVerificationGate"; +import { Connection } from "./Connection"; import { ErrorView } from "./ErrorView"; import { TransactionDetail } from "./TransactionDetail"; import { TransactionList } from "./TransactionList"; @@ -116,23 +117,17 @@ export const CardItemTransactionList = ({ const hasFilters = Object.values(filters).some(isNotNullish); - const { data, nextData, reload, setAfter } = useUrqlPaginatedQuery( - { - query: CardTransactionsPageDocument, - variables: { - cardId, - first: NUM_TO_RENDER, - filters: { - ...filters, - paymentProduct: undefined, - status: filters.status ?? DEFAULT_STATUSES, - }, - canQueryCardOnTransaction: true, - canViewAccount, - }, + const [data, { isLoading, reload, setVariables }] = useQuery(CardTransactionsPageDocument, { + cardId, + first: NUM_TO_RENDER, + filters: { + ...filters, + paymentProduct: undefined, + status: filters.status ?? DEFAULT_STATUSES, }, - [cardId, filters], - ); + canQueryCardOnTransaction: true, + canViewAccount, + }); const [activeTransactionId, setActiveTransactionId] = useState(null); @@ -174,7 +169,9 @@ export const CardItemTransactionList = ({ }) } available={availableFilters} - onRefresh={reload} + onRefresh={() => { + reload(); + }} large={large} /> @@ -195,66 +192,73 @@ export const CardItemTransactionList = ({ result.match({ Error: error => , Ok: ({ card }) => ( - ( - setActiveTransactionId(item.id)} /> - )} - pageSize={NUM_TO_RENDER} - activeRowId={activeTransactionId ?? undefined} - onActiveRowChange={onActiveRowChange} - loading={{ - isLoading: nextData.isLoading(), - count: 2, - }} - onEndReached={() => { - if (card?.transactions?.pageInfo.hasNextPage ?? false) { - setAfter(card?.transactions?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => - hasFilters ? ( - - ) : ( - - {cardRequiresIdentityVerification ? ( - <> - + + {transactions => ( + ( + setActiveTransactionId(item.id)} /> + )} + pageSize={NUM_TO_RENDER} + activeRowId={activeTransactionId ?? undefined} + onActiveRowChange={onActiveRowChange} + loading={{ + isLoading, + count: 2, + }} + onEndReached={() => { + if (transactions?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: transactions?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => + hasFilters ? ( + + ) : ( + + {cardRequiresIdentityVerification ? ( + <> + - - - ) : null} - - ) - } - /> + + + ) : null} + + ) + } + /> + )} + ), }), })} diff --git a/clients/banking/src/components/CardItemVirtualDetails.tsx b/clients/banking/src/components/CardItemVirtualDetails.tsx index 997747520..b247eb7d9 100644 --- a/clients/banking/src/components/CardItemVirtualDetails.tsx +++ b/clients/banking/src/components/CardItemVirtualDetails.tsx @@ -1,11 +1,11 @@ import { Option } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Fill } from "@swan-io/lake/src/components/Fill"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; @@ -78,7 +78,7 @@ export const CardItemVirtualDetails = ({ lastRelevantIdentification, hasBindingUserError, }: Props) => { - const [cardNumberViewing, viewCardNumbers] = useUrqlMutation(ViewCardNumbersDocument); + const [viewCardNumbers, cardNumberViewing] = useMutation(ViewCardNumbersDocument); const onPressRevealCardNumbers = () => { viewCardNumbers({ diff --git a/clients/banking/src/components/CardWizard.tsx b/clients/banking/src/components/CardWizard.tsx index b28200ed0..87f6be8fd 100644 --- a/clients/banking/src/components/CardWizard.tsx +++ b/clients/banking/src/components/CardWizard.tsx @@ -1,4 +1,5 @@ import { Array, AsyncData, Future, Option, Result } from "@swan-io/boxed"; +import { ClientError, useMutation, useQuery } from "@swan-io/graphql-client"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; @@ -8,16 +9,13 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { TransitionView } from "@swan-io/lake/src/components/TransitionView"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { animations, breakpoints, colors, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotNullish, isNullish } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; import { useRef, useState } from "react"; import { ScrollView, StyleSheet, View } from "react-native"; -import { match } from "ts-pattern"; -import { useQuery } from "urql"; +import { P, match } from "ts-pattern"; import { AccountMembershipFragment, AddCardDocument, @@ -31,7 +29,6 @@ import { CreateMultiConsentDocument, GetCardProductsDocument, GetCardProductsQuery, - GetEligibleCardMembershipsDocument, SpendingLimitInput, } from "../graphql/partner"; import { t } from "../utils/i18n"; @@ -208,19 +205,19 @@ export const CardWizard = ({ preselectedAccountMembership, physicalCardOrderVisible, }: Props) => { - const [{ data }] = useQuery({ - query: GetCardProductsDocument, - variables: { accountMembershipId: accountMembership.id }, + const [data, { setVariables }] = useQuery(GetCardProductsDocument, { + accountMembershipId: accountMembership.id, + first: 20, }); const [step, setStep] = useState(INITIAL_STEP); - const [, addCards] = useUrqlMutation(AddCardsDocument); - const [, addCard] = useUrqlMutation(AddCardDocument); - const [, addCardsWithGroupDelivery] = useUrqlMutation(AddCardsWithGroupDeliveryDocument); - const [, addSingleUseCards] = useUrqlMutation(AddSingleUseVirtualCardsDocument); - const [, addSingleUseCard] = useUrqlMutation(AddSingleUseVirtualCardDocument); - const [, createMultiConsent] = useUrqlMutation(CreateMultiConsentDocument); + const [addCards] = useMutation(AddCardsDocument); + const [addCard] = useMutation(AddCardDocument); + const [addCardsWithGroupDelivery] = useMutation(AddCardsWithGroupDeliveryDocument); + const [addSingleUseCards] = useMutation(AddSingleUseVirtualCardsDocument); + const [addSingleUseCard] = useMutation(AddSingleUseVirtualCardDocument); + const [createMultiConsent] = useMutation(CreateMultiConsentDocument); const addCardsWrapper = (input: AddCardsInput) => { setCardOrder(AsyncData.Loading()); @@ -348,7 +345,7 @@ export const CardWizard = ({ if (cardsRequiringConsent.length === 0) { // no need for consent, redirect immediately return Future.value( - Result.Ok, Error>( + Result.Ok, ClientError>( Option.Some( window.location.origin + Router.AccountCardsList({ @@ -389,619 +386,621 @@ export const CardWizard = ({ AsyncData.NotAsked(), ); - const cardProducts = data?.projectInfo.cardProducts ?? []; - const accountId = accountMembership.account?.id; + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with(AsyncData.P.Done(Result.P.Ok(P.select())), data => { + const cardProducts = data?.projectInfo.cardProducts ?? []; + const accountId = accountMembership.account?.id; - // not ideal but we need to keep the hook at top-level - const { data: members, setAfter: setMembersAfterCursor } = useUrqlPaginatedQuery( - { - query: GetEligibleCardMembershipsDocument, - variables: { - accountId: accountId ?? "", - first: 20, - }, - }, - [accountId], - ); + const canOrderPhysicalCard = step.cardFormat === "VirtualAndPhysical"; + + if (accountId == null) { + return ; + } + + const hasMoreThanOneMember = + preselectedAccountMembership != null + ? false + : (data.accountMembership?.account?.allMemberships.totalCount ?? 0) > 1; + + const account = data.accountMembership?.account; + const members = data.accountMembership?.account?.memberships; + + return ( + + {({ large }) => ( + + + + {onPressClose != null && ( + <> + + + + + )} + + + + {t("cardWizard.header.cardProduct")} + + + + {t("cardWizard.header.cardFormat")} + + + + {t("cardWizard.header.cardSettings")} + + + + {t("cardWizard.header.members")} + + + + {t("cardWizard.header.delivery")} + - const canOrderPhysicalCard = step.cardFormat === "VirtualAndPhysical"; - - if (accountId == null) { - return ; - } - - if (members.isNotAsked() || members.isLoading()) { - return ; - } - - const account = members - .get() - .map(({ account }) => account) - .getWithDefault(undefined); - - const hasMoreThanOneMember = - preselectedAccountMembership != null ? false : (account?.allMemberships.totalCount ?? 0) > 1; - - return ( - - {({ large }) => ( - - - - {onPressClose != null && ( - <> - - - - - )} - - - - {t("cardWizard.header.cardProduct")} - - - - {t("cardWizard.header.cardFormat")} - - - - {t("cardWizard.header.cardSettings")} - - - - {t("cardWizard.header.members")} - - - - {t("cardWizard.header.delivery")} - - - - {t("cardWizard.header.address")} - + + {t("cardWizard.header.address")} + + + - - - - - - {match(step) - .with( - { name: "CardProductMembers" }, - ({ - cardProduct, - cardFormat, - cardName, - memberships, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }) => - members != null && ( - { - if (canOrderPhysicalCard) { - if (memberships.length === 1) { - setStep({ - name: "CardProductIndividualDelivery", - cardName, - cardProduct, - cardFormat, - memberships, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }); - } else { - setStep({ - name: "CardProductDelivery", - cardName, - cardProduct, - cardFormat, - memberships, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }); - } - } else { - if (cardFormat === "SingleUseVirtual") { - addSingleUseCardsWrapper({ - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - - cards: memberships.map(member => { - return { - name: cardName, - accountMembershipId: member.id, - spendingLimit, - }; - }), - }); - } else { - addCardsWrapper({ - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - cards: memberships.map(member => { - return { - accountMembershipId: member.id, + + + + {match(step) + .with( + { name: "CardProductMembers" }, + ({ + cardProduct, + cardFormat, + cardName, + memberships, + spendingLimit, + eCommerce, + withdrawal, + international, + nonMainCurrencyTransactions, + }) => + members != null && ( + setVariables({ after })} + account={account} + style={styles.container} + contentContainerStyle={[styles.contents, large && styles.desktopContents]} + onSubmit={memberships => { + if (canOrderPhysicalCard) { + if (memberships.length === 1) { + setStep({ + name: "CardProductIndividualDelivery", + cardName, + cardProduct, + cardFormat, + memberships, spendingLimit, - name: cardName, eCommerce, withdrawal, international, nonMainCurrencyTransactions, - }; - }), - }); - } - } - }} - /> - ), - ) - .otherwise(step => ( - - {match(step) - .with({ name: "CardProductType" }, ({ cardProduct }) => ( - setStep({ name: "CardProductFormat", cardProduct })} - /> - )) - .with({ name: "CardProductFormat" }, ({ cardProduct, cardFormat }) => ( - - setStep({ name: "CardProductSettings", cardProduct, cardFormat }) - } - /> - )) - .with( - { name: "CardProductSettings" }, - ({ - cardName, - cardProduct, - cardFormat, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }) => ( - { - if (hasMoreThanOneMember) { - setStep({ - name: "CardProductMembers", - cardProduct, - cardFormat, - ...cardSettings, - }); - } else { - const memberships = - preselectedAccountMembership != null - ? [preselectedAccountMembership] - : account?.memberships.edges.map(({ node }) => node) ?? []; - - if (canOrderPhysicalCard) { + }); + } else { setStep({ - name: "CardProductIndividualDelivery", + name: "CardProductDelivery", + cardName, cardProduct, cardFormat, memberships, - ...cardSettings, + spendingLimit, + eCommerce, + withdrawal, + international, + nonMainCurrencyTransactions, }); - } else { - if (cardFormat === "SingleUseVirtual") { - addSingleUseCardsWrapper({ - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - cards: memberships.map(accountMembership => { - return { - name: cardSettings.cardName, - accountMembershipId: accountMembership.id, - spendingLimit: cardSettings.spendingLimit, - }; + } + } else { + if (cardFormat === "SingleUseVirtual") { + addSingleUseCardsWrapper({ + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, }), - }); - } else { - addCardsWrapper({ - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - cards: memberships.map(membership => { - return { - accountMembershipId: membership.id, - spendingLimit: cardSettings.spendingLimit, - name: cardSettings.cardName, - eCommerce: cardSettings.eCommerce, - withdrawal: cardSettings.withdrawal, - international: cardSettings.international, - nonMainCurrencyTransactions: - cardSettings.nonMainCurrencyTransactions, - }; + + cards: memberships.map(member => { + return { + name: cardName, + accountMembershipId: member.id, + spendingLimit, + }; + }), + }); + } else { + addCardsWrapper({ + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, }), - }); - } + cards: memberships.map(member => { + return { + accountMembershipId: member.id, + spendingLimit, + name: cardName, + eCommerce, + withdrawal, + international, + nonMainCurrencyTransactions, + }; + }), + }); } } }} /> ), - ) - .with( - { name: "CardProductDelivery" }, - ({ - cardName, - cardProduct, - cardFormat, - memberships, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }) => ( - { - if (mode === "Grouped") { - setStep({ - name: "CardProductGroupedDelivery", - cardName, - cardProduct, - cardFormat, - memberships, - spendingLimit, - eCommerce, - withdrawal, - international, - nonMainCurrencyTransactions, - }); - } else { - setStep({ - name: "CardProductIndividualDelivery", + ) + .otherwise(step => ( + + {match(step) + .with({ name: "CardProductType" }, ({ cardProduct }) => ( + + setStep({ name: "CardProductFormat", cardProduct }) + } + /> + )) + .with({ name: "CardProductFormat" }, ({ cardProduct, cardFormat }) => ( + + setStep({ name: "CardProductSettings", cardProduct, cardFormat }) + } + /> + )) + .with( + { name: "CardProductSettings" }, + ({ + cardName, + cardProduct, + cardFormat, + spendingLimit, + eCommerce, + withdrawal, + international, + nonMainCurrencyTransactions, + }) => ( + - ), - ) - .with( - { name: "CardProductGroupedDelivery" }, - ({ - memberships, - cardProduct, - spendingLimit, - eCommerce, - cardName, - withdrawal, - international, - nonMainCurrencyTransactions, - }) => { - const accountMembership = data?.accountMembership; - - if (accountMembership?.account == null || accountMembership?.user == null) { - return ; - } - - return ( - name, - ) - .otherwise(() => undefined), - country: - accountMembership.account.holder.residencyAddress.country ?? "", - firstName: accountMembership.user.firstName ?? "", - lastName: accountMembership.user.lastName ?? "", - phoneNumber: accountMembership.user.mobilePhoneNumber ?? "", - postalCode: - accountMembership.account.holder.residencyAddress.postalCode ?? "", - state: accountMembership.account.holder.residencyAddress.state, - }} - onSubmit={groupedDeliveryConfig => { - setCardOrder(AsyncData.Loading()); - - addCardsWithGroupDelivery({ - input: { - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - groupDeliveryAddress: groupedDeliveryConfig.address, - cards: groupedDeliveryConfig.members.map(membership => ({ - accountMembershipId: membership.id, + }} + accountHolder={accountMembership.account?.holder} + onSubmit={cardSettings => { + if (hasMoreThanOneMember) { + setStep({ + name: "CardProductMembers", + cardProduct, + cardFormat, + ...cardSettings, + }); + } else { + const memberships = + preselectedAccountMembership != null + ? [preselectedAccountMembership] + : account?.memberships.edges.map(({ node }) => node) ?? []; + + if (canOrderPhysicalCard) { + setStep({ + name: "CardProductIndividualDelivery", + cardProduct, + cardFormat, + memberships, + ...cardSettings, + }); + } else { + if (cardFormat === "SingleUseVirtual") { + addSingleUseCardsWrapper({ + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, + }), + cards: memberships.map(accountMembership => { + return { + name: cardSettings.cardName, + accountMembershipId: accountMembership.id, + spendingLimit: cardSettings.spendingLimit, + }; + }), + }); + } else { + addCardsWrapper({ + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, + }), + cards: memberships.map(membership => { + return { + accountMembershipId: membership.id, + spendingLimit: cardSettings.spendingLimit, + name: cardSettings.cardName, + eCommerce: cardSettings.eCommerce, + withdrawal: cardSettings.withdrawal, + international: cardSettings.international, + nonMainCurrencyTransactions: + cardSettings.nonMainCurrencyTransactions, + }; + }), + }); + } + } + } + }} + /> + ), + ) + .with( + { name: "CardProductDelivery" }, + ({ + cardName, + cardProduct, + cardFormat, + memberships, + spendingLimit, + eCommerce, + withdrawal, + international, + nonMainCurrencyTransactions, + }) => ( + { + if (mode === "Grouped") { + setStep({ + name: "CardProductGroupedDelivery", + cardName, + cardProduct, + cardFormat, + memberships, spendingLimit, eCommerce, withdrawal, - name: cardName, international, nonMainCurrencyTransactions, - printPhysicalCard: true, - })), - }, - }) - .mapOk(data => data.addCardsWithGroupDelivery) - .mapOkToResult(filterRejectionsToResult) - .flatMapOk(data => generateMultiConsent(data.cards)) - .tap(() => setCardOrder(AsyncData.NotAsked())) - .tapOk(value => { - value.match({ - Some: consentUrl => window.location.replace(consentUrl), - None: () => {}, - }); - }) - .tapError(error => { - showToast({ - variant: "error", - error, - title: translateError(error), }); - }); - }} - /> - ); - }, - ) - .with( - { name: "CardProductIndividualDelivery" }, - ({ - memberships, - cardProduct, - spendingLimit, - eCommerce, - cardName, - withdrawal, - international, - nonMainCurrencyTransactions, - }) => { - const accountMembership = data?.accountMembership; - - if (accountMembership?.account == null || accountMembership?.user == null) { - return ; - } - - return ( - name, - ) - .otherwise(() => undefined), - country: - accountMembership.account.holder.residencyAddress.country ?? "", - firstName: accountMembership.user.firstName ?? "", - lastName: accountMembership.user.lastName ?? "", - phoneNumber: accountMembership.user.mobilePhoneNumber ?? "", - postalCode: - accountMembership.account.holder.residencyAddress.postalCode ?? "", - state: accountMembership.account.holder.residencyAddress.state, - }} - onSubmit={individualDeliveryConfig => { - addCardsWrapper({ - cardProductId: cardProduct.id, - consentRedirectUrl: - window.location.origin + - Router.AccountCardsList({ - accountMembershipId: accountMembership.id, - }), - cards: individualDeliveryConfig.map( - ({ - member, - address: { - firstName, - lastName, - companyName, - phoneNumber, - ...address - }, - }) => ({ - accountMembershipId: member.id, + } else { + setStep({ + name: "CardProductIndividualDelivery", + cardName, + cardProduct, + cardFormat, + memberships, spendingLimit, eCommerce, - name: cardName, withdrawal, international, nonMainCurrencyTransactions, - physicalCard: { - deliveryAddress: address, - }, - }), - ), - }); - }} - /> - ); - }, - ) - .exhaustive()} - - ))} - - - - - - match(step) - .with({ name: "CardProductType" }, () => onPressClose?.()) - .with({ name: "CardProductFormat" }, ({ name, ...rest }) => - cardProducts.length <= 1 - ? onPressClose?.() - : setStep({ name: "CardProductType", ...rest }), - ) - .with({ name: "CardProductSettings" }, ({ cardProduct, name, ...rest }) => - setStep({ name: "CardProductFormat", cardProduct, ...rest }), - ) - .with({ name: "CardProductMembers" }, ({ name, ...rest }) => - setStep({ name: "CardProductSettings", ...rest }), - ) - .with({ name: "CardProductDelivery" }, ({ name, ...rest }) => - setStep({ name: "CardProductMembers", ...rest }), + }); + } + }} + /> + ), ) .with( { name: "CardProductGroupedDelivery" }, + ({ + memberships, + cardProduct, + spendingLimit, + eCommerce, + cardName, + withdrawal, + international, + nonMainCurrencyTransactions, + }) => { + const accountMembership = data?.accountMembership; + + if ( + accountMembership?.account == null || + accountMembership?.user == null + ) { + return ; + } + + return ( + name, + ) + .otherwise(() => undefined), + country: + accountMembership.account.holder.residencyAddress.country ?? "", + firstName: accountMembership.user.firstName ?? "", + lastName: accountMembership.user.lastName ?? "", + phoneNumber: accountMembership.user.mobilePhoneNumber ?? "", + postalCode: + accountMembership.account.holder.residencyAddress.postalCode ?? + "", + state: accountMembership.account.holder.residencyAddress.state, + }} + onSubmit={groupedDeliveryConfig => { + setCardOrder(AsyncData.Loading()); + + addCardsWithGroupDelivery({ + input: { + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, + }), + groupDeliveryAddress: groupedDeliveryConfig.address, + cards: groupedDeliveryConfig.members.map(membership => ({ + accountMembershipId: membership.id, + spendingLimit, + eCommerce, + withdrawal, + name: cardName, + international, + nonMainCurrencyTransactions, + printPhysicalCard: true, + })), + }, + }) + .mapOk(data => data.addCardsWithGroupDelivery) + .mapOkToResult(filterRejectionsToResult) + .flatMapOk(data => generateMultiConsent(data.cards)) + .tap(() => setCardOrder(AsyncData.NotAsked())) + .tapOk(value => { + value.match({ + Some: consentUrl => window.location.replace(consentUrl), + None: () => {}, + }); + }) + .tapError(error => { + showToast({ + variant: "error", + error, + title: translateError(error), + }); + }); + }} + /> + ); + }, + ) + .with( { name: "CardProductIndividualDelivery" }, - ({ name, ...rest }) => - setStep( - rest.memberships.length === 1 - ? hasMoreThanOneMember - ? { name: "CardProductMembers", ...rest } - : { name: "CardProductSettings", ...rest } - : { name: "CardProductDelivery", ...rest }, - ), + ({ + memberships, + cardProduct, + spendingLimit, + eCommerce, + cardName, + withdrawal, + international, + nonMainCurrencyTransactions, + }) => { + const accountMembership = data?.accountMembership; + + if ( + accountMembership?.account == null || + accountMembership?.user == null + ) { + return ; + } + + return ( + name, + ) + .otherwise(() => undefined), + country: + accountMembership.account.holder.residencyAddress.country ?? "", + firstName: accountMembership.user.firstName ?? "", + lastName: accountMembership.user.lastName ?? "", + phoneNumber: accountMembership.user.mobilePhoneNumber ?? "", + postalCode: + accountMembership.account.holder.residencyAddress.postalCode ?? + "", + state: accountMembership.account.holder.residencyAddress.state, + }} + onSubmit={individualDeliveryConfig => { + addCardsWrapper({ + cardProductId: cardProduct.id, + consentRedirectUrl: + window.location.origin + + Router.AccountCardsList({ + accountMembershipId: accountMembership.id, + }), + cards: individualDeliveryConfig.map( + ({ + member, + address: { + firstName, + lastName, + companyName, + phoneNumber, + ...address + }, + }) => ({ + accountMembershipId: member.id, + spendingLimit, + eCommerce, + name: cardName, + withdrawal, + international, + nonMainCurrencyTransactions, + physicalCard: { + deliveryAddress: address, + }, + }), + ), + }); + }} + /> + ); + }, ) - .otherwise(() => {}) - } - > - {match(step.name) - .with("CardProductType", () => t("common.cancel")) - .with("CardProductFormat", () => - cardProducts.length <= 1 ? t("common.cancel") : t("common.previous"), - ) - .with("CardProductSettings", () => t("common.previous")) - .with("CardProductMembers", () => t("common.previous")) - .with("CardProductDelivery", () => t("common.previous")) - .otherwise(() => t("common.previous"))} - - - - match(step.name) - .with("CardProductType", () => { - cardWizardProductRef.current?.submit(); - }) - .with("CardProductFormat", () => { - cardWizardFormatRef.current?.submit(); - }) - .with("CardProductSettings", () => { - cardWizardSettingsRef.current?.submit(); - }) - .with("CardProductMembers", () => { - cardWizardMembersRef.current?.submit(); - }) - .with("CardProductDelivery", () => { - cardWizardDeliveryRef.current?.submit(); - }) - .with("CardProductGroupedDelivery", () => { - cardWizardGroupedDeliveryRef.current?.submit(); - }) - .with("CardProductIndividualDelivery", () => { - cardWizardIndividualDeliveryRef.current?.submit(); - }) - .otherwise(() => {}) - } - > - {t("common.next")} - - + .exhaustive()} + + ))} + + + + + + match(step) + .with({ name: "CardProductType" }, () => onPressClose?.()) + .with({ name: "CardProductFormat" }, ({ name, ...rest }) => + cardProducts.length <= 1 + ? onPressClose?.() + : setStep({ name: "CardProductType", ...rest }), + ) + .with({ name: "CardProductSettings" }, ({ cardProduct, name, ...rest }) => + setStep({ name: "CardProductFormat", cardProduct, ...rest }), + ) + .with({ name: "CardProductMembers" }, ({ name, ...rest }) => + setStep({ name: "CardProductSettings", ...rest }), + ) + .with({ name: "CardProductDelivery" }, ({ name, ...rest }) => + setStep({ name: "CardProductMembers", ...rest }), + ) + .with( + { name: "CardProductGroupedDelivery" }, + { name: "CardProductIndividualDelivery" }, + ({ name, ...rest }) => + setStep( + rest.memberships.length === 1 + ? hasMoreThanOneMember + ? { name: "CardProductMembers", ...rest } + : { name: "CardProductSettings", ...rest } + : { name: "CardProductDelivery", ...rest }, + ), + ) + .otherwise(() => {}) + } + > + {match(step.name) + .with("CardProductType", () => t("common.cancel")) + .with("CardProductFormat", () => + cardProducts.length <= 1 ? t("common.cancel") : t("common.previous"), + ) + .with("CardProductSettings", () => t("common.previous")) + .with("CardProductMembers", () => t("common.previous")) + .with("CardProductDelivery", () => t("common.previous")) + .otherwise(() => t("common.previous"))} + + + + match(step.name) + .with("CardProductType", () => { + cardWizardProductRef.current?.submit(); + }) + .with("CardProductFormat", () => { + cardWizardFormatRef.current?.submit(); + }) + .with("CardProductSettings", () => { + cardWizardSettingsRef.current?.submit(); + }) + .with("CardProductMembers", () => { + cardWizardMembersRef.current?.submit(); + }) + .with("CardProductDelivery", () => { + cardWizardDeliveryRef.current?.submit(); + }) + .with("CardProductGroupedDelivery", () => { + cardWizardGroupedDeliveryRef.current?.submit(); + }) + .with("CardProductIndividualDelivery", () => { + cardWizardIndividualDeliveryRef.current?.submit(); + }) + .otherwise(() => {}) + } + > + {t("common.next")} + + + + - - - )} - - ); + )} + + ); + }) + .exhaustive(); }; diff --git a/clients/banking/src/components/CardWizardMembers.tsx b/clients/banking/src/components/CardWizardMembers.tsx index 23cc39267..d26a2846f 100644 --- a/clients/banking/src/components/CardWizardMembers.tsx +++ b/clients/banking/src/components/CardWizardMembers.tsx @@ -1,3 +1,4 @@ +import { useForwardPagination } from "@swan-io/graphql-client"; import { Avatar } from "@swan-io/lake/src/components/Avatar"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; @@ -95,7 +96,9 @@ export const CardWizardMembers = forwardRef( [currentMembers], ); - const memberships = account?.memberships; + const connection = account?.memberships; + + const memberships = useForwardPagination(connection); const onScroll = useCallback( (event: NativeSyntheticEvent) => { diff --git a/clients/banking/src/components/CardsArea.tsx b/clients/banking/src/components/CardsArea.tsx index 83b7899d0..25704ee03 100644 --- a/clients/banking/src/components/CardsArea.tsx +++ b/clients/banking/src/components/CardsArea.tsx @@ -1,4 +1,5 @@ -import { Option } from "@swan-io/boxed"; +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useDeferredQuery } from "@swan-io/graphql-client"; import { Breadcrumbs, BreadcrumbsRoot } from "@swan-io/lake/src/components/Breadcrumbs"; import { FullViewportLayer } from "@swan-io/lake/src/components/FullViewportLayer"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; @@ -8,10 +9,9 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, colors, spacings } from "@swan-io/lake/src/constants/design"; import { isNotNullish, isNullish } from "@swan-io/lake/src/utils/nullish"; -import { Suspense, useMemo } from "react"; +import { Suspense, useEffect, useMemo } from "react"; import { StyleSheet, View } from "react-native"; import { P, match } from "ts-pattern"; -import { useQuery } from "urql"; import { AccountAreaQuery, CardCountWithAccountDocument, @@ -23,6 +23,7 @@ import { t } from "../utils/i18n"; import { Router } from "../utils/routes"; import { CardItemArea } from "./CardItemArea"; import { CardWizard } from "./CardWizard"; +import { ErrorView } from "./ErrorView"; import { Redirect } from "./Redirect"; const styles = StyleSheet.create({ @@ -91,43 +92,52 @@ const useDisplayableCardsInformation = ({ } as const; }, [accountId]); - const [withAccountQuery] = useQuery({ - query: CardCountWithAccountDocument, - pause: !hasAccountId, - variables: { - first: 1, - filters: filtersWithAccount, - }, - }); + const [withAccountQuery, { query: queryWithAccount }] = useDeferredQuery( + CardCountWithAccountDocument, + ); - const [withoutAccountQuery] = useQuery({ - query: CardCountWithoutAccountDocument, - pause: hasAccountId, - variables: { - accountMembershipId, - first: 1, - filters: relevantCardsFilter, - }, - }); + const [withoutAccountQuery, { query: queryWithoutAccount }] = useDeferredQuery( + CardCountWithoutAccountDocument, + ); + + useEffect(() => { + if (hasAccountId) { + queryWithAccount({ + first: 1, + filters: filtersWithAccount, + }); + } else { + queryWithoutAccount({ + accountMembershipId, + first: 1, + filters: relevantCardsFilter, + }); + } + }, [ + accountMembershipId, + accountId, + hasAccountId, + filtersWithAccount, + queryWithAccount, + queryWithoutAccount, + ]); if (hasAccountId) { - return { + return withAccountQuery.mapOk(data => ({ onlyCardId: - withAccountQuery.data?.cards.totalCount === 1 - ? Option.fromNullable(withAccountQuery.data?.cards.edges[0]?.node.id) + data?.cards.totalCount === 1 + ? Option.fromNullable(data?.cards.edges[0]?.node.id) : Option.None(), - totalDisplayableCardCount: withAccountQuery.data?.cards.totalCount ?? 0, - }; + totalDisplayableCardCount: data?.cards.totalCount ?? 0, + })); } else { - return { + return withoutAccountQuery.mapOk(data => ({ onlyCardId: - withoutAccountQuery.data?.accountMembership?.cards.totalCount === 1 - ? Option.fromNullable( - withoutAccountQuery.data?.accountMembership?.cards.edges[0]?.node.id, - ) + data?.accountMembership?.cards.totalCount === 1 + ? Option.fromNullable(data?.accountMembership?.cards.edges[0]?.node.id) : Option.None(), - totalDisplayableCardCount: withoutAccountQuery.data?.accountMembership?.cards.totalCount ?? 0, - }; + totalDisplayableCardCount: data?.accountMembership?.cards.totalCount ?? 0, + })); } }; @@ -145,7 +155,7 @@ export const CardsArea = ({ }: Props) => { const route = Router.useRoute(["AccountCardsList", "AccountCardsItemArea"]); - const { onlyCardId, totalDisplayableCardCount } = useDisplayableCardsInformation({ + const data = useDisplayableCardsInformation({ accountMembershipId, accountId, }); @@ -160,111 +170,123 @@ export const CardsArea = ({ [accountMembershipId], ); - if (onlyCardId.isSome() && route?.name !== "AccountCardsItemArea") { - return ( - - ); - } - if (isNullish(route?.name)) { return ; } - return ( - - {({ large }) => ( - - - {totalDisplayableCardCount > 1 ? ( - - - - ) : null} + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with( + AsyncData.P.Done(Result.P.Ok(P.select())), + ({ onlyCardId, totalDisplayableCardCount }) => { + if (onlyCardId.isSome() && route?.name !== "AccountCardsItemArea") { + return ( + + ); + } - {onlyCardId.isSome() ? : null} + return ( + + {({ large }) => ( + + + {totalDisplayableCardCount > 1 ? ( + + + + ) : null} - }> - - {match(route) - .with( - { name: "AccountCardsList" }, - ({ params: { accountMembershipId, new: _, ...params } }) => ( - - ), - ) - .with({ name: "AccountCardsItemArea" }, ({ params: { cardId } }) => ( - <> - {canAddCard && cardOrderVisible && onlyCardId.isSome() ? ( - - - Router.push("AccountCardsItem", { - cardId, - accountMembershipId, - new: "", - }) - } - > - {t("common.new")} - - - ) : null} + {onlyCardId.isSome() ? : null} - - - )) - .with(P.nullish, () => null) - .exhaustive()} - - + }> + + {match(route) + .with( + { name: "AccountCardsList" }, + ({ params: { accountMembershipId, new: _, ...params } }) => ( + + ), + ) + .with({ name: "AccountCardsItemArea" }, ({ params: { cardId } }) => ( + <> + {canAddCard && cardOrderVisible && onlyCardId.isSome() ? ( + + + Router.push("AccountCardsItem", { + cardId, + accountMembershipId, + new: "", + }) + } + > + {t("common.new")} + + + ) : null} - - { - match(route) - .with({ name: P.string }, ({ name, params }) => { - Router.push(name === "AccountCardsItemArea" ? "AccountCardsItem" : name, { - ...params, - new: undefined, - }); - }) - .otherwise(() => {}); - }} - /> - - - - )} - - ); + + + )) + .with(P.nullish, () => null) + .exhaustive()} + + + + + { + match(route) + .with({ name: P.string }, ({ name, params }) => { + Router.push( + name === "AccountCardsItemArea" ? "AccountCardsItem" : name, + { + ...params, + new: undefined, + }, + ); + }) + .otherwise(() => {}); + }} + /> + + + + )} + + ); + }, + ) + .exhaustive(); }; diff --git a/clients/banking/src/components/Connection.tsx b/clients/banking/src/components/Connection.tsx new file mode 100644 index 000000000..5fc520e93 --- /dev/null +++ b/clients/banking/src/components/Connection.tsx @@ -0,0 +1,12 @@ +import { Connection as ConnectionType, useForwardPagination } from "@swan-io/graphql-client"; + +export const Connection = >({ + connection, + children, +}: { + connection: A; + children: (value: A) => React.ReactNode; +}) => { + const paginated = useForwardPagination(connection); + return children(paginated); +}; diff --git a/clients/banking/src/components/ErrorView.tsx b/clients/banking/src/components/ErrorView.tsx index 1befe3b13..8ca29db20 100644 --- a/clients/banking/src/components/ErrorView.tsx +++ b/clients/banking/src/components/ErrorView.tsx @@ -1,12 +1,14 @@ +import { Array, Option } from "@swan-io/boxed"; +import { ClientError } from "@swan-io/graphql-client"; import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Space } from "@swan-io/lake/src/components/Space"; -import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; +import { useState } from "react"; import { StyleProp, StyleSheet, ViewStyle } from "react-native"; -import { isCombinedError } from "../utils/urql"; +import { errorToRequestId } from "../utils/gql"; const styles = StyleSheet.create({ base: { @@ -16,24 +18,35 @@ const styles = StyleSheet.create({ }); type Props = { - error?: Error; + error?: ClientError; style?: StyleProp; }; -export const ErrorView = ({ error, style }: Props) => ( - - - +export const ErrorView = ({ error, style }: Props) => { + const [requestId] = useState>(() => { + if (error == undefined) { + return Option.None(); + } + return Array.findMap(ClientError.toArray(error), error => + Option.fromNullable(errorToRequestId.get(error)), + ); + }); - - {translateError(error)} - + return ( + + + - {isCombinedError(error) && isNotNullish(error.requestId) ? ( - <> - - ID: {error.requestId} - - ) : null} - -); + + {translateError(error)} + + + {requestId.isSome() ? ( + <> + + ID: {requestId.get()} + + ) : null} + + ); +}; diff --git a/clients/banking/src/components/MembershipCancelConfirmationModal.tsx b/clients/banking/src/components/MembershipCancelConfirmationModal.tsx index 77042b2a9..f14a97ba2 100644 --- a/clients/banking/src/components/MembershipCancelConfirmationModal.tsx +++ b/clients/banking/src/components/MembershipCancelConfirmationModal.tsx @@ -1,8 +1,8 @@ +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Space } from "@swan-io/lake/src/components/Space"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; @@ -23,9 +23,7 @@ export const MembershipCancelConfirmationModal = ({ onSuccess, onPressClose, }: Props) => { - const [membershipDisabling, disableMembership] = useUrqlMutation( - DisableAccountMembershipDocument, - ); + const [disableMembership, membershipDisabling] = useMutation(DisableAccountMembershipDocument); const onPressConfirm = () => { if (accountMembershipId != null) { diff --git a/clients/banking/src/components/MembershipConflictResolutionEditor.tsx b/clients/banking/src/components/MembershipConflictResolutionEditor.tsx index 666c5c972..5cda48ba1 100644 --- a/clients/banking/src/components/MembershipConflictResolutionEditor.tsx +++ b/clients/banking/src/components/MembershipConflictResolutionEditor.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; @@ -5,7 +6,6 @@ import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { ReadOnlyFieldList } from "@swan-io/lake/src/components/ReadOnlyFieldList"; import { Space } from "@swan-io/lake/src/components/Space"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; @@ -45,7 +45,7 @@ export const MembershipConflictResolutionEditor = ({ accountMembership, onAction, }: Props) => { - const [membershipUpdate, updateMembership] = useUrqlMutation(UpdateAccountMembershipDocument); + const [updateMembership, membershipUpdate] = useMutation(UpdateAccountMembershipDocument); const [isCancelConfirmationModalOpen, setIsCancelConfirmationModalOpen] = useState(false); const acceptMembership = () => { diff --git a/clients/banking/src/components/MembershipDetailArea.tsx b/clients/banking/src/components/MembershipDetailArea.tsx index 1b99edd3a..7b613a4a2 100644 --- a/clients/banking/src/components/MembershipDetailArea.tsx +++ b/clients/banking/src/components/MembershipDetailArea.tsx @@ -1,9 +1,13 @@ +import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { ListRightPanelContent } from "@swan-io/lake/src/components/ListRightPanel"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { Space } from "@swan-io/lake/src/components/Space"; +import { useIsSuspendable } from "@swan-io/lake/src/components/Suspendable"; import { TabView } from "@swan-io/lake/src/components/TabView"; import { Tag } from "@swan-io/lake/src/components/Tag"; import { Tile } from "@swan-io/lake/src/components/Tile"; @@ -15,7 +19,6 @@ import dayjs from "dayjs"; import { useMemo } from "react"; import { ScrollView, StyleSheet, View } from "react-native"; import { P, match } from "ts-pattern"; -import { useQuery } from "urql"; import { AccountMembershipFragment, MembershipDetailDocument } from "../graphql/partner"; import { getMemberName } from "../utils/accountMembership"; import { t } from "../utils/i18n"; @@ -90,290 +93,306 @@ export const MembershipDetailArea = ({ }: Props) => { const route = Router.useRoute(membershipsDetailRoutes); - const [{ data }, reload] = useQuery({ - query: MembershipDetailDocument, - variables: { accountMembershipId: editingAccountMembershipId }, - }); + const suspense = useIsSuspendable(); + + const [data, { reload }] = useQuery( + MembershipDetailDocument, + { + accountMembershipId: editingAccountMembershipId, + }, + { suspense }, + ); const accountMembership = useMemo(() => { - return match(data) - .returnType() - .with( - { - accountMembership: { - canManageAccountMembership: false, - canInitiatePayments: false, - canManageBeneficiaries: false, - canViewAccount: false, - canManageCards: false, - statusInfo: { - __typename: "AccountMembershipBindingUserErrorStatusInfo", - idVerifiedMatchError: true, + return data.mapOk(data => + match(data) + .returnType() + .with( + { + accountMembership: { + canManageAccountMembership: false, + canInitiatePayments: false, + canManageBeneficiaries: false, + canViewAccount: false, + canManageCards: false, + statusInfo: { + __typename: "AccountMembershipBindingUserErrorStatusInfo", + idVerifiedMatchError: true, + }, }, + projectInfo: { B2BMembershipIDVerification: false }, }, - projectInfo: { B2BMembershipIDVerification: false }, - }, - ({ accountMembership }) => ({ - ...accountMembership, - statusInfo: { - ...accountMembership.statusInfo, - idVerifiedMatchError: false, - }, - }), - ) - .otherwise(() => data?.accountMembership ?? undefined); + ({ accountMembership }) => ({ + ...accountMembership, + statusInfo: { + ...accountMembership.statusInfo, + idVerifiedMatchError: false, + }, + }), + ) + .otherwise(() => data?.accountMembership ?? undefined), + ); }, [data]); - if (accountMembership == null) { - return null; - } - - const requiresIdentityVerification = - shouldDisplayIdVerification && accountMembership.hasRequiredIdentificationLevel === false; + return match(accountMembership) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ( + + )) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with(AsyncData.P.Done(Result.P.Ok(P.select())), accountMembership => { + if (accountMembership == null) { + return null; + } - return ( - - - - ( - - ), - ) - .with({ __typename: "AccountMembershipBindingUserErrorStatusInfo" }, () => ( - - )) - .otherwise(() => null)} - > - - {match(accountMembership.statusInfo) - .with({ __typename: "AccountMembershipEnabledStatusInfo" }, () => ( - {t("memberships.status.active")} - )) - .with( - { - __typename: "AccountMembershipBindingUserErrorStatusInfo", - idVerifiedMatchError: true, - }, - () => {t("memberships.status.limitedAccess")}, - ) - .with({ __typename: "AccountMembershipBindingUserErrorStatusInfo" }, () => ( - {t("memberships.status.conflict")} - )) - .with({ __typename: "AccountMembershipInvitationSentStatusInfo" }, () => ( - {t("memberships.status.invitationSent")} - )) - .with({ __typename: "AccountMembershipSuspendedStatusInfo" }, () => ( - {t("memberships.status.temporarilyBlocked")} - )) - .with({ __typename: "AccountMembershipDisabledStatusInfo" }, () => ( - {t("memberships.status.permanentlyBlocked")} - )) - .with({ __typename: "AccountMembershipConsentPendingStatusInfo" }, () => null) - .exhaustive()} + const requiresIdentityVerification = + shouldDisplayIdVerification && accountMembership.hasRequiredIdentificationLevel === false; + return ( + + + + ( + + ), + ) + .with({ __typename: "AccountMembershipBindingUserErrorStatusInfo" }, () => ( + + )) + .otherwise(() => null)} + > + + {match(accountMembership.statusInfo) + .with({ __typename: "AccountMembershipEnabledStatusInfo" }, () => ( + {t("memberships.status.active")} + )) + .with( + { + __typename: "AccountMembershipBindingUserErrorStatusInfo", + idVerifiedMatchError: true, + }, + () => {t("memberships.status.limitedAccess")}, + ) + .with({ __typename: "AccountMembershipBindingUserErrorStatusInfo" }, () => ( + {t("memberships.status.conflict")} + )) + .with({ __typename: "AccountMembershipInvitationSentStatusInfo" }, () => ( + {t("memberships.status.invitationSent")} + )) + .with({ __typename: "AccountMembershipSuspendedStatusInfo" }, () => ( + {t("memberships.status.temporarilyBlocked")} + )) + .with({ __typename: "AccountMembershipDisabledStatusInfo" }, () => ( + {t("memberships.status.permanentlyBlocked")} + )) + .with({ __typename: "AccountMembershipConsentPendingStatusInfo" }, () => null) + .exhaustive()} - + - - {getMemberName({ accountMembership })} - + + {getMemberName({ accountMembership })} + - + - - {t("membershipDetail.addedAt", { - date: dayjs(accountMembership.createdAt).format("LL"), - })} - - - - + + {t("membershipDetail.addedAt", { + date: dayjs(accountMembership.createdAt).format("LL"), + })} + + + + - + - {match(accountMembership) - .with( - { - statusInfo: { - __typename: "AccountMembershipBindingUserErrorStatusInfo", - idVerifiedMatchError: P.not(true), - }, - user: P.nonNullable, - }, - accountMembership => ( - - { - onAccountMembershipUpdate(); - reload(); - }} - /> - - ), - ) - .with( - { - statusInfo: { - __typename: P.union( - "AccountMembershipDisabledStatusInfo", - "AccountMembershipEnabledStatusInfo", - "AccountMembershipBindingUserErrorStatusInfo", - "AccountMembershipInvitationSentStatusInfo", - "AccountMembershipSuspendedStatusInfo", + {match(accountMembership) + .with( + { + statusInfo: { + __typename: "AccountMembershipBindingUserErrorStatusInfo", + idVerifiedMatchError: P.not(true), + }, + user: P.nonNullable, + }, + accountMembership => ( + + { + onAccountMembershipUpdate(); + reload(); + }} + /> + ), - }, - }, - accountMembership => ( - <> - [ - { - label: t("membershipDetail.cards"), - url: Router.AccountMembersDetailsCardList({ - ...params, - accountMembershipId: currentUserAccountMembershipId, - editingAccountMembershipId, - }), - }, - ], - ) - .otherwise(() => []), - ]} - otherLabel={t("common.tabs.other")} - /> - - - {match({ route, currentUserAccountMembership, accountMembership }) - .with( - { route: { name: "AccountMembersDetailsRoot" } }, - ({ - route: { - params: { showInvitationLink }, - }, - }) => ( - { - reload(); - onRefreshRequest(); - }} - large={large} - showInvitationLink={isNotNullishOrEmpty(showInvitationLink)} - /> - ), - ) - .with({ route: { name: "AccountMembersDetailsRights" } }, () => ( - { - reload(); - onRefreshRequest(); - }} - large={large} - /> - )) - .with( - P.union( + ) + .with( + { + statusInfo: { + __typename: P.union( + "AccountMembershipDisabledStatusInfo", + "AccountMembershipEnabledStatusInfo", + "AccountMembershipBindingUserErrorStatusInfo", + "AccountMembershipInvitationSentStatusInfo", + "AccountMembershipSuspendedStatusInfo", + ), + }, + }, + accountMembership => ( + <> + ( - - [ + { + label: t("membershipDetail.cards"), + url: Router.AccountMembersDetailsCardList({ + ...params, + accountMembershipId: currentUserAccountMembershipId, + editingAccountMembershipId, + }), + }, + ], + ) + .otherwise(() => []), + ]} + otherLabel={t("common.tabs.other")} + /> + + + {match({ route, currentUserAccountMembership, accountMembership }) + .with( + { route: { name: "AccountMembersDetailsRoot" } }, + ({ + route: { + params: { showInvitationLink }, + }, + }) => ( + { + reload(); + onRefreshRequest(); + }} + large={large} + showInvitationLink={isNotNullishOrEmpty(showInvitationLink)} + /> + ), + ) + .with({ route: { name: "AccountMembersDetailsRights" } }, () => ( + { + reload(); + onRefreshRequest(); + }} + large={large} /> - - ), - ) - .otherwise(() => null)} - - - ), - ) - .otherwise(() => ( - - ))} - - - ); + )) + .with( + P.union( + { + route: { name: "AccountMembersDetailsCardList" }, + currentUserAccountMembership: { canManageCards: true }, + }, + { + route: { name: "AccountMembersDetailsCardList" }, + accountMembership: { id: currentUserAccountMembershipId }, + }, + ), + ({ + route: { + params: { + accountMembershipId, + editingAccountMembershipId, + newCard: isCardWizardOpen, + ...params + }, + }, + }) => ( + + + + ), + ) + .otherwise(() => null)} + + + ), + ) + .otherwise(() => ( + + ))} + + + ); + }) + .exhaustive(); }; diff --git a/clients/banking/src/components/MembershipDetailEditor.tsx b/clients/banking/src/components/MembershipDetailEditor.tsx index 64ebab321..99f8c5957 100644 --- a/clients/banking/src/components/MembershipDetailEditor.tsx +++ b/clients/banking/src/components/MembershipDetailEditor.tsx @@ -1,11 +1,11 @@ import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; import { LakeTextInput } from "@swan-io/lake/src/components/LakeTextInput"; import { Space } from "@swan-io/lake/src/components/Space"; import { backgroundColor } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { Request, badStatusToError } from "@swan-io/request"; @@ -81,11 +81,9 @@ export const MembershipDetailEditor = ({ large, }: Props) => { const [isCancelConfirmationModalOpen, setIsCancelConfirmationModalOpen] = useState(false); - const [membershipUpdate, updateMembership] = useUrqlMutation(UpdateAccountMembershipDocument); - const [membershipSuspension, suspendMembership] = useUrqlMutation( - SuspendAccountMembershipDocument, - ); - const [membershipUnsuspension, unsuspendMembership] = useUrqlMutation( + const [updateMembership, membershipUpdate] = useMutation(UpdateAccountMembershipDocument); + const [suspendMembership, membershipSuspension] = useMutation(SuspendAccountMembershipDocument); + const [unsuspendMembership, membershipUnsuspension] = useMutation( ResumeAccountMembershipDocument, ); diff --git a/clients/banking/src/components/MembershipDetailRights.tsx b/clients/banking/src/components/MembershipDetailRights.tsx index 0c1ea38c7..3d8a25b8a 100644 --- a/clients/banking/src/components/MembershipDetailRights.tsx +++ b/clients/banking/src/components/MembershipDetailRights.tsx @@ -1,10 +1,10 @@ import { Option } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Fill } from "@swan-io/lake/src/components/Fill"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeLabelledCheckbox } from "@swan-io/lake/src/components/LakeCheckbox"; import { Space } from "@swan-io/lake/src/components/Space"; import { backgroundColor } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { identity } from "@swan-io/lake/src/utils/function"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -77,12 +77,9 @@ export const MembershipDetailRights = ({ const [isCancelConfirmationModalOpen, setIsCancelConfirmationModalOpen] = useState(false); const [valuesToConfirm, setValuesToConfirm] = useState>(Option.None()); - const [membershipUpdate, updateMembership] = useUrqlMutation(UpdateAccountMembershipDocument); - - const [membershipSuspension, suspendMembership] = useUrqlMutation( - SuspendAccountMembershipDocument, - ); - const [membershipUnsuspension, unsuspendMembership] = useUrqlMutation( + const [updateMembership, membershipUpdate] = useMutation(UpdateAccountMembershipDocument); + const [suspendMembership, membershipSuspension] = useMutation(SuspendAccountMembershipDocument); + const [unsuspendMembership, membershipUnsuspension] = useMutation( ResumeAccountMembershipDocument, ); diff --git a/clients/banking/src/components/MembershipInvitationLinkModal.tsx b/clients/banking/src/components/MembershipInvitationLinkModal.tsx index c3012dcc1..8b4641508 100644 --- a/clients/banking/src/components/MembershipInvitationLinkModal.tsx +++ b/clients/banking/src/components/MembershipInvitationLinkModal.tsx @@ -1,11 +1,12 @@ import { Option } from "@swan-io/boxed"; +import { useDeferredQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; import { LakeTextInput } from "@swan-io/lake/src/components/LakeTextInput"; import { Space } from "@swan-io/lake/src/components/Space"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; +import { useEffect } from "react"; import { P, match } from "ts-pattern"; -import { useQuery } from "urql"; import { MembershipDetailDocument } from "../graphql/partner"; import { getMemberName } from "../utils/accountMembership"; import { t } from "../utils/i18n"; @@ -17,11 +18,14 @@ type Props = { onPressClose: () => void; }; export const MembershipInvitationLinkModal = ({ accountMembershipId, onPressClose }: Props) => { - const [{ data }] = useQuery({ - query: MembershipDetailDocument, - variables: { accountMembershipId: accountMembershipId as string }, - pause: accountMembershipId == null, - }); + const [data, { query }] = useDeferredQuery(MembershipDetailDocument); + + useEffect(() => { + if (accountMembershipId != null) { + const request = query({ accountMembershipId }); + return () => request.cancel(); + } + }, [accountMembershipId, query]); const value = match(projectConfiguration) .with( @@ -31,20 +35,21 @@ export const MembershipInvitationLinkModal = ({ accountMembershipId, onPressClos ) .otherwise(() => `${__env.BANKING_URL}/api/invitation/${accountMembershipId ?? ""}`); - const accountMembership = data?.accountMembership; - return ( result.toOption()) + .flatMap(data => Option.fromNullable(data.accountMembership)) + .map(accountMembership => + t("members.invitationTitle.name", { + fullName: getMemberName({ accountMembership }), + }), + ) + .getWithDefault(t("members.invitationTitle"))} > [ - "BindingUserError" as const, - "Enabled" as const, - "InvitationSent" as const, - "Suspended" as const, - ]) - .otherwise(() => filters.statuses), - canInitiatePayments: match(filters.canInitiatePayments) - .with("true", () => true) - .with("false", () => false) - .otherwise(() => undefined), - canManageAccountMembership: match(filters.canManageAccountMembership) - .with("true", () => true) - .with("false", () => false) - .otherwise(() => undefined), - canManageBeneficiaries: match(filters.canManageBeneficiaries) - .with("true", () => true) - .with("false", () => false) - .otherwise(() => undefined), - canManageCards: match(filters.canManageCards) - .with("true", () => true) - .with("false", () => false) - .otherwise(() => undefined), - canViewAccount: match(filters.canViewAccount) - .with("true", () => true) - .with("false", () => false) - .otherwise(() => undefined), - search: filters.search, - }, - }, - [accountId, filters], - ); + const [data, { isLoading, reload, setVariables }] = useQuery(MembersPageDocument, { + first: PER_PAGE, + accountId, + status: match(filters.statuses) + .with(undefined, () => [ + "BindingUserError" as const, + "Enabled" as const, + "InvitationSent" as const, + "Suspended" as const, + ]) + .otherwise(() => filters.statuses), + canInitiatePayments: match(filters.canInitiatePayments) + .with("true", () => true) + .with("false", () => false) + .otherwise(() => undefined), + canManageAccountMembership: match(filters.canManageAccountMembership) + .with("true", () => true) + .with("false", () => false) + .otherwise(() => undefined), + canManageBeneficiaries: match(filters.canManageBeneficiaries) + .with("true", () => true) + .with("false", () => false) + .otherwise(() => undefined), + canManageCards: match(filters.canManageCards) + .with("true", () => true) + .with("false", () => false) + .otherwise(() => undefined), + canViewAccount: match(filters.canViewAccount) + .with("true", () => true) + .with("false", () => false) + .otherwise(() => undefined), + search: filters.search, + }); const editingAccountMembershipId = match(route) .with( @@ -254,9 +248,11 @@ export const MembershipsArea = ({ ...filters, }) } - onRefresh={reload} + onRefresh={() => { + reload(); + }} totalCount={memberships.length} - isFetching={nextData.isLoading()} + isFetching={isLoading} large={large} > {memberCreationVisible ? ( @@ -288,48 +284,56 @@ export const MembershipsArea = ({ result.match({ Error: error => , Ok: ({ account }) => ( - node) ?? []} - accountMembershipId={accountMembershipId} - onActiveRowChange={onActiveRowChange} - editingAccountMembershipId={editingAccountMembershipId ?? undefined} - onEndReached={() => { - if (account?.memberships.pageInfo.hasNextPage === true) { - setAfter(account?.memberships.pageInfo.endCursor ?? undefined); - } - }} - loading={{ - isLoading: nextData.isLoading(), - count: PER_PAGE, - }} - getRowLink={({ item }) => ( - ({ - backgroundColor: colors.warning[50], - }), - ) - .with( - { __typename: "AccountMembershipBindingUserErrorStatusInfo" }, - () => ({ - backgroundColor: colors.negative[50], - }), - ) - .otherwise(() => undefined)} - to={Router.AccountMembersDetailsRoot({ - accountMembershipId, - ...params, - editingAccountMembershipId: item.id, - })} + + {memberships => ( + node) ?? []} + accountMembershipId={accountMembershipId} + onActiveRowChange={onActiveRowChange} + editingAccountMembershipId={editingAccountMembershipId ?? undefined} + onEndReached={() => { + if (memberships?.pageInfo.hasNextPage === true) { + setVariables({ + after: memberships.pageInfo.endCursor ?? undefined, + }); + } + }} + loading={{ + isLoading, + count: PER_PAGE, + }} + getRowLink={({ item }) => ( + ({ + backgroundColor: colors.warning[50], + }), + ) + .with( + { __typename: "AccountMembershipBindingUserErrorStatusInfo" }, + () => ({ + backgroundColor: colors.negative[50], + }), + ) + .otherwise(() => undefined)} + to={Router.AccountMembersDetailsRoot({ + accountMembershipId, + ...params, + editingAccountMembershipId: item.id, + })} + /> + )} + onRefreshRequest={() => { + reload(); + }} /> )} - onRefreshRequest={reload} - /> + ), }), })} @@ -349,21 +353,21 @@ export const MembershipsArea = ({ onClose={() => Router.push("AccountMembersList", { accountMembershipId, ...params })} items={memberships} render={(membership, large) => ( - }> - - + { + reload(); + }} + large={large} + /> )} closeLabel={t("common.closeButton")} previousLabel={t("common.previous")} diff --git a/clients/banking/src/components/NewMembershipWizard.tsx b/clients/banking/src/components/NewMembershipWizard.tsx index cd023c45f..9cb847bf0 100644 --- a/clients/banking/src/components/NewMembershipWizard.tsx +++ b/clients/banking/src/components/NewMembershipWizard.tsx @@ -1,4 +1,5 @@ import { Array, Option, Result } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeLabelledCheckbox } from "@swan-io/lake/src/components/LakeCheckbox"; @@ -7,7 +8,6 @@ import { LakeTextInput } from "@swan-io/lake/src/components/LakeTextInput"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { Space } from "@swan-io/lake/src/components/Space"; import { breakpoints, colors, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { emptyToUndefined, isNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -139,7 +139,7 @@ export const NewMembershipWizard = ({ const [step, setStep] = useState("Informations"); const [partiallySavedValues, setPartiallySavedValues] = useState | null>(null); - const [memberAddition, addMember] = useUrqlMutation(AddAccountMembershipDocument); + const [addMember, memberAddition] = useMutation(AddAccountMembershipDocument); const steps: Step[] = match({ accountCountry, partiallySavedValues }) .with({ accountCountry: "DEU" }, { accountCountry: "NLD" }, () => [ diff --git a/clients/banking/src/components/ProjectRootRedirect.tsx b/clients/banking/src/components/ProjectRootRedirect.tsx index 6bb6889fa..338a3b398 100644 --- a/clients/banking/src/components/ProjectRootRedirect.tsx +++ b/clients/banking/src/components/ProjectRootRedirect.tsx @@ -1,11 +1,14 @@ +import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { usePersistedState } from "@swan-io/lake/src/hooks/usePersistedState"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; -import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql"; -import { match, P } from "ts-pattern"; +import { P, match } from "ts-pattern"; import { GetFirstAccountMembershipDocument } from "../graphql/partner"; import { AccountNotFoundPage } from "../pages/NotFoundPage"; import { projectConfiguration } from "../utils/projectId"; import { Router } from "../utils/routes"; +import { ErrorView } from "./ErrorView"; import { Redirect } from "./Redirect"; type Props = { @@ -22,46 +25,53 @@ export const ProjectRootRedirect = ({ to, source }: Props) => { {}, ); - const [{ data }] = useQueryWithErrorBoundary({ - query: GetFirstAccountMembershipDocument, - variables: { - filters: { - status: ["BindingUserError", "ConsentPending", "Enabled", "InvitationSent"], - }, + const [data] = useQuery(GetFirstAccountMembershipDocument, { + filters: { + status: ["BindingUserError", "ConsentPending", "Enabled", "InvitationSent"], }, }); - const state = match(accountMembershipState) - .with({ accountMembershipId: P.string }, value => value) - .otherwise(() => undefined); + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with(AsyncData.P.Done(Result.P.Ok(P.select())), data => { + const state = match(accountMembershipState) + .with({ accountMembershipId: P.string }, value => value) + .otherwise(() => undefined); - // source = onboarding is set by packages/onboarding/src/pages/PopupCallbackPage.tsx - if (isNotNullish(state) && source === "onboarding") { - return ( - - ); - } + // source = onboarding is set by packages/onboarding/src/pages/PopupCallbackPage.tsx + if (isNotNullish(state) && source === "onboarding") { + return ( + + ); + } - // ignore localStorage if finishing an onboarding, in this case we want to - // redirect to the newly created membership - if (isNotNullish(state) && source !== "invitation") { - return ; - } + // ignore localStorage if finishing an onboarding, in this case we want to + // redirect to the newly created membership + if (isNotNullish(state) && source !== "invitation") { + return ( + + ); + } - const accountMembershipId = data?.user?.accountMemberships.edges[0]?.node.id; + const accountMembershipId = data?.user?.accountMemberships.edges[0]?.node.id; - if (isNotNullish(accountMembershipId)) { - return ( - Router.AccountPaymentsRoot({ accountMembershipId })) - .with("members", () => Router.AccountMembersList({ accountMembershipId })) - .otherwise(() => Router.AccountRoot({ accountMembershipId }))} - /> - ); - } + if (isNotNullish(accountMembershipId)) { + return ( + Router.AccountPaymentsRoot({ accountMembershipId })) + .with("members", () => Router.AccountMembersList({ accountMembershipId })) + .otherwise(() => Router.AccountRoot({ accountMembershipId }))} + /> + ); + } - const projectName = data?.projectInfo?.name ?? ""; + const projectName = data?.projectInfo?.name ?? ""; - return ; + return ; + }) + .exhaustive(); }; diff --git a/clients/banking/src/components/RecurringTransferList.tsx b/clients/banking/src/components/RecurringTransferList.tsx index 2c3c70dee..352ef83e1 100644 --- a/clients/banking/src/components/RecurringTransferList.tsx +++ b/clients/banking/src/components/RecurringTransferList.tsx @@ -1,4 +1,5 @@ import { AsyncData, Dict, Result } from "@swan-io/boxed"; +import { useMutation, useQuery } from "@swan-io/graphql-client"; import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; @@ -31,8 +32,6 @@ import { Toggle } from "@swan-io/lake/src/components/Toggle"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, colors } from "@swan-io/lake/src/constants/design"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { GetNode } from "@swan-io/lake/src/utils/types"; @@ -51,6 +50,7 @@ import { } from "../graphql/partner"; import { formatCurrency, formatDateTime, t } from "../utils/i18n"; import { Router } from "../utils/routes"; +import { Connection } from "./Connection"; import { ErrorView } from "./ErrorView"; import { RightPanelTransactionList } from "./RightPanelTransactionList"; @@ -113,32 +113,13 @@ const RecurringTransferHistory = ({ large, canViewAccount, }: RecurringTransferHistoryProps) => { - const { data, nextData, isForceReloading, reload, setAfter } = useUrqlPaginatedQuery( - { - query: StandingOrdersHistoryPageDocument, - variables: { - standingOrderId: recurringTransferId, - orderBy: { field: "createdAt", direction: "Desc" }, - first: NUM_TO_RENDER, - canQueryCardOnTransaction, - canViewAccount, - }, - }, - [], - ); - - const transactions = data - .toOption() - .flatMap(result => result.toOption()) - .map(({ standingOrder }) => standingOrder?.payments.edges ?? []) - .map(edges => - edges - .filter(({ node }) => Boolean(node.transactions?.totalCount)) - .reduce< - { node: TransactionDetailsFragment }[] - >((list, { node }) => [...list, ...(node.transactions?.edges ?? [])], []), - ) - .getWithDefault([]); + const [data, { isLoading, reload, setVariables }] = useQuery(StandingOrdersHistoryPageDocument, { + standingOrderId: recurringTransferId, + orderBy: { field: "createdAt", direction: "Desc" }, + first: NUM_TO_RENDER, + canQueryCardOnTransaction, + canViewAccount, + }); return ( <> @@ -150,8 +131,10 @@ const RecurringTransferHistory = ({ mode="secondary" size="small" icon="arrow-counterclockwise-filled" - loading={isForceReloading} - onPress={reload} + loading={data.isLoading()} + onPress={() => { + reload(); + }} /> @@ -171,27 +154,41 @@ const RecurringTransferHistory = ({ result.match({ Error: error => , Ok: data => ( - { - if (data.standingOrder?.payments.pageInfo.hasNextPage ?? false) { - setAfter(data.standingOrder?.payments.pageInfo.endCursor ?? undefined); - } - }} - loading={{ - isLoading: nextData.isLoading(), - count: 5, + + {payments => { + const transactions = (payments?.edges ?? []) + .filter(({ node }) => Boolean(node.transactions?.totalCount)) + .reduce< + { node: TransactionDetailsFragment }[] + >((list, { node }) => [...list, ...(node.transactions?.edges ?? [])], []); + + return ( + { + if (data.standingOrder?.payments.pageInfo.hasNextPage ?? false) { + setVariables({ + after: data.standingOrder?.payments.pageInfo.endCursor ?? undefined, + }); + } + }} + loading={{ + isLoading, + count: 5, + }} + renderEmptyList={() => ( + + )} + /> + ); }} - renderEmptyList={() => ( - - )} - /> + ), }), })} @@ -634,7 +631,7 @@ export const RecurringTransferList = ({ const { desktop } = useResponsive(); const route = Router.useRoute(["AccountPaymentsRecurringTransferDetailsArea"]); - const [cancelResult, cancelRecurringTransfer] = useUrqlMutation(CancelStandingOrderDocument); + const [cancelRecurringTransfer, cancelResult] = useMutation(CancelStandingOrderDocument); const [filters, setFilters] = useState(() => ({ canceled: undefined, @@ -642,17 +639,11 @@ export const RecurringTransferList = ({ const hasFilters = Dict.values(filters).some(isNotNullish); - const { data, nextData, reload, isForceReloading, setAfter } = useUrqlPaginatedQuery( - { - query: GetStandingOrdersDocument, - variables: { - status: filters.canceled === true ? "Canceled" : "Enabled", - accountId, - first: PAGE_SIZE, - }, - }, - [filters], - ); + const [data, { isLoading, reload, setVariables }] = useQuery(GetStandingOrdersDocument, { + status: filters.canceled === true ? "Canceled" : "Enabled", + accountId, + first: PAGE_SIZE, + }); const { endCursor, hasNextPage } = useMemo( () => @@ -667,9 +658,9 @@ export const RecurringTransferList = ({ const onEndReached = useCallback(() => { if (hasNextPage && endCursor != null) { - setAfter(endCursor); + setVariables({ after: endCursor }); } - }, [hasNextPage, endCursor, setAfter]); + }, [hasNextPage, endCursor, setVariables]); const activeRecurringTransferId = route?.name === "AccountPaymentsRecurringTransferDetailsArea" @@ -705,15 +696,6 @@ export const RecurringTransferList = ({ } }; - const recurringTransfers = data.mapOk( - result => result.account?.standingOrders.edges.map(({ node }) => node) ?? [], - ); - - const recurringTransferList = recurringTransfers - .toOption() - .flatMap(result => result.toOption()) - .getWithDefault([]); - const extraInfo = useMemo( () => ({ onCancel: setRecurringTransferToCancelId, @@ -735,8 +717,10 @@ export const RecurringTransferList = ({ mode="secondary" size="small" icon="arrow-counterclockwise-filled" - loading={isForceReloading} - onPress={reload} + loading={data.isLoading()} + onPress={() => { + reload(); + }} /> @@ -752,7 +736,7 @@ export const RecurringTransferList = ({ - {recurringTransfers.match({ + {data.match({ NotAsked: () => null, Loading: () => ( result.match({ Error: error => , - Ok: recurringTransfers => ( - ( - openStandingOrderDetails(item.id)} /> - )} - columns={columns} - smallColumns={smallColumns} - onEndReached={onEndReached} - renderEmptyList={() => ( + Ok: data => ( + + {standingOrders => ( <> - item.node) ?? []} + activeRowId={activeRecurringTransferId ?? undefined} + extraInfo={extraInfo} + getRowLink={({ item }) => ( + openStandingOrderDetails(item.id)} /> + )} + columns={columns} + smallColumns={smallColumns} + onEndReached={onEndReached} + renderEmptyList={() => ( + <> + + + + + + {hasFilters + ? t("recurringTransfer.emptyWithFilters.title") + : t("recurringTransfer.empty.title")} + + + + + + {hasFilters + ? t("recurringTransfer.emptyWithFilters.subtitle") + : t("recurringTransfer.empty.subtitle")} + + + )} + loading={{ + isLoading, + count: PAGE_SIZE, + }} /> - - - - {hasFilters - ? t("recurringTransfer.emptyWithFilters.title") - : t("recurringTransfer.empty.title")} - - - - - - {hasFilters - ? t("recurringTransfer.emptyWithFilters.subtitle") - : t("recurringTransfer.empty.subtitle")} - + item.node) ?? []} + activeId={activeRecurringTransferId} + onActiveIdChange={openStandingOrderDetails} + onClose={closeRightPanel} + closeLabel={t("common.closeButton")} + previousLabel={t("common.previous")} + nextLabel={t("common.next")} + render={(item, large) => ( + + )} + /> )} - loading={{ - isLoading: nextData.isLoading(), - count: PAGE_SIZE, - }} - /> + ), }), })} - ( - - )} - /> - ( ({ templateLanguage, collection, refetchCollection }, forwardedRef) => { const [showConfirmModal, setShowConfirmModal] = useBoolean(false); - const [, generateSupportingDocumentUploadUrl] = useUrqlMutation( + const [generateSupportingDocumentUploadUrl] = useMutation( GenerateSupportingDocumentUploadUrlDocument, ); - const [reviewRequest, requestSupportingDocumentCollectionReview] = useUrqlMutation( + const [requestSupportingDocumentCollectionReview, reviewRequest] = useMutation( RequestSupportingDocumentCollectionReviewDocument, ); diff --git a/clients/banking/src/components/TransactionDetail.tsx b/clients/banking/src/components/TransactionDetail.tsx index 5619e0525..03137bf43 100644 --- a/clients/banking/src/components/TransactionDetail.tsx +++ b/clients/banking/src/components/TransactionDetail.tsx @@ -1,3 +1,4 @@ +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { Icon, IconName } from "@swan-io/lake/src/components/Icon"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; @@ -11,11 +12,11 @@ import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { ReadOnlyFieldList } from "@swan-io/lake/src/components/ReadOnlyFieldList"; import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; +import { useIsSuspendable } from "@swan-io/lake/src/components/Suspendable"; import { Tag } from "@swan-io/lake/src/components/Tag"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotEmpty, isNotNullish, @@ -112,12 +113,15 @@ export const TransactionDetail = ({ canQueryCardOnTransaction, canViewAccount, }: Props) => { - const { data } = useUrqlQuery( + const suspense = useIsSuspendable(); + const [data] = useQuery( + TransactionDocument, { - query: TransactionDocument, - variables: { id: transactionId, canViewAccount, canQueryCardOnTransaction }, + id: transactionId, + canViewAccount, + canQueryCardOnTransaction, }, - [transactionId], + { suspense }, ); if (data.isNotAsked() || data.isLoading()) { diff --git a/clients/banking/src/components/TransactionsArea.tsx b/clients/banking/src/components/TransactionsArea.tsx index 67b57a0e0..215b94d15 100644 --- a/clients/banking/src/components/TransactionsArea.tsx +++ b/clients/banking/src/components/TransactionsArea.tsx @@ -1,4 +1,5 @@ -import { Option } from "@swan-io/boxed"; +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { BottomPanel } from "@swan-io/lake/src/components/BottomPanel"; import { Box } from "@swan-io/lake/src/components/Box"; import { Icon } from "@swan-io/lake/src/components/Icon"; @@ -6,6 +7,7 @@ import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Link } from "@swan-io/lake/src/components/Link"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { Space } from "@swan-io/lake/src/components/Space"; import { TabView } from "@swan-io/lake/src/components/TabView"; @@ -23,9 +25,8 @@ import { isNotEmpty } from "@swan-io/lake/src/utils/nullish"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; import { useState } from "react"; import { StyleSheet, View } from "react-native"; -import { match } from "ts-pattern"; -import { useQuery } from "urql"; -import { Amount, GetAccountBalanceDocument } from "../graphql/partner"; +import { P, match } from "ts-pattern"; +import { GetAccountBalanceDocument } from "../graphql/partner"; import { TransactionListPage } from "../pages/TransactionListPage"; import { UpcomingTransactionListPage } from "../pages/UpcomingTransactionListPage"; import { formatCurrency, t } from "../utils/i18n"; @@ -39,7 +40,6 @@ type Props = { canQueryCardOnTransaction: boolean; accountStatementsVisible: boolean; canViewAccount: boolean; - onBalanceReceive: (amount: Amount) => void; }; const styles = StyleSheet.create({ @@ -103,26 +103,9 @@ export const TransactionsArea = ({ accountMembershipId, canQueryCardOnTransaction, accountStatementsVisible, - onBalanceReceive, canViewAccount, }: Props) => { - const [{ data }] = useQuery({ - query: GetAccountBalanceDocument, - variables: { accountId }, - }); - - const shouldShowDetailedBalance = Option.fromNullable(data?.account) - .flatMap(account => - Option.allFromDict({ - fundingSources: Option.fromNullable(account.fundingSources), - merchantProfiles: Option.fromNullable(account.merchantProfiles), - }), - ) - .map( - ({ fundingSources, merchantProfiles }) => - fundingSources.totalCount > 0 || merchantProfiles.totalCount > 0, - ) - .getWithDefault(false); + const [data] = useQuery(GetAccountBalanceDocument, { accountId }); const [updatedUpcommingTransactionCount, setUpdatedUpcommingTransactionCount] = useState< number | undefined @@ -131,112 +114,256 @@ export const TransactionsArea = ({ const [balanceDetailsVisible, setBalanceDetailsVisible] = useState(false); const route = Router.useRoute(accountTransactionsRoutes); - const account = data?.account; - const availableBalance = account?.balances?.available; - const bookedBalance = account?.balances?.booked; - const pendingBalance = account?.balances?.pending; - const reservedBalance = account?.balances?.reserved; - - return ( - - {({ small, large }) => ( - <> - {availableBalance && bookedBalance && pendingBalance && reservedBalance ? ( - <> - - - - - {formatCurrency(Number(availableBalance.value), availableBalance.currency)} - - - {t("transactions.availableBalance")} - - - - {shouldShowDetailedBalance && ( - { - setBalanceDetailsVisible(!balanceDetailsVisible); - }} - color="swan" - style={({ hovered }) => [hovered && styles.balanceDetailsButton]} - /> - )} - - - {balanceDetailsVisible && large ? ( - <> - - = + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with(AsyncData.P.Done(Result.P.Ok(P.select())), ({ account }) => { + const shouldShowDetailedBalance = Option.fromNullable(account) + .flatMap(account => + Option.allFromDict({ + fundingSources: Option.fromNullable(account.fundingSources), + merchantProfiles: Option.fromNullable(account.merchantProfiles), + }), + ) + .map( + ({ fundingSources, merchantProfiles }) => + fundingSources.totalCount > 0 || merchantProfiles.totalCount > 0, + ) + .getWithDefault(false); + + const availableBalance = account?.balances?.available; + const bookedBalance = account?.balances?.booked; + const pendingBalance = account?.balances?.pending; + const reservedBalance = account?.balances?.reserved; + + return ( + + {({ small, large }) => ( + <> + {availableBalance && bookedBalance && pendingBalance && reservedBalance ? ( + <> + + + + + {formatCurrency( + Number(availableBalance.value), + availableBalance.currency, + )} + + + + {t("transactions.availableBalance")} + - - + + {shouldShowDetailedBalance && ( + { + setBalanceDetailsVisible(!balanceDetailsVisible); + }} + color="swan" + style={({ hovered }) => [hovered && styles.balanceDetailsButton]} + /> + )} + + + {balanceDetailsVisible && large ? ( + <> + + = + + + + + {formatCurrency( + Number(bookedBalance.value), + bookedBalance.currency, + )} + + + + {t("transactions.bookedBalance")} + + + + + {Number(pendingBalance.value) < 0 ? "-" : "+"} + + + + + {formatCurrency( + Math.abs(Number(pendingBalance.value)), + pendingBalance.currency, + )} + + + + {t("transactions.pendingBalance")} + + + + + - + + + + + {formatCurrency( + Number(reservedBalance.value), + reservedBalance.currency, + )} + + + + {t("transactions.reservedBalance")} + + + + + [ + pressed && styles.linkPressed, + styles.link, + ]} + > + + {t("common.learnMore")} + + + + + + + + ) : null} + + + + {balanceDetailsVisible && !large && ( + { + setBalanceDetailsVisible(!balanceDetailsVisible); + }} + > + + + + + - {formatCurrency(Number(bookedBalance.value), bookedBalance.currency)} + {t("transactions.availableBalance")} + + + + [pressed && styles.linkPressed, styles.link]} + > + + {t("balances.learnMore")} + + + + + + + + + + + {formatCurrency( + Number(availableBalance.value), + availableBalance.currency, + )} - {t("transactions.bookedBalance")} + {t("transactions.availableBalance")} - - - {Number(pendingBalance.value) < 0 ? "-" : "+"} - + + = + + + {formatCurrency(Number(bookedBalance.value), bookedBalance.currency)} + - + {t("transactions.bookedBalance")} + + + {Number(pendingBalance.value) < 0 ? "-" : "+"} + + {formatCurrency( Math.abs(Number(pendingBalance.value)), pendingBalance.currency, )} - + {t("transactions.pendingBalance")} - - - - - + - - - + {formatCurrency( Number(reservedBalance.value), reservedBalance.currency, @@ -246,228 +373,119 @@ export const TransactionsArea = ({ {t("transactions.reservedBalance")} - + + + )} + - + + ) : ( + + )} + + + + + + {match(route) + .with( + { name: "AccountTransactionsListRoot" }, + { name: "AccountTransactionsListStatementsArea" }, + ({ + name, + params: { + accountMembershipId, + consentId, + standingOrder, + status: consentStatus, + ...params + }, + }) => { + return ( + <> + + + + Router.push("AccountTransactionsListRoot", { + accountMembershipId, + ...params, + }) + } > - [pressed && styles.linkPressed, styles.link]} - > - - {t("common.learnMore")} - - - - - - + {({ large }) => ( + + + + )} + - ) : null} - - - - {balanceDetailsVisible && !large && ( - { - setBalanceDetailsVisible(!balanceDetailsVisible); - }} - > - - - - - - {t("transactions.availableBalance")} - - - - [pressed && styles.linkPressed, styles.link]} - > - - {t("balances.learnMore")} - - - - - - - - - - - {formatCurrency(Number(availableBalance.value), availableBalance.currency)} - - - - {t("transactions.availableBalance")} - - - - = - - - {formatCurrency(Number(bookedBalance.value), bookedBalance.currency)} - - - - {t("transactions.bookedBalance")} - - - {Number(pendingBalance.value) < 0 ? "-" : "+"} - - - {formatCurrency( - Math.abs(Number(pendingBalance.value)), - pendingBalance.currency, - )} - - - - {t("transactions.pendingBalance")} - - - - - - - {formatCurrency(Number(reservedBalance.value), reservedBalance.currency)} - - - - {t("transactions.reservedBalance")} - - - - )} - - - - - ) : ( - - )} - - - - - - {match(route) - .with( - { name: "AccountTransactionsListRoot" }, - { name: "AccountTransactionsListStatementsArea" }, - { name: "AccountTransactionsListDetail" }, - ({ - name, - params: { - accountMembershipId, - consentId, - standingOrder, - status: consentStatus, - ...params - }, - }) => { - return ( - <> - { + return ( + - - - Router.push("AccountTransactionsListRoot", { - accountMembershipId, - ...params, - }) - } - > - {({ large }) => ( - - - - )} - - - ); - }, - ) - .with({ name: "AccountTransactionsUpcoming" }, () => { - return ( - - ); - }) - .otherwise(() => ( - - ))} - - )} - - ); + ); + }) + .otherwise(() => ( + + ))} + + )} + + ); + }) + .exhaustive(); }; diff --git a/clients/banking/src/components/TransferInternationalWizard.tsx b/clients/banking/src/components/TransferInternationalWizard.tsx index a327ab14f..136251dac 100644 --- a/clients/banking/src/components/TransferInternationalWizard.tsx +++ b/clients/banking/src/components/TransferInternationalWizard.tsx @@ -1,4 +1,5 @@ import { Result } from "@swan-io/boxed"; +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; @@ -6,7 +7,6 @@ import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -89,9 +89,7 @@ export const TransferInternationalWizard = ({ }: Props) => { const [step, setStep] = useState({ name: "Amount" }); - const [transfer, initiateTransfers] = useUrqlMutation( - InitiateInternationalCreditTransferDocument, - ); + const [initiateTransfers, transfer] = useMutation(InitiateInternationalCreditTransferDocument); const initiateTransfer = ({ amount, diff --git a/clients/banking/src/components/TransferInternationalWizardAmount.tsx b/clients/banking/src/components/TransferInternationalWizardAmount.tsx index 640766225..483e45df5 100644 --- a/clients/banking/src/components/TransferInternationalWizardAmount.tsx +++ b/clients/banking/src/components/TransferInternationalWizardAmount.tsx @@ -1,4 +1,5 @@ -import { AsyncData, Result } from "@swan-io/boxed"; +import { Array, AsyncData, Option, Result } from "@swan-io/boxed"; +import { ClientError, useDeferredQuery, useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; @@ -12,7 +13,6 @@ import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { colors, radii } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { useEffect, useState } from "react"; import { ActivityIndicator, StyleSheet, View } from "react-native"; @@ -24,7 +24,6 @@ import { GetInternationalCreditTransferQuoteQuery, } from "../graphql/partner"; import { Currency, currencies, formatCurrency, formatNestedMessage, t } from "../utils/i18n"; -import { isCombinedError } from "../utils/urql"; import { ErrorView } from "./ErrorView"; const styles = StyleSheet.create({ @@ -70,23 +69,21 @@ export const TransferInternationalWizardAmount = ({ onSave, }: Props) => { const [input, setInput] = useState(); - const { data: balance } = useUrqlQuery( - { - query: GetAvailableAccountBalanceDocument, - variables: { accountMembershipId }, - }, - [accountMembershipId], - ); + const [balance] = useQuery(GetAvailableAccountBalanceDocument, { accountMembershipId }); - const { data: quote } = useUrqlQuery( - { - query: GetInternationalCreditTransferQuoteDocument, - variables: { accountId, ...(input ?? { value: "", currency: "" }) }, - pause: !input || input?.value === "0" || Number.isNaN(Number(input?.value)), - }, - [input], + const [quote, { query: queryQuote, reset: resetQuote }] = useDeferredQuery( + GetInternationalCreditTransferQuoteDocument, ); + useEffect(() => { + if (input != null && input.value !== "0" && !Number.isNaN(Number(input.value))) { + const request = queryQuote({ accountId, ...input }); + return () => request.cancel(); + } else { + resetQuote(); + } + }, [input, accountId, queryQuote, resetQuote]); + const { Field, submitForm, listenFields } = useForm({ amount: { initialValue: initialAmount ?? { @@ -117,24 +114,21 @@ export const TransferInternationalWizardAmount = ({ const errors = match(quote) .with(AsyncData.P.Done(Result.P.Error(P.select())), error => { - if (isCombinedError(error)) { + return Array.filterMap(ClientError.toArray(error), error => { return match(error) .with( { - graphQLErrors: P.array({ - extensions: { - code: "QuoteValidationError", - meta: { - fields: P.array({ message: P.select(P.string) }), - }, + extensions: { + code: "QuoteValidationError", + meta: { + fields: P.array({ message: P.select(P.string) }), }, - }), + }, }, - ([messages]) => messages ?? [], + ([messages]) => Option.fromNullable(messages), ) - .otherwise(() => []); - } - return []; + .otherwise(() => Option.None()); + }); }) .otherwise(() => []); diff --git a/clients/banking/src/components/TransferInternationalWizardBeneficiary.tsx b/clients/banking/src/components/TransferInternationalWizardBeneficiary.tsx index 35755657c..b3bc8bdc2 100644 --- a/clients/banking/src/components/TransferInternationalWizardBeneficiary.tsx +++ b/clients/banking/src/components/TransferInternationalWizardBeneficiary.tsx @@ -8,13 +8,13 @@ import { Tile } from "@swan-io/lake/src/components/Tile"; import { TransitionView } from "@swan-io/lake/src/components/TransitionView"; import { animations, colors } from "@swan-io/lake/src/constants/design"; import { useDebounce } from "@swan-io/lake/src/hooks/useDebounce"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullishOrEmpty, isNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; import { useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, View } from "react-native"; import { hasDefinedKeys, useForm } from "react-ux-form"; import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { noop } from "@swan-io/lake/src/utils/function"; @@ -69,19 +69,13 @@ export const TransferInternationalWizardBeneficiary = ({ const dynamicFormApiRef = useRef(null); - const { data } = useUrqlQuery( - { - query: GetInternationalBeneficiaryDynamicFormsDocument, - variables: { - dynamicFields, - amountValue: amount.value, - currency: amount.currency, - //TODO: Remove English fallback as soon as the backend manages "fi" in the InternationalCreditTransferDisplayLanguage type - language: locale.language === "fi" ? "en" : locale.language, - }, - }, - [locale.language, dynamicFields], - ); + const [data] = useQuery(GetInternationalBeneficiaryDynamicFormsDocument, { + dynamicFields, + amountValue: amount.value, + currency: amount.currency, + //TODO: Remove English fallback as soon as the backend manages "fi" in the InternationalCreditTransferDisplayLanguage type + language: locale.language === "fi" ? "en" : locale.language, + }); const { Field, submitForm, FieldsListener, listenFields, setFieldValue, getFieldState } = useForm<{ diff --git a/clients/banking/src/components/TransferInternationalWizardDetails.tsx b/clients/banking/src/components/TransferInternationalWizardDetails.tsx index 66b3a0a26..0897305a3 100644 --- a/clients/banking/src/components/TransferInternationalWizardDetails.tsx +++ b/clients/banking/src/components/TransferInternationalWizardDetails.tsx @@ -1,3 +1,5 @@ +import { Array, AsyncData, Option, Result } from "@swan-io/boxed"; +import { ClientError, useQuery } from "@swan-io/graphql-client"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { Space } from "@swan-io/lake/src/components/Space"; @@ -5,15 +7,12 @@ import { Tile } from "@swan-io/lake/src/components/Tile"; import { TransitionView } from "@swan-io/lake/src/components/TransitionView"; import { animations, colors } from "@swan-io/lake/src/constants/design"; import { useDebounce } from "@swan-io/lake/src/hooks/useDebounce"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; +import { showToast } from "@swan-io/lake/src/state/toasts"; +import { noop } from "@swan-io/lake/src/utils/function"; import { isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, View } from "react-native"; import { hasDefinedKeys, useForm } from "react-ux-form"; - -import { AsyncData, Result } from "@swan-io/boxed"; -import { showToast } from "@swan-io/lake/src/state/toasts"; -import { noop } from "@swan-io/lake/src/utils/function"; import { P, match } from "ts-pattern"; import { GetInternationalCreditTransferTransactionDetailsDynamicFormDocument, @@ -21,7 +20,6 @@ import { InternationalCreditTransferRouteInput, } from "../graphql/partner"; import { locale, t } from "../utils/i18n"; -import { isCombinedError } from "../utils/urql"; import { DynamicFormApi, DynamicFormField, @@ -57,21 +55,15 @@ export const TransferInternationalWizardDetails = ({ const dynamicFormApiRef = useRef(null); - const { data } = useUrqlQuery( - { - query: GetInternationalCreditTransferTransactionDetailsDynamicFormDocument, - variables: { - name: beneficiary.name, - route: beneficiary.route as InternationalCreditTransferRouteInput, - amountValue: amount.value, - currency: amount.currency, - language: locale.language as InternationalCreditTransferDisplayLanguage, - dynamicFields, - beneficiaryDetails: beneficiary.results, - }, - }, - [locale.language, dynamicFields], - ); + const [data] = useQuery(GetInternationalCreditTransferTransactionDetailsDynamicFormDocument, { + name: beneficiary.name, + route: beneficiary.route as InternationalCreditTransferRouteInput, + amountValue: amount.value, + currency: amount.currency, + language: locale.language as InternationalCreditTransferDisplayLanguage, + dynamicFields, + beneficiaryDetails: beneficiary.results, + }); const { Field, submitForm, getFieldState, listenFields } = useForm<{ results: ResultItem[]; @@ -93,24 +85,26 @@ export const TransferInternationalWizardDetails = ({ ({ fields }) => setFields(fields), ) .with(AsyncData.P.Done(Result.P.Error(P.select())), error => { - if (isCombinedError(error)) { - match(error) + const messages = Array.filterMap(ClientError.toArray(error), error => { + return match(error) .with( { - graphQLErrors: P.array({ - extensions: { - code: "BeneficiaryValidationError", - meta: { - fields: P.array({ message: P.select(P.string) }), - }, + extensions: { + code: "BeneficiaryValidationError", + meta: { + fields: P.array({ message: P.select(P.string) }), }, - }), - }, - ([messages]) => { - onPressPrevious(messages); + }, }, + ([messages]) => Option.fromNullable(messages), ) - .otherwise(error => showToast({ variant: "error", error, title: t("error.generic") })); + .otherwise(() => Option.None()); + }); + + if (messages.length > 0) { + onPressPrevious(messages); + } else { + showToast({ variant: "error", error, title: t("error.generic") }); } }) .otherwise(noop); diff --git a/clients/banking/src/components/TransferList.tsx b/clients/banking/src/components/TransferList.tsx index 601b2217c..76242ab68 100644 --- a/clients/banking/src/components/TransferList.tsx +++ b/clients/banking/src/components/TransferList.tsx @@ -1,4 +1,5 @@ import { Array, Option } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty, @@ -10,7 +11,6 @@ import { Pressable } from "@swan-io/lake/src/components/Pressable"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { useCallback, useMemo, useRef, useState } from "react"; import { StyleSheet } from "react-native"; @@ -21,6 +21,7 @@ import { TransactionList } from "../components/TransactionList"; import { TransactionListPageDocument } from "../graphql/partner"; import { t } from "../utils/i18n"; import { Router } from "../utils/routes"; +import { Connection } from "./Connection"; import { TransactionFiltersState, TransactionListFilter, @@ -98,33 +99,20 @@ export const TransferList = ({ ]; }, []); - const { data, nextData, reload, setAfter } = useUrqlPaginatedQuery( - { - query: TransactionListPageDocument, - variables: { - accountId, - first: NUM_TO_RENDER, - filters: { - ...filters, - paymentProduct, - status: filters.status ?? DEFAULT_STATUSES, - }, - canQueryCardOnTransaction, - canViewAccount, - }, + const [data, { isLoading, reload, setVariables }] = useQuery(TransactionListPageDocument, { + accountId, + first: NUM_TO_RENDER, + filters: { + ...filters, + paymentProduct, + status: filters.status ?? DEFAULT_STATUSES, }, - [filters, canQueryCardOnTransaction], - ); + canQueryCardOnTransaction, + canViewAccount, + }); const [activeTransactionId, setActiveTransactionId] = useState(null); - const transactions = data - .toOption() - .flatMap(result => result.toOption()) - .flatMap(data => Option.fromNullable(data.account?.transactions)) - .map(({ edges }) => edges.map(({ node }) => node)) - .getWithDefault([]); - const panelRef = useRef(null); const onActiveRowChange = useCallback( @@ -146,7 +134,9 @@ export const TransferList = ({ ...filters, }) } - onRefresh={reload} + onRefresh={() => { + reload(); + }} large={large} available={["isAfterUpdatedAt", "isBeforeUpdatedAt", "status"]} filtersDefinition={{ @@ -176,66 +166,74 @@ export const TransferList = ({ result.match({ Error: error => , Ok: data => ( - ( - setActiveTransactionId(item.id)} /> - )} - pageSize={NUM_TO_RENDER} - activeRowId={activeTransactionId ?? undefined} - onActiveRowChange={onActiveRowChange} - loading={{ - isLoading: nextData.isLoading(), - count: 2, - }} - onEndReached={() => { - if (data.account?.transactions?.pageInfo.hasNextPage ?? false) { - setAfter(data.account?.transactions?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => - hasFilters ? ( - + {transactions => ( + <> + ( + setActiveTransactionId(item.id)} /> + )} + pageSize={NUM_TO_RENDER} + activeRowId={activeTransactionId ?? undefined} + onActiveRowChange={onActiveRowChange} + loading={{ + isLoading, + count: 2, + }} + onEndReached={() => { + if (data.account?.transactions?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: data.account?.transactions?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => + hasFilters ? ( + + ) : ( + + ) + } /> - ) : ( - item.id} + activeId={activeTransactionId} + onActiveIdChange={setActiveTransactionId} + onClose={() => setActiveTransactionId(null)} + items={transactions?.edges.map(item => item.node) ?? []} + render={(transaction, large) => ( + + )} + closeLabel={t("common.closeButton")} + previousLabel={t("common.previous")} + nextLabel={t("common.next")} /> - ) - } - /> + + )} + ), }), })} - - item.id} - activeId={activeTransactionId} - onActiveIdChange={setActiveTransactionId} - onClose={() => setActiveTransactionId(null)} - items={transactions} - render={(transaction, large) => ( - - )} - closeLabel={t("common.closeButton")} - previousLabel={t("common.previous")} - nextLabel={t("common.next")} - /> )} diff --git a/clients/banking/src/components/TransferRecurringWizard.tsx b/clients/banking/src/components/TransferRecurringWizard.tsx index f6d9e7461..dd166e656 100644 --- a/clients/banking/src/components/TransferRecurringWizard.tsx +++ b/clients/banking/src/components/TransferRecurringWizard.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; @@ -5,7 +6,6 @@ import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; @@ -92,7 +92,7 @@ export const TransferRecurringWizard = ({ accountId, accountMembershipId, }: Props) => { - const [standingOrderScheduling, scheduleStandingOrder] = useUrqlMutation( + const [scheduleStandingOrder, standingOrderScheduling] = useMutation( ScheduleStandingOrderDocument, ); const [step, setStep] = useState({ name: "Beneficiary" }); diff --git a/clients/banking/src/components/TransferRegularWizard.tsx b/clients/banking/src/components/TransferRegularWizard.tsx index 7189c8922..65c5f627e 100644 --- a/clients/banking/src/components/TransferRegularWizard.tsx +++ b/clients/banking/src/components/TransferRegularWizard.tsx @@ -1,3 +1,4 @@ +import { useMutation } from "@swan-io/graphql-client"; import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; @@ -5,7 +6,6 @@ import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlMutation } from "@swan-io/lake/src/hooks/useUrqlMutation"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; @@ -91,7 +91,7 @@ export const TransferRegularWizard = ({ accountId, accountMembershipId, }: Props) => { - const [transfer, initiateTransfers] = useUrqlMutation(InitiateSepaCreditTransfersDocument); + const [initiateTransfers, transfer] = useMutation(InitiateSepaCreditTransfersDocument); const [step, setStep] = useState({ name: "Beneficiary" }); const initiateTransfer = ({ diff --git a/clients/banking/src/components/TransferRegularWizardDetails.tsx b/clients/banking/src/components/TransferRegularWizardDetails.tsx index db3a85316..87180edc7 100644 --- a/clients/banking/src/components/TransferRegularWizardDetails.tsx +++ b/clients/banking/src/components/TransferRegularWizardDetails.tsx @@ -1,4 +1,5 @@ import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; @@ -10,7 +11,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { animations, colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { nullishOrEmptyToUndefined } from "@swan-io/lake/src/utils/nullish"; import { ActivityIndicator, StyleSheet, View } from "react-native"; import { hasDefinedKeys, toOptionalValidator, useForm } from "react-ux-form"; @@ -49,13 +49,7 @@ export const TransferRegularWizardDetails = ({ onPressPrevious, onSave, }: Props) => { - const { data } = useUrqlQuery( - { - query: GetAvailableAccountBalanceDocument, - variables: { accountMembershipId }, - }, - [accountMembershipId], - ); + const [data] = useQuery(GetAvailableAccountBalanceDocument, { accountMembershipId }); const { Field, submitForm } = useForm({ amount: { diff --git a/clients/banking/src/components/TransferRegularWizardSchedule.tsx b/clients/banking/src/components/TransferRegularWizardSchedule.tsx index ff23b396f..6f2cd646c 100644 --- a/clients/banking/src/components/TransferRegularWizardSchedule.tsx +++ b/clients/banking/src/components/TransferRegularWizardSchedule.tsx @@ -1,4 +1,5 @@ import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; @@ -12,7 +13,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { animations, colors } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { DatePicker, isDateInRange } from "@swan-io/shared-business/src/components/DatePicker"; import dayjs from "dayjs"; import { electronicFormat } from "iban"; @@ -71,15 +71,9 @@ export const TransferRegularWizardSchedule = ({ onSave, loading, }: Props) => { - const { data } = useUrqlQuery( - { - query: GetIbanValidationDocument, - variables: { - iban: electronicFormat(beneficiary.iban), - }, - }, - [beneficiary.iban], - ); + const [data] = useQuery(GetIbanValidationDocument, { + iban: electronicFormat(beneficiary.iban), + }); const { Field, FieldsListener, submitForm } = useForm({ isScheduled: { diff --git a/clients/banking/src/components/TransferWizardBeneficiary.tsx b/clients/banking/src/components/TransferWizardBeneficiary.tsx index 758fdda6b..914842a76 100644 --- a/clients/banking/src/components/TransferWizardBeneficiary.tsx +++ b/clients/banking/src/components/TransferWizardBeneficiary.tsx @@ -1,4 +1,5 @@ import { AsyncData, Result } from "@swan-io/boxed"; +import { useDeferredQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton"; @@ -10,8 +11,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { animations, colors } from "@swan-io/lake/src/constants/design"; -import { useDebounce } from "@swan-io/lake/src/hooks/useDebounce"; -import { useDeferredUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { printIbanFormat, validateIban } from "@swan-io/shared-business/src/utils/validation"; import { electronicFormat } from "iban"; import { useEffect } from "react"; @@ -50,21 +49,12 @@ export const TransferWizardBeneficiary = ({ initialBeneficiary, onSave, }: Props) => { - const { - data: ibanVerification, - query: queryIbanVerification, - reset: resetIbanVerification, - } = useDeferredUrqlQuery(GetIbanValidationDocument); - const { - data: beneficiaryVerification, - query: queryBeneficiaryVerification, - reset: resetBeneficiaryVerification, - } = useDeferredUrqlQuery(GetBeneficiaryVerificationDocument); - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const debouncedQueryIbanVerification = useDebounce(queryIbanVerification, 500); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const debouncedQueryBeneficiaryVerification = useDebounce(queryBeneficiaryVerification, 500); + const [ibanVerification, { query: queryIbanVerification, reset: resetIbanVerification }] = + useDeferredQuery(GetIbanValidationDocument, { debounce: 500 }); + const [ + beneficiaryVerification, + { query: queryBeneficiaryVerification, reset: resetBeneficiaryVerification }, + ] = useDeferredQuery(GetBeneficiaryVerificationDocument, { debounce: 500 }); const { Field, listenFields, submitForm, FieldsListener, setFieldValue } = useForm({ name: { @@ -80,57 +70,55 @@ export const TransferWizardBeneficiary = ({ useEffect(() => { return listenFields(["iban"], ({ iban }) => { - if (!iban.valid) { - resetBeneficiaryVerification(); - resetIbanVerification(); - return; - } - - const isTransferFromDutchAccountToDutchIBAN = - accountCountry === "NLD" && iban.value.startsWith("NL"); + if (iban.valid) { + const isTransferFromDutchAccountToDutchIBAN = + accountCountry === "NLD" && iban.value.startsWith("NL"); - if (!isTransferFromDutchAccountToDutchIBAN) { - debouncedQueryIbanVerification({ - iban: electronicFormat(iban.value), - }); + if (!isTransferFromDutchAccountToDutchIBAN) { + queryIbanVerification({ + iban: electronicFormat(iban.value), + }); + } + } else { + resetIbanVerification(); + resetBeneficiaryVerification(); } }); }, [ accountCountry, listenFields, - debouncedQueryIbanVerification, - resetBeneficiaryVerification, + queryIbanVerification, resetIbanVerification, + resetBeneficiaryVerification, ]); useEffect(() => { return listenFields(["iban", "name"], ({ iban, name }) => { - if (!iban.valid) { - resetBeneficiaryVerification(); - resetIbanVerification(); - return; - } + if (iban.valid) { + const isTransferFromDutchAccountToDutchIBAN = + accountCountry === "NLD" && iban.value.startsWith("NL"); - const isTransferFromDutchAccountToDutchIBAN = - accountCountry === "NLD" && iban.value.startsWith("NL"); - - if (isTransferFromDutchAccountToDutchIBAN) { - debouncedQueryBeneficiaryVerification({ - input: { - debtorAccountId: accountId, - iban: electronicFormat(iban.value), - name: name.value, - }, - }); + if (isTransferFromDutchAccountToDutchIBAN) { + queryBeneficiaryVerification({ + input: { + debtorAccountId: accountId, + iban: electronicFormat(iban.value), + name: name.value, + }, + }); + } + } else { + resetIbanVerification(); + resetBeneficiaryVerification(); } }); }, [ accountCountry, accountId, listenFields, - debouncedQueryBeneficiaryVerification, - resetBeneficiaryVerification, + queryBeneficiaryVerification, resetIbanVerification, + resetBeneficiaryVerification, ]); const onPressSubmit = () => { diff --git a/clients/banking/src/graphql/partner.gql b/clients/banking/src/graphql/partner.gql index e748b6724..aad816a16 100644 --- a/clients/banking/src/graphql/partner.gql +++ b/clients/banking/src/graphql/partner.gql @@ -318,6 +318,9 @@ query AccountArea($accountMembershipId: ID!) { } holder { id + info { + type + } supportingDocumentCollections(first: 1) { edges { node { @@ -1827,7 +1830,7 @@ fragment AddressInfo on AddressInfo { state } -query GetCardProducts($accountMembershipId: ID!) { +query GetCardProducts($accountMembershipId: ID!, $after: String, $first: Int!, $search: String) { accountMembership(id: $accountMembershipId) { id account { @@ -1847,6 +1850,29 @@ query GetCardProducts($accountMembershipId: ID!) { ...AddressInfo } } + allMemberships: memberships( + filters: { status: [Enabled, InvitationSent, BindingUserError] } + ) { + totalCount + } + memberships( + after: $after + first: $first + filters: { search: $search, status: [Enabled, InvitationSent, BindingUserError] } + ) { + totalCount + pageInfo { + endCursor + hasNextPage + } + edges { + cursor + node { + id + ...AccountMembership + } + } + } } user { id diff --git a/clients/banking/src/pages/AccountActivationPage.tsx b/clients/banking/src/pages/AccountActivationPage.tsx index c0fa4aa50..e84bd1c12 100644 --- a/clients/banking/src/pages/AccountActivationPage.tsx +++ b/clients/banking/src/pages/AccountActivationPage.tsx @@ -1,4 +1,5 @@ -import { Option } from "@swan-io/boxed"; +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Avatar } from "@swan-io/lake/src/components/Avatar"; import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; import { Box } from "@swan-io/lake/src/components/Box"; @@ -10,6 +11,7 @@ import { LakeButton } from "@swan-io/lake/src/components/LakeButton"; import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; import { Link } from "@swan-io/lake/src/components/Link"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { ReadOnlyFieldList } from "@swan-io/lake/src/components/ReadOnlyFieldList"; import { Separator } from "@swan-io/lake/src/components/Separator"; import { Space } from "@swan-io/lake/src/components/Space"; @@ -21,11 +23,10 @@ import { backgroundColor, colors, radii, spacings } from "@swan-io/lake/src/cons import { useBoolean } from "@swan-io/lake/src/hooks/useBoolean"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; import { isNotNullish, isNotNullishOrEmpty, isNullish } from "@swan-io/lake/src/utils/nullish"; -import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql"; import { isMobile } from "@swan-io/lake/src/utils/userAgent"; import { AdditionalInfo, SupportChat } from "@swan-io/shared-business/src/components/SupportChat"; import dayjs from "dayjs"; -import { ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { ReactNode, useCallback, useRef, useState } from "react"; import { Pressable, ScrollView, StyleSheet, View } from "react-native"; import { P, match } from "ts-pattern"; import { ErrorView } from "../components/ErrorView"; @@ -39,6 +40,7 @@ import { openPopup } from "../states/popup"; import { env } from "../utils/env"; import { formatNestedMessage, t } from "../utils/i18n"; import { getIdentificationLevelStatusInfo, isReadyToSign } from "../utils/identification"; +import { projectConfiguration } from "../utils/projectId"; import { Router } from "../utils/routes"; const styles = StyleSheet.create({ @@ -301,557 +303,559 @@ export const AccountActivationPage = ({ }: Props) => { const documentsFormRef = useRef(null); - const [ - { - data: { accountMembership, projectInfo }, - }, - reexecuteQuery, - ] = useQueryWithErrorBoundary({ - query: AccountActivationPageDocument, - variables: { accountMembershipId }, - }); - - const account = accountMembership?.account; - const emailAddress = account?.legalRepresentativeMembership.email; - - const holder = account?.holder; - const holderName = holder?.info.name; - const isCompany = holder?.info.__typename === "AccountHolderCompanyInfo"; - const country = holder?.residencyAddress.country; - const templateLanguage = match(country) - .with("FR", () => "fr" as const) - .with("DE", () => "de" as const) - .with("ES", () => "es" as const) - .otherwise(() => "en" as const); - - const user = accountMembership?.user; - const firstName = user?.firstName; - const lastName = user?.lastName; - const phoneNumber = user?.mobilePhoneNumber; - const birthDate = user?.birthDate; - const fullName = [firstName, lastName].filter(isNotNullishOrEmpty).join(" "); - - const { supportingDocumentSettings } = projectInfo; - const documentCollectMode = supportingDocumentSettings?.collectMode; - const documentCollection = holder?.supportingDocumentCollections.edges[0]?.node; - const documentCollectionStatus = documentCollection?.statusInfo.status; - - const IBAN = account?.IBAN; - const BIC = account?.BIC; - const hasIBAN = isNotNullish(IBAN); - const hasTransactions = (account?.transactions?.totalCount ?? 0) >= 1; - - const initials = [firstName, lastName] - .map(name => name?.[0]) - .filter(isNotNullishOrEmpty) - .join(""); + const [data, { reload }] = useQuery(AccountActivationPageDocument, { accountMembershipId }); const [isSendingDocumentCollection, setIsSendingDocumentCollection] = useState(false); - const formattedBirthDate = useMemo( - () => (isNotNullishOrEmpty(birthDate) ? dayjs(birthDate).format("LL") : undefined), - [birthDate], - ); - - const step = useMemo(() => { - return ( - match({ - hasRequiredIdentificationLevel, - account, - requireFirstTransfer, - }) - .returnType() - // Handle legacy account that didn't go through the new process - .with( - { account: { paymentLevel: "Unlimited", paymentAccountType: "PaymentService" } }, - () => "Done", - ) - // Case where the membership doesn't yet have a user, should occur - .with({ hasRequiredIdentificationLevel: undefined }, () => undefined) - .with( - { - hasRequiredIdentificationLevel: false, - }, - () => - match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) - .returnType() - // this branch shouldn't occur but is required to typecheck - .with(Option.P.Some({ status: P.union("Valid", "NotSupported") }), () => undefined) - .with( - Option.P.None, - Option.P.Some({ status: P.union("NotStarted", "Started", "Canceled", "Expired") }), - () => "IdentityVerificationTodo", - ) - .with(Option.P.Some({ status: "Pending" }), () => "IdentityVerificationPending") - .with(Option.P.Some({ status: "Invalid" }), () => "IdentityVerificationToRedo") - .exhaustive(), - ) - .with({ hasRequiredIdentificationLevel: true }, ({ account }): Step | undefined => { - if (isCompany) { - return match(documentCollectionStatus) - .returnType() - .with(P.nullish, () => undefined) - .with("WaitingForDocument", "Canceled", "Rejected", () => - documentCollectMode === "EndCustomer" - ? "SupportingDocumentsEmailTodo" - : "SupportingDocumentsFormTodo", - ) - .with("PendingReview", () => - documentCollectMode === "EndCustomer" - ? "SupportingDocumentsEmailPending" - : "SupportingDocumentsFormPending", - ) - .with("Approved", () => "Done") - .exhaustive(); - } - - if (requireFirstTransfer && !hasTransactions) { - return accountVisible && hasIBAN - ? "AddMoneyToYourNewAccountViaIbanTodo" - : "AddMoneyToYourNewAccountIbanMissing"; - } - if (!requireFirstTransfer) { - return match([account?.holder.verificationStatus, documentCollectMode]) - .with([P.union("NotStarted", "Pending"), P._], () => "Done" as const) - .with([P.union("Pending", "WaitingForInformation"), "API"], () => "Done" as const) - .otherwise(() => "StepNotDisplayed" as const); - } - return "Done"; - }) - .exhaustive() - ); - }, [ - account, - requireFirstTransfer, - isCompany, - hasTransactions, - documentCollectionStatus, - documentCollectMode, - accountVisible, - hasIBAN, - hasRequiredIdentificationLevel, - lastRelevantIdentification, - ]); - const [contentVisible, setContentVisible] = useBoolean(false); const { desktop } = useResponsive(); const { desktop: large } = useResponsive(1520); const refetchQueries = useCallback(() => { refetchAccountAreaQuery(); - reexecuteQuery({ requestPolicy: "network-only" }); - }, [refetchAccountAreaQuery, reexecuteQuery]); - - const handleProveIdentity = useCallback(() => { - const identificationLevel = accountMembership?.recommendedIdentificationLevel ?? "Expert"; - const params = new URLSearchParams(); - params.set("projectId", projectInfo.id); - params.set("identificationLevel", identificationLevel); - params.set("redirectTo", Router.PopupCallback()); - - openPopup({ - url: `/auth/login?${params.toString()}`, - onClose: refetchQueries, - }); - }, [projectInfo, refetchQueries, accountMembership]); - - if (isNullish(step)) { - return ; - } - - if (holder?.verificationStatus === "Refused") { - return ( - - - - - - {t("accountActivation.refused.title")} - - - - - - {formatNestedMessage("accountActivation.refused.description", { - name: holder.info.name, - email: ( - - support@swan.io - - ), - })} - - - ); - } - - const content = match(step) + reload(); + }, [refetchAccountAreaQuery, reload]); + + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) .with( - "IdentityVerificationPending", - "SupportingDocumentsEmailPending", - "SupportingDocumentsFormPending", - () => null, - ) - .with("IdentityVerificationTodo", "IdentityVerificationToRedo", () => ( - - - - + AsyncData.P.Done( + Result.P.Ok({ + accountMembership: P.select("accountMembership", P.not(P.nullish)), + projectInfo: P.select("projectInfo", P.not(P.nullish)), + }), + ), + ({ accountMembership, projectInfo }) => { + const account = accountMembership?.account; + const emailAddress = account?.legalRepresentativeMembership.email; + + const holder = account?.holder; + const holderName = holder?.info.name; + const isCompany = holder?.info.__typename === "AccountHolderCompanyInfo"; + const country = holder?.residencyAddress.country; + const templateLanguage = match(country) + .with("FR", () => "fr" as const) + .with("DE", () => "de" as const) + .with("ES", () => "es" as const) + .otherwise(() => "en" as const); + + const user = accountMembership?.user; + const firstName = user?.firstName; + const lastName = user?.lastName; + const phoneNumber = user?.mobilePhoneNumber; + const birthDate = user?.birthDate; + const fullName = [firstName, lastName].filter(isNotNullishOrEmpty).join(" "); + + const { supportingDocumentSettings } = projectInfo; + const documentCollectMode = supportingDocumentSettings?.collectMode; + const documentCollection = holder?.supportingDocumentCollections.edges[0]?.node; + const documentCollectionStatus = documentCollection?.statusInfo.status; + + const IBAN = account?.IBAN; + const BIC = account?.BIC; + const hasIBAN = isNotNullish(IBAN); + const hasTransactions = (account?.transactions?.totalCount ?? 0) >= 1; + + const initials = [firstName, lastName] + .map(name => name?.[0]) + .filter(isNotNullishOrEmpty) + .join(""); + + const step = match({ + hasRequiredIdentificationLevel, + account, + requireFirstTransfer, + }) + .returnType() + // Handle legacy account that didn't go through the new process + .with( + { account: { pantLevel: "Unlimited", paymentAccountType: "PaymentService" } }, + () => "Done", + ) + // Case where the membership doesn't yet have a user, should occur + .with({ hasRequiredIdentificationLevel: undefined }, () => undefined) + .with( + { + hasRequiredIdentificationLevel: false, + }, + () => + match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) + .returnType() + // this branch shouldn't occur but is required to typecheck + .with(Option.P.Some({ status: P.union("Valid", "NotSupported") }), () => undefined) + .with( + Option.P.None, + Option.P.Some({ + status: P.union("NotStarted", "Started", "Canceled", "Expired"), + }), + () => "IdentityVerificationTodo", + ) + .with(Option.P.Some({ status: "Pending" }), () => "IdentityVerificationPending") + .with(Option.P.Some({ status: "Invalid" }), () => "IdentityVerificationToRedo") + .exhaustive(), + ) + .with({ hasRequiredIdentificationLevel: true }, ({ account }): Step | undefined => { + if (isCompany) { + return match(documentCollectionStatus) + .returnType() + .with(P.nullish, () => undefined) + .with("WaitingForDocument", "Canceled", "Rejected", () => + documentCollectMode === "EndCustomer" + ? "SupportingDocumentsEmailTodo" + : "SupportingDocumentsFormTodo", + ) + .with("PendingReview", () => + documentCollectMode === "EndCustomer" + ? "SupportingDocumentsEmailPending" + : "SupportingDocumentsFormPending", + ) + .with("Approved", () => "Done") + .exhaustive(); + } + + if (requireFirstTransfer && !hasTransactions) { + return accountVisible && hasIBAN + ? "AddMoneyToYourNewAccountViaIbanTodo" + : "AddMoneyToYourNewAccountIbanMissing"; + } + if (!requireFirstTransfer) { + return match([account?.holder.verificationStatus, documentCollectMode]) + .with([P.union("NotStarted", "Pending"), P._], () => "Done" as const) + .with([P.union("Pending", "WaitingForInformation"), "API"], () => "Done" as const) + .otherwise(() => "StepNotDisplayed" as const); + } + return "Done"; + }) + .exhaustive(); + + const handleProveIdentity = () => { + const identificationLevel = accountMembership?.recommendedIdentificationLevel ?? "Expert"; + const params = new URLSearchParams(); + match(projectConfiguration.map(({ projectId }) => projectId)) + .with(Option.P.Some(P.select()), projectId => params.set("projectId", projectId)) + .otherwise(() => {}); + params.set("identificationLevel", identificationLevel); + params.set("redirectTo", Router.PopupCallback()); + + openPopup({ + url: `/auth/login?${params.toString()}`, + onClose: refetchQueries, + }); + }; + + if (isNullish(step)) { + return ; + } + + if (holder?.verificationStatus === "Refused") { + return ( + + + + + + {t("accountActivation.refused.title")} + + + + + + {formatNestedMessage("accountActivation.refused.description", { + name: holder.info.name, + email: ( + + support@swan.io + + ), + })} + + + ); + } + + const content = match(step) + .with( + "IdentityVerificationPending", + "SupportingDocumentsEmailPending", + "SupportingDocumentsFormPending", + () => null, + ) + .with("IdentityVerificationTodo", "IdentityVerificationToRedo", () => ( + + + + + + + {fullName} + - - {fullName} - + - + + {isNotNullishOrEmpty(emailAddress) && ( + <> + {emailAddress} - - {isNotNullishOrEmpty(emailAddress) && ( - <> - {emailAddress} + {large && } + + )} - {large && } - - )} + {isNotNullishOrEmpty(phoneNumber) && ( + <> + + {phoneNumber} + - {isNotNullishOrEmpty(phoneNumber) && ( - <> - - {phoneNumber} - + {large && } + + )} - {large && } - - )} + {isNotNullish(birthDate) && ( + {dayjs(birthDate).format("LL")} + )} + + + + + + {lastRelevantIdentification.map(isReadyToSign).getWithDefault(false) + ? t("accountActivation.identity.button.signVerification") + : t("accountActivation.identity.button.verifyMyIdentity")} + + + + )) + .with("SupportingDocumentsEmailTodo", () => ( + + + {t("accountActivation.documents.title")} + + + + {t("accountActivation.documents.subtitle")} + + + + + + + + {isNotNullish(emailAddress) + ? t("accountActivation.documents.email.title", { emailAddress }) + : t("accountActivation.documents.email.titleNoMail")} + - {isNotNullish(formattedBirthDate) && ( - {formattedBirthDate} - )} - + + {t("accountActivation.documents.email.text")} + + + )) + .with("SupportingDocumentsFormTodo", () => ( + + + + {t("accountActivation.documents.title")} + - - - - {lastRelevantIdentification.map(isReadyToSign).getWithDefault(false) - ? t("accountActivation.identity.button.signVerification") - : t("accountActivation.identity.button.verifyMyIdentity")} - - - - )) - .with("SupportingDocumentsEmailTodo", () => ( - - - {t("accountActivation.documents.title")} - - - - {t("accountActivation.documents.subtitle")} - - - - - - - - {isNotNullish(emailAddress) - ? t("accountActivation.documents.email.title", { emailAddress }) - : t("accountActivation.documents.email.titleNoMail")} - - - - {t("accountActivation.documents.email.text")} - - - )) - .with("SupportingDocumentsFormTodo", () => ( - - - - {t("accountActivation.documents.title")} - + + {t("accountActivation.documents.subtitle")} + + + {isNotNullish(documentCollection) && ( + + )} + + + + + + { + const ref = documentsFormRef.current; + if (ref == null) { + return; + } + setIsSendingDocumentCollection(true); + ref.submit().tap(() => setIsSendingDocumentCollection(false)); + }} + > + {t("accountActivation.documents.button.submit")} + + + + )) + .with("AddMoneyToYourNewAccountIbanMissing", () => ( + + + {t("accountActivation.addMoney.title")} + + + + {t("accountActivation.addMoney.subtitle")} + + + + + + + + {t("accountActivation.addMoney.illustration.title")} + - - {t("accountActivation.documents.subtitle")} - - - {isNotNullish(documentCollection) && ( - - )} - + - + + {t("accountActivation.addMoney.illustration.text", { projectName })} + + + + )) + .with("AddMoneyToYourNewAccountViaIbanTodo", () => ( + + + {t("accountActivation.addMoney.title")} + + + + {t("accountActivation.addMoney.subtitle")} + + + + {isNotNullishOrEmpty(holderName) && ( + + )} - - { - const ref = documentsFormRef.current; - if (ref == null) { - return; - } - setIsSendingDocumentCollection(true); - ref.submit().tap(() => setIsSendingDocumentCollection(false)); - }} - > - {t("accountActivation.documents.button.submit")} - - - - )) - .with("AddMoneyToYourNewAccountIbanMissing", () => ( - - - {t("accountActivation.addMoney.title")} - - - - {t("accountActivation.addMoney.subtitle")} - - - - - - - - {t("accountActivation.addMoney.illustration.title")} - - - - - - {t("accountActivation.addMoney.illustration.text", { projectName })} - - - - )) - .with("AddMoneyToYourNewAccountViaIbanTodo", () => ( - - - {t("accountActivation.addMoney.title")} - - - - {t("accountActivation.addMoney.subtitle")} - - - - {isNotNullishOrEmpty(holderName) && ( - - )} + {isNotNullishOrEmpty(IBAN) && ( + + )} - {isNotNullishOrEmpty(IBAN) && ( - - )} + {isNotNullishOrEmpty(BIC) && ( + + )} + + + )) + .with("Done", () => ( + + + {t("accountActivation.done.title")} + + + + {t("accountActivation.done.subtitle")} + + + + + + + + {t("accountActivation.done.illustration.title")} + - {isNotNullishOrEmpty(BIC) && ( - - )} - - - )) - .with("Done", () => ( - - - {t("accountActivation.done.title")} - - - - {t("accountActivation.done.subtitle")} - - - - - - - - {t("accountActivation.done.illustration.title")} - - - - {t("accountActivation.done.illustration.text")} - - - )) - .with("StepNotDisplayed", () => null) - .exhaustive(); + + {t("accountActivation.done.illustration.text")} + + + )) + .with("StepNotDisplayed", () => null) + .exhaustive(); + + return ( + + + + + {t("accountActivation.title")} + - return ( - - - - - {t("accountActivation.title")} - + - + + - - + {isNotNullishOrEmpty(holderName) && ( + <> + - {isNotNullishOrEmpty(holderName) && ( - <> - + + {holderName} + + + )} + + + + {t("accountActivation.description")} + + + + + + () + .with("IdentityVerificationTodo", "IdentityVerificationToRedo", () => "todo") + .with("IdentityVerificationPending", () => "inert") + .otherwise(() => "done")} + footer={match(step) + .with("IdentityVerificationPending", () => ( + + {t("accountActivation.identity.alert.pending.text")} + + )) + .with("IdentityVerificationToRedo", () => ( + + {t("accountActivation.identity.alert.error.text")} + + )) + .otherwise(() => null)} + /> + + {isCompany && + STEP_INDEXES[step] >= STEP_INDEXES["SupportingDocumentsEmailTodo"] && ( + () + .with( + "SupportingDocumentsEmailTodo", + "SupportingDocumentsFormTodo", + () => "todo", + ) + .with( + "SupportingDocumentsEmailPending", + "SupportingDocumentsFormPending", + () => "inert", + ) + .otherwise(() => "done")} + footer={match(step) + .with( + "SupportingDocumentsEmailPending", + "SupportingDocumentsFormPending", + () => ( + + {t("accountActivation.pendingDocuments.text")} + + ), + ) + .otherwise(() => null)} + /> + )} - - {holderName} - + {!isCompany && + requireFirstTransfer && + STEP_INDEXES[step] >= STEP_INDEXES["AddMoneyToYourNewAccountViaIbanTodo"] && ( + + )} + + + {env.APP_TYPE === "LIVE" && ( + <> + + + + + {({ onPressShow }) => ( + + + + + + {t("needHelpButton.text")} + + + )} + + + + )} + + + {isNotNullish(content) && ( + <> + {desktop ? ( + <> + + + {content} + + ) : ( + {content} + )} )} - - - {t("accountActivation.description")} - - - - - - () - .with("IdentityVerificationTodo", "IdentityVerificationToRedo", () => "todo") - .with("IdentityVerificationPending", () => "inert") - .otherwise(() => "done")} - footer={match(step) - .with("IdentityVerificationPending", () => ( - - {t("accountActivation.identity.alert.pending.text")} - - )) - .with("IdentityVerificationToRedo", () => ( - - {t("accountActivation.identity.alert.error.text")} - - )) - .otherwise(() => null)} - /> - - {isCompany && STEP_INDEXES[step] >= STEP_INDEXES["SupportingDocumentsEmailTodo"] && ( - () - .with("SupportingDocumentsEmailTodo", "SupportingDocumentsFormTodo", () => "todo") - .with( - "SupportingDocumentsEmailPending", - "SupportingDocumentsFormPending", - () => "inert", - ) - .otherwise(() => "done")} - footer={match(step) - .with("SupportingDocumentsEmailPending", "SupportingDocumentsFormPending", () => ( - - {t("accountActivation.pendingDocuments.text")} - - )) - .otherwise(() => null)} - /> - )} - - {!isCompany && - requireFirstTransfer && - STEP_INDEXES[step] >= STEP_INDEXES["AddMoneyToYourNewAccountViaIbanTodo"] && ( - - )} - - - {env.APP_TYPE === "LIVE" && ( - <> - - - - - {({ onPressShow }) => ( - - - - - - {t("needHelpButton.text")} - - - )} - - - - )} - - - {isNotNullish(content) && ( - <> - {desktop ? ( - <> - - - {content} - - ) : ( - {content} - )} - - )} - - ); + ); + }, + ) + .otherwise(() => ); }; diff --git a/clients/banking/src/pages/AccountDetailsBillingPage.tsx b/clients/banking/src/pages/AccountDetailsBillingPage.tsx index 9915869de..371ab3d6c 100644 --- a/clients/banking/src/pages/AccountDetailsBillingPage.tsx +++ b/clients/banking/src/pages/AccountDetailsBillingPage.tsx @@ -1,4 +1,5 @@ import { Link } from "@swan-io/chicane"; +import { useQuery } from "@swan-io/graphql-client"; import { FixedListViewEmpty, PlainListViewPlaceholder, @@ -16,11 +17,11 @@ import { ColumnConfig, PlainListView } from "@swan-io/lake/src/components/PlainL import { Tag } from "@swan-io/lake/src/components/Tag"; import { colors } from "@swan-io/lake/src/constants/design"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { GetNode } from "@swan-io/lake/src/utils/types"; import dayjs from "dayjs"; import { match } from "ts-pattern"; +import { Connection } from "../components/Connection"; import { ErrorView } from "../components/ErrorView"; import { AccountDetailsBillingPageDocument, @@ -201,16 +202,10 @@ const PER_PAGE = 20; export const AccountDetailsBillingPage = ({ accountId }: Props) => { // use useResponsive to fit with scroll behavior set in AccountArea const { desktop } = useResponsive(); - const { data, nextData, setAfter } = useUrqlPaginatedQuery( - { - query: AccountDetailsBillingPageDocument, - variables: { - accountId, - first: PER_PAGE, - }, - }, - [accountId], - ); + const [data, { isLoading, setVariables }] = useQuery(AccountDetailsBillingPageDocument, { + accountId, + first: PER_PAGE, + }); return data.match({ NotAsked: () => null, @@ -226,34 +221,38 @@ export const AccountDetailsBillingPage = ({ accountId }: Props) => { Done: result => result.match({ Ok: ({ account }) => ( - node) ?? []} - keyExtractor={item => item.id} - headerHeight={48} - rowHeight={48} - groupHeaderHeight={48} - extraInfo={undefined} - columns={columns} - smallColumns={smallColumns} - loading={{ - isLoading: nextData.isLoading(), - count: PER_PAGE, - }} - onEndReached={() => { - if (account?.invoices?.pageInfo.hasNextPage === true) { - setAfter(account?.invoices?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => ( - + {invoices => ( + node) ?? []} + keyExtractor={item => item.id} + headerHeight={48} + rowHeight={48} + groupHeaderHeight={48} + extraInfo={undefined} + columns={columns} + smallColumns={smallColumns} + loading={{ + isLoading, + count: PER_PAGE, + }} + onEndReached={() => { + if (invoices?.pageInfo.hasNextPage === true) { + setVariables({ after: invoices?.pageInfo.endCursor ?? undefined }); + } + }} + renderEmptyList={() => ( + + )} /> )} - /> + ), Error: error => , }), diff --git a/clients/banking/src/pages/AccountDetailsIbanPage.tsx b/clients/banking/src/pages/AccountDetailsIbanPage.tsx index 91a73d07f..ca2efde0b 100644 --- a/clients/banking/src/pages/AccountDetailsIbanPage.tsx +++ b/clients/banking/src/pages/AccountDetailsIbanPage.tsx @@ -1,4 +1,5 @@ import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { Icon } from "@swan-io/lake/src/components/Icon"; import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert"; @@ -13,7 +14,6 @@ import { Space } from "@swan-io/lake/src/components/Space"; import { Tile } from "@swan-io/lake/src/components/Tile"; import { TilePlaceholder } from "@swan-io/lake/src/components/TilePlaceholder"; import { colors, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullishOrEmpty, isNullishOrEmpty } from "@swan-io/lake/src/utils/nullish"; import { getCountryName, isCountryCCA3 } from "@swan-io/shared-business/src/constants/countries"; import { printIbanFormat } from "@swan-io/shared-business/src/utils/validation"; @@ -24,7 +24,6 @@ import { ErrorView } from "../components/ErrorView"; import { LakeCopyTextLine } from "../components/LakeCopyTextLine"; import { AccountDetailsIbanPageDocument } from "../graphql/partner"; import { formatNestedMessage, t } from "../utils/i18n"; -import { isUnauthorizedError } from "../utils/urql"; import { NotFoundPage } from "./NotFoundPage"; const styles = StyleSheet.create({ @@ -65,10 +64,7 @@ type Props = { }; export const AccountDetailsIbanPage = ({ accountId, largeBreakpoint }: Props) => { - const { data } = useUrqlQuery( - { query: AccountDetailsIbanPageDocument, variables: { accountId } }, - [], - ); + const [data] = useQuery(AccountDetailsIbanPageDocument, { accountId }); return ( @@ -80,9 +76,7 @@ export const AccountDetailsIbanPage = ({ accountId, largeBreakpoint }: Props) => )) - .with(AsyncData.P.Done(Result.P.Error(P.select())), error => - isUnauthorizedError(error) ? <> : , - ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) .with(AsyncData.P.Done(Result.P.Ok(P.select())), ({ account }) => { if (!account) { return ; diff --git a/clients/banking/src/pages/AccountDetailsSettingsPage.tsx b/clients/banking/src/pages/AccountDetailsSettingsPage.tsx index aee36bc78..6107a1110 100644 --- a/clients/banking/src/pages/AccountDetailsSettingsPage.tsx +++ b/clients/banking/src/pages/AccountDetailsSettingsPage.tsx @@ -1,4 +1,5 @@ import { AsyncData, Dict, Result } from "@swan-io/boxed"; +import { useMutation, useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; import { Icon } from "@swan-io/lake/src/components/Icon"; @@ -15,7 +16,6 @@ import { Tile, TileGrid } from "@swan-io/lake/src/components/Tile"; import { TilePlaceholder } from "@swan-io/lake/src/components/TilePlaceholder"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { colors, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { isNotEmpty, @@ -23,7 +23,7 @@ import { isNullish, isNullishOrEmpty, } from "@swan-io/lake/src/utils/nullish"; -import { filterRejectionsToPromise, parseOperationResult } from "@swan-io/lake/src/utils/urql"; +import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { TaxIdentificationNumberInput } from "@swan-io/shared-business/src/components/TaxIdentificationNumberInput"; import { CountryCCA3 } from "@swan-io/shared-business/src/constants/countries"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; @@ -35,7 +35,6 @@ import { ReactNode, useMemo } from "react"; import { ScrollView, StyleSheet, View } from "react-native"; import { combineValidators, hasDefinedKeys, toOptionalValidator, useForm } from "react-ux-form"; import { P, match } from "ts-pattern"; -import { useMutation } from "urql"; import { ErrorView } from "../components/ErrorView"; import { AccountDetailsSettingsPageDocument, @@ -44,7 +43,6 @@ import { UpdateAccountDocument, } from "../graphql/partner"; import { t } from "../utils/i18n"; -import { isUnauthorizedError } from "../utils/urql"; import { validateAccountNameLength, validateRequired, @@ -108,7 +106,7 @@ const UpdateAccountForm = ({ canManageAccountMembership: boolean; }) => { const accountCountry = account.country ?? "FRA"; - const [, updateAccount] = useMutation(UpdateAccountDocument); + const [updateAccount] = useMutation(UpdateAccountDocument); const holderInfo = account.holder.info; const isCompany = holderInfo?.__typename === "AccountHolderCompanyInfo"; @@ -344,16 +342,16 @@ const UpdateAccountForm = ({ taxIdentificationNumber, }, }) - .then(parseOperationResult) - .then(({ updateAccount, updateAccountHolder }) => - Promise.all([ - filterRejectionsToPromise(updateAccount), - filterRejectionsToPromise(updateAccountHolder), + .mapOkToResult(({ updateAccount, updateAccountHolder }) => + Result.all([ + filterRejectionsToResult(updateAccount), + filterRejectionsToResult(updateAccountHolder), ]), ) - .catch((error: unknown) => { + .tapError((error: unknown) => { showToast({ variant: "error", error, title: translateError(error) }); - }); + }) + .toPromise(); } }); }} @@ -383,24 +381,16 @@ export const AccountDetailsSettingsPage = ({ canManageAccountMembership, largeBreakpoint, }: Props) => { - const { data } = useUrqlQuery( - { - query: AccountDetailsSettingsPageDocument, - variables: { - accountId, - filters: { status: "Active", type: "SwanTCU" }, - }, - }, - [], - ); + const [data] = useQuery(AccountDetailsSettingsPageDocument, { + accountId, + filters: { status: "Active", type: "SwanTCU" }, + }); return ( {match(data) .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) - .with(AsyncData.P.Done(Result.P.Error(P.select())), error => - isUnauthorizedError(error) ? <> : , - ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) .with(AsyncData.P.Done(Result.P.Ok(P.select())), ({ account }) => isNullish(account) ? ( diff --git a/clients/banking/src/pages/AccountDetailsVirtualIbansPage.tsx b/clients/banking/src/pages/AccountDetailsVirtualIbansPage.tsx index 4a38e2a23..6b1edf147 100644 --- a/clients/banking/src/pages/AccountDetailsVirtualIbansPage.tsx +++ b/clients/banking/src/pages/AccountDetailsVirtualIbansPage.tsx @@ -1,3 +1,5 @@ +import { Option } from "@swan-io/boxed"; +import { useMutation, useQuery } from "@swan-io/graphql-client"; import { FixedListViewEmpty, PlainListViewPlaceholder, @@ -17,17 +19,16 @@ import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; import { useBoolean } from "@swan-io/lake/src/hooks/useBoolean"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { showToast } from "@swan-io/lake/src/state/toasts"; import { GetEdge } from "@swan-io/lake/src/utils/types"; -import { filterRejectionsToPromise, parseOperationResult } from "@swan-io/lake/src/utils/urql"; +import { filterRejectionsToResult } from "@swan-io/lake/src/utils/urql"; import { LakeModal } from "@swan-io/shared-business/src/components/LakeModal"; import { translateError } from "@swan-io/shared-business/src/utils/i18n"; import { printIbanFormat } from "@swan-io/shared-business/src/utils/validation"; import { useMemo } from "react"; import { StyleSheet, View } from "react-native"; import { match } from "ts-pattern"; -import { useMutation } from "urql"; +import { Connection } from "../components/Connection"; import { ErrorView } from "../components/ErrorView"; import { AccountDetailsVirtualIbansPageDocument, @@ -176,18 +177,17 @@ const smallColumns: ColumnConfig[] = [ const Actions = ({ onCancel, virtualIbanId }: { onCancel: () => void; virtualIbanId: string }) => { const [modalVisible, setModalVisible] = useBoolean(false); - const [{ fetching }, cancelVirtualIban] = useMutation(CancelVirtualIbanDocument); + const [cancelVirtualIban, virtualIbanCancelation] = useMutation(CancelVirtualIbanDocument); const onPressCancel = () => { cancelVirtualIban({ virtualIbanId }) - .then(parseOperationResult) - .then(data => data.cancelVirtualIbanEntry) - .then(filterRejectionsToPromise) - .then(onCancel) - .catch((error: unknown) => + .mapOkToResult(data => Option.fromNullable(data.cancelVirtualIbanEntry).toResult(undefined)) + .mapOkToResult(filterRejectionsToResult) + .tapOk(onCancel) + .tapError((error: unknown) => showToast({ variant: "error", error, title: translateError(error) }), ) - .finally(setModalVisible.off); + .tap(setModalVisible.off); }; return ( @@ -211,7 +211,12 @@ const Actions = ({ onCancel, virtualIbanId }: { onCancel: () => void; virtualIba - + {t("accountDetails.virtualIbans.cancelVirtualIban")} @@ -225,24 +230,19 @@ const keyExtractor = ({ node: { id } }: Edge) => id; export const AccountDetailsVirtualIbansPage = ({ accountId }: Props) => { // use useResponsive to fit with scroll behavior set in AccountArea const { desktop } = useResponsive(); - const [{ fetching: adding }, addVirtualIban] = useMutation(AddVirtualIbanDocument); + const [addVirtualIban, virtualIbanAddition] = useMutation(AddVirtualIbanDocument); - const { data, nextData, reload, setAfter } = useUrqlPaginatedQuery( - { - query: AccountDetailsVirtualIbansPageDocument, - variables: { first: 20, accountId }, - }, - [accountId], + const [data, { isLoading, reload, setVariables }] = useQuery( + AccountDetailsVirtualIbansPageDocument, + { first: 20, accountId }, ); const onPressNew = () => { addVirtualIban({ accountId }) - .then(parseOperationResult) - .then(data => data.addVirtualIbanEntry) - .then(data => data ?? Promise.reject()) - .then(filterRejectionsToPromise) - .then(reload) - .catch((error: unknown) => { + .mapOkToResult(data => Option.fromNullable(data.addVirtualIbanEntry).toResult(undefined)) + .mapOkToResult(filterRejectionsToResult) + .tapOk(reload) + .tapError((error: unknown) => { showToast({ variant: "error", error, title: translateError(error) }); }); }; @@ -263,68 +263,74 @@ export const AccountDetailsVirtualIbansPage = ({ accountId }: Props) => { Done: result => result.match({ Error: error => , - Ok: data => { - const entries = data.account?.virtualIbanEntries; - const edges = entries?.edges ?? []; - const unlimited = data.account?.paymentLevel === "Unlimited"; + Ok: data => ( + + {virtualIbanEntries => { + const edges = virtualIbanEntries?.edges ?? []; + const unlimited = data.account?.paymentLevel === "Unlimited"; + const totalCount = virtualIbanEntries?.totalCount ?? 0; - return ( - <> - {edges.length > 0 && unlimited && ( - - - {t("common.new")} - - - )} + return ( + <> + {totalCount > 0 && unlimited && ( + + + {t("common.new")} + + + )} - { - if (Boolean(entries?.pageInfo.hasNextPage)) { - setAfter(entries?.pageInfo.endCursor ?? undefined); - } - }} - headerHeight={48} - groupHeaderHeight={48} - rowHeight={56} - loading={{ isLoading: nextData.isLoading(), count: 20 }} - renderEmptyList={() => ( - - {unlimited && ( - - - {t("common.new")} - - + { + if (Boolean(virtualIbanEntries?.pageInfo.hasNextPage)) { + setVariables({ + after: virtualIbanEntries?.pageInfo.endCursor ?? undefined, + }); + } + }} + headerHeight={48} + groupHeaderHeight={48} + rowHeight={56} + loading={{ isLoading, count: 20 }} + renderEmptyList={() => ( + + {unlimited && ( + + + {t("common.new")} + + + )} + )} - - )} - /> - - ); - }, + /> + + ); + }} + + ), }), }) } diff --git a/clients/banking/src/pages/CardListPage.tsx b/clients/banking/src/pages/CardListPage.tsx index e2c899c5a..5a0e65fe9 100644 --- a/clients/banking/src/pages/CardListPage.tsx +++ b/clients/banking/src/pages/CardListPage.tsx @@ -1,5 +1,6 @@ import { Array, Option } from "@swan-io/boxed"; import { Link } from "@swan-io/chicane"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty, @@ -9,13 +10,13 @@ import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeBu import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; import { useMemo } from "react"; import { StyleSheet, View } from "react-native"; import { match } from "ts-pattern"; import { CardList } from "../components/CardList"; import { CardFilters, CardListFilter } from "../components/CardListFilter"; +import { Connection } from "../components/Connection"; import { ErrorView } from "../components/ErrorView"; import { CardListPageDataFragment, @@ -91,53 +92,31 @@ const usePageData = ({ .with("Canceled", () => CANCELED_STATUSES) .exhaustive(); - const withAccountQuery = useUrqlPaginatedQuery( - { - query: CardListPageWithAccountDocument, - pause: !canQueryEveryCards, - variables: { - first: PER_PAGE, - filters: { - statuses, - types: filters.type, - search: filters.search, - accountId, - }, + if (canQueryEveryCards) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [data, rest] = useQuery(CardListPageWithAccountDocument, { + first: PER_PAGE, + filters: { + statuses, + types: filters.type, + search: filters.search, + accountId, }, - }, - [filters, accountId], - ); + }); + return [data.mapOk(data => data.cards), rest] as const; + } else { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [data, rest] = useQuery(CardListPageWithoutAccountDocument, { + first: PER_PAGE, + filters: { statuses, types: filters.type, search: filters.search }, + accountMembershipId, + }); - const withoutAccountQuery = useUrqlPaginatedQuery( - { - query: CardListPageWithoutAccountDocument, - pause: canQueryEveryCards, - variables: { - first: PER_PAGE, - filters: { statuses, types: filters.type, search: filters.search }, - accountMembershipId, - }, - }, - [filters, accountMembershipId], - ); - - return canQueryEveryCards - ? { - ...withAccountQuery, - data: withAccountQuery.data.map(result => result.map(data => data.cards)), - nextData: withAccountQuery.nextData.map(result => result.map(data => data.cards)), - setAfter: withAccountQuery.setAfter, - } - : { - ...withoutAccountQuery, - data: withoutAccountQuery.data.map(result => - result.map(data => data.accountMembership?.cards ?? EMPTY_CARD_FRAGMENT), - ), - nextData: withoutAccountQuery.nextData.map(result => - result.map(data => data.accountMembership?.cards ?? EMPTY_CARD_FRAGMENT), - ), - setAfter: withoutAccountQuery.setAfter, - }; + return [ + data.mapOk(data => data.accountMembership?.cards ?? EMPTY_CARD_FRAGMENT), + rest, + ] as const; + } }; export const CardListPage = ({ @@ -168,7 +147,7 @@ export const CardListPage = ({ const hasFilters = Object.values(filters).some(isNotNullish); - const { data, nextData, setAfter, reload } = usePageData({ + const [data, { isLoading, setVariables, reload }] = usePageData({ canManageAccountMembership, accountMembershipId, canManageCards, @@ -213,7 +192,9 @@ export const CardListPage = ({ ...filters, }) } - onRefresh={reload} + onRefresh={() => { + reload(); + }} large={large} > {canAddCard && cardOrderVisible ? ( @@ -243,40 +224,46 @@ export const CardListPage = ({ result.match({ Error: error => , Ok: cards => ( - ( - + {cards => ( + ( + + )} + loading={{ + isLoading, + count: 20, + }} + onRefreshRequest={() => { + reload(); + }} + onEndReached={() => { + if (cards.pageInfo.hasNextPage ?? false) { + setVariables({ after: cards.pageInfo.endCursor ?? undefined }); + } + }} + renderEmptyList={() => + hasFilters ? ( + + ) : ( + empty + ) + } /> )} - loading={{ - isLoading: nextData.isLoading(), - count: 20, - }} - onRefreshRequest={reload} - onEndReached={() => { - if (cards.pageInfo.hasNextPage ?? false) { - setAfter(cards.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => - hasFilters ? ( - - ) : ( - empty - ) - } - /> + ), }), })} diff --git a/clients/banking/src/pages/ProfilePage.tsx b/clients/banking/src/pages/ProfilePage.tsx index 6e6439448..936d09072 100644 --- a/clients/banking/src/pages/ProfilePage.tsx +++ b/clients/banking/src/pages/ProfilePage.tsx @@ -1,4 +1,5 @@ -import { Option } from "@swan-io/boxed"; +import { AsyncData, Option, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Avatar } from "@swan-io/lake/src/components/Avatar"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; @@ -8,6 +9,7 @@ import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading"; import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel"; import { LakeSelect } from "@swan-io/lake/src/components/LakeSelect"; import { LakeText } from "@swan-io/lake/src/components/LakeText"; +import { LoadingView } from "@swan-io/lake/src/components/LoadingView"; import { ReadOnlyFieldList } from "@swan-io/lake/src/components/ReadOnlyFieldList"; import { ResponsiveContainer } from "@swan-io/lake/src/components/ResponsiveContainer"; import { Separator } from "@swan-io/lake/src/components/Separator"; @@ -17,12 +19,12 @@ import { Tile, TileRows } from "@swan-io/lake/src/components/Tile"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { backgroundColor, breakpoints, colors, spacings } from "@swan-io/lake/src/constants/design"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; -import { useQueryWithErrorBoundary } from "@swan-io/lake/src/utils/urql"; import { AdditionalInfo, SupportChat } from "@swan-io/shared-business/src/components/SupportChat"; import dayjs from "dayjs"; import { useCallback, useMemo } from "react"; import { ScrollView, StyleSheet, View } from "react-native"; import { P, match } from "ts-pattern"; +import { ErrorView } from "../components/ErrorView"; import { IdentificationFragment, IdentificationLevel, @@ -31,7 +33,9 @@ import { import { openPopup } from "../states/popup"; import { languages, locale, setPreferredLanguage, t } from "../utils/i18n"; import { getIdentificationLevelStatusInfo, isReadyToSign } from "../utils/identification"; +import { projectConfiguration } from "../utils/projectId"; import { Router } from "../utils/routes"; +import { NotFoundPage } from "./NotFoundPage"; const styles = StyleSheet.create({ container: { @@ -70,29 +74,20 @@ export const ProfilePage = ({ hasRequiredIdentificationLevel, lastRelevantIdentification, }: Props) => { - const [{ data }] = useQueryWithErrorBoundary({ query: ProfilePageDocument }); - - const { user } = data; - const firstName = user?.firstName ?? ""; - const lastName = user?.lastName ?? ""; - const phoneNumber = user?.mobilePhoneNumber ?? undefined; - const birthDate = user?.birthDate ?? undefined; - - const initials = [firstName, lastName] - .filter(name => name !== "") - .map(name => name[0]) - .join(""); + const [data] = useQuery(ProfilePageDocument, {}); const handleProveIdentity = useCallback(() => { const params = new URLSearchParams(); - params.set("projectId", data.projectInfo.id); + match(projectConfiguration.map(({ projectId }) => projectId)) + .with(Option.P.Some(P.select()), projectId => params.set("projectId", projectId)) + .otherwise(() => {}); params.set("redirectTo", Router.PopupCallback()); params.set("identificationLevel", recommendedIdentificationLevel); openPopup({ url: `/auth/login?${params.toString()}`, onClose: () => refetchAccountAreaQuery(), }); - }, [data.projectInfo, refetchAccountAreaQuery, recommendedIdentificationLevel]); + }, [refetchAccountAreaQuery, recommendedIdentificationLevel]); const languageOptions = useMemo( () => @@ -149,275 +144,306 @@ export const ProfilePage = ({ .otherwise(() => null) : null; - return ( - - {({ small, large }) => ( - - {large ? ( - <> - - {t("profile.personalInformation")} - - - - - ) : null} - - {small ? ( - - - - - - - - {firstName} {lastName} + return match(data) + .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) + .with(AsyncData.P.Done(Result.P.Error(P.select())), error => ) + .with(AsyncData.P.Done(Result.P.Ok({ user: P.select(P.not(P.nullish)) })), user => { + const firstName = user.firstName ?? ""; + const lastName = user.lastName ?? ""; + const phoneNumber = user.mobilePhoneNumber ?? undefined; + const birthDate = user.birthDate ?? undefined; + + const initials = [firstName, lastName] + .filter(name => name !== "") + .map(name => name.charAt(0)) + .join(""); + + return ( + + {({ small, large }) => ( + + {large ? ( + <> + + {t("profile.personalInformation")} - {shouldDisplayIdVerification && hasRequiredIdentificationLevel === true ? ( - <> - - {t("profile.verified")} - - ) : ( - match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) - .with( - Option.P.None, - Option.P.Some({ - status: P.union( - "NotStarted", - "Started", - "Canceled", - "Expired", - "Invalid", + + + ) : null} + + {small ? ( + + + + + + + + {firstName} {lastName} + + + {shouldDisplayIdVerification && hasRequiredIdentificationLevel === true ? ( + <> + + {t("profile.verified")} + + ) : ( + match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) + .with( + Option.P.None, + Option.P.Some({ + status: P.union( + "NotStarted", + "Started", + "Canceled", + "Expired", + "Invalid", + ), + }), + () => ( + <> + + {t("profile.actionRequired")} + + ), + ) + .otherwise(() => null) + )} + + + {shouldDisplayIdVerification && + hasRequiredIdentificationLevel === false && + match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) + .with( + Option.P.None, + Option.Some({ + status: P.union( + "NotStarted", + "Started", + "Invalid", + "Canceled", + "Expired", + ), + }), + () => ( + <> + + + + {lastRelevantIdentification.map(isReadyToSign).getWithDefault(false) + ? t("profile.finalizeVerification") + : t("profile.verifyIdentity")} + + ), - }), - () => ( + ) + .otherwise(() => null)} + + + + + + + {email}} + /> + + {isNotNullish(phoneNumber) && ( + {phoneNumber}} + /> + )} + + {isNotNullish(birthDate) && ( + ( + + {dayjs(birthDate).format("LL")} + + )} + /> + )} + + + + ) : ( + + + + + + + + {firstName} {lastName} + + + + + + {email} + + {isNotNullish(phoneNumber) && ( <> - - {t("profile.actionRequired")} + + {phoneNumber} - ), - ) - .otherwise(() => null) - )} - - - {shouldDisplayIdVerification && - hasRequiredIdentificationLevel === false && - match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) - .with( - Option.P.None, - Option.Some({ - status: P.union("NotStarted", "Started", "Invalid", "Canceled", "Expired"), - }), - () => ( - <> - + )} - - {lastRelevantIdentification.map(isReadyToSign).getWithDefault(false) - ? t("profile.finalizeVerification") - : t("profile.verifyIdentity")} - + {isNotNullish(birthDate) && ( + <> + + + + {dayjs(birthDate).format("LL")} + + + )} + + + {shouldDisplayIdVerification && hasRequiredIdentificationLevel === true && ( + <> + + {t("profile.verified")} - ), - ) - .otherwise(() => null)} - + )} + + + {shouldDisplayIdVerification && hasRequiredIdentificationLevel === false + ? hasRequiredIdentificationLevel === false && + match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) + .with( + Option.P.None, + Option.Some({ + status: P.union( + "NotStarted", + "Started", + "Invalid", + "Canceled", + "Expired", + ), + }), + () => ( + <> + + + + {lastRelevantIdentification + .map(isReadyToSign) + .getWithDefault(false) + ? t("profile.finalizeVerification") + : t("profile.verifyIdentity")} + + + ), + ) + .otherwise(() => null) + : null} + + + )} - - {email}} - /> - - {isNotNullish(phoneNumber) && ( - {phoneNumber}} - /> - )} - - {isNotNullish(birthDate) && ( - ( - - {dayjs(birthDate).format("LL")} - - )} + ( + { + setPreferredLanguage(locale); + }} /> )} - + /> - - ) : ( - - - - - - - - {firstName} {lastName} - - + - - {email} - - {isNotNullish(phoneNumber) && ( - <> - - {phoneNumber} - - )} - - {isNotNullish(birthDate) && ( - <> - - {dayjs(birthDate).format("LL")} - - )} - + + {t("profile.support")} + - {shouldDisplayIdVerification && hasRequiredIdentificationLevel === true && ( - <> - - {t("profile.verified")} - - )} - + - {shouldDisplayIdVerification && hasRequiredIdentificationLevel === false - ? hasRequiredIdentificationLevel === false && - match(lastRelevantIdentification.map(getIdentificationLevelStatusInfo)) - .with( - Option.P.None, - Option.Some({ - status: P.union( - "NotStarted", - "Started", - "Invalid", - "Canceled", - "Expired", - ), - }), - () => ( - <> - - - - {lastRelevantIdentification.map(isReadyToSign).getWithDefault(false) - ? t("profile.finalizeVerification") - : t("profile.verifyIdentity")} - - - ), - ) - .otherwise(() => null) - : null} - - - )} - - - - - ( - { - setPreferredLanguage(locale); - }} - /> - )} - /> - + + + + {t("profile.chat")} + - + + {t("profile.chat.description")} + - - {t("profile.support")} - + + + {({ onPressShow }) => ( + + {t("profile.chat.sendMessage")} + + )} + + + - + + + {t("profile.faq")} + - - - - {t("profile.chat")} - + + {t("profile.faq.description")} + - - {t("profile.chat.description")} - - - - - {({ onPressShow }) => ( + - {t("profile.chat.sendMessage")} + {t("profile.faq.goToSupport")} - )} - - - + + + - - - {t("profile.faq")} - - - - {t("profile.faq.description")} - - - - - {t("profile.faq.goToSupport")} - - - - - - - - )} - - ); + + + )} + + ); + }) + .otherwise(() => ); }; diff --git a/clients/banking/src/pages/ProjectLoginPage.tsx b/clients/banking/src/pages/ProjectLoginPage.tsx index ccc6cad66..abe776271 100644 --- a/clients/banking/src/pages/ProjectLoginPage.tsx +++ b/clients/banking/src/pages/ProjectLoginPage.tsx @@ -1,4 +1,5 @@ import { AsyncData, Result } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { BorderedIcon } from "@swan-io/lake/src/components/BorderedIcon"; import { Box } from "@swan-io/lake/src/components/Box"; import { Fill } from "@swan-io/lake/src/components/Fill"; @@ -21,19 +22,16 @@ import { } from "@swan-io/lake/src/constants/design"; import { useResponsive } from "@swan-io/lake/src/hooks/useResponsive"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; -import { parseOperationResult } from "@swan-io/lake/src/utils/urql"; import { isMobile } from "@swan-io/lake/src/utils/userAgent"; -import { useCallback, useLayoutEffect, useState } from "react"; +import { useCallback } from "react"; import { Image, ScrollView, StyleSheet, View } from "react-native"; import { P, match } from "ts-pattern"; import { ErrorView } from "../components/ErrorView"; -import { AuthStatusDocument } from "../graphql/partner"; import { ProjectLoginPageDocument } from "../graphql/unauthenticated"; import { openPopup } from "../states/popup"; import { env } from "../utils/env"; import { getFirstSupportedLanguage, t } from "../utils/i18n"; import { Router } from "../utils/routes"; -import { partnerClient, unauthenticatedClient } from "../utils/urql"; const styles = StyleSheet.create({ base: { @@ -137,42 +135,8 @@ export const ProjectLoginPage = ({ sessionExpired?: boolean; }) => { const { desktop } = useResponsive(breakpoints.medium); - const [projectInfos, setProjectInfos] = useState< - AsyncData> - >(AsyncData.Loading()); - - useLayoutEffect(() => { - const envType = env.APP_TYPE === "LIVE" ? "Live" : "Sandbox"; - - Promise.all([ - partnerClient.query(AuthStatusDocument, {}).toPromise(), - unauthenticatedClient - .query(ProjectLoginPageDocument, { projectId, env: envType }) - .toPromise(), - ]) - .then(([authStatusQuery, projectInfosQuery]) => { - const authenticated = isNotNullish(authStatusQuery.data?.user); - - if (authenticated) { - return Router.push("ProjectRootRedirect"); - } - - const { projectInfoById } = parseOperationResult(projectInfosQuery); - - setProjectInfos( - AsyncData.Done( - Result.Ok({ - accentColor: projectInfoById.accentColor ?? invariantColors.gray, - name: projectInfoById.name, - logoUri: projectInfoById.logoUri ?? undefined, - }), - ), - ); - }) - .catch(error => { - setProjectInfos(AsyncData.Done(Result.Error(error))); - }); - }, [projectId]); + const envType = env.APP_TYPE === "LIVE" ? "Live" : "Sandbox"; + const [projectInfos] = useQuery(ProjectLoginPageDocument, { projectId, env: envType }); const handleButtonPress = useCallback(() => { const redirectTo = Router.ProjectRootRedirect(); @@ -196,7 +160,13 @@ export const ProjectLoginPage = ({ } }, [projectId]); - return match(projectInfos) + return match( + projectInfos.mapOk(({ projectInfoById }) => ({ + accentColor: projectInfoById.accentColor ?? invariantColors.gray, + name: projectInfoById.name, + logoUri: projectInfoById.logoUri ?? undefined, + })), + ) .with(AsyncData.P.Done(Result.P.Ok(P.select())), ({ accentColor, name, logoUri }) => { return ( diff --git a/clients/banking/src/pages/TransactionListPage.tsx b/clients/banking/src/pages/TransactionListPage.tsx index 3214f0738..ce59f34dd 100644 --- a/clients/banking/src/pages/TransactionListPage.tsx +++ b/clients/banking/src/pages/TransactionListPage.tsx @@ -1,4 +1,5 @@ -import { Array, AsyncData, Option, Result } from "@swan-io/boxed"; +import { Array, Option } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { Box } from "@swan-io/lake/src/components/Box"; import { FixedListViewEmpty, @@ -13,11 +14,11 @@ import { RightPanel } from "@swan-io/lake/src/components/RightPanel"; import { Space } from "@swan-io/lake/src/components/Space"; import { commonStyles } from "@swan-io/lake/src/constants/commonStyles"; import { breakpoints, spacings } from "@swan-io/lake/src/constants/design"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { isNotNullish } from "@swan-io/lake/src/utils/nullish"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { StyleSheet } from "react-native"; -import { P, match } from "ts-pattern"; +import { match } from "ts-pattern"; +import { Connection } from "../components/Connection"; import { ErrorView } from "../components/ErrorView"; import { TransactionDetail } from "../components/TransactionDetail"; import { TransactionList } from "../components/TransactionList"; @@ -25,7 +26,7 @@ import { TransactionFiltersState, TransactionListFilter, } from "../components/TransactionListFilter"; -import { Amount, PaymentProduct, TransactionListPageDocument } from "../graphql/partner"; +import { PaymentProduct, TransactionListPageDocument } from "../graphql/partner"; import { useTransferToastWithRedirect } from "../hooks/useTransferToastWithRedirect"; import { t } from "../utils/i18n"; import { Router } from "../utils/routes"; @@ -59,7 +60,6 @@ type Props = { canQueryCardOnTransaction: boolean; accountStatementsVisible: boolean; canViewAccount: boolean; - onBalanceReceive: (amount: Amount) => void; transferConsent: Option<{ status: string; isStandingOrder: boolean }>; params: { isAfterUpdatedAt?: string | undefined; @@ -81,7 +81,6 @@ const DEFAULT_STATUSES = [ export const TransactionListPage = ({ accountId, accountMembershipId, - onBalanceReceive, transferConsent, params, canQueryCardOnTransaction, @@ -142,33 +141,20 @@ export const TransactionListPage = ({ return actualPaymentProduct.length > 0 ? actualPaymentProduct : undefined; }, [filters]); - const { data, nextData, reload, setAfter } = useUrqlPaginatedQuery( - { - query: TransactionListPageDocument, - variables: { - accountId, - first: NUM_TO_RENDER, - filters: { - ...filters, - paymentProduct, - status: filters.status ?? DEFAULT_STATUSES, - }, - canQueryCardOnTransaction, - canViewAccount, - }, + const [data, { isLoading, reload, setVariables }] = useQuery(TransactionListPageDocument, { + accountId, + first: NUM_TO_RENDER, + filters: { + ...filters, + paymentProduct, + status: filters.status ?? DEFAULT_STATUSES, }, - [filters, canQueryCardOnTransaction], - ); + canQueryCardOnTransaction, + canViewAccount, + }); const [activeTransactionId, setActiveTransactionId] = useState(null); - const transactions = data - .toOption() - .flatMap(result => result.toOption()) - .flatMap(data => Option.fromNullable(data.account?.transactions)) - .map(({ edges }) => edges.map(({ node }) => node)) - .getWithDefault([]); - const panelRef = useRef(null); const onActiveRowChange = useCallback( @@ -176,23 +162,6 @@ export const TransactionListPage = ({ [], ); - useEffect(() => { - match(data) - .with( - AsyncData.P.Done( - Result.P.Ok({ - account: { - balances: { - available: P.select(), - }, - }, - }), - ), - availableBalance => onBalanceReceive(availableBalance), - ) - .otherwise(() => {}); - }, [data, onBalanceReceive]); - return ( {({ large }) => ( @@ -207,7 +176,9 @@ export const TransactionListPage = ({ ...filters, }) } - onRefresh={reload} + onRefresh={() => { + reload(); + }} large={large} > {accountStatementsVisible ? ( @@ -243,104 +214,119 @@ export const TransactionListPage = ({ result.match({ Error: error => , Ok: data => ( - ( - setActiveTransactionId(item.id)} /> - )} - pageSize={NUM_TO_RENDER} - activeRowId={activeTransactionId ?? undefined} - onActiveRowChange={onActiveRowChange} - loading={{ - isLoading: nextData.isLoading(), - count: 2, - }} - onEndReached={() => { - if (data.account?.transactions?.pageInfo.hasNextPage ?? false) { - setAfter(data.account?.transactions?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => - hasFilters ? ( - + {transactions => ( + <> + ( + setActiveTransactionId(item.id)} /> + )} + pageSize={NUM_TO_RENDER} + activeRowId={activeTransactionId ?? undefined} + onActiveRowChange={onActiveRowChange} + loading={{ + isLoading, + count: 2, + }} + onEndReached={() => { + if (transactions?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: transactions?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => + hasFilters ? ( + + ) : ( + + ) + } /> - ) : ( - - ) - } - /> - ), - }), - })} - {match(route) - .with({ name: "AccountTransactionsListDetail" }, ({ params: { transactionId } }) => ( - { - setActiveTransactionId(null); - Router.push("AccountTransactionsListRoot", { accountMembershipId }); - }} - > - {({ large }) => ( - <> - - { - setActiveTransactionId(null); - Router.push("AccountTransactionsListRoot", { accountMembershipId }); - }} - children={null} - /> - + {match(route) + .with( + { name: "AccountTransactionsListDetail" }, + ({ params: { transactionId } }) => ( + { + setActiveTransactionId(null); + Router.push("AccountTransactionsListRoot", { + accountMembershipId, + }); + }} + > + {({ large }) => ( + <> + + { + setActiveTransactionId(null); + Router.push("AccountTransactionsListRoot", { + accountMembershipId, + }); + }} + children={null} + /> + - + - - - )} - - )) - .otherwise(() => ( - item.id} - activeId={activeTransactionId} - onActiveIdChange={setActiveTransactionId} - onClose={() => setActiveTransactionId(null)} - items={transactions} - render={(transaction, large) => ( - - )} - closeLabel={t("common.closeButton")} - previousLabel={t("common.previous")} - nextLabel={t("common.next")} - /> - ))} + + + )} + + ), + ) + .otherwise(() => ( + item.id} + activeId={activeTransactionId} + onActiveIdChange={setActiveTransactionId} + onClose={() => setActiveTransactionId(null)} + items={transactions?.edges.map(item => item.node) ?? []} + render={(transaction, large) => ( + + )} + closeLabel={t("common.closeButton")} + previousLabel={t("common.previous")} + nextLabel={t("common.next")} + /> + ))} + + )} + + ), + }), + })} )} diff --git a/clients/banking/src/pages/UpcomingTransactionListPage.tsx b/clients/banking/src/pages/UpcomingTransactionListPage.tsx index 0f48574a6..45b164d62 100644 --- a/clients/banking/src/pages/UpcomingTransactionListPage.tsx +++ b/clients/banking/src/pages/UpcomingTransactionListPage.tsx @@ -1,4 +1,5 @@ import { Option } from "@swan-io/boxed"; +import { useQuery } from "@swan-io/graphql-client"; import { FixedListViewEmpty, PlainListViewPlaceholder, @@ -6,8 +7,8 @@ import { import { FocusTrapRef } from "@swan-io/lake/src/components/FocusTrap"; import { ListRightPanel } from "@swan-io/lake/src/components/ListRightPanel"; import { Pressable } from "@swan-io/lake/src/components/Pressable"; -import { useUrqlPaginatedQuery } from "@swan-io/lake/src/hooks/useUrqlQuery"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Connection } from "../components/Connection"; import { ErrorView } from "../components/ErrorView"; import { TransactionDetail } from "../components/TransactionDetail"; import { TransactionList } from "../components/TransactionList"; @@ -31,28 +32,15 @@ export const UpcomingTransactionListPage = ({ onUpcomingTransactionCountUpdated, canViewAccount, }: Props) => { - const { data, nextData, setAfter } = useUrqlPaginatedQuery( - { - query: UpcomingTransactionListPageDocument, - variables: { - accountId, - first: NUM_TO_RENDER, - canQueryCardOnTransaction, - canViewAccount, - }, - }, - [canQueryCardOnTransaction], - ); + const [data, { isLoading, setVariables }] = useQuery(UpcomingTransactionListPageDocument, { + accountId, + first: NUM_TO_RENDER, + canQueryCardOnTransaction, + canViewAccount, + }); const [activeTransactionId, setActiveTransactionId] = useState(null); - const transactions = data - .toOption() - .flatMap(result => result.toOption()) - .flatMap(data => Option.fromNullable(data.account?.transactions)) - .map(({ edges }) => edges.map(({ node }) => node)) - .getWithDefault([]); - const count = data .toOption() .flatMap(result => result.toOption()) @@ -89,57 +77,65 @@ export const UpcomingTransactionListPage = ({ result.match({ Error: error => , Ok: data => ( - ( - setActiveTransactionId(item.id)} /> - )} - pageSize={NUM_TO_RENDER} - activeRowId={activeTransactionId ?? undefined} - onActiveRowChange={onActiveRowChange} - loading={{ - isLoading: nextData.isLoading(), - count: 2, - }} - onEndReached={() => { - if (data.account?.transactions?.pageInfo.hasNextPage ?? false) { - setAfter(data.account?.transactions?.pageInfo.endCursor ?? undefined); - } - }} - renderEmptyList={() => ( - + + {transactions => ( + <> + ( + setActiveTransactionId(item.id)} /> + )} + pageSize={NUM_TO_RENDER} + activeRowId={activeTransactionId ?? undefined} + onActiveRowChange={onActiveRowChange} + loading={{ + isLoading, + count: 2, + }} + onEndReached={() => { + if (transactions?.pageInfo.hasNextPage ?? false) { + setVariables({ + after: transactions?.pageInfo.endCursor ?? undefined, + }); + } + }} + renderEmptyList={() => ( + + )} + /> + + item.id} + activeId={activeTransactionId} + onActiveIdChange={setActiveTransactionId} + onClose={() => setActiveTransactionId(null)} + items={transactions?.edges.map(item => item.node) ?? []} + render={(transaction, large) => ( + + )} + closeLabel={t("common.closeButton")} + previousLabel={t("common.previous")} + nextLabel={t("common.next")} + /> + )} - /> + ), }), })} - - item.id} - activeId={activeTransactionId} - onActiveIdChange={setActiveTransactionId} - onClose={() => setActiveTransactionId(null)} - items={transactions} - render={(transaction, large) => ( - - )} - closeLabel={t("common.closeButton")} - previousLabel={t("common.previous")} - nextLabel={t("common.next")} - /> ); }; diff --git a/clients/banking/src/utils/exchanges/requestIdExchange.ts b/clients/banking/src/utils/exchanges/requestIdExchange.ts deleted file mode 100644 index 50e7587ed..000000000 --- a/clients/banking/src/utils/exchanges/requestIdExchange.ts +++ /dev/null @@ -1,97 +0,0 @@ -// https://github.com/FormidableLabs/urql/blob/a01563329ceb1c40305d6170a64bd69ac2bb4645/docs/common-questions.md - -import { isNotNullish, isNullish } from "@swan-io/lake/src/utils/nullish"; -import { Exchange, makeOperation } from "@urql/core"; -import { customAlphabet } from "nanoid"; -import { OperationContext } from "urql"; -import { fromValue, map, mergeMap, pipe } from "wonka"; - -const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; -const nanoid = customAlphabet(alphabet, 8); - -const unwrapFetchOptions = (context: OperationContext): RequestInit => - typeof context.fetchOptions === "function" ? context.fetchOptions() : context.fetchOptions ?? {}; - -const unwrapHeaders = (headers?: HeadersInit): Record => { - if (isNullish(headers)) { - return {}; - } - - const isHeaders = headers instanceof Headers; - const isArray = Array.isArray(headers); - - if (!isArray && !isHeaders) { - return headers; - } - - return (isArray ? headers : [...headers.entries()]).reduce((acc, [key, value]) => { - return isNullish(key) || isNullish(value) ? acc : { ...acc, [key]: value }; - }, {}); -}; - -const generateTraceId = () => { - const buffer = new Uint8Array(16); - crypto.getRandomValues(buffer); - return Array.from(buffer, value => value.toString(16).padStart(2, "0")).join(""); -}; - -const generateSpanId = () => { - const buffer = new Uint8Array(8); - crypto.getRandomValues(buffer); - return Array.from(buffer, value => value.toString(16).padStart(2, "0")).join(""); -}; - -const traceparentVersion = "00"; -const traceFlags = "01"; - -export const requestIdExchange: Exchange = - ({ forward }) => - ops$ => { - return pipe( - ops$, - mergeMap(operation => { - const requestId = "req-" + nanoid(); - const fetchOptions = unwrapFetchOptions(operation.context); - const traceparent = `${traceparentVersion}-${generateTraceId()}-${generateSpanId()}-${traceFlags}`; - - const finalOptions = { - ...fetchOptions, - headers: { - ...unwrapHeaders(fetchOptions.headers), - "x-swan-request-id": requestId, - traceparent, - }, - }; - - return pipe( - fromValue(finalOptions), - map(fetchOptions => { - return makeOperation(operation.kind, operation, { - ...operation.context, - fetchOptions, - }); - }), - ); - }), - forward, - mergeMap(result => { - return pipe( - fromValue(result), - map(result => { - // Mutate the fetch output to add back requestId in CombinedError - if (isNotNullish(result.error)) { - const fetchOptions = unwrapFetchOptions(result.operation.context); - const headers = unwrapHeaders(fetchOptions.headers); - const requestId = headers["x-swan-request-id"]; - - if (isNotNullish(requestId)) { - result.error.requestId = requestId; - } - } - - return result; - }), - ); - }), - ); - }; diff --git a/clients/banking/src/utils/exchanges/suspenseDedupExchange.ts b/clients/banking/src/utils/exchanges/suspenseDedupExchange.ts deleted file mode 100644 index 3829cad4e..000000000 --- a/clients/banking/src/utils/exchanges/suspenseDedupExchange.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Exchange, Operation, OperationResult, makeOperation } from "@urql/core"; -import { map, pipe, tap } from "wonka"; - -const RENDER_LEEWAY = 500; - -/** - * Because we use react suspense and network-only request policy, network requests are - * fired twice: one at initial component render: suspense throw a promise, go up - * in the react tree to display a loader, then render the app with data, reexecuting - * the urql useQuery hook. - * - * This might be solve with the future `use` hook: https://github.com/reactjs/rfcs/pull/229 - */ -export const suspenseDedupExchange: Exchange = ({ forward }) => { - const operations = new Map(); - - const processIncomingOperation = (operation: Operation): Operation => { - if (operation.kind !== "query" || operation.context.requestPolicy !== "network-only") { - return operation; - } - - if (new Date().getTime() - (operations.get(operation.key) ?? 0) <= RENDER_LEEWAY) { - return makeOperation(operation.kind, operation, { - ...operation.context, - requestPolicy: "cache-only", - }); - } - - return operation; - }; - - const processIncomingResults = ({ operation }: OperationResult): void => { - if (operation.context.requestPolicy === "network-only") { - operations.set(operation.key, new Date().getTime()); - } - }; - - return ops$ => { - return pipe(forward(pipe(ops$, map(processIncomingOperation))), tap(processIncomingResults)); - }; -}; diff --git a/clients/banking/src/utils/gql.ts b/clients/banking/src/utils/gql.ts new file mode 100644 index 000000000..82e3ebed6 --- /dev/null +++ b/clients/banking/src/utils/gql.ts @@ -0,0 +1,126 @@ +import { Future, Option, Result } from "@swan-io/boxed"; +import { + Client, + ClientError, + InvalidGraphQLResponseError, + MakeRequest, + parseGraphQLError, + print, +} from "@swan-io/graphql-client"; +import { Request, badStatusToError, emptyToError } from "@swan-io/request"; +import { GraphQLError } from "graphql"; +import { P, match } from "ts-pattern"; + +import { isNullish } from "@swan-io/lake/src/utils/nullish"; +import { customAlphabet } from "nanoid"; +import { projectConfiguration } from "./projectId"; +import { Router } from "./routes"; +const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; +const nanoid = customAlphabet(alphabet, 8); + +const generateTraceId = () => { + const buffer = new Uint8Array(16); + crypto.getRandomValues(buffer); + return Array.from(buffer, value => value.toString(16).padStart(2, "0")).join(""); +}; + +const generateSpanId = () => { + const buffer = new Uint8Array(8); + crypto.getRandomValues(buffer); + return Array.from(buffer, value => value.toString(16).padStart(2, "0")).join(""); +}; + +const traceparentVersion = "00"; +const traceFlags = "01"; + +export const errorToRequestId = new WeakMap(); + +const isUnauthorizedResponse = (response: unknown) => { + const value = response as { status?: number; statusCode?: number } | undefined; + return value?.status === 401 || value?.statusCode === 401; +}; + +const isUnauthorizedLikeString = (value: string) => { + const lowerCased = value.toLowerCase(); + return lowerCased.includes("unauthenticated") || lowerCased.includes("unauthorized"); +}; + +export const filterOutUnauthorizedError = (clientError: ClientError) => { + if ( + ClientError.toArray(clientError).some(error => { + return match(error) + .with({ status: 401 }, () => true) + .with({ extensions: { response: P.select() } }, response => + isUnauthorizedResponse(response), + ) + .with({ message: P.select(P.string) }, message => isUnauthorizedLikeString(message)) + .otherwise(() => false); + }) + ) { + // never resolve, this way we never trigger an error screen + return Future.make>(resolve => { + if (isNullish(Router.getRoute(["ProjectLogin"]))) { + window.location.replace(Router.ProjectLogin({ sessionExpired: "true" })); + } else { + resolve(Result.Error(clientError)); + } + }); + } else { + return Future.value(Result.Error(clientError)); + } +}; + +const makeRequest: MakeRequest = ({ url, headers, operationName, document, variables }) => { + const requestId = "req-" + nanoid(); + const traceparent = `${traceparentVersion}-${generateTraceId()}-${generateSpanId()}-${traceFlags}`; + + return Request.make({ + url, + method: "POST", + responseType: "json", + headers: { + ...headers, + "x-swan-request-id": requestId, + traceparent, + }, + body: JSON.stringify({ + operationName, + query: print(document), + variables, + }), + }) + .mapOkToResult(badStatusToError) + .mapOkToResult(emptyToError) + .mapOkToResult(payload => + match(payload as unknown) + .returnType>() + .with({ errors: P.select(P.array()) }, errors => + Result.Error(errors.map(parseGraphQLError)), + ) + .with({ data: P.select(P.not(P.nullish)) }, data => { + return Result.Ok(data); + }) + .otherwise(response => Result.Error(new InvalidGraphQLResponseError(response))), + ) + .flatMapError(filterOutUnauthorizedError) + .tapError(errors => { + ClientError.forEach(errors, error => { + errorToRequestId.set(error, requestId); + }); + }); +}; + +export const partnerClient = new Client({ + url: match(projectConfiguration) + .with( + Option.P.Some({ projectId: P.select(), mode: "MultiProject" }), + projectId => `/api/projects/${projectId}/partner`, + ) + .otherwise(() => `/api/partner`), + makeRequest, +}); + +export const unauthenticatedClient = new Client({ + url: `/api/unauthenticated`, + makeRequest, +}); diff --git a/clients/banking/src/utils/logger.ts b/clients/banking/src/utils/logger.ts index 2986b4604..3043b597d 100644 --- a/clients/banking/src/utils/logger.ts +++ b/clients/banking/src/utils/logger.ts @@ -1,7 +1,6 @@ import { captureException, init } from "@sentry/react"; import { P, match } from "ts-pattern"; import { env } from "./env"; -import { isCombinedError } from "./urql"; export { setUser as setSentryUser } from "@sentry/react"; @@ -24,10 +23,8 @@ export const initSentry = () => { }; export const logFrontendError = (exception: unknown, extra?: Record) => { - if (!isCombinedError(exception)) { - captureException(exception, { - extra, - tags: { scope: "frontend" }, - }); - } + captureException(exception, { + extra, + tags: { scope: "frontend" }, + }); }; diff --git a/clients/banking/src/utils/urql.ts b/clients/banking/src/utils/urql.ts deleted file mode 100644 index c99843f64..000000000 --- a/clients/banking/src/utils/urql.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Option } from "@swan-io/boxed"; -import { isNullish } from "@swan-io/lake/src/utils/nullish"; -import { CombinedError } from "@urql/core"; -import { cacheExchange } from "@urql/exchange-graphcache"; -import { relayPagination } from "@urql/exchange-graphcache/extras"; -import { P, match } from "ts-pattern"; -import { Client, errorExchange, fetchExchange } from "urql"; -import idLessObjects from "../../../../scripts/graphql/dist/partner-idless-objects.json"; -import schema from "../graphql/introspection.json"; -import { GraphCacheConfig } from "../graphql/partner"; -import { requestIdExchange } from "./exchanges/requestIdExchange"; -import { suspenseDedupExchange } from "./exchanges/suspenseDedupExchange"; -import { projectConfiguration } from "./projectId"; -import { Router } from "./routes"; - -export const isCombinedError = (error: unknown): error is CombinedError => - error instanceof CombinedError; - -const isUnauthorizedResponse = (response: unknown) => { - const value = response as { status?: number; statusCode?: number } | undefined; - return value?.status === 401 || value?.statusCode === 401; -}; - -const isUnauthorizedLikeString = (value: string) => { - const lowerCased = value.toLowerCase(); - return lowerCased.includes("unauthenticated") || lowerCased.includes("unauthorized"); -}; - -export const isUnauthorizedError = (error: unknown) => { - if (!isCombinedError(error)) { - return false; - } - - const { graphQLErrors, message } = error; - const response = error.response as unknown; - - return ( - isUnauthorizedResponse(response) || - isUnauthorizedLikeString(message) || - graphQLErrors.some( - ({ extensions, message }) => - isUnauthorizedResponse(extensions.response) || isUnauthorizedLikeString(message), - ) - ); -}; - -const onError = (error: CombinedError) => { - if (isUnauthorizedError(error) && isNullish(Router.getRoute(["ProjectLogin"]))) { - window.location.replace(Router.ProjectLogin({ sessionExpired: "true" })); - } -}; - -const partnerCache = cacheExchange({ - schema: schema as NonNullable, - - keys: { - ...Object.fromEntries(idLessObjects.map(item => [item, (_: unknown) => null])), - ValidIban: ({ iban }) => iban ?? null, - }, - - resolvers: { - Query: { - accounts: relayPagination({ mergeMode: "inwards" }), - accountMemberships: relayPagination({ mergeMode: "inwards" }), - cards: relayPagination({ mergeMode: "inwards" }), - }, - AccountMembership: { - cards: relayPagination({ mergeMode: "inwards" }), - }, - Account: { - invoices: relayPagination({ mergeMode: "inwards" }), - memberships: relayPagination({ mergeMode: "inwards" }), - statements: relayPagination({ mergeMode: "inwards" }), - // TODO Uncomment for transfert section revamp - // standingOrders: relayPagination({ mergeMode: "inwards" }), - transactions: relayPagination({ mergeMode: "inwards" }), - virtualIbanEntries: relayPagination({ mergeMode: "inwards" }), - }, - Card: { - transactions: relayPagination({ mergeMode: "inwards" }), - }, - StandingOrder: { - payments: relayPagination({ mergeMode: "inwards" }), - }, - User: { - accountMemberships: relayPagination({ mergeMode: "inwards" }), - }, - }, -}); - -export const partnerClient = new Client({ - url: match(projectConfiguration) - .with( - Option.P.Some({ projectId: P.select(), mode: "MultiProject" }), - projectId => `/api/projects/${projectId}/partner`, - ) - .otherwise(() => `/api/partner`), - - requestPolicy: "network-only", - suspense: true, - fetchOptions: { credentials: "include" }, - exchanges: [ - suspenseDedupExchange, - partnerCache, - requestIdExchange, - errorExchange({ onError }), - fetchExchange, - ], -}); - -export const unauthenticatedClient = new Client({ - url: "/api/unauthenticated", - requestPolicy: "network-only", - exchanges: [requestIdExchange, errorExchange({ onError }), fetchExchange], -}); diff --git a/scripts/graphql/codegen.ts b/scripts/graphql/codegen.ts index 95b3f6bd1..a8551db37 100644 --- a/scripts/graphql/codegen.ts +++ b/scripts/graphql/codegen.ts @@ -113,7 +113,7 @@ const config: CodegenConfig = { [file("../../clients/banking/src/graphql/partner.ts")]: { documents: file("../../clients/banking/src/graphql/partner.gql"), schema: file("./dist/partner-schema.gql"), - plugins: frontendPlugins, + plugins: frontendPlugins.filter(item => item !== "typescript-urql-graphcache"), config: frontendConfig, documentTransforms: [{ transform: addTypenames }], }, @@ -122,19 +122,10 @@ const config: CodegenConfig = { documents: file("../../clients/banking/src/graphql/unauthenticated.gql"), schema: file("./dist/unauthenticated-schema.gql"), config: frontendConfig, - plugins: frontendPlugins, + plugins: frontendPlugins.filter(item => item !== "typescript-urql-graphcache"), documentTransforms: [{ transform: addTypenames }], }, - [file("../../clients/banking/src/graphql/introspection.json")]: { - schema: file("./dist/partner-schema.gql"), - plugins: ["introspection"], - config: { descriptions: false }, - hooks: { - afterOneFileWrite: "yarn tsx scripts/graphql/cleanIntrospectionSchema.ts", - }, - }, - [file("../../server/src/graphql/partner.ts")]: { documents: file("../../server/src/graphql/partner.gql"), schema: file("./dist/partner-schema.gql"), diff --git a/yarn.lock b/yarn.lock index 7e2cd79ba..c8fb19b58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@0no-co/graphql.web@1.0.6", "@0no-co/graphql.web@^1.0.5", "@0no-co/graphql.web@^1.0.6": +"@0no-co/graphql.web@^1.0.5", "@0no-co/graphql.web@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@0no-co/graphql.web/-/graphql.web-1.0.6.tgz#3def68bbaf654a301bd910ce3744506cad97ab9a" integrity sha512-KZ7TnwMcQJcFgzjoY623AVxtlDQonkqp3rSz0wb15/jHPyU1v5gynUibEpuutDeoyGJ5Tp+FwxjGyDGDwq3vIw== @@ -2270,6 +2270,16 @@ "@swan-io/request" "^1.0.4" ts-pattern "^5.1.0" +"@swan-io/graphql-client@0.1.0-beta4": + version "0.1.0-beta4" + resolved "https://registry.yarnpkg.com/@swan-io/graphql-client/-/graphql-client-0.1.0-beta4.tgz#55ac53cda82a087f9c676a7f3393e9b5c0a28960" + integrity sha512-MTMsFw45AxKXfNeMowTe8WVtpxo4yIOYye8NoA3VjL8u7TXOGzrMix25BiQroyl6bS2fGvm6Z/Xwa2ManRxCsQ== + dependencies: + "@0no-co/graphql.web" "^1.0.6" + "@swan-io/boxed" "^2.1.1" + "@swan-io/request" "^1.0.4" + ts-pattern "^5.1.0" + "@swan-io/lake@7.3.3": version "7.3.3" resolved "https://registry.yarnpkg.com/@swan-io/lake/-/lake-7.3.3.tgz#6a6ec7acf9c33badb570711ad20760cc3bd92a42" @@ -2757,7 +2767,7 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@urql/core@>=5.0.0", "@urql/core@^5.0.0": +"@urql/core@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@urql/core/-/core-5.0.0.tgz#690e664cf66f733077c558bf685adbdac2b25823" integrity sha512-kFkZxusq/VBQKEUcQFtf7AilMotLO+oGpE4WFhCiminZm8ZU2aulXSDWla50TaD0pj704FnWlXts6lRm0uHdDg== @@ -2765,22 +2775,6 @@ "@0no-co/graphql.web" "^1.0.5" wonka "^6.3.2" -"@urql/devtools@2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@urql/devtools/-/devtools-2.0.3.tgz#780998c37386c72af9402a8f88c1b388e62f28cd" - integrity sha512-TktPLiBS9LcBPHD6qcnb8wqOVcg3Bx0iCtvQ80uPpfofwwBGJmqnQTjUdEFU6kwaLOFZULQ9+Uo4831G823mQw== - dependencies: - wonka ">= 4.0.9" - -"@urql/exchange-graphcache@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@urql/exchange-graphcache/-/exchange-graphcache-7.0.0.tgz#3130874005d298909d64710b95365d987c08aea7" - integrity sha512-xKk+MVt6ZKokKcrreK09n4s04dPwzu2BF/rHWvV0Aun7BhlLU4Kzmvsq1sTMZArUxwB4aaKhI0StMbPMfxICSg== - dependencies: - "@0no-co/graphql.web" "^1.0.5" - "@urql/core" ">=5.0.0" - wonka "^6.3.2" - "@urql/introspection@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@urql/introspection/-/introspection-1.0.3.tgz#e35ffe8aa03e91df7472b07d7d730d8bb029ec10" @@ -8375,7 +8369,7 @@ urlpattern-polyfill@^8.0.0: resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz#99f096e35eff8bf4b5a2aa7d58a1523d6ebc7ce5" integrity sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ== -urql@4.0.7, urql@^4.0.7: +urql@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/urql/-/urql-4.0.7.tgz#7c2c7eecce3bf0d2bd2d5be372515bf6dd2f13c2" integrity sha512-wnOONtZoYEobmamM5ushUBGil6UCUPd7SH5uEAqsz5Y/qBV88/2QG6jq7v6xP+413x5Lqy0h0hCGRB0KIeG6Kg== @@ -8642,7 +8636,7 @@ why-is-node-running@^2.2.2: siginfo "^2.0.0" stackback "0.0.2" -wonka@6.3.4, "wonka@>= 4.0.9", wonka@^6.3.2: +wonka@^6.3.2: version "6.3.4" resolved "https://registry.yarnpkg.com/wonka/-/wonka-6.3.4.tgz#76eb9316e3d67d7febf4945202b5bdb2db534594" integrity sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==