diff --git a/.circleci/config.yml b/.circleci/config.yml index 63368fb3e..3dc5fdf4d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -257,7 +257,6 @@ workflows: branches: only: - dev - - MP-356_member-stats-and-history - deployQa: context: org-global diff --git a/.editorconfig b/.editorconfig index c1e2c6435..1aa1cc721 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,4 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +quote_type = single diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..86c23c727 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "semi": false +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..5cf3fb350 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "prettier.requireConfig": true, + "editor.formatOnSave": false +} diff --git a/craco.config.js b/craco.config.js index 8f8e6c8c3..1e2d52d01 100644 --- a/craco.config.js +++ b/craco.config.js @@ -43,6 +43,7 @@ module.exports = { '@gamificationAdmin': resolve('src/apps/gamification-admin/src'), '@talentSearch': resolve('src/apps/talent-search/src'), '@profiles': resolve('src/apps/profiles/src'), + '@wallet': resolve('src/apps/wallet/src'), '@platform': resolve('src/apps/platform/src'), // aliases used in SCSS files diff --git a/package.json b/package.json index bde1c9251..9fdc1ddf8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@storybook/react": "^7.0.5", "@stripe/react-stripe-js": "1.13.0", "@stripe/stripe-js": "1.41.0", + "@tanstack/react-table": "^8.11.7", "@types/testing-library__jest-dom": "^5.14.5", "apexcharts": "^3.36.0", "axios": "^1.1.2", @@ -85,6 +86,7 @@ "react-helmet": "^6.1.0", "react-html-parser": "^2.0.2", "react-markdown": "8.0.6", + "react-otp-input": "^3.1.1", "react-popper": "^2.3.0", "react-redux": "^8.0.4", "react-redux-toastr": "^7.6.10", diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index eb69e5806..6dd61530c 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -9,8 +9,12 @@ import { profilesRoutes } from '~/apps/profiles' import { talentSearchRoutes } from '~/apps/talent-search' import { accountsRoutes } from '~/apps/accounts' import { onboardingRoutes } from '~/apps/onboarding' +import { walletRoutes } from '~/apps/wallet' -const Home: LazyLoadedComponent = lazyLoad(() => import('./routes/home'), 'HomePage') +const Home: LazyLoadedComponent = lazyLoad( + () => import('./routes/home'), + 'HomePage', +) const homeRoutes: ReadonlyArray = [ { @@ -32,6 +36,7 @@ export const platformRoutes: Array = [ ...gamificationAdminRoutes, ...talentSearchRoutes, ...profilesRoutes, + ...walletRoutes, ...accountsRoutes, ...homeRoutes, ] diff --git a/src/apps/wallet/.prettierrc b/src/apps/wallet/.prettierrc new file mode 100644 index 000000000..35288c5c7 --- /dev/null +++ b/src/apps/wallet/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "trailingComma": "all", + "jsxSingleQuote": true, + "jsxBracketSameLine": true, + "printWidth": 120 +} diff --git a/src/apps/wallet/README.md b/src/apps/wallet/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/wallet/index.tsx b/src/apps/wallet/index.tsx new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/wallet/index.tsx @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/wallet/src/WalletApp.tsx b/src/apps/wallet/src/WalletApp.tsx new file mode 100644 index 000000000..aa9aaad7b --- /dev/null +++ b/src/apps/wallet/src/WalletApp.tsx @@ -0,0 +1,20 @@ +import { FC, useContext } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { toolTitle } from './wallet.routes' +import { WalletSwr } from './lib' + +const AccountsApp: FC<{}> = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + + return ( + + + {getChildRoutes(toolTitle)} + + ) +} + +export default AccountsApp diff --git a/src/apps/wallet/src/home/WalletHomePage.tsx b/src/apps/wallet/src/home/WalletHomePage.tsx new file mode 100644 index 000000000..0fafad41b --- /dev/null +++ b/src/apps/wallet/src/home/WalletHomePage.tsx @@ -0,0 +1,19 @@ +import { FC, useContext } from 'react' + +import { profileContext, ProfileContextData } from '~/libs/core' +import { LoadingSpinner } from '~/libs/ui' + +import { WalletLayout } from './page-layout' + +const AccountSettingsPage: FC<{}> = () => { + const { profile, initialized }: ProfileContextData = useContext(profileContext) + + return ( + <> + + {initialized && profile && } + + ) +} + +export default AccountSettingsPage diff --git a/src/apps/wallet/src/home/index.ts b/src/apps/wallet/src/home/index.ts new file mode 100644 index 000000000..f3370c004 --- /dev/null +++ b/src/apps/wallet/src/home/index.ts @@ -0,0 +1 @@ +export { default as WalletHomePage } from './WalletHomePage' diff --git a/src/apps/wallet/src/home/page-layout/WalletLayout.module.scss b/src/apps/wallet/src/home/page-layout/WalletLayout.module.scss new file mode 100644 index 000000000..6ff4b2df4 --- /dev/null +++ b/src/apps/wallet/src/home/page-layout/WalletLayout.module.scss @@ -0,0 +1,5 @@ +@import '@libs/ui/styles/includes'; + +.contentLayoutOuter { + margin: $sp-8 auto !important; +} \ No newline at end of file diff --git a/src/apps/wallet/src/home/page-layout/WalletLayout.tsx b/src/apps/wallet/src/home/page-layout/WalletLayout.tsx new file mode 100644 index 000000000..b7cdb2703 --- /dev/null +++ b/src/apps/wallet/src/home/page-layout/WalletLayout.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react' + +import { UserProfile } from '~/libs/core' +import { ContentLayout } from '~/libs/ui' + +import { WalletTabs } from '../tabs' + +import styles from './WalletLayout.module.scss' + +interface WalletHomeLayoutProps { + profile: UserProfile +} + +const WalletLayout: FC = (props: WalletHomeLayoutProps) => ( + + + +) + +export default WalletLayout diff --git a/src/apps/wallet/src/home/page-layout/index.ts b/src/apps/wallet/src/home/page-layout/index.ts new file mode 100644 index 000000000..df2160a7c --- /dev/null +++ b/src/apps/wallet/src/home/page-layout/index.ts @@ -0,0 +1 @@ +export { default as WalletLayout } from './WalletLayout' diff --git a/src/apps/wallet/src/home/tabs/WalletTabs.module.scss b/src/apps/wallet/src/home/tabs/WalletTabs.module.scss new file mode 100644 index 000000000..30e3c9b0c --- /dev/null +++ b/src/apps/wallet/src/home/tabs/WalletTabs.module.scss @@ -0,0 +1,11 @@ +@import '@libs/ui/styles/includes'; + +.container { + form { + @include ltelg { + :global(.input-el) { + margin-bottom: $sp-4; + } + } + } +} diff --git a/src/apps/wallet/src/home/tabs/WalletTabs.tsx b/src/apps/wallet/src/home/tabs/WalletTabs.tsx new file mode 100644 index 000000000..17b0db0bb --- /dev/null +++ b/src/apps/wallet/src/home/tabs/WalletTabs.tsx @@ -0,0 +1,55 @@ +import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' + +import { UserProfile } from '~/libs/core' +import { PageTitle, TabsNavbar, TabsNavItem } from '~/libs/ui' + +import { getHashFromTabId, getTabIdFromHash, WalletTabsConfig, WalletTabViews } from './config' +import { PaymentsTab } from './payments' +import { WinningsTab } from './winnings' +import { HomeTab } from './home' +import { TaxFormsTab } from './tax-forms' +import styles from './WalletTabs.module.scss' + +interface WalletHomeProps { + profile: UserProfile +} + +const WalletTabs: FC = (props: WalletHomeProps) => { + const { hash }: { hash: string } = useLocation() + + const activeTabHash: string = useMemo(() => getTabIdFromHash(hash), [hash]) + + const [activeTab, setActiveTab]: [string, Dispatch>] = useState(activeTabHash) + + useEffect(() => { + setActiveTab(activeTabHash) + }, [activeTabHash]) + + function handleTabChange(tabId: string): void { + setActiveTab(tabId) + window.location.hash = getHashFromTabId(tabId) + } + + return ( +
+ + + + {[WalletTabsConfig.find((tab: TabsNavItem) => tab.id === activeTab)?.title, 'Wallet', 'Topcoder'].join( + ' | ', + )} + + + {activeTab === WalletTabViews.withdrawalmethods && } + + {activeTab === WalletTabViews.winnings && } + + {activeTab === WalletTabViews.home && } + + {activeTab === WalletTabViews.taxforms && } +
+ ) +} + +export default WalletTabs diff --git a/src/apps/wallet/src/home/tabs/config/index.ts b/src/apps/wallet/src/home/tabs/config/index.ts new file mode 100644 index 000000000..329aa026f --- /dev/null +++ b/src/apps/wallet/src/home/tabs/config/index.ts @@ -0,0 +1 @@ +export * from './wallet-tabs-config' diff --git a/src/apps/wallet/src/home/tabs/config/wallet-tabs-config.ts b/src/apps/wallet/src/home/tabs/config/wallet-tabs-config.ts new file mode 100644 index 000000000..4a31f3f23 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/config/wallet-tabs-config.ts @@ -0,0 +1,55 @@ +import { TabsNavItem } from '~/libs/ui' + +export enum WalletTabViews { + home = '0', + winnings = '1', + taxforms = '2', + withdrawalmethods = '3', +} + +export const WalletTabsConfig: TabsNavItem[] = [ + { + id: WalletTabViews.home, + title: 'Wallet', + }, + { + id: WalletTabViews.winnings, + title: 'Winnings', + }, + { + id: WalletTabViews.withdrawalmethods, + title: 'Withdrawal Methods', + }, + { + id: WalletTabViews.taxforms, + title: 'Tax Forms', + }, +] + +export function getHashFromTabId(tabId: string): string { + switch (tabId) { + case WalletTabViews.home: + return '#home' + case WalletTabViews.winnings: + return '#winnings' + case WalletTabViews.taxforms: + return '#tax-forms' + case WalletTabViews.withdrawalmethods: + return '#withdrawal-methods' + default: + return '#home' + } +} + +export function getTabIdFromHash(hash: string): string { + switch (hash) { + case '#winnings': + return WalletTabViews.winnings + case '#tax-forms': + return WalletTabViews.taxforms + case '#withdrawal-methods': + return WalletTabViews.withdrawalmethods + default: + return WalletTabViews.home + } +} diff --git a/src/apps/wallet/src/home/tabs/home/Home.module.scss b/src/apps/wallet/src/home/tabs/home/Home.module.scss new file mode 100644 index 000000000..b58cd7ece --- /dev/null +++ b/src/apps/wallet/src/home/tabs/home/Home.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.container { + background-color: $black-5; + padding: 100px 32px; + margin: $sp-8 0; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 50px; + box-sizing: border-box; +} + +.banner { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + & > * { + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; + } +} + +@media (max-width: 768px) { + .banner { + flex-direction: column; + } +} + +.info-row-container { + display: flex; + flex-direction: column; + gap: 20px; + padding-left: 50px; + padding-right: 50px; +} diff --git a/src/apps/wallet/src/home/tabs/home/HomeTab.tsx b/src/apps/wallet/src/home/tabs/home/HomeTab.tsx new file mode 100644 index 000000000..e37eefb93 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/home/HomeTab.tsx @@ -0,0 +1,122 @@ +/* eslint-disable react/jsx-wrap-multilines */ +import { FC, useEffect, useState } from 'react' + +import { UserProfile } from '~/libs/core' +import { IconOutline, LinkButton, LoadingCircles } from '~/libs/ui' + +import { Balance, WalletDetails } from '../../../lib/models/WalletDetails' +import { getWalletDetails } from '../../../lib/services/wallet' +import { InfoRow } from '../../../lib' +import { BannerImage, BannerText } from '../../../lib/assets/home' +import Chip from '../../../lib/components/chip/Chip' + +import styles from './Home.module.scss' + +interface HomeTabProps { + profile: UserProfile +} + +const HomeTab: FC = () => { + const [walletDetails, setWalletDetails] = useState(undefined) + const [isLoading, setIsLoading] = useState(false) + const [balanceSum, setBalanceSum] = useState(0) + const [error, setError] = useState(undefined) + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const fetchWalletDetails = async () => { + setIsLoading(true) + try { + const details = await getWalletDetails() + setWalletDetails(details) + } catch (apiError) { + setError('Error fetching wallet details') + } + + setIsLoading(false) + } + + fetchWalletDetails() + }, []) + + useEffect(() => { + if (walletDetails) { + setBalanceSum( + walletDetails.account.balances.reduce((sum: number, balance: Balance) => sum + balance.amount, 0), + ) + } + }, [walletDetails]) + + if (error) { + return
{error}
+ } + + return ( +
+
+ + +
+ {isLoading && } + {!isLoading && ( +
+ + } + /> + {!walletDetails?.withdrawalMethod.isSetupComplete && ( + + ) + } + action={ + + } + /> + )} + + {!walletDetails?.taxForm.isSetupComplete && ( + } + action={ + + } + /> + )} +
+ )} +
+ ) +} + +export default HomeTab diff --git a/src/apps/wallet/src/home/tabs/home/index.ts b/src/apps/wallet/src/home/tabs/home/index.ts new file mode 100644 index 000000000..fff571480 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/home/index.ts @@ -0,0 +1 @@ +export { default as HomeTab } from './HomeTab' diff --git a/src/apps/wallet/src/home/tabs/index.ts b/src/apps/wallet/src/home/tabs/index.ts new file mode 100644 index 000000000..40e188685 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/index.ts @@ -0,0 +1 @@ +export { default as WalletTabs } from './WalletTabs' diff --git a/src/apps/wallet/src/home/tabs/payments/PaymentsTab.module.scss b/src/apps/wallet/src/home/tabs/payments/PaymentsTab.module.scss new file mode 100644 index 000000000..c6d8d6f81 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/payments/PaymentsTab.module.scss @@ -0,0 +1,125 @@ +@import '@libs/ui/styles/includes'; + +.container { + background-color: $black-5; + padding: $sp-6; + margin: $sp-8 0; + border-radius: 6px; + + @include ltelg { + padding: $sp-4; + } + + .paymentsHeader { + display: flex; + justify-content: flex-start; + gap: 5px; + align-items: center; + + @include ltelg { + flex-direction: column; + } + + .managePaymentsLink { + font-weight: $font-weight-bold; + color: $turq-160; + display: flex; + align-items: center; + + @include ltelg { + margin-top: $sp-4; + } + + svg { + margin-left: $sp-2; + max-width: 100%; + } + } + } + + .content { + background-color: $tc-white; + border-radius: 4px; + margin-top: $sp-8; + + .confirmSelectionReset { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: $sp-8; + + @include ltelg { + flex-direction: column; + align-items: flex-start; + + button { + margin-top: $sp-4; + } + } + } + + .providerContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: $sp-8; + margin-bottom: $sp-4; + + .alternateProviderButton { + margin-top: $sp-4; + padding-left: 0px !important; + padding-right: 0px !important; + } + } + + .providersSingleRow { + margin-top: $sp-4; + display: grid; + grid-template-columns: repeat(1, 1fr); + width: 100%; + } + + .providersStacked { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(1, 1fr); + margin-top: 24px; + + @media (min-width: '768px') { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: auto auto; + } + } + + .providerSubmitted { + margin-top: $sp-8; + + @include ltelg { + flex-direction: column; + } + + .providerSubmittedIcon { + background: linear-gradient(264.69deg, #198807 2.17%, #017c6d 97.49%); + padding: $sp-4; + border-radius: 4px; + width: 64px; + height: 64px; + color: $tc-white; + margin-right: $sp-4; + + @include ltelg { + margin-bottom: $sp-4; + } + } + + button { + align-self: center; + + @include ltelg { + align-self: flex-start; + margin-top: $sp-4; + } + } + } + } +} diff --git a/src/apps/wallet/src/home/tabs/payments/PaymentsTab.tsx b/src/apps/wallet/src/home/tabs/payments/PaymentsTab.tsx new file mode 100644 index 000000000..dbb9edde5 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/payments/PaymentsTab.tsx @@ -0,0 +1,347 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable react/jsx-no-bind */ +import { FC, useEffect, useState } from 'react' +import { toast } from 'react-toastify' + +import { Button, Collapsible, LoadingCircles } from '~/libs/ui' +import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid' + +import { Chip, IconDollar, IconSpeed, IconWorld, PayoneerLogo, PayPalLogo } from '../../../lib' +import { PaymentProvider } from '../../../lib/models/PaymentProvider' +import { PaymentProviderCard } from '../../../lib/components/payment-provider-card' +import { OtpModal } from '../../../lib/components/otp-modal' +import { TransactionResponse } from '../../../lib/models/TransactionId' +import { + confirmPaymentProvider, + getPaymentProviderRegistrationLink, + getUserPaymentProviders, removePaymentProvider, resendOtp, setPaymentProvider, +} from '../../../lib/services/wallet' + +import { PaymentInfoModal } from './payment-info-modal' +import styles from './PaymentsTab.module.scss' + +const PAYMENT_PROVIDER_DETAILS = { + Payoneer: { + details: [ + { + icon: , + label: 'FEES', + value: '$0-$3 + Currency Conversion Rates May Apply', + }, + { + icon: , + label: 'COUNTRIES', + value: 'Available in 150+ countries', + }, + { + icon: , + label: 'SPEED', + value: 'Up to 1 Business Day', + }, + ], + logo: , + }, + Paypal: { + details: [ + { + icon: , + label: 'FEES', + value: '3.49% + an international fee (non US) + a fixed fee depending upon currency', + }, + { + icon: , + label: 'COUNTRIES', + value: 'Available in 200+ countries', + }, + { + icon: , + label: 'SPEED', + value: 'Up to 1 Business Day', + }, + ], + logo: , + }, +} + +const PaymentsTab: FC = () => { + const [selectedPaymentProvider, setSelectedPaymentProvider] = useState(undefined) + const [setupRequired, setSetupRequired] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [showAlternateProvider, setShowAlternateProvider] = useState(false) + + const [paymentInfoModalFlow, setPaymentInfoModalFlow] = useState(undefined) + const [otpFlow, setOtpFlow] = useState(undefined) + + const fetchPaymentProviders = async (refresh: boolean = true) => { + setIsLoading(refresh) + + try { + const providers = await getUserPaymentProviders() + if (providers.length === 0) { + setSetupRequired(true) + } else { + setSetupRequired(false) + setSelectedPaymentProvider(providers[0]) + } + + } catch (apiError) { + setSelectedPaymentProvider(undefined) + } + + setIsLoading(false) + } + + useEffect(() => { + fetchPaymentProviders() + }, []) + + useEffect(() => { + if (selectedPaymentProvider?.status === 'OTP_VERIFIED') { + const queryParams = new URLSearchParams(window.location.search) + const code = queryParams.get('code') + + if (code) { + if (selectedPaymentProvider.type === 'Paypal' && selectedPaymentProvider.transactionId) { + confirmPaymentProvider('Paypal', code, selectedPaymentProvider.transactionId) + .then((response: any) => { + fetchPaymentProviders() + toast.success( + response.message ?? 'Payment provider added successfully.', + { position: toast.POSITION.BOTTOM_RIGHT }, + ) + }) + .catch((err: any) => { + toast.error( + err.message ?? 'Something went wrong. Please try again.', + { position: toast.POSITION.BOTTOM_RIGHT }, + ) + }) + } + } + } + }, [selectedPaymentProvider?.status, selectedPaymentProvider?.type, selectedPaymentProvider?.transactionId]) + + function renderProviders(): JSX.Element { + return ( +
+ + +
+ ) + } + + function renderConnectedProvider(): JSX.Element | undefined { + if (selectedPaymentProvider === undefined) return undefined + + return ( +
+

Chosen Payment Provider

+
+ +
+
+ ) + } + + return ( +
+
+

WITHDRAWAL METHODS

+ {!isLoading && setupRequired && } +
+ +
+ PAYMENT PROVIDER}> +

+ Topcoder is partnered with several payment providers to send payments to our community members. + Once a provider is set up, payments will be routed to your selected payment provider at the + completion of work. Currently, members can be paid through one of the following providers: + Payoneer® or PayPal®. +

+ + {isLoading && } + + {!isLoading && selectedPaymentProvider === undefined && renderProviders()} + {!isLoading && selectedPaymentProvider !== undefined && renderConnectedProvider()} + +

+ Provider details are based on the latest information from their official sites; we advise + confirming the current terms directly before finalizing your payment option. +

+
+
+ + {paymentInfoModalFlow && ( + { + setOtpFlow({ + ...response, + type: 'SETUP_PAYMENT_PROVIDER', + }) + fetchPaymentProviders(false) + }) + .catch((err: Error) => { + toast.error( + err.message ?? 'Something went wrong. Please try again.', + { position: toast.POSITION.BOTTOM_RIGHT }, + ) + }) + }} + /> + )} + {otpFlow && ( + + )} +
+ ) +} + +export default PaymentsTab diff --git a/src/apps/wallet/src/home/tabs/payments/index.ts b/src/apps/wallet/src/home/tabs/payments/index.ts new file mode 100644 index 000000000..8dc41bc69 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/payments/index.ts @@ -0,0 +1 @@ +export { default as PaymentsTab } from './PaymentsTab' diff --git a/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.module.scss b/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.module.scss new file mode 100644 index 000000000..3917b7e01 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.module.scss @@ -0,0 +1,13 @@ +@import '@libs/ui/styles/includes'; + +.infoModal { + :global(.react-responsive-modal-closeButton) { + display: flex; + } + + .modalContent { + display: flex; + flex-direction: column; + gap: 25px; + } +} diff --git a/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.tsx b/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.tsx new file mode 100644 index 000000000..cdb6df8be --- /dev/null +++ b/src/apps/wallet/src/home/tabs/payments/payment-info-modal/PaymentInfoModal.tsx @@ -0,0 +1,85 @@ +/* eslint-disable react/jsx-wrap-multilines */ +/* eslint-disable react/jsx-no-bind */ +import { FC } from 'react' + +import { BaseModal, Button, IconOutline, LinkButton } from '~/libs/ui' + +import { PayoneerLogo, PayPalLogo } from '../../../../lib' + +import styles from './PaymentInfoModal.module.scss' + +interface PaymentInfoModalProps { + selectedPaymentProvider: string + handlePaymentSelection: (provider: string) => void + handleModalClose: () => void +} + +function renderPayoneer(): JSX.Element { + return ( + <> + +

+ You can elect to receive payments through Payoneer either to your Payoneer prepaid MasterCard or by + using their Global Bank Transfer service. The Payoneer Bank Transfer Service offers a local bank + transfer option (where available) and a wire transfer option. Certain fees may apply. +

+

+ You will be directed to Payoneer's website in a new tab to complete your connection. Please make + sure your account is fully verified to ensure withdrawal success. + + You can return here after finishing up on Payoneer's site. + +

+ + ) +} + +function renderPaypal(): JSX.Element { + return ( + <> + +

You can elect to receive payments deposited directly to your PayPal account. Certain fees may apply.

+

+ You will be directed to PayPal's website in a new tab to complete your connection. Please make + sure your account is fully verified to ensure withdrawal success. + {' '} + + You can return here after finishing up + on PayPal's site. + +

+ + ) +} + +const PaymentInfoModal: FC = (props: PaymentInfoModalProps) => ( + + + + + + ) +} + +export default PaymentsTable diff --git a/src/apps/wallet/src/lib/components/setting-section/SettingSection.module.scss b/src/apps/wallet/src/lib/components/setting-section/SettingSection.module.scss new file mode 100644 index 000000000..dd4087c74 --- /dev/null +++ b/src/apps/wallet/src/lib/components/setting-section/SettingSection.module.scss @@ -0,0 +1,26 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + padding: $sp-4; + border: 1px solid $black-20; + border-radius: 8px; + margin-top: $sp-4; + flex-wrap: wrap; + + .contentMiddle { + display: flex; + flex-direction: column; + flex: 1; + align-self: center; + + @include ltelg { + margin-right: $sp-4; + } + + .infoText { + color: #767676; + max-width: 85%; + } + } +} \ No newline at end of file diff --git a/src/apps/wallet/src/lib/components/setting-section/SettingSection.tsx b/src/apps/wallet/src/lib/components/setting-section/SettingSection.tsx new file mode 100644 index 000000000..380286fdf --- /dev/null +++ b/src/apps/wallet/src/lib/components/setting-section/SettingSection.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import styles from './SettingSection.module.scss' + +interface SettingSectionProps { + containerClassName?: string + readonly title: string + readonly infoText?: string + actionElement?: React.ReactNode + leftElement?: React.ReactNode +} + +const SettingSection: FC = (props: SettingSectionProps) => ( +
+ {props.leftElement} + +
+

{props.title}

+

+

+ + {props.actionElement} +
+) + +export default SettingSection diff --git a/src/apps/wallet/src/lib/components/setting-section/index.ts b/src/apps/wallet/src/lib/components/setting-section/index.ts new file mode 100644 index 000000000..a2a2fec21 --- /dev/null +++ b/src/apps/wallet/src/lib/components/setting-section/index.ts @@ -0,0 +1 @@ +export { default as SettingSection } from './SettingSection' diff --git a/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.module.scss b/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.module.scss new file mode 100644 index 000000000..46c3eb47d --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.module.scss @@ -0,0 +1,46 @@ +@import '@libs/ui/styles/includes'; + +.card { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + background-color: white; + padding: 16px; + border-radius: 8px; + border: 1px solid #eaeaea; + margin: 16px 0; + + .header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 24px; + + .icon { + max-width: 48px; + max-height: 48px; + } + } + + .content { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; + + .additionalInfoPurpose { + ul { + list-style-type: disc; + padding: 10px 24px; + } + } + } + + .footer { + display: flex; + width: 100%; + justify-content: space-between; + margin-top: 24px; + } +} diff --git a/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.tsx b/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.tsx new file mode 100644 index 000000000..3a8892142 --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-card/TaxFormCard.tsx @@ -0,0 +1,83 @@ +import React from 'react' + +import { Button, PageDivider } from '~/libs/ui' + +import styles from './TaxFormCard.module.scss' + +interface TaxFormCardProps { + formTitle: string + formDescription: string + reasonTitle: string + reasonDescription: string + instructionsLink: string + instructionsLabel: string + completionLabel: string + additionalInfo?: { + link?: { + text: string + href: string + } + purpose?: { + title: string + points: string[] + } + note?: string + } + icon: React.ReactNode + onSetupClick: () => void +} + +const TaxFormCard: React.FC = (props: TaxFormCardProps) => ( +
+
+
{props.icon}
+

{props.formTitle}

+
+ + + +
+
{props.formDescription}
+ +

{props.reasonTitle}

+
{props.reasonDescription}
+ + {props.additionalInfo?.link && ( + + )} + + {props.additionalInfo?.purpose && ( +
+

{props.additionalInfo.purpose.title}

+
    + {props.additionalInfo.purpose.points.map((point: string) => ( +
  • {point}
  • + ))} +
+
+ )} + + {props.additionalInfo?.note && ( +
+
{props.additionalInfo.note}
+
+ )} +
+ +
+
+
+) + +export default TaxFormCard diff --git a/src/apps/wallet/src/lib/components/tax-form-card/index.ts b/src/apps/wallet/src/lib/components/tax-form-card/index.ts new file mode 100644 index 000000000..4b7cccbab --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-card/index.ts @@ -0,0 +1 @@ +export { default as TaxFormCard } from './TaxFormCard' diff --git a/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.module.scss b/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.module.scss new file mode 100644 index 000000000..bf64b99b9 --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.module.scss @@ -0,0 +1,92 @@ +@import '@libs/ui/styles/includes'; + +.card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: white; + padding: 16px; + border-radius: 8px; + gap: $sp-4; + border: 1px solid #eaeaea; + margin: 16px 0; + + .iconContainer { + width: 64px; + height: 64px; + display: flex; + justify-content: center; + align-items: center; + + background: linear-gradient(264.69deg, #198807 2.17%, #017c6d 97.49%); + border-radius: 4px; + + .icon { + width: 26.67px; + height: 26.67px; + top: 2.67px; + left: 2.67px; + fill: white; + } + } + + .content { + display: flex; + flex-direction: column; + flex: 1; + } + + .actionItems { + display: flex; + align-items: center; + width: 64px; + justify-content: space-between; + + .actionButton { + width: 32px; + height: 32px; + border: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + } + + .downloadIcon { + width: 24px; + height: 24px; + fill: $turq-160; + } + + .deleteIcon { + width: 24px; + height: 24px; + fill: $red-140; + } + } + + .actionItemsStacked { + display: flex; + flex-direction: column; + align-items: flex-end; + + .warningLabel { + color: $legacy-120; + } + } + + .actionButton, + .loadingWrapper { + transition: opacity 0.3s ease, transform 0.3s ease; + opacity: 1; + transform: scale(1); + } + + .hidden { + opacity: 0; + display: none; + transform: scale(0.95); + pointer-events: none; + } +} diff --git a/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.tsx b/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.tsx new file mode 100644 index 000000000..74d0673dd --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-detail/TaxFormDetail.tsx @@ -0,0 +1,140 @@ +import React, { useMemo } from 'react' + +import { DownloadIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/react/solid' +import { ConfirmModal, IconSolid } from '~/libs/ui' + +import { IconCheckCircle } from '../../assets/tax-forms' +import { ActionBarItem } from '../action-bar-item' +import { ConfirmFlowData } from '../../models/ConfirmFlowData' + +import styles from './TaxFormDetail.module.scss' + +interface TaxFormDetailProps { + title: string + description: string + status: string + onGetRecipientURL?: () => void + onResendOtpClick?: () => void + onDownloadClick?: () => void + onDeleteClick?: () => void +} + +const TaxFormDetail: React.FC = (props: TaxFormDetailProps) => { + const [confirmFlow, setConfirmFlow] = React.useState(undefined) + + const renderConfirmModalContent = useMemo(() => { + if (confirmFlow?.content === undefined) { + return undefined + } + + if (typeof confirmFlow?.content === 'function') { + return confirmFlow?.content() + } + + return confirmFlow?.content + }, [confirmFlow]) + + const renderOtpPending = (): JSX.Element => ( + + ) + + const renderOtpVerified = (): JSX.Element => ( + + ) + + const renderActive = (): JSX.Element => ( +
+ + +
+ ) + + return ( + <> +
+
+ {props.status === 'ACTIVE' && } + {props.status !== 'ACTIVE' && } +
+ +
+
{props.title}
+
{props.description}
+
+ + {props.status === 'OTP_PENDING' && renderOtpPending()} + {props.status === 'OTP_VERIFIED' && renderOtpVerified()} + {props.status === 'ACTIVE' && renderActive()} +
+ {confirmFlow && ( + +
{renderConfirmModalContent}
+
+ )} + + ) +} + +export default TaxFormDetail diff --git a/src/apps/wallet/src/lib/components/tax-form-detail/index.ts b/src/apps/wallet/src/lib/components/tax-form-detail/index.ts new file mode 100644 index 000000000..bfe5dc606 --- /dev/null +++ b/src/apps/wallet/src/lib/components/tax-form-detail/index.ts @@ -0,0 +1 @@ +export { default as TaxFormDetail } from './TaxFormDetail' diff --git a/src/apps/wallet/src/lib/index.ts b/src/apps/wallet/src/lib/index.ts new file mode 100644 index 000000000..0f5e459b8 --- /dev/null +++ b/src/apps/wallet/src/lib/index.ts @@ -0,0 +1,3 @@ +export * from './wallet-swr' +export * from './assets' +export * from './components' diff --git a/src/apps/wallet/src/lib/models/ApiError.ts b/src/apps/wallet/src/lib/models/ApiError.ts new file mode 100644 index 000000000..9b0135c9e --- /dev/null +++ b/src/apps/wallet/src/lib/models/ApiError.ts @@ -0,0 +1,4 @@ +export interface ApiError { + code: string + message: string +} diff --git a/src/apps/wallet/src/lib/models/ApiResponse.ts b/src/apps/wallet/src/lib/models/ApiResponse.ts new file mode 100644 index 000000000..3221e7793 --- /dev/null +++ b/src/apps/wallet/src/lib/models/ApiResponse.ts @@ -0,0 +1,4 @@ +export default interface ApiResponse { + status: 'success' | 'error' + data: T +} diff --git a/src/apps/wallet/src/lib/models/ConfirmFlowData.ts b/src/apps/wallet/src/lib/models/ConfirmFlowData.ts new file mode 100644 index 000000000..29c7753e5 --- /dev/null +++ b/src/apps/wallet/src/lib/models/ConfirmFlowData.ts @@ -0,0 +1,6 @@ +export interface ConfirmFlowData { + title: string; + action: string; + content: React.ReactNode | (() => React.ReactNode) + callback?: () => void; +} diff --git a/src/apps/wallet/src/lib/models/OtpVerificationResponse.ts b/src/apps/wallet/src/lib/models/OtpVerificationResponse.ts new file mode 100644 index 000000000..53456138d --- /dev/null +++ b/src/apps/wallet/src/lib/models/OtpVerificationResponse.ts @@ -0,0 +1,3 @@ +export interface OtpVerificationResponse { + data: unknown; + } diff --git a/src/apps/wallet/src/lib/models/PaginationInfo.ts b/src/apps/wallet/src/lib/models/PaginationInfo.ts new file mode 100644 index 000000000..0d2d1e030 --- /dev/null +++ b/src/apps/wallet/src/lib/models/PaginationInfo.ts @@ -0,0 +1,6 @@ +export interface PaginationInfo { + totalItems: number; + totalPages: number; + pageSize: number; + currentPage: number; +} diff --git a/src/apps/wallet/src/lib/models/PaymentProvider.ts b/src/apps/wallet/src/lib/models/PaymentProvider.ts new file mode 100644 index 000000000..c78e84687 --- /dev/null +++ b/src/apps/wallet/src/lib/models/PaymentProvider.ts @@ -0,0 +1,9 @@ +export interface PaymentProvider { + id?: number + upmId?: string + type: 'Payoneer' | 'Paypal' + name: 'Payoneer' | 'Paypal' + description: string + status: string + transactionId?: string +} diff --git a/src/apps/wallet/src/lib/models/ResendOtpResponse.ts b/src/apps/wallet/src/lib/models/ResendOtpResponse.ts new file mode 100644 index 000000000..1fd04f75a --- /dev/null +++ b/src/apps/wallet/src/lib/models/ResendOtpResponse.ts @@ -0,0 +1,3 @@ +export interface ResendOtpResponse { + transactionId: string; + } diff --git a/src/apps/wallet/src/lib/models/TaxForm.ts b/src/apps/wallet/src/lib/models/TaxForm.ts new file mode 100644 index 000000000..6578be894 --- /dev/null +++ b/src/apps/wallet/src/lib/models/TaxForm.ts @@ -0,0 +1,18 @@ +export interface TaxForm { + id: string + userId: string + dateFiled: string + withholdingAmount: string + withholdingPercentage: string + taxForm: { + name: string + text: string + description: string + } + status: string + transactionId: string +} + +export interface SetupTaxFormResponse { + transactionId: string +} diff --git a/src/apps/wallet/src/lib/models/TransactionId.ts b/src/apps/wallet/src/lib/models/TransactionId.ts new file mode 100644 index 000000000..a0e860446 --- /dev/null +++ b/src/apps/wallet/src/lib/models/TransactionId.ts @@ -0,0 +1,5 @@ +export interface TransactionResponse { + transactionId: string + type?: string; + email: string +} diff --git a/src/apps/wallet/src/lib/models/WalletDetails.ts b/src/apps/wallet/src/lib/models/WalletDetails.ts new file mode 100644 index 000000000..b7baf2aeb --- /dev/null +++ b/src/apps/wallet/src/lib/models/WalletDetails.ts @@ -0,0 +1,19 @@ +export interface Balance { + amount: number + type: string + unit: string +} + +export interface AccountDetails { + balances: Balance[] +} + +export interface WalletDetails { + account: AccountDetails + withdrawalMethod: { + isSetupComplete: boolean + } + taxForm: { + isSetupComplete: boolean + } +} diff --git a/src/apps/wallet/src/lib/models/WinningDetail.ts b/src/apps/wallet/src/lib/models/WinningDetail.ts new file mode 100644 index 000000000..7e7a35990 --- /dev/null +++ b/src/apps/wallet/src/lib/models/WinningDetail.ts @@ -0,0 +1,42 @@ +export interface PaymentDetail { + id: string + netAmount: string + grossAmount: string + totalAmount: string + installmentNumber: number + status: string + currency: string + datePaid: string +} + +export interface Winning { + id: string + description: string + type: string + createDate: string + netPayment: string + status: string + releaseDate: string + datePaid: string + canBeReleased: boolean + currency: string + details: PaymentDetail[] +} + +export interface WinningDetail { + id: string + type: string + winnerId: string + origin: string + category: string + title: string + description: string + externalId: string + attributes: { + url: string + } + details: PaymentDetail[] + createdAt: string + releaseDate: string + datePaid: string +} diff --git a/src/apps/wallet/src/lib/services/wallet.ts b/src/apps/wallet/src/lib/services/wallet.ts new file mode 100644 index 000000000..44182d6c7 --- /dev/null +++ b/src/apps/wallet/src/lib/services/wallet.ts @@ -0,0 +1,242 @@ +import { AxiosError } from 'axios' + +import { EnvironmentConfig } from '~/config' +import { xhrDeleteAsync, xhrGetAsync, xhrPostAsync, xhrPostAsyncWithBlobHandling } from '~/libs/core' + +import { WalletDetails } from '../models/WalletDetails' +import { PaymentProvider } from '../models/PaymentProvider' +import { WinningDetail } from '../models/WinningDetail' +import { TaxForm } from '../models/TaxForm' +import { OtpVerificationResponse } from '../models/OtpVerificationResponse' +import { TransactionResponse } from '../models/TransactionId' +import { PaginationInfo } from '../models/PaginationInfo' +import ApiResponse from '../models/ApiResponse' + +const baseUrl = `${EnvironmentConfig.API.V5}/payments` + +export async function getWalletDetails(): Promise { + const response = await xhrGetAsync>(`${baseUrl}/wallet`) + + if (response.status === 'error') { + throw new Error('Error fetching wallet details') + } + + return response.data +} + +export async function getUserPaymentProviders(): Promise { + const response = await xhrGetAsync>(`${baseUrl}/user/payment-methods`) + + if (response.status === 'error') { + throw new Error('Error fetching user payment providers') + } + + return response.data +} + +export async function getUserTaxFormDetails(): Promise { + const response = await xhrGetAsync>(`${baseUrl}/user/tax-forms`) + if (response.status === 'error') { + throw new Error('Error fetching user tax form details') + } + + return response.data +} + +// eslint-disable-next-line max-len +export async function getPayments(userId: string, limit: number, offset: number, filters: Record): Promise<{ + winnings: WinningDetail[], + pagination: PaginationInfo +}> { + const filteredFilters: Record = {} + + for (const key in filters) { + if (filters[key].length > 0 && key !== 'pageSize') { + filteredFilters[key] = filters[key][0] + } + } + + const body = JSON.stringify({ + limit, + offset, + winnerId: userId, + ...filteredFilters, + }) + + const url = `${baseUrl}/user/winnings` + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Error fetching payments') + } + + if (response.data.winnings === null || response.data.winnings === undefined) { + response.data.winnings = [] + } + + return response.data +} + +export async function setPaymentProvider( + type: string, +): Promise { + const body = JSON.stringify({ + details: {}, + setDefault: true, + type, + }) + + const url = `${baseUrl}/user/payment-method` + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Error setting payment provider') + } + + return response.data +} + +export async function confirmPaymentProvider(provider: string, code: string, transactionId: string): Promise { + const body = JSON.stringify({ + code, + provider, + transactionId, + }) + + const url = `${baseUrl}/payment-provider/paypal/confirm` + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Error confirming payment provider') + } + + return response.data +} + +export async function getPaymentProviderRegistrationLink(type: string): Promise { + const url = `${baseUrl}/user/payment-method/${type}/registration-link` + const response = await xhrGetAsync>(url) + + if (response.status === 'error') { + throw new Error('Error getting payment provider registration link') + } + + return response.data +} + +export async function removePaymentProvider(type: string): Promise { + const url = `${baseUrl}/user/payment-method/${type}` + const response = await xhrDeleteAsync>(url) + + if (response.status === 'error') { + throw new Error('Error getting payment provider registration link') + } + + return response.data +} + +export async function setupTaxForm(userId: string, taxForm: string): Promise { + const body = JSON.stringify({ + taxForm, + userId, + }) + + const url = `${baseUrl}/user/tax-form` + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Error setting tax form') + } + + return response.data +} + +export async function removeTaxForm(taxFormId: string): Promise { + const url = `${baseUrl}/user/tax-forms/${taxFormId}` + const response = await xhrDeleteAsync>(url) + + if (response.status === 'error') { + throw new Error('Error removing tax form') + } + + return response.data +} + +export async function getRecipientViewURL(): Promise { + const url = `${baseUrl}/user/tax-form/view` + const response = await xhrGetAsync>(url) + + if (response.status === 'error') { + throw new Error('Error removing tax form') + } + + return response.data +} + +export async function processPayments(paymentIds: string[]): Promise<{ processed: boolean }> { + const body = JSON.stringify({ + paymentIds, + }) + const url = `${baseUrl}/withdraw` + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Error processing payments') + } + + return response.data +} + +// eslint-disable-next-line max-len +export async function verifyOtp(transactionId: string, code: string, blob: boolean = false): Promise { + const body = JSON.stringify({ + otpCode: code, + transactionId, + }) + + const url = `${baseUrl}/otp/verify` + try { + // eslint-disable-next-line max-len + const response = await xhrPostAsyncWithBlobHandling | Blob>(url, body, { + responseType: blob ? 'blob' : 'json', + }) + + if (response instanceof Blob) { + return response as Blob + } + + if (response.status === 'error') { + throw new Error('OTP verification failed or OTP has expired') + } + + return response.data + } catch (err) { + throw new Error('OTP verification failed or OTP has expired') + } +} + +export async function resendOtp(transactionId: string): Promise { + const body = JSON.stringify({ + transactionId, + }) + + const url = `${baseUrl}/otp/resend` + try { + const response = await xhrPostAsync>(url, body) + + if (response.status === 'error') { + throw new Error('Failed to resend OTP.') + } + + return response.data + } catch (err) { + if (err instanceof AxiosError && err.response?.data?.error !== undefined) { + throw new Error(err.response.data.error?.message) + } + + throw new Error('Failed to resend OTP.') + } +} diff --git a/src/apps/wallet/src/lib/wallet-swr/WalletSwr.tsx b/src/apps/wallet/src/lib/wallet-swr/WalletSwr.tsx new file mode 100644 index 000000000..ae683103f --- /dev/null +++ b/src/apps/wallet/src/lib/wallet-swr/WalletSwr.tsx @@ -0,0 +1,23 @@ +import { FC, ReactNode } from 'react' +import { SWRConfig } from 'swr' + +import { xhrGetAsync } from '~/libs/core' + +interface WalletSwrProps { + children: ReactNode +} + +const WalletSwr: FC = (props: WalletSwrProps) => ( + xhrGetAsync(resource), + refreshInterval: 0, + revalidateOnFocus: false, + revalidateOnMount: true, + }} + > + {props.children} + +) + +export default WalletSwr diff --git a/src/apps/wallet/src/lib/wallet-swr/index.ts b/src/apps/wallet/src/lib/wallet-swr/index.ts new file mode 100644 index 000000000..31c20cbba --- /dev/null +++ b/src/apps/wallet/src/lib/wallet-swr/index.ts @@ -0,0 +1 @@ +export { default as WalletSwr } from './WalletSwr' diff --git a/src/apps/wallet/src/wallet.routes.tsx b/src/apps/wallet/src/wallet.routes.tsx new file mode 100644 index 000000000..d4769d588 --- /dev/null +++ b/src/apps/wallet/src/wallet.routes.tsx @@ -0,0 +1,32 @@ +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' + +const WalletApp: LazyLoadedComponent = lazyLoad(() => import('./WalletApp')) +const WalletHomePage: LazyLoadedComponent = lazyLoad( + () => import('./home'), + 'WalletHomePage', +) + +// prettier-ignore +export const rootRoute: string = EnvironmentConfig.SUBDOMAIN === AppSubdomain.wallet ? '' : `/${AppSubdomain.wallet}` + +export const toolTitle = ToolTitle.wallet +export const absoluteRootRoute: string = `${window.location.origin}/${rootRoute}` + +export const walletRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + { + children: [], + element: , + id: 'Dashboard', + route: '', + }, + ], + domain: AppSubdomain.wallet, + element: , + id: toolTitle, + route: rootRoute, + }, +] diff --git a/src/config/constants.ts b/src/config/constants.ts index 48a4103c0..1275e8e47 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -7,7 +7,8 @@ export enum AppSubdomain { tcAcademy = 'academy', onboarding = 'onboarding', work = 'work', - talentSearch = 'talent-search' + talentSearch = 'talent-search', + wallet = 'wallet', } export enum ToolTitle { @@ -19,7 +20,9 @@ export enum ToolTitle { tcAcademy = 'Topcoder Academy', selfService = 'Self Service Challenges', onboarding = ' ', - talentSearch = 'Expert Talent' + talentSearch = 'Expert Talent', + wallet = 'Wallet', + walletAdmin = 'Wallet Admin', } export const PageSubheaderPortalId: string = 'page-subheader-portal-el' diff --git a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts index f4ac33a36..7531539ce 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts @@ -4,5 +4,11 @@ export enum UserRole { member = 'Topcoder User', tcaAdmin = 'TCA Admin', administrator = 'administrator', - connectManager = 'Connect Manager' + connectManager = 'Connect Manager', + paymentAdmin = 'Payment Admin', + paymentViewer = 'Payment Viewer', + paymentProviderAdmin = 'PaymentProvider Admin', + paymentProviderViewer = 'PaymentProvider Viewer', + taxFormAdmin = 'TaxForm Admin', + taxFormViewer = 'TaxForm Viewer' } diff --git a/src/libs/core/lib/xhr/xhr-functions/index.ts b/src/libs/core/lib/xhr/xhr-functions/index.ts index 8e6373428..c0c677ca2 100644 --- a/src/libs/core/lib/xhr/xhr-functions/index.ts +++ b/src/libs/core/lib/xhr/xhr-functions/index.ts @@ -6,6 +6,7 @@ export { getPaginatedAsync as xhrGetPaginatedAsync, patchAsync as xhrPatchAsync, postAsync as xhrPostAsync, + postAsyncWithBlobHandling as xhrPostAsyncWithBlobHandling, putAsync as xhrPutAsync, type PaginatedResponse, } from './xhr.functions' diff --git a/src/libs/core/lib/xhr/xhr-functions/xhr.functions.ts b/src/libs/core/lib/xhr/xhr-functions/xhr.functions.ts index 742902710..af5a3d9e2 100644 --- a/src/libs/core/lib/xhr/xhr-functions/xhr.functions.ts +++ b/src/libs/core/lib/xhr/xhr-functions/xhr.functions.ts @@ -1,5 +1,10 @@ import { identity } from 'lodash' -import axios, { AxiosHeaders, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import axios, { + AxiosHeaders, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, +} from 'axios' import { tokenGetAsync, TokenModel } from '../../auth' @@ -15,7 +20,6 @@ export const getResonseXHeader = ( ): T => (parser(headers.get(headerName)) ?? defaultValue) as T export function createInstance(): AxiosInstance { - // create the instance const created: AxiosInstance = axios.create({ headers: { @@ -30,22 +34,38 @@ export function createInstance(): AxiosInstance { return created } -export async function deleteAsync(url: string, xhrInstance: AxiosInstance = globalInstance): Promise { +export async function deleteAsync( + url: string, + xhrInstance: AxiosInstance = globalInstance, +): Promise { const output: AxiosResponse = await xhrInstance.delete(url) return output.data } -export async function getAsync(url: string, xhrInstance: AxiosInstance = globalInstance): Promise { +export async function getAsync( + url: string, + xhrInstance: AxiosInstance = globalInstance, +): Promise { const output: AxiosResponse = await xhrInstance.get(url) return output.data } +export async function getAsyncWithBlobHandling( + url: string, + xhrInstance: AxiosInstance = globalInstance, +): Promise { + const response: AxiosResponse = await xhrInstance.get(url, { + responseType: 'blob', + }) + return response.data +} + export interface PaginatedResponse { - data: T; - total: number; - page: number; - perPage: number; - totalPages: number; + data: T + total: number + page: number + perPage: number + totalPages: number } export async function getPaginatedAsync( @@ -56,19 +76,48 @@ export async function getPaginatedAsync( return { data: output.data, - page: getResonseXHeader(output.headers as AxiosHeaders, 'x-page', Number, 0), - perPage: getResonseXHeader(output.headers as AxiosHeaders, 'x-per-page', Number, 0), - total: getResonseXHeader(output.headers as AxiosHeaders, 'x-total', Number, 0), - totalPages: getResonseXHeader(output.headers as AxiosHeaders, 'x-total-pages', Number, 0), + page: getResonseXHeader( + output.headers as AxiosHeaders, + 'x-page', + Number, + 0, + ), + perPage: getResonseXHeader( + output.headers as AxiosHeaders, + 'x-per-page', + Number, + 0, + ), + total: getResonseXHeader( + output.headers as AxiosHeaders, + 'x-total', + Number, + 0, + ), + totalPages: getResonseXHeader( + output.headers as AxiosHeaders, + 'x-total-pages', + Number, + 0, + ), } } -export async function getBlobAsync(url: string, xhrInstance: AxiosInstance = globalInstance): Promise { - const output: AxiosResponse = await xhrInstance.get(url, { responseType: 'blob' }) +export async function getBlobAsync( + url: string, + xhrInstance: AxiosInstance = globalInstance, +): Promise { + const output: AxiosResponse = await xhrInstance.get(url, { + responseType: 'blob', + }) return output.data } -export async function patchAsync(url: string, data: T, xhrInstance: AxiosInstance = globalInstance): Promise { +export async function patchAsync( + url: string, + data: T, + xhrInstance: AxiosInstance = globalInstance, +): Promise { const output: AxiosResponse = await xhrInstance.patch(url, data) return output.data } @@ -83,6 +132,16 @@ export async function postAsync( return output.data } +export async function postAsyncWithBlobHandling( + url: string, + data: T, + config?: AxiosRequestConfig, + xhrInstance: AxiosInstance = globalInstance, +): Promise { + const response: AxiosResponse = await xhrInstance.post(url, data, config) + return response.data +} + export async function putAsync( url: string, data: T, @@ -94,7 +153,6 @@ export async function putAsync( } function interceptAuth(instance: AxiosInstance): void { - // add the auth token to all xhr calls instance.interceptors.request.use(async config => { const tokenData: TokenModel = await tokenGetAsync() @@ -109,12 +167,10 @@ function interceptAuth(instance: AxiosInstance): void { } function interceptError(instance: AxiosInstance): void { - // handle all http errors instance.interceptors.response.use( config => config, (error: any) => { - // if there is server error message, then return it inside `message` property of error error.message = error?.response?.data?.message || error.message // if there is server errors data, then return it inside `errors` property of error diff --git a/src/libs/ui/lib/components/modals/base-modal/BaseModal.tsx b/src/libs/ui/lib/components/modals/base-modal/BaseModal.tsx index 0c8d11476..e08bd7ed1 100644 --- a/src/libs/ui/lib/components/modals/base-modal/BaseModal.tsx +++ b/src/libs/ui/lib/components/modals/base-modal/BaseModal.tsx @@ -5,7 +5,10 @@ import classNames from 'classnames' import { LoadingSpinner } from '../../loading-spinner' import { IconOutline } from '../../svgs' -import { ModalContentResponse, useFetchModalContent } from './use-fetch-modal-content' +import { + ModalContentResponse, + useFetchModalContent, +} from './use-fetch-modal-content' import styles from './BaseModal.module.scss' export interface BaseModalProps extends ModalProps { @@ -15,12 +18,15 @@ export interface BaseModalProps extends ModalProps { theme?: 'danger' | 'clear' size?: 'body' | 'lg' | 'md' | 'sm' title?: string | ReactNode + spacer?: boolean buttons?: ReactNode } const BaseModal: FC = (props: BaseModalProps) => { - - const { content }: ModalContentResponse = useFetchModalContent(props.contentUrl, props.open) + const { content }: ModalContentResponse = useFetchModalContent( + props.contentUrl, + props.open, + ) const renterContent: () => ReactNode = () => { if (props.children || !props.contentUrl) { @@ -39,11 +45,14 @@ const BaseModal: FC = (props: BaseModalProps) => { ) } - const handleBodyScroll = useCallback((force?: boolean) => { - const isOpen = force ?? props.open - document.documentElement.style.overflow = isOpen ? 'hidden' : '' - document.body.style.overflow = isOpen ? 'hidden' : '' - }, [props.open]) + const handleBodyScroll = useCallback( + (force?: boolean) => { + const isOpen = force ?? props.open + document.documentElement.style.overflow = isOpen ? 'hidden' : '' + document.body.style.overflow = isOpen ? 'hidden' : '' + }, + [props.open], + ) useEffect(() => { if (props.blockScroll) { @@ -65,27 +74,37 @@ const BaseModal: FC = (props: BaseModalProps) => { props.theme && styles[`theme-${props.theme}`], ), }} - closeIcon={} + closeIcon={( + + )} // send blockScroll as false unless we get a specific true from props blockScroll={props.blockScroll === true} > {props.title && ( <>
- { - typeof props.title === 'string' ? ( -

{props.title}

- ) : ( - props.title - ) - } + {typeof props.title === 'string' ? ( +

{props.title}

+ ) : ( + props.title + )}
-
+ {props.spacer !== false &&
} )} -
+
{renterContent()} {props.children}
diff --git a/tsconfig.paths.json b/tsconfig.paths.json index 97684204d..3f83bc357 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -27,6 +27,9 @@ "@profiles/*": [ "./src/apps/profiles/src/*" ], + "@wallet/*": [ + "./src/apps/wallet/src/*" + ], "@libs/ui/styles/*": [ "./src/libs/ui/lib/styles/*" ] diff --git a/yarn.lock b/yarn.lock index c722b66b2..bda7fe7e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4779,6 +4779,18 @@ dependencies: defer-to-connect "^2.0.0" +"@tanstack/react-table@^8.11.7": + version "8.11.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.11.7.tgz#a2934c1ee32025d58c9dc4d13cbc15fe0a3e045e" + integrity sha512-ZbzfMkLjxUTzNPBXJYH38pv2VpC9WUA+Qe5USSHEBz0dysDTv4z/ARI3csOed/5gmlmrPzVUN3UXGuUMbod3Jg== + dependencies: + "@tanstack/table-core" "8.11.7" + +"@tanstack/table-core@8.11.7": + version "8.11.7" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.11.7.tgz#266af5af8576e8860df8abfe4e32e9c4ffbc76b0" + integrity sha512-N3ksnkbPbsF3PjubuZCB/etTqvctpXWRHIXTmYfJFnhynQKjeZu8BCuHvdlLPpumKbA+bjY4Ay9AELYLOXPWBg== + "@testing-library/dom@^8.3.0": version "8.20.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6" @@ -7126,9 +7138,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426: - version "1.0.30001439" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz#ab7371faeb4adff4b74dad1718a6fd122e45d9cb" - integrity sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A== + version "1.0.30001574" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001574.tgz" + integrity sha512-BtYEK4r/iHt/txm81KBudCUcTy7t+s9emrIaHqjYurQ10x71zJ5VQ9x1dYPcz/b+pKSp4y/v1xSI67A+LzpNyg== capital-case@^1.0.4: version "1.0.4" @@ -16295,6 +16307,11 @@ react-only-when@^1.0.2: resolved "https://registry.yarnpkg.com/react-only-when/-/react-only-when-1.0.2.tgz#a8a79b48dd6cfbd91ddc710674a94153e88039d3" integrity sha512-agE6l3L6bqaVuwNtjihTQ36M+VBfPS63KOzcNL4ZTmlwSxQPvhzIqmBWfiol0/wLYmKxCcBqgXkEJpvj5Kob8Q== +react-otp-input@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-3.1.1.tgz#910169629812c40a614e6c175cc2c5f36102bb61" + integrity sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw== + react-popper@^2.2.5, react-popper@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"