From 3f9bd3ef28fa3299e0f10493d7348227587ac442 Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Tue, 1 Oct 2024 16:44:05 -0700 Subject: [PATCH] fix(staking): localize numbers & dates the best English variant --- apps/staking/package.json | 1 + apps/staking/src/api.ts | 114 --- .../src/components/AccountHistory/index.tsx | 137 ---- .../src/components/AccountSummary/index.tsx | 13 +- apps/staking/src/components/Date/index.tsx | 28 + .../OracleIntegrityStaking/index.tsx | 11 +- .../src/components/ProgramSection/index.tsx | 7 +- .../src/components/Root/i18n-provider.tsx | 30 + apps/staking/src/components/Root/index.tsx | 91 +-- .../src/components/SparkChart/index.tsx | 43 +- .../src/components/StakingTimeline/index.tsx | 20 +- apps/staking/src/components/Tokens/index.tsx | 11 +- apps/staking/src/hooks/use-api.tsx | 1 - pnpm-lock.yaml | 699 ++++++++---------- 14 files changed, 476 insertions(+), 730 deletions(-) delete mode 100644 apps/staking/src/components/AccountHistory/index.tsx create mode 100644 apps/staking/src/components/Date/index.tsx create mode 100644 apps/staking/src/components/Root/i18n-provider.tsx diff --git a/apps/staking/package.json b/apps/staking/package.json index a593011e9f..ee722a591e 100644 --- a/apps/staking/package.json +++ b/apps/staking/package.json @@ -34,6 +34,7 @@ "@solana/wallet-adapter-react-ui": "^0.9.27", "@solana/wallet-adapter-wallets": "0.19.10", "@solana/web3.js": "^1.95.2", + "bcp-47": "^2.1.0", "clsx": "^2.1.1", "dnum": "^2.13.1", "framer-motion": "^11.3.8", diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 0b95a6a31b..26b012a94f 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -1,6 +1,3 @@ -// TODO remove these disables when moving off the mock APIs -/* eslint-disable @typescript-eslint/no-unused-vars */ - import type { HermesClient, PublisherCaps } from "@pythnetwork/hermes-client"; import { epochToDate, @@ -86,68 +83,6 @@ export type StakeDetails = ReturnType< (typeof StakeDetails)[keyof typeof StakeDetails] >; -export enum AccountHistoryItemType { - AddTokens, - LockedDeposit, - Withdrawal, - RewardsCredited, - Claim, - Slash, - Unlock, - StakeCreated, - StakeFinishedWarmup, - UnstakeCreated, - UnstakeExitedCooldown, -} - -const AccountHistoryAction = { - AddTokens: () => ({ type: AccountHistoryItemType.AddTokens as const }), - LockedDeposit: (unlockDate: Date) => ({ - type: AccountHistoryItemType.LockedDeposit as const, - unlockDate, - }), - Withdrawal: () => ({ type: AccountHistoryItemType.Withdrawal as const }), - RewardsCredited: () => ({ - type: AccountHistoryItemType.RewardsCredited as const, - }), - Claim: () => ({ type: AccountHistoryItemType.Claim as const }), - Slash: (publisherName: string) => ({ - type: AccountHistoryItemType.Slash as const, - publisherName, - }), - Unlock: () => ({ type: AccountHistoryItemType.Unlock as const }), - StakeCreated: (details: StakeDetails) => ({ - type: AccountHistoryItemType.StakeCreated as const, - details, - }), - StakeFinishedWarmup: (details: StakeDetails) => ({ - type: AccountHistoryItemType.StakeFinishedWarmup as const, - details, - }), - UnstakeCreated: (details: StakeDetails) => ({ - type: AccountHistoryItemType.UnstakeCreated as const, - details, - }), - UnstakeExitedCooldown: (details: StakeDetails) => ({ - type: AccountHistoryItemType.UnstakeExitedCooldown as const, - details, - }), -}; - -export type AccountHistoryAction = ReturnType< - (typeof AccountHistoryAction)[keyof typeof AccountHistoryAction] ->; - -export type AccountHistory = { - timestamp: Date; - action: AccountHistoryAction; - amount: bigint; - accountTotal: bigint; - availableToWithdraw: bigint; - availableRewards: bigint; - locked: bigint; -}[]; - export const getAllStakeAccountAddresses = async ( client: PythStakingClient, ): Promise => client.getAllStakeAccountPositions(); @@ -325,14 +260,6 @@ const getPublisherCap = (publisherCaps: PublisherCaps, publisher: PublicKey) => )?.cap ?? 0, ); -export const loadAccountHistory = async ( - _client: PythStakingClient, - _stakeAccount: PublicKey, -): Promise => { - await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); - return mkMockHistory(); -}; - export const createStakeAccountAndDeposit = async ( client: PythStakingClient, amount: bigint, @@ -462,44 +389,3 @@ export const optPublisherOut = async ( ): Promise => { await client.removePublisherStakeAccount(stakeAccount, publisherKey); }; - -const MOCK_DELAY = 500; - -const mkMockHistory = (): AccountHistory => [ - { - timestamp: new Date("2024-06-10T00:00:00Z"), - action: AccountHistoryAction.AddTokens(), - amount: 2_000_000n, - accountTotal: 2_000_000n, - availableRewards: 0n, - availableToWithdraw: 2_000_000n, - locked: 0n, - }, - { - timestamp: new Date("2024-06-14T02:00:00Z"), - action: AccountHistoryAction.RewardsCredited(), - amount: 200n, - accountTotal: 2_000_000n, - availableRewards: 200n, - availableToWithdraw: 2_000_000n, - locked: 0n, - }, - { - timestamp: new Date("2024-06-16T08:00:00Z"), - action: AccountHistoryAction.Claim(), - amount: 200n, - accountTotal: 2_000_200n, - availableRewards: 0n, - availableToWithdraw: 2_000_200n, - locked: 0n, - }, - { - timestamp: new Date("2024-06-16T08:00:00Z"), - action: AccountHistoryAction.Slash("Cboe"), - amount: 1000n, - accountTotal: 1_999_200n, - availableRewards: 0n, - availableToWithdraw: 1_999_200n, - locked: 0n, - }, -]; diff --git a/apps/staking/src/components/AccountHistory/index.tsx b/apps/staking/src/components/AccountHistory/index.tsx deleted file mode 100644 index 31bf9230cd..0000000000 --- a/apps/staking/src/components/AccountHistory/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { ArrowPathIcon } from "@heroicons/react/24/outline"; - -import { - type AccountHistoryAction, - type StakeDetails, - AccountHistoryItemType, - StakeType, -} from "../../api"; -import type { States, StateType as ApiStateType } from "../../hooks/use-api"; -import { StateType, useData } from "../../hooks/use-data"; -import { Tokens } from "../Tokens"; - -const ONE_SECOND_IN_MS = 1000; -const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; -const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS; - -type Props = { api: States[ApiStateType.Loaded] }; - -export const AccountHistory = ({ api }: Props) => { - const history = useData(api.accountHistoryCacheKey, api.loadAccountHistory, { - refreshInterval: REFRESH_INTERVAL, - }); - - switch (history.type) { - case StateType.NotLoaded: - case StateType.Loading: { - return ; - } - case StateType.Error: { - return

Uh oh, an error occured!

; - } - case StateType.Loaded: { - return ( - - - - - - - - - - - - - - {history.data.map( - ( - { - accountTotal, - action, - amount, - availableRewards, - availableToWithdraw, - locked, - timestamp, - }, - i, - ) => ( - - - - - - - - - - ), - )} - -
TimestampDescriptionAmountAccount TotalAvailable RewardsAvailable to WithdrawLocked
{timestamp.toLocaleString()} - {action} - - {amount} - - {accountTotal} - - {availableRewards} - - {availableToWithdraw} - - {locked} -
- ); - } - } -}; - -const Description = ({ children }: { children: AccountHistoryAction }) => { - switch (children.type) { - case AccountHistoryItemType.Claim: { - return "Rewards claimed"; - } - case AccountHistoryItemType.AddTokens: { - return "Tokens added"; - } - case AccountHistoryItemType.LockedDeposit: { - return `Locked tokens deposited, unlocking ${children.unlockDate.toLocaleString()}`; - } - case AccountHistoryItemType.RewardsCredited: { - return "Rewards credited"; - } - case AccountHistoryItemType.Slash: { - return `Staked tokens slashed from ${children.publisherName}`; - } - case AccountHistoryItemType.StakeCreated: { - return `Created stake position for ${getStakeDetails(children.details)}`; - } - case AccountHistoryItemType.StakeFinishedWarmup: { - return `Warmup complete for position for ${getStakeDetails(children.details)}`; - } - case AccountHistoryItemType.Unlock: { - return "Locked tokens unlocked"; - } - case AccountHistoryItemType.UnstakeCreated: { - return `Requested unstake for position for ${getStakeDetails(children.details)}`; - } - case AccountHistoryItemType.UnstakeExitedCooldown: { - return `Cooldown completed for ${getStakeDetails(children.details)}`; - } - case AccountHistoryItemType.Withdrawal: { - return "Tokens withdrawn to wallet"; - } - } -}; - -const getStakeDetails = (details: StakeDetails): string => { - switch (details.type) { - case StakeType.Governance: { - return "Governance Staking"; - } - case StakeType.IntegrityStaking: { - return `Integrity Staking, publisher: ${details.publisherName}`; - } - } -}; diff --git a/apps/staking/src/components/AccountSummary/index.tsx b/apps/staking/src/components/AccountSummary/index.tsx index 3f3fe598ad..6f46052658 100644 --- a/apps/staking/src/components/AccountSummary/index.tsx +++ b/apps/staking/src/components/AccountSummary/index.tsx @@ -17,6 +17,7 @@ import background from "./background.png"; import { type States, StateType as ApiStateType } from "../../hooks/use-api"; import { StateType, useAsync } from "../../hooks/use-async"; import { Button } from "../Button"; +import { Date } from "../Date"; import { ModalDialog } from "../ModalDialog"; import { Tokens } from "../Tokens"; import { TransferButton } from "../TransferButton"; @@ -82,7 +83,7 @@ export const AccountSummary = ({ {lastSlash && (

{lastSlash.amount} were slashed on{" "} - {lastSlash.date.toLocaleString()} + {lastSlash.date}

)} @@ -112,7 +113,7 @@ export const AccountSummary = ({ {unlockSchedule.map((unlock, i) => ( - {unlock.date.toLocaleString()} + {unlock.date} {unlock.amount} @@ -238,7 +239,7 @@ export const AccountSummary = ({ <> Rewards expire one year from the epoch in which they were earned. You have rewards expiring on{" "} - {expiringRewards.toLocaleDateString()}. + {expiringRewards}. ), })} @@ -311,13 +312,13 @@ const OisUnstake = ({ {cooldown > 0n && (
{cooldown} end{" "} - {epochToDate(currentEpoch + 2n).toLocaleString()} + {epochToDate(currentEpoch + 2n)}
)} {cooldown2 > 0n && (
{cooldown2} end{" "} - {epochToDate(currentEpoch + 1n).toLocaleString()} + {epochToDate(currentEpoch + 1n)}
)} @@ -439,7 +440,7 @@ const ClaimDialog = ({
Rewards expire one year from the epoch in which they were earned. You have rewards expiring on{" "} - {expiringRewards.toLocaleDateString()}. + {expiringRewards}.
)} diff --git a/apps/staking/src/components/Date/index.tsx b/apps/staking/src/components/Date/index.tsx new file mode 100644 index 0000000000..467aaeb92c --- /dev/null +++ b/apps/staking/src/components/Date/index.tsx @@ -0,0 +1,28 @@ +import { useMemo, type HTMLProps } from "react"; +import { useDateFormatter, type DateFormatterOptions } from "react-aria"; + +type Props = Omit, "children"> & { + children: Date; + options?: DateFormatterOptions | undefined | "time"; +}; + +export const Date = ({ children, options, ...props }: Props) => { + const formatter = useDateFormatter( + options === "time" + ? { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + } + : options, + ); + const value = useMemo( + () => formatter.format(children), + [formatter, children], + ); + + return {value}; +}; diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index 2e7f200801..865550945d 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -21,7 +21,7 @@ import { type HTMLAttributes, type FormEvent, } from "react"; -import { useFilter } from "react-aria"; +import { useFilter, useCollator } from "react-aria"; import { SearchField, Input, @@ -586,6 +586,7 @@ const PublisherList = ({ const [sort, setSort] = useState(SortOption.RemainingPoolDescending); const filter = useFilter({ sensitivity: "base", usage: "search" }); const [currentPage, setPage] = useState(1); + const collator = useCollator(); const filteredSortedPublishers = useMemo( () => publishers @@ -605,9 +606,9 @@ const PublisherList = ({ return 1; } } - return doSort(a, b, yieldRate, sort); + return doSort(collator, a, b, yieldRate, sort); }), - [publishers, search, sort, filter, yieldRate, yoursFirst], + [publishers, search, sort, filter, yieldRate, yoursFirst, collator], ); const paginatedPublishers = useMemo( @@ -917,6 +918,7 @@ const getPageRange = ( }; const doSort = ( + collator: Intl.Collator, a: PublisherProps["publisher"], b: PublisherProps["publisher"], yieldRate: bigint, @@ -925,7 +927,8 @@ const doSort = ( switch (sort) { case SortOption.PublisherNameAscending: case SortOption.PublisherNameDescending: { - const value = (a.name ?? a.publicKey.toBase58()).localeCompare( + const value = collator.compare( + a.name ?? a.publicKey.toBase58(), b.name ?? b.publicKey.toBase58(), ); return sort === SortOption.PublisherNameAscending ? -1 * value : value; diff --git a/apps/staking/src/components/ProgramSection/index.tsx b/apps/staking/src/components/ProgramSection/index.tsx index 7e4ece04fc..c5febaa20c 100644 --- a/apps/staking/src/components/ProgramSection/index.tsx +++ b/apps/staking/src/components/ProgramSection/index.tsx @@ -5,6 +5,7 @@ import type { HTMLAttributes, ReactNode, ComponentProps } from "react"; import { DialogTrigger } from "react-aria-components"; import { Button } from "../Button"; +import { Date } from "../Date"; import { ModalDialog } from "../ModalDialog"; import { StakingTimeline } from "../StakingTimeline"; import { Tokens } from "../Tokens"; @@ -159,7 +160,7 @@ const TokenOverview = ({ {...(warmup > 0n && { details: (
- Staking {epochToDate(currentEpoch + 1n).toLocaleString()} + Staking {epochToDate(currentEpoch + 1n)}
), })} @@ -210,13 +211,13 @@ const TokenOverview = ({ {cooldown > 0n && (
{cooldown} end{" "} - {epochToDate(currentEpoch + 2n).toLocaleString()} + {epochToDate(currentEpoch + 2n)}
)} {cooldown2 > 0n && (
{cooldown2} end{" "} - {epochToDate(currentEpoch + 1n).toLocaleString()} + {epochToDate(currentEpoch + 1n)}
)} diff --git a/apps/staking/src/components/Root/i18n-provider.tsx b/apps/staking/src/components/Root/i18n-provider.tsx new file mode 100644 index 0000000000..b79480218d --- /dev/null +++ b/apps/staking/src/components/Root/i18n-provider.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { parse } from "bcp-47"; +import { useMemo, type ComponentProps } from "react"; +import { I18nProvider as I18nProviderBase, useIsSSR } from "react-aria"; + +const SUPPORTED_LANGUAGES = new Set(["en"]); +const DEFAULT_LOCALE = "en-US"; + +export const I18nProvider = ( + props: Omit, "locale">, +) => { + const isSSR = useIsSSR(); + const locale = useMemo( + () => + isSSR + ? DEFAULT_LOCALE + : window.navigator.languages.find((locale) => { + const language = parse(locale).language; + return ( + language !== undefined && + language !== null && + SUPPORTED_LANGUAGES.has(language) + ); + }) ?? DEFAULT_LOCALE, + [isSSR], + ); + + return ; +}; diff --git a/apps/staking/src/components/Root/index.tsx b/apps/staking/src/components/Root/index.tsx index 6686a104dc..2402fb42ad 100644 --- a/apps/staking/src/components/Root/index.tsx +++ b/apps/staking/src/components/Root/index.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import { Red_Hat_Text, Red_Hat_Mono } from "next/font/google"; import type { ReactNode, CSSProperties } from "react"; +import { I18nProvider } from "./i18n-provider"; import { RestrictedRegionBanner } from "./restricted-region-banner"; import { IS_PRODUCTION_SERVER, @@ -39,48 +40,50 @@ type Props = { }; export const Root = ({ children }: Props) => ( - - - - - - -
- - - {children} - -