diff --git a/src/app/_components/Hero.tsx b/src/app/_components/Hero.tsx index 6bbe97e5..39123e78 100644 --- a/src/app/_components/Hero.tsx +++ b/src/app/_components/Hero.tsx @@ -3,13 +3,13 @@ import Link from "next/link"; import { Button } from "@/common/ui/components"; import useWallet from "@/modules/auth/hooks/useWallet"; import useRegistration from "@/modules/core/hooks/useRegistration"; +import { DonationRandomButton } from "@/modules/donation"; const Hero = () => { const wallet = useWallet(); const accountId = wallet?.wallet?.accountId || ""; const { registration, loading } = useRegistration(accountId); - const isRegisteredProject = !!registration.id; return ( @@ -23,12 +23,7 @@ const Hero = () => {
participate in funding rounds.
- + {!loading && ( - ); + return isAuthenticated ? : ; }; const MobileMenuButton = ({ onClick }: { onClick: () => void }) => { diff --git a/src/app/_store/models.ts b/src/app/_store/models.ts index 4f7d9712..bd465db7 100644 --- a/src/app/_store/models.ts +++ b/src/app/_store/models.ts @@ -2,10 +2,12 @@ import { Models } from "@rematch/core"; import { auth } from "@/modules/auth/state"; import { core } from "@/modules/core/state"; +import { donationModel } from "@/modules/donation"; import { navModel, profilesModel } from "@/modules/profile/models"; export interface RootModel extends Models { auth: typeof auth; + donation: typeof donationModel; profiles: typeof profilesModel; nav: typeof navModel; core: typeof core; @@ -13,6 +15,7 @@ export interface RootModel extends Models { export const models: RootModel = { auth, + donation: donationModel, profiles: profilesModel, nav: navModel, core, diff --git a/src/app/tests.tsx b/src/app/tests.tsx index e3c3073f..fd95cd61 100644 --- a/src/app/tests.tsx +++ b/src/app/tests.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import { expect, test } from "vitest"; import { renderWithStore } from "./_store/testEnv"; @@ -7,10 +7,15 @@ import Homepage from "./page"; renderWithStore(); test("Homepage", async () => { - expect( - screen.getByText("Donate Randomly"), - "random donation button", - ).toBeDefined(); + await waitFor( + () => + expect( + screen.getByText("Donate Randomly"), + "random donation button", + ).toBeDefined(), + + { timeout: 5000 }, + ); // await waitFor( // () => diff --git a/src/common/api/pagoda/hooks.ts b/src/common/api/pagoda/hooks.ts index 7e536b5e..bd7f2f8c 100644 --- a/src/common/api/pagoda/hooks.ts +++ b/src/common/api/pagoda/hooks.ts @@ -1,7 +1,8 @@ -import { PAGODA_REQUEST_CONFIG } from "@/common/constants"; +import { NEAR_TOKEN_DENOM, PAGODA_REQUEST_CONFIG } from "@/common/constants"; +import { walletApi } from "@/common/contracts"; +import { ByAccountId, ByTokenId } from "@/common/types"; import { swrHooks } from "./generated"; -import { ByAccountId } from "../potlock"; export const useNearAccountBalance = ({ accountId }: ByAccountId) => { const queryResult = swrHooks.useGetAccountsAccountIdBalancesNEAR( @@ -10,7 +11,7 @@ export const useNearAccountBalance = ({ accountId }: ByAccountId) => { PAGODA_REQUEST_CONFIG, ); - return { ...queryResult, data: queryResult.data?.data }; + return { ...queryResult, data: queryResult.data?.data.balance }; }; export const useFtAccountBalances = ({ accountId }: ByAccountId) => { @@ -20,5 +21,43 @@ export const useFtAccountBalances = ({ accountId }: ByAccountId) => { PAGODA_REQUEST_CONFIG, ); - return { ...queryResult, data: queryResult.data?.data }; + return { ...queryResult, data: queryResult.data?.data.balances }; +}; + +export type TokenMetadataInputs = ByTokenId & { + disabled?: boolean; +}; + +export const useTokenMetadata = ({ + tokenId, + disabled = false, +}: TokenMetadataInputs) => { + const nearQueryResult = swrHooks.useGetAccountsAccountIdBalancesNEAR( + walletApi.accountId ?? "unknown", + undefined, + + { + ...PAGODA_REQUEST_CONFIG, + swr: { enabled: !disabled && tokenId === NEAR_TOKEN_DENOM }, + }, + ); + + const ftQueryResult = swrHooks.useGetNep141MetadataContractAccountId( + tokenId, + undefined, + + { + ...PAGODA_REQUEST_CONFIG, + swr: { enabled: !disabled && tokenId !== NEAR_TOKEN_DENOM }, + }, + ); + + return { + ...(tokenId === NEAR_TOKEN_DENOM ? nearQueryResult : ftQueryResult), + + data: + tokenId === NEAR_TOKEN_DENOM + ? nearQueryResult.data?.data.balance.metadata + : ftQueryResult.data?.data.metadata, + }; }; diff --git a/src/common/api/potlock/hooks.ts b/src/common/api/potlock/hooks.ts index 4c05c5d6..6c65899f 100644 --- a/src/common/api/potlock/hooks.ts +++ b/src/common/api/potlock/hooks.ts @@ -1,27 +1,51 @@ import { POTLOCK_REQUEST_CONFIG } from "@/common/constants"; +import { ByAccountId, ByListId, ConditionalExecution } from "@/common/types"; import { swrHooks } from "./generated"; import { - ByAccountId, ByPotId, V1AccountsPotApplicationsRetrieveParams, + V1ListsRandomRegistrationRetrieveParams, } from "./types"; -export const useAccounts = () => { - const queryResult = swrHooks.useV1AccountsRetrieve(POTLOCK_REQUEST_CONFIG); +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_donate_contract_config_retrieve + */ +export const useDonationConfig = () => { + const queryResult = swrHooks.useV1DonateContractConfigRetrieve( + POTLOCK_REQUEST_CONFIG, + ); return { ...queryResult, data: queryResult.data?.data }; }; -export const useAccount = ({ accountId }: ByAccountId) => { - const queryResult = swrHooks.useV1AccountsRetrieve2( - accountId, - POTLOCK_REQUEST_CONFIG, - ); +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_accounts_retrieve + */ +export const useAccounts = (params?: ConditionalExecution) => { + const queryResult = swrHooks.useV1AccountsRetrieve({ + ...POTLOCK_REQUEST_CONFIG, + swr: { enabled: params?.enabled ?? true }, + }); + + return { ...queryResult, data: queryResult.data?.data }; +}; + +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_accounts_retrieve_2 + */ +export const useAccount = ({ accountId }: Partial) => { + const queryResult = swrHooks.useV1AccountsRetrieve2(accountId ?? "unknown", { + ...POTLOCK_REQUEST_CONFIG, + swr: { enabled: Boolean(accountId) }, + }); return { ...queryResult, data: queryResult.data?.data }; }; +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_accounts_active_pots_retrieve + */ export const useAccountActivePots = ({ accountId }: ByAccountId) => { const queryResult = swrHooks.useV1AccountsActivePotsRetrieve( accountId, @@ -32,6 +56,9 @@ export const useAccountActivePots = ({ accountId }: ByAccountId) => { return { ...queryResult, data: queryResult.data?.data }; }; +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_accounts_pot_applications_retrieve + */ export const useAccountPotApplications = ({ accountId, status, @@ -45,12 +72,18 @@ export const useAccountPotApplications = ({ return { ...queryResult, data: queryResult.data?.data }; }; +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_pots_retrieve + */ export const usePots = () => { const queryResult = swrHooks.useV1PotsRetrieve(POTLOCK_REQUEST_CONFIG); return { ...queryResult, data: queryResult.data?.data }; }; +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_accounts_donations_received_retrieve + */ export const useAccountDonationsReceived = ({ accountId }: ByAccountId) => { const queryResult = swrHooks.useV1AccountsDonationsReceivedRetrieve( accountId, @@ -60,10 +93,36 @@ export const useAccountDonationsReceived = ({ accountId }: ByAccountId) => { return { ...queryResult, data: queryResult.data?.data }; }; -export const usePot = ({ potId }: ByPotId) => { - const queryResult = swrHooks.useV1PotsRetrieve2( - potId, - POTLOCK_REQUEST_CONFIG, +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_pots_retrieve_2 + */ +export const usePot = ({ potId }: Partial) => { + const queryResult = swrHooks.useV1PotsRetrieve2(potId ?? "unknown", { + ...POTLOCK_REQUEST_CONFIG, + swr: { enabled: Boolean(potId) }, + }); + + return { ...queryResult, data: queryResult.data?.data }; +}; + +/** + * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_lists_random_registration_retrieve + * + * Note: automatic refresh is disabled for optimization. + * Call `mutate()` for manual refresh. + */ +export const useRandomListRegistration = ({ + listId, + status, +}: ByListId & V1ListsRandomRegistrationRetrieveParams) => { + const queryResult = swrHooks.useV1ListsRandomRegistrationRetrieve( + listId, + { status }, + + { + ...POTLOCK_REQUEST_CONFIG, + swr: { revalidateIfStale: false, revalidateOnFocus: false }, + }, ); return { ...queryResult, data: queryResult.data?.data }; diff --git a/src/common/api/potlock/types.ts b/src/common/api/potlock/types.ts index a83ff5e5..1e493284 100644 --- a/src/common/api/potlock/types.ts +++ b/src/common/api/potlock/types.ts @@ -1,13 +1,7 @@ -import { Account, Pot } from "./generated/client"; +import { Pot } from "./generated/client"; export * from "./generated/client"; -export type AccountId = Account["id"]; - -export interface ByAccountId { - accountId: AccountId; -} - export type PotId = Pot["id"]; export interface ByPotId { diff --git a/src/common/assets/svgs/near-icon.tsx b/src/common/assets/svgs/near-icon.tsx index 34df6847..72d44edc 100644 --- a/src/common/assets/svgs/near-icon.tsx +++ b/src/common/assets/svgs/near-icon.tsx @@ -1,7 +1,7 @@ -const NearIcon = (props: any) => ( +const NearIcon = ({ width = 16, height = 16, ...props }: any) => ( ( - +const TwitterSvg = (props: any) => ( + ); diff --git a/src/common/contracts/potlock/donate.ts b/src/common/contracts/potlock/donate.ts index 2b82035d..d1917eee 100644 --- a/src/common/contracts/potlock/donate.ts +++ b/src/common/contracts/potlock/donate.ts @@ -1,4 +1,4 @@ -import { MemoryCache } from "@wpdas/naxios"; +import { ChangeMethodArgs, MemoryCache } from "@wpdas/naxios"; import { POTLOCK_DONATE_CONTRACT_ID } from "@/common/constants"; @@ -53,9 +53,11 @@ export const getDonationsForDonor = (args: { donor_id: string }) => export const donateNearDirectly = ( args: DirectDonationArgs, - depositAmountFloat: number, + depositAmountYocto: string, + callbackUrl?: ChangeMethodArgs["callbackUrl"], ) => contractApi.call("donate", { args, - deposit: depositAmountFloat.toString(), + deposit: depositAmountYocto, + callbackUrl, }); diff --git a/src/common/contracts/potlock/interfaces/donate.interfaces.ts b/src/common/contracts/potlock/interfaces/donate.interfaces.ts index 70faea5f..7f45b5e3 100644 --- a/src/common/contracts/potlock/interfaces/donate.interfaces.ts +++ b/src/common/contracts/potlock/interfaces/donate.interfaces.ts @@ -11,23 +11,22 @@ export interface Config { } export interface DirectDonation { - id: string; + id: number; donor_id: string; total_amount: string; ft_id: string; - message: string; + message?: null | string; donated_at_ms: number; recipient_id: string; protocol_fee: string; - referrer_id: null | string; - referrer_fee: null | string; + referrer_id?: null | string; + referrer_fee?: null | string; base_currency: string; - amount?: string; } export type DirectDonationArgs = { recipient_id: string; message?: string | null; referrer_id?: string | null; - bypass_protocol_fee?: boolean | null; + bypass_protocol_fee?: boolean; }; diff --git a/src/common/lib/converters.ts b/src/common/lib/converters.ts index 8cfb0941..80c0a403 100644 --- a/src/common/lib/converters.ts +++ b/src/common/lib/converters.ts @@ -3,6 +3,9 @@ import Big from "big.js"; import formatWithCommas from "./formatWithCommas"; import { NEAR_DEFAULT_TOKEN_DECIMALS } from "../constants"; +/** + * @deprecated Use `yoctoNearToFloat` + */ export const yoctosToNear = (amountYoctos: string, abbreviate?: boolean) => { return ( formatWithCommas(Big(amountYoctos).div(1e24).toFixed(2)) + @@ -10,10 +13,18 @@ export const yoctosToNear = (amountYoctos: string, abbreviate?: boolean) => { ); }; -export const bigNumToFloat = (amount: string, decimals: number) => { +export const bigStringToFloat = (amount: string, decimals: number) => { const decimalMultiplier = Big(10).pow(decimals); return parseFloat(Big(amount).div(decimalMultiplier).toFixed(2)); }; +export const floatToBigNum = (amount: number, decimals: number) => { + const decimalMultiplier = Big(10).pow(decimals); + return Big(amount).mul(decimalMultiplier); +}; + export const yoctoNearToFloat = (amountYoctoNear: string) => - bigNumToFloat(amountYoctoNear, NEAR_DEFAULT_TOKEN_DECIMALS); + bigStringToFloat(amountYoctoNear, NEAR_DEFAULT_TOKEN_DECIMALS); + +export const floatToYoctoNear = (amountFloat: number) => + floatToBigNum(amountFloat, NEAR_DEFAULT_TOKEN_DECIMALS).toFixed().toString(); diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 00000000..bca869b1 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,26 @@ +import { Account } from "near-api-js"; + +export interface ConditionalExecution { + enabled?: boolean; +} + +export type AccountId = Account["accountId"]; + +export interface ByAccountId { + accountId: AccountId; +} + +/** + * Either "NEAR" or FT contract account id. + */ +export type TokenId = "near" | AccountId; + +export interface ByTokenId { + tokenId: TokenId; +} + +export type ListId = number; + +export interface ByListId { + listId: ListId; +} diff --git a/src/common/ui/components/checkbox.tsx b/src/common/ui/components/checkbox.tsx index 5cd52929..2102d9e5 100644 --- a/src/common/ui/components/checkbox.tsx +++ b/src/common/ui/components/checkbox.tsx @@ -1,20 +1,24 @@ "use client"; -import * as React from "react"; +import { forwardRef } from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { Check } from "lucide-react"; import { cn } from "../utils"; -const Checkbox = React.forwardRef< +export const Checkbox = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; -export { Checkbox }; +Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/src/common/ui/components/clipboard-copy-button.tsx b/src/common/ui/components/clipboard-copy-button.tsx new file mode 100644 index 00000000..0f76e4b3 --- /dev/null +++ b/src/common/ui/components/clipboard-copy-button.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { CopyToClipboard } from "react-copy-to-clipboard"; + +export type ClipboardCopyButtonProps = { + text: string; +}; + +export const ClipboardCopyButton: React.FC = ({ + text, +}) => { + const [copied, setCopied] = useState(false); + + const onCopy = useCallback(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, []); + + return copied ? ( + + + + ) : ( + + + + + + ); +}; diff --git a/src/common/ui/components/dialog.tsx b/src/common/ui/components/dialog.tsx index 74f13f88..adf5fb5a 100644 --- a/src/common/ui/components/dialog.tsx +++ b/src/common/ui/components/dialog.tsx @@ -36,6 +36,7 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; export type DialogContentProps = React.ComponentPropsWithoutRef< typeof DialogPrimitive.Content > & { + contrastActions?: boolean; onBackClick?: VoidFunction; onCloseClick: VoidFunction; }; @@ -43,66 +44,91 @@ export type DialogContentProps = React.ComponentPropsWithoutRef< const DialogContent = forwardRef< React.ElementRef, DialogContentProps ->(({ className, children, onBackClick, onCloseClick, ...props }, ref) => ( - - - - {children} - -
- {typeof onBackClick === "function" && ( - - )} +>( + ( + { + className, + children, + contrastActions = false, + onBackClick, + onCloseClick, + ...props + }, + + ref, + ) => { + const actionIconClassName = cn({ + "color-white": !contrastActions, + "color-black": contrastActions, + }); - + + - - Close - -
- - -)); + {children} + +
+ {typeof onBackClick === "function" && ( + + )} + + + + Close + +
+ + + ); + }, +); + DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeaderPattern: React.FC<{ className?: string }> = (props) => ( diff --git a/src/common/ui/components/form.tsx b/src/common/ui/components/form.tsx index 05589c92..df4b1edd 100644 --- a/src/common/ui/components/form.tsx +++ b/src/common/ui/components/form.tsx @@ -74,12 +74,12 @@ const FormItemContext = createContext( const FormItem = forwardRef< HTMLDivElement, React.HTMLAttributes ->(({ className, ...props }, ref) => { +>(({ ...props }, ref) => { const id = useId(); return ( -
+
); }); diff --git a/src/common/ui/components/index.ts b/src/common/ui/components/index.ts index 12d28044..6e01af69 100644 --- a/src/common/ui/components/index.ts +++ b/src/common/ui/components/index.ts @@ -3,6 +3,7 @@ export * from "./alert"; export * from "./avatar"; export * from "./button"; export * from "./checkbox"; +export * from "./clipboard-copy-button"; export * from "./dialog"; export * from "./dropdown-menu"; export * from "./Filter"; @@ -18,6 +19,6 @@ export * from "./skeleton"; export * from "./SortSelect"; export * from "./switch"; export * from "./textarea"; -export * from "./text-field"; export * from "./toggle"; export * from "./toggle-group"; +export * from "./typography"; diff --git a/src/common/ui/components/radio-group.tsx b/src/common/ui/components/radio-group.tsx index 5b44c96c..46d0afe0 100644 --- a/src/common/ui/components/radio-group.tsx +++ b/src/common/ui/components/radio-group.tsx @@ -6,6 +6,7 @@ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { Circle } from "lucide-react"; import { Label } from "./label"; +import { Skeleton } from "./skeleton"; import { cn } from "../utils"; export const RadioGroup = forwardRef< @@ -27,6 +28,7 @@ export type RadioGroupItemProps = Omit< React.ComponentPropsWithoutRef, "color" > & { + isLoading?: boolean; id: string; label: string; hint?: string; @@ -35,10 +37,12 @@ export type RadioGroupItemProps = Omit< export const RadioGroupItem = forwardRef< React.ElementRef, RadioGroupItemProps ->(({ className, checked, disabled, label, hint, ...props }, ref) => { +>(({ isLoading, className, checked, disabled, label, hint, ...props }, ref) => { const inputProps = { checked, disabled, ...props }; - return ( + return isLoading ? ( + + ) : (
{ + return ( +
+ {positioning === "icon-text" && children} + + + {content} + + + {positioning === "text-icon" && children} +
+ ); +}; diff --git a/src/common/ui/form-fields/checkbox.tsx b/src/common/ui/form-fields/checkbox.tsx new file mode 100644 index 00000000..9ead53d8 --- /dev/null +++ b/src/common/ui/form-fields/checkbox.tsx @@ -0,0 +1,24 @@ +import { Checkbox } from "../components"; +import { FormControl, FormItem, FormLabel } from "../components/form"; + +export type CheckboxFieldProps = Pick< + React.ComponentProps, + "checked" | "onCheckedChange" +> & { label: React.ReactNode }; + +export const CheckboxField: React.FC = ({ + label, + ...props +}) => { + return ( + + + + + + + {label} + + + ); +}; diff --git a/src/common/ui/form-fields/index.ts b/src/common/ui/form-fields/index.ts new file mode 100644 index 00000000..f102f13b --- /dev/null +++ b/src/common/ui/form-fields/index.ts @@ -0,0 +1,2 @@ +export * from "./checkbox"; +export * from "./text"; diff --git a/src/common/ui/components/text-field.tsx b/src/common/ui/form-fields/text.tsx similarity index 60% rename from src/common/ui/components/text-field.tsx rename to src/common/ui/form-fields/text.tsx index a7384156..cbad06ec 100644 --- a/src/common/ui/components/text-field.tsx +++ b/src/common/ui/form-fields/text.tsx @@ -1,5 +1,12 @@ import { forwardRef } from "react"; +import { + FormControl, + FormDescription, + FormItem, + FormLabel, + FormMessage, +} from "../components"; import { cn } from "../utils"; export interface TextFieldProps @@ -9,6 +16,8 @@ export interface TextFieldProps labelExtension?: React.ReactNode; fieldExtension?: React.ReactNode; appendix?: string | null; + description?: string; + customErrorMessage?: string | null; } export const TextField = forwardRef( @@ -20,8 +29,11 @@ export const TextField = forwardRef( labelExtension, fieldExtension = null, appendix, + description, + customErrorMessage, ...props }, + ref, ) => { const appendixElement = appendix ? ( @@ -45,11 +57,11 @@ export const TextField = forwardRef( ) : null; return ( -
+
- + {label} - + {labelExtension}
@@ -70,27 +82,32 @@ export const TextField = forwardRef( > {fieldExtensionElement} - + + + {appendixElement}
-
+ + {description && {description}} + {customErrorMessage} + ); }, ); diff --git a/src/modules/auth/components/SignInButton.tsx b/src/modules/auth/components/SignInButton.tsx new file mode 100644 index 00000000..0ba60df6 --- /dev/null +++ b/src/modules/auth/components/SignInButton.tsx @@ -0,0 +1,21 @@ +import { useCallback } from "react"; + +import { walletApi } from "@/common/contracts"; +import { Button } from "@/common/ui/components"; + +export const SignInButton: React.FC = () => { + const onClick = useCallback(() => { + walletApi.signInModal(); + }, []); + + return ( + + ); +}; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 00000000..218368df --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1 @@ +export * from "./components/SignInButton"; diff --git a/src/modules/core/components/AvailableTokenBalance.tsx b/src/modules/core/components/AvailableTokenBalance.tsx new file mode 100644 index 00000000..5188f164 --- /dev/null +++ b/src/modules/core/components/AvailableTokenBalance.tsx @@ -0,0 +1,30 @@ +import { ByTokenId } from "@/common/types"; +import { Skeleton } from "@/common/ui/components"; + +import { useAvailableBalance } from "../hooks/balance"; + +export const AvailableTokenBalance = ({ tokenId }: ByTokenId) => { + const { isBalanceLoading, balanceString } = useAvailableBalance({ tokenId }); + + return balanceString === null ? ( + <> + {isBalanceLoading ? ( + + ) : ( + + Unable to load available balance! + + )} + + ) : ( +
+ + {balanceString} + + + + available + +
+ ); +}; diff --git a/src/modules/core/components/ModalErrorBody.tsx b/src/modules/core/components/ModalErrorBody.tsx new file mode 100644 index 00000000..15d36abd --- /dev/null +++ b/src/modules/core/components/ModalErrorBody.tsx @@ -0,0 +1,30 @@ +import { + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/common/ui/components"; + +import { RuntimeErrorAlert, RuntimeErrorAlertProps } from "./RuntimeErrorAlert"; + +export type ModalErrorBodyProps = Pick< + Required, + "title" +> & + Pick & { + heading?: string; + }; + +export const ModalErrorBody: React.FC = ({ + heading, + ...props +}) => ( + <> + + {heading ?? "Error"} + + + + + + +); diff --git a/src/modules/core/components/RuntimeErrorAlert.tsx b/src/modules/core/components/RuntimeErrorAlert.tsx index ed9d9a04..e7e835f3 100644 --- a/src/modules/core/components/RuntimeErrorAlert.tsx +++ b/src/modules/core/components/RuntimeErrorAlert.tsx @@ -1,19 +1,27 @@ import { Alert, AlertDescription, AlertTitle } from "@/common/ui/components"; export type RuntimeErrorAlertProps = { - customMessage?: string; + title?: string; + message?: string; + callToAction?: React.ReactNode; }; export const RuntimeErrorAlert: React.FC = ({ - customMessage, + title = "Runtime error!", + message, + callToAction, }) => ( - Runtime error! + {title} - {customMessage} + {message} - Please contact PotLock team for help. + {callToAction ?? ( + + Please contact PotLock team for help. + + )} ); diff --git a/src/modules/core/components/TokenIcon.tsx b/src/modules/core/components/TokenIcon.tsx new file mode 100644 index 00000000..19c50aa8 --- /dev/null +++ b/src/modules/core/components/TokenIcon.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +import { pagoda } from "@/common/api/pagoda"; +import NearIcon from "@/common/assets/svgs/near-icon"; +import { NEAR_TOKEN_DENOM } from "@/common/constants"; +import { AccountId } from "@/common/types"; +import { cn } from "@/common/ui/utils"; + +type TokenIconSize = "small" | "medium"; + +const variants: Record< + TokenIconSize, + { sizePx: number; rootClass: string; placeholderClass: string } +> = { + small: { sizePx: 16, rootClass: "p-[1px]", placeholderClass: "text-4" }, + medium: { sizePx: 20, rootClass: "p-0.5", placeholderClass: "text-5" }, +}; + +export type TokenIconProps = { + /** + * Either "NEAR" or FT contract account id. + */ + tokenId: "near" | AccountId; + + className?: string; + size?: TokenIconSize; +}; + +export const TokenIcon = ({ + tokenId, + className, + size = "medium", +}: TokenIconProps) => { + const { data: token, isLoading } = pagoda.useTokenMetadata({ + tokenId, + disabled: tokenId === NEAR_TOKEN_DENOM, + }); + + const { sizePx, rootClass, placeholderClass } = variants[size]; + + return ( + + {tokenId === NEAR_TOKEN_DENOM ? ( + + ) : ( + <> + {token?.icon ? ( + {`Token + ) : ( + + {token?.symbol ?? isLoading ? "⋯" : "🪙"} + + )} + + )} + + ); +}; diff --git a/src/modules/core/components/TotalTokenValue.tsx b/src/modules/core/components/TotalTokenValue.tsx new file mode 100644 index 00000000..82fa7370 --- /dev/null +++ b/src/modules/core/components/TotalTokenValue.tsx @@ -0,0 +1,62 @@ +import { pagoda } from "@/common/api/pagoda"; +import { + NEAR_DEFAULT_TOKEN_DECIMALS, + NEAR_TOKEN_DENOM, +} from "@/common/constants"; +import { bigStringToFloat } from "@/common/lib"; +import { ByTokenId } from "@/common/types"; +import { Skeleton } from "@/common/ui/components"; + +import { TokenIcon } from "./TokenIcon"; +import { useNearUsdDisplayValue } from "../hooks/price"; + +export type TotalTokenValueProps = ByTokenId & + ({ amountFloat: number } | { amountBigString: string }); + +export const TotalTokenValue = ({ + tokenId, + ...props +}: TotalTokenValueProps) => { + const { isLoading: isTokenMetadataLoading, data: tokenMetadata } = + pagoda.useTokenMetadata({ tokenId }); + + const amount = + "amountFloat" in props + ? props.amountFloat + : bigStringToFloat( + props.amountBigString, + tokenMetadata?.decimals ?? NEAR_DEFAULT_TOKEN_DECIMALS, + ); + + const totalNearAmountUsdDisplayValue = useNearUsdDisplayValue(amount); + + const totalAmountUsdDisplayValue = + tokenId === NEAR_TOKEN_DENOM ? totalNearAmountUsdDisplayValue : null; + + return ( +
+ + + {isTokenMetadataLoading ? ( + + ) : ( + {`${amount} ${tokenMetadata?.symbol ?? "⋯"}`} + )} + + {totalAmountUsdDisplayValue && ( + + {totalAmountUsdDisplayValue} + + )} +
+ ); +}; diff --git a/src/modules/core/constants.ts b/src/modules/core/constants.ts index 185146dc..acc63c80 100644 --- a/src/modules/core/constants.ts +++ b/src/modules/core/constants.ts @@ -1,5 +1,7 @@ import { RegistrationStatus } from "@/common/contracts/potlock/interfaces/lists.interfaces"; +export const TOTAL_FEE_BASIS_POINTS = 10_000; + type StatusConfig = { [key in RegistrationStatus]: { background: string; diff --git a/src/modules/core/context/PriceContext.tsx b/src/modules/core/context/PriceContext.tsx deleted file mode 100644 index b80ea328..00000000 --- a/src/modules/core/context/PriceContext.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// import React, { ReactNode, createContext, useContext } from "react"; - -// import { useQuery } from "@tanstack/react-query"; - -// import { fetchNearPrice } from "@/common/services"; - -// interface PriceContextType { -// price?: number; -// error?: Error | null; -// isLoading: boolean; -// } - -// const PriceContext = createContext(undefined); - -// export const PriceProvider: React.FC<{ children: ReactNode }> = ({ -// children, -// }) => { -// const { data, error, isLoading } = useQuery({ -// queryKey: ["nearPrice"], -// queryFn: fetchNearPrice, -// }); - -// return ( -// -// {children} -// -// ); -// }; - -// export const usePrice = (): PriceContextType => { -// const context = useContext(PriceContext); -// if (context === undefined) { -// throw new Error("usePrice must be used within a PriceProvider"); -// } -// return context; -// }; diff --git a/src/modules/core/hooks/balance.ts b/src/modules/core/hooks/balance.ts new file mode 100644 index 00000000..12ae8c84 --- /dev/null +++ b/src/modules/core/hooks/balance.ts @@ -0,0 +1,55 @@ +import { useMemo } from "react"; + +import { pagoda } from "@/common/api/pagoda"; +import { NEAR_TOKEN_DENOM } from "@/common/constants"; +import { walletApi } from "@/common/contracts"; +import { bigStringToFloat } from "@/common/lib"; +import { ByTokenId } from "@/common/types"; + +import { balanceToString } from "../utils"; + +export type AvailableBalance = { + isBalanceLoading: boolean; + balanceFloat: number | null; + balanceString: string | null; +}; + +export const useAvailableBalance = ({ + tokenId, +}: ByTokenId): AvailableBalance => { + const { isLoading: isNearBalanceLoading, data: availableNearBalance } = + pagoda.useNearAccountBalance({ + accountId: walletApi.accountId ?? "unknown", + }); + + const { isLoading: isFtBalanceLoading, data: availableFtBalances } = + pagoda.useFtAccountBalances({ + accountId: walletApi.accountId ?? "unknown", + }); + + const data = useMemo( + () => + (tokenId === NEAR_TOKEN_DENOM + ? availableNearBalance + : availableFtBalances?.find( + (ftBalance) => ftBalance.contract_account_id === tokenId, + )) ?? null, + + [availableFtBalances, availableNearBalance, tokenId], + ); + + const floatValue = useMemo( + () => + data === null + ? null + : bigStringToFloat(data?.amount, data?.metadata.decimals), + + [data], + ); + + return { + isBalanceLoading: isNearBalanceLoading || isFtBalanceLoading, + balanceFloat: floatValue, + balanceString: data === null ? null : balanceToString(data), + }; +}; diff --git a/src/modules/core/hooks/price.ts b/src/modules/core/hooks/price.ts index 6eff014f..7d79099d 100644 --- a/src/modules/core/hooks/price.ts +++ b/src/modules/core/hooks/price.ts @@ -3,15 +3,14 @@ import { useMemo } from "react"; import { coingecko } from "@/common/api/coingecko"; import formatWithCommas from "@/common/lib/formatWithCommas"; -export const useNearUsdDisplayValue = (amountNearFloat: number): string => { +export const useNearUsdDisplayValue = ( + amountNearFloat: number, +): string | null => { const { data: oneNearUsdPrice } = coingecko.useOneNearUsdPrice(); + const value = oneNearUsdPrice ? amountNearFloat * oneNearUsdPrice : 0.0; return useMemo( - () => - `~$ ${formatWithCommas( - (oneNearUsdPrice ? amountNearFloat * oneNearUsdPrice : 0.0).toString(), - )}`, - - [amountNearFloat, oneNearUsdPrice], + () => (isNaN(value) ? null : `~$ ${formatWithCommas(value.toString())}`), + [value], ); }; diff --git a/src/modules/core/index.ts b/src/modules/core/index.ts index c7877d09..ee27bb8d 100644 --- a/src/modules/core/index.ts +++ b/src/modules/core/index.ts @@ -1,3 +1,8 @@ export * from "./utils"; +export * from "./hooks/balance"; export * from "./hooks/price"; +export * from "./components/AvailableTokenBalance"; +export * from "./components/ModalErrorBody"; export * from "./components/RuntimeErrorAlert"; +export * from "./components/TokenIcon"; +export * from "./components/TotalTokenValue"; diff --git a/src/modules/core/utils/index.ts b/src/modules/core/utils/index.ts index ced2d790..412deb21 100644 --- a/src/modules/core/utils/index.ts +++ b/src/modules/core/utils/index.ts @@ -1,17 +1,12 @@ import { store } from "@/app/_store"; import { NearBalanceResponse } from "@/common/api/pagoda"; -import { bigNumToFloat, formatWithCommas } from "@/common/lib"; - -export const balanceToFloat = ( - amount: NearBalanceResponse["balance"]["amount"], - decimals: NearBalanceResponse["balance"]["metadata"]["decimals"], -) => bigNumToFloat(amount, decimals); +import { bigStringToFloat, formatWithCommas } from "@/common/lib"; export const balanceToString = ({ amount, metadata, }: NearBalanceResponse["balance"]) => - `${balanceToFloat(amount, metadata.decimals)} ${metadata.symbol}`; + `${bigStringToFloat(amount, metadata.decimals)} ${metadata.symbol}`; export const nearToUsd = () => store.getState().core.nearToUsd; diff --git a/src/modules/donation/components/DonationBreakdown.tsx b/src/modules/donation/components/DonationBreakdown.tsx new file mode 100644 index 00000000..714cd175 --- /dev/null +++ b/src/modules/donation/components/DonationBreakdown.tsx @@ -0,0 +1,96 @@ +import { NEAR_TOKEN_DENOM } from "@/common/constants"; +import { ByTokenId } from "@/common/types"; +import { TextWithIcon } from "@/common/ui/components"; +import { TokenIcon } from "@/modules/core"; + +import { DonationFees } from "../hooks/fees"; + +export type DonationBreakdownProps = ByTokenId & { + fees: DonationFees; +}; + +export const DonationBreakdown: React.FC = ({ + fees: { + projectAllocationAmount, + projectAllocationPercent, + protocolFeeAmount, + protocolFeePercent, + referralFeeAmount, + referralFeePercent, + chefFeeAmount, + chefFeePercent, + }, + + ...props +}) => { + const totalFees = [ + { + label: "Project allocation", + amount: projectAllocationAmount, + percentage: projectAllocationPercent, + }, + + { + label: "Protocol fees", + amount: protocolFeeAmount, + percentage: protocolFeePercent, + display: protocolFeeAmount > 0, + }, + + { + label: "Chef fees", + amount: chefFeeAmount, + percentage: chefFeePercent, + display: chefFeeAmount > 0, + }, + + { + label: "Referral fees", + amount: referralFeeAmount, + percentage: referralFeePercent, + display: referralFeeAmount > 0, + }, + + { + label: "On-Chain Storage", + amount: "< 0.01", + tokenId: NEAR_TOKEN_DENOM, + }, + ]; + + return ( +
+ + Breakdown + + +
+ {totalFees.map( + ({ + display = true, + label, + amount, + percentage, + tokenId = props.tokenId, + }) => + display && ( +
+ + {label + (percentage ? ` (${percentage}%)` : "")} + + + + + +
+ ), + )} +
+
+ ); +}; diff --git a/src/modules/donation/components/DonationConfirmation.tsx b/src/modules/donation/components/DonationConfirmation.tsx new file mode 100644 index 00000000..be6ddf6f --- /dev/null +++ b/src/modules/donation/components/DonationConfirmation.tsx @@ -0,0 +1,158 @@ +import { useCallback, useState } from "react"; + +import { Pencil } from "lucide-react"; +import { UseFormReturn } from "react-hook-form"; + +import { + Button, + DialogDescription, + DialogHeader, + DialogTitle, + FormControl, + FormField, + FormItem, + FormLabel, + Textarea, +} from "@/common/ui/components"; +import { CheckboxField } from "@/common/ui/form-fields"; +import { cn } from "@/common/ui/utils"; +import { TotalTokenValue } from "@/modules/core"; +import { ProfileLink } from "@/modules/profile"; + +import { DonationBreakdown } from "./DonationBreakdown"; +import { useDonationFees } from "../hooks/fees"; +import { DonationInputs } from "../models"; + +export type DonationConfirmationProps = { + form: UseFormReturn; +}; + +export const DonationConfirmation: React.FC = ({ + form, +}) => { + const [isMessageFieldVisible, setIsMessageFieldVisible] = useState(false); + const values = form.watch(); + const fees = useDonationFees(values); + + const onAddNoteClick = useCallback(() => { + setIsMessageFieldVisible(true); + form.setValue("message", "", { shouldDirty: true }); + }, [form]); + + const onDeleteNoteClick = useCallback(() => { + setIsMessageFieldVisible(false); + form.resetField("message"); + }, [form]); + + const totalAmount = + values.potDonationDistribution?.reduce( + (total, { amount }) => total + amount, + 0.0, + ) ?? values.amount; + + const { protocolFeeRecipientAccountId, protocolFeePercent, chefFeePercent } = + fees; + + return ( + <> + + {"Confirm donation"} + + + +
+ + Total amount + + + +
+ + + +
+ {protocolFeeRecipientAccountId !== undefined && ( + ( + + {`Remove ${protocolFeePercent}% Protocol Fees`} + + + } + /> + )} + /> + )} + + {values.potAccountId && ( + ( + + {`Remove ${chefFeePercent}% Chef Fees`} + + {values.potAccountId && ( + + )} + + } + /> + )} + /> + )} +
+ + {values.recipientAccountId && ( + { + const isSpecified = typeof field.value === "string"; + + return ( + + + + +