diff --git a/package-lock.json b/package-lock.json index 6545d4c..f7b50b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.1", "@popperjs/core": "^2.11.8", + "decimal.js-light": "^2.5.1", "react-hook-form": "^7.54.0", "react-popper": "^2.3.0", "tw-colors": "^3.3.2" @@ -5367,6 +5368,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", diff --git a/package.json b/package.json index e9f32f1..2565c41 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@popperjs/core": "^2.11.8", "react-hook-form": "^7.54.0", "react-popper": "^2.3.0", + "decimal.js-light": "^2.5.1", "tw-colors": "^3.3.2" } -} +} \ No newline at end of file diff --git a/src/components/CounterButton/CounterButton.stories.tsx b/src/components/CounterButton/CounterButton.stories.tsx new file mode 100644 index 0000000..ff1dbca --- /dev/null +++ b/src/components/CounterButton/CounterButton.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import { CounterButton } from "./CounterButton"; + +const meta: Meta = { + component: CounterButton, + tags: ["autodocs"], + args: { + onAdd: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + counter: 0, + max: 5, + }, +}; + +export const WithCounter: Story = { + args: { + counter: 2, + max: 5, + }, +}; + +export const AlmostAtMax: Story = { + args: { + counter: 4, + max: 5, + }, +}; + +export const AtMaxCapacity: Story = { + args: { + counter: 5, + max: 5, + }, +}; + +export const AlwaysShowCounter: Story = { + args: { + counter: 0, + max: 3, + alwaysShowCounter: true, + }, +}; + +export const AlwaysShowCounterWithValue: Story = { + args: { + counter: 1, + max: 3, + alwaysShowCounter: true, + }, +}; + +export const SingleMax: Story = { + args: { + counter: 0, + max: 1, + }, +}; + +export const SingleMaxAtCapacity: Story = { + args: { + counter: 1, + max: 1, + }, +}; diff --git a/src/components/CounterButton/CounterButton.tsx b/src/components/CounterButton/CounterButton.tsx new file mode 100644 index 0000000..177a1dd --- /dev/null +++ b/src/components/CounterButton/CounterButton.tsx @@ -0,0 +1,37 @@ +import { AiOutlinePlus } from "react-icons/ai"; +import { twJoin } from "tailwind-merge"; + +interface CounterButtonProps { + counter: number; + max: number; + onAdd: () => void; + alwaysShowCounter?: boolean; +} + +export function CounterButton({ counter, max, onAdd, alwaysShowCounter = false }: CounterButtonProps) { + const isClickable = counter < max; + const showsCounter = (0 < counter && 1 < max) || (alwaysShowCounter && counter === 0); + + return ( +
+ {isClickable && ( +
+ +
+ )} + {showsCounter && ( +
+ {counter}/{max} +
+ )} +
+ ); +} diff --git a/src/components/CounterButton/index.tsx b/src/components/CounterButton/index.tsx new file mode 100644 index 0000000..bdc44d6 --- /dev/null +++ b/src/components/CounterButton/index.tsx @@ -0,0 +1 @@ +export { CounterButton } from "./CounterButton"; diff --git a/src/components/FinalityProviderItem/FinalityProviderItem.stories.tsx b/src/components/FinalityProviderItem/FinalityProviderItem.stories.tsx new file mode 100644 index 0000000..73921c3 --- /dev/null +++ b/src/components/FinalityProviderItem/FinalityProviderItem.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FinalityProviderItem } from "./FinalityProviderItem"; + +const meta: Meta = { + component: FinalityProviderItem, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +}; + +export default meta; + +type Story = StoryObj; + +const mockProvider = { + logo_url: "/images/fps/lombard.jpeg", + rank: 1, + description: { + moniker: "Lombard Protocol", + }, +}; + +const mockProviderWithoutLogo = { + logo_url: undefined, + rank: 5, + description: { + moniker: "Bitcoin Staking Provider", + }, +}; + +export const Default: Story = { + args: { + bsnId: "bsn123", + bsnName: "Babylon", + bsnLogoUrl: "/images/fps/pumpbtc.jpeg", + provider: mockProvider, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const WithoutBsnLogo: Story = { + args: { + bsnId: "bsn456", + bsnName: "Babylon Chain", + bsnLogoUrl: undefined, + provider: mockProvider, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const WithoutProviderLogo: Story = { + args: { + bsnId: "bsn789", + bsnName: "Babylon Network", + bsnLogoUrl: "/images/fps/solv.jpeg", + provider: mockProviderWithoutLogo, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const HighRankProvider: Story = { + args: { + bsnId: "bsn999", + bsnName: "Babylon Testnet", + bsnLogoUrl: "/images/fps/pumpbtc.jpeg", + provider: { + logo_url: "/images/fps/solv.jpeg", + rank: 99, + description: { + moniker: "High Rank Provider", + }, + }, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const LongNames: Story = { + args: { + bsnId: "bsn_very_long_id_123456789", + bsnName: "Very Long Babylon Network Name That Might Wrap", + bsnLogoUrl: "/images/fps/lombard.jpeg", + provider: { + logo_url: "/images/fps/pumpbtc.jpeg", + rank: 42, + description: { + moniker: "Very Long Finality Provider Name That Should Handle Text Overflow", + }, + }, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; diff --git a/src/components/FinalityProviderItem/FinalityProviderItem.tsx b/src/components/FinalityProviderItem/FinalityProviderItem.tsx new file mode 100644 index 0000000..f69de9b --- /dev/null +++ b/src/components/FinalityProviderItem/FinalityProviderItem.tsx @@ -0,0 +1,60 @@ +import { Avatar } from "../Avatar"; +import { Text } from "../Text"; +import { FinalityProviderLogo } from "../FinalityProviderLogo"; + +interface ProviderDescription { + moniker?: string; +} + +interface Provider { + logo_url?: string; + rank: number; + description?: ProviderDescription; +} + +interface FinalityProviderItemProps { + bsnId: string; + bsnName: string; + bsnLogoUrl?: string; + provider: Provider; + onRemove: (bsnId?: string) => void; +} + +export function FinalityProviderItem({ bsnId, bsnName, bsnLogoUrl, provider, onRemove }: FinalityProviderItemProps) { + if (!provider) return null; + + const renderBsnLogo = () => { + if (!bsnLogoUrl) return null; + + return ; + }; + + return ( +
+
+ +
+
+ {renderBsnLogo()} + {bsnName} +
+ + {provider.description?.moniker} + +
+
+ + +
+ ); +} diff --git a/src/components/FinalityProviderItem/index.ts b/src/components/FinalityProviderItem/index.ts new file mode 100644 index 0000000..8122f61 --- /dev/null +++ b/src/components/FinalityProviderItem/index.ts @@ -0,0 +1 @@ +export * from "./FinalityProviderItem"; diff --git a/src/components/FinalityProviderLogo/FinalityProviderLogo.stories.tsx b/src/components/FinalityProviderLogo/FinalityProviderLogo.stories.tsx new file mode 100644 index 0000000..5337342 --- /dev/null +++ b/src/components/FinalityProviderLogo/FinalityProviderLogo.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FinalityProviderLogo } from "./FinalityProviderLogo"; + +const meta: Meta = { + component: FinalityProviderLogo, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const WithImage: Story = { + args: { + logoUrl: "/images/fps/lombard.jpeg", + rank: 1, + moniker: "Lombard Protocol", + }, +}; + +export const WithImageLarge: Story = { + args: { + logoUrl: "/images/fps/pumpbtc.jpeg", + rank: 2, + moniker: "PumpBTC", + size: "lg", + }, +}; + +export const WithImageSmall: Story = { + args: { + logoUrl: "/images/fps/solv.jpeg", + rank: 3, + moniker: "Solv Protocol", + size: "sm", + }, +}; + +export const FallbackWithMoniker: Story = { + args: { + rank: 1, + moniker: "Babylon Network", + }, +}; + +export const FallbackWithoutMoniker: Story = { + args: { + rank: 5, + }, +}; + +export const FallbackLarge: Story = { + args: { + rank: 2, + moniker: "Large Provider", + size: "lg", + }, +}; + +export const FallbackSmall: Story = { + args: { + rank: 3, + moniker: "Small Provider", + size: "sm", + }, +}; + +export const InvalidImage: Story = { + args: { + logoUrl: "/invalid-image-url.jpg", + rank: 4, + moniker: "Provider with Invalid Image", + }, +}; diff --git a/src/components/FinalityProviderLogo/FinalityProviderLogo.tsx b/src/components/FinalityProviderLogo/FinalityProviderLogo.tsx new file mode 100644 index 0000000..051103f --- /dev/null +++ b/src/components/FinalityProviderLogo/FinalityProviderLogo.tsx @@ -0,0 +1,53 @@ +import { Text } from "../Text"; +import { useState } from "react"; +import { twMerge } from "tailwind-merge"; + +interface FinalityProviderLogoProps { + logoUrl?: string; + rank: number; + moniker?: string; + className?: string; + size?: "lg" | "md" | "sm"; +} + +const STYLES = { + lg: { + logo: "size-10", + subLogo: "text-[0.8rem]", + }, + md: { + logo: "size-6", + subLogo: "text-[0.5rem]", + }, + sm: { + logo: "size-5", + subLogo: "text-[0.4rem]", + }, +}; + +export const FinalityProviderLogo = ({ logoUrl, rank, moniker, size = "md", className }: FinalityProviderLogoProps) => { + const [imageError, setImageError] = useState(false); + const styles = STYLES[size]; + + const fallbackLabel = moniker?.charAt(0).toUpperCase() ?? String(rank); + + return ( + + {logoUrl && !imageError ? ( + {moniker setImageError(true)} + /> + ) : ( + + {fallbackLabel} + + )} + + ); +}; diff --git a/src/components/FinalityProviderLogo/index.ts b/src/components/FinalityProviderLogo/index.ts new file mode 100644 index 0000000..a89d1cd --- /dev/null +++ b/src/components/FinalityProviderLogo/index.ts @@ -0,0 +1 @@ +export { FinalityProviderLogo } from "./FinalityProviderLogo"; diff --git a/src/components/ProvidersList/ProvidersList.stories.tsx b/src/components/ProvidersList/ProvidersList.stories.tsx new file mode 100644 index 0000000..f7d5000 --- /dev/null +++ b/src/components/ProvidersList/ProvidersList.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ProvidersList } from "./ProvidersList"; + +const meta: Meta = { + component: ProvidersList, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +}; + +export default meta; + +type Story = StoryObj; + +const mockProviders = [ + { + bsnId: "bsn123", + bsnName: "Babylon", + bsnLogoUrl: "/images/fps/pumpbtc.jpeg", + provider: { + logo_url: "/images/fps/lombard.jpeg", + rank: 1, + description: { + moniker: "Lombard Protocol", + }, + }, + }, + { + bsnId: "bsn456", + bsnName: "Babylon Chain", + bsnLogoUrl: "/images/fps/solv.jpeg", + provider: { + logo_url: "/images/fps/pumpbtc.jpeg", + rank: 2, + description: { + moniker: "PumpBTC Provider", + }, + }, + }, + { + bsnId: "bsn789", + bsnName: "Babylon Network", + bsnLogoUrl: undefined, + provider: { + logo_url: "/images/fps/solv.jpeg", + rank: 3, + description: { + moniker: "Solv Protocol", + }, + }, + }, +]; + +export const Default: Story = { + args: { + items: mockProviders, + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const SingleProvider: Story = { + args: { + items: [mockProviders[0]], + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const EmptyList: Story = { + args: { + items: [], + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const WithoutLogos: Story = { + args: { + items: [ + { + bsnId: "bsn111", + bsnName: "Babylon", + bsnLogoUrl: undefined, + provider: { + logo_url: undefined, + rank: 1, + description: { + moniker: "Provider Without Logo", + }, + }, + }, + { + bsnId: "bsn222", + bsnName: "Babylon Chain", + bsnLogoUrl: undefined, + provider: { + logo_url: undefined, + rank: 2, + description: { + moniker: "Another Provider", + }, + }, + }, + ], + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; + +export const MixedProviders: Story = { + args: { + items: [ + { + bsnId: "bsn001", + bsnName: "Babylon Premium", + bsnLogoUrl: "/images/fps/lombard.jpeg", + provider: { + logo_url: "/images/fps/pumpbtc.jpeg", + rank: 1, + description: { + moniker: "Top Ranked Provider", + }, + }, + }, + { + bsnId: "bsn002", + bsnName: "Babylon Standard", + bsnLogoUrl: undefined, + provider: { + logo_url: "/images/fps/solv.jpeg", + rank: 15, + description: { + moniker: "Mid Tier Provider", + }, + }, + }, + { + bsnId: "bsn003", + bsnName: "Babylon Basic", + bsnLogoUrl: "/images/fps/pumpbtc.jpeg", + provider: { + logo_url: undefined, + rank: 99, + description: { + moniker: "Basic Provider Service", + }, + }, + }, + ], + onRemove: (bsnId) => alert(`Remove clicked for ${bsnId}`), + }, +}; diff --git a/src/components/ProvidersList/ProvidersList.tsx b/src/components/ProvidersList/ProvidersList.tsx new file mode 100644 index 0000000..90fd9f5 --- /dev/null +++ b/src/components/ProvidersList/ProvidersList.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; + +import { FinalityProviderItem } from "../FinalityProviderItem/FinalityProviderItem"; + +interface ProviderDescription { + moniker?: string; +} + +interface Provider { + logo_url?: string; + rank: number; + description?: ProviderDescription; +} + +export interface ProviderItem { + bsnId: string; + bsnName: string; + bsnLogoUrl?: string; + provider: Provider; +} + +interface ProvidersListProps { + items: ProviderItem[]; + onRemove: (bsnId?: string) => void; +} + +export function ProvidersList({ items, onRemove }: ProvidersListProps) { + const values = useMemo(() => items, [items]); + + if (values.length === 0) return null; + + return ( +
+ {values.map(({ bsnId, bsnName, bsnLogoUrl, provider }) => ( + + ))} +
+ ); +} diff --git a/src/components/ProvidersList/index.ts b/src/components/ProvidersList/index.ts new file mode 100644 index 0000000..8c1aa06 --- /dev/null +++ b/src/components/ProvidersList/index.ts @@ -0,0 +1 @@ +export { ProvidersList } from "./ProvidersList"; diff --git a/src/components/SubSection/SubSection.stories.tsx b/src/components/SubSection/SubSection.stories.tsx new file mode 100644 index 0000000..a9979b2 --- /dev/null +++ b/src/components/SubSection/SubSection.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { SubSection } from "./SubSection"; +import { Text } from "../Text"; + +const meta: Meta = { + component: SubSection, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: Lorem ipsum dolor sit amet, + }, +}; diff --git a/src/components/SubSection/SubSection.tsx b/src/components/SubSection/SubSection.tsx new file mode 100644 index 0000000..3a1fa4a --- /dev/null +++ b/src/components/SubSection/SubSection.tsx @@ -0,0 +1,16 @@ +import type { CSSProperties, ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; + +export const SubSection = ({ + children, + style, + className, +}: { + children: ReactNode; + style?: CSSProperties; + className?: string; +}) => ( +
+ {children} +
+); diff --git a/src/components/SubSection/index.ts b/src/components/SubSection/index.ts new file mode 100644 index 0000000..54fd90c --- /dev/null +++ b/src/components/SubSection/index.ts @@ -0,0 +1 @@ +export { SubSection } from "./SubSection"; diff --git a/src/components/SubSection/index.tsx b/src/components/SubSection/index.tsx new file mode 100644 index 0000000..54fd90c --- /dev/null +++ b/src/components/SubSection/index.tsx @@ -0,0 +1 @@ +export { SubSection } from "./SubSection"; diff --git a/src/index.tsx b/src/index.tsx index 1eec8f5..3fc3eae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,9 @@ export * from "./components/Card"; export * from "./components/Toggle"; export * from "./components/List"; export * from "./components/Badge"; +export * from "./components/SubSection"; +export * from "./components/FinalityProviderLogo"; +export * from "./components/CounterButton"; export * from "./widgets/form/Form"; export * from "./widgets/form/NumberField"; @@ -26,6 +29,11 @@ export * from "./widgets/form/SelectField"; export * from "./widgets/form/HiddenField"; export * from "./widgets/form/hooks"; +export * from "./widgets/new-design/AmountSubsection"; +export * from "./widgets/new-design/FinalityProviderSubsection"; +export * from "./widgets/new-design/FeesSection"; +export * from "./widgets/new-design/PreviewModal"; + export * from "./components/ListLegacy"; export { ScrollLocker } from "./context/Dialog.context"; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 0000000..66cd837 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,62 @@ +import { Decimal } from "decimal.js-light"; + +export interface FormatCurrencyOptions { + prefix?: T; + precision?: number; + zeroDisplay?: string; + format?: Intl.NumberFormatOptions; +} + +const defaultFormatOptions: Required = { + prefix: "$", + precision: 2, + zeroDisplay: "-", + format: { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, +}; + +export function formatCurrency(currencyValue: number, options: FormatCurrencyOptions = {}): string { + const { prefix, precision, zeroDisplay, format } = { + ...defaultFormatOptions, + ...options, + }; + + if (currencyValue === 0) return zeroDisplay; + + const formatted = currencyValue.toLocaleString("en", format ?? { maximumFractionDigits: precision }); + + return `${prefix}${formatted}`; +} + +/** + * Converts token amount to formatted currency string + * @param amount The amount of token + * @param price The price of token in currency + * @param options Formatting options + */ +export function calculateTokenValueInCurrency(amount: number, price: number, options?: FormatCurrencyOptions): string { + const currencyValue = amount * price; + return formatCurrency(currencyValue, options); +} + +/** + * Limits the number of decimal places of a given number to a specified maximum. + * + * @param value The original number that you want to limit the decimal places for. + * @param maxDecimals The maximum number of decimal places that the result should have. + * @returns The number rounded to the specified number of decimal places. + * + * @example + * maxDecimals(3.14159, 2); // returns 3.14 + * maxDecimals(1.005, 2); // returns 1.01 + * maxDecimals(10, 0); // returns 10 + * maxDecimals(0.00010000, 8); // returns 0.0001 + * maxDecimals(0.00010000, 8); // returns 0.0001 + * maxDecimals(3.141, 3); // returns 3.141 + * maxDecimals(3.149, 3); // returns 3.149 + */ +export const maxDecimals = (value: number, maxDecimals: number, rm?: number): number => { + return new Decimal(value).toDecimalPlaces(maxDecimals, rm).toNumber(); +}; diff --git a/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx b/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx new file mode 100644 index 0000000..31ebef7 --- /dev/null +++ b/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import * as yup from "yup"; + +import { AmountSubsection } from "./AmountSubsection"; +import { Form } from "@/widgets/form/Form"; + +const meta: Meta = { + component: AmountSubsection, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +// Define a simple validation schema for the field used in this story +const schema = yup + .object() + .shape({ + amount: yup.number().required().positive(), + }) + .required(); + +export const Default: Story = { + args: { + fieldName: "amount", + currencyIcon: "https://placehold.co/40x40", + currencyName: "BBN", + placeholder: "Enter Amount", + displayBalance: true, + balanceDetails: { + balance: 1234.56789, + symbol: "BBN", + price: 0.25, + displayUSD: true, + decimals: 8, + }, + }, + decorators: [ + (Story) => ( +
+ + + ), + ], +}; diff --git a/src/widgets/new-design/AmountSubsection/AmountSubsection.tsx b/src/widgets/new-design/AmountSubsection/AmountSubsection.tsx new file mode 100644 index 0000000..fac22c2 --- /dev/null +++ b/src/widgets/new-design/AmountSubsection/AmountSubsection.tsx @@ -0,0 +1,95 @@ +import { HiddenField } from "@/widgets/form/HiddenField"; +import { SubSection } from "@/components/SubSection"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { calculateTokenValueInCurrency, maxDecimals } from "@/utils/helpers"; + +interface BalanceDetails { + balance: number | string; + symbol: string; + price?: number; + displayUSD?: boolean; + decimals?: number; +} + +interface Props { + fieldName: string; + currencyIcon: string; + currencyName: string; + placeholder?: string; + displayBalance?: boolean; + balanceDetails?: BalanceDetails; + min?: string; + step?: string; + autoFocus?: boolean; +} + +export const AmountSubsection = ({ + fieldName, + currencyIcon, + currencyName, + displayBalance, + placeholder = "Enter Amount", + balanceDetails, + min = "0", + step = "any", + autoFocus = true, +}: Props) => { + const amount = useWatch({ name: fieldName, defaultValue: "" }); + const { setValue } = useFormContext(); + + const amountValue = parseFloat((amount as string) || "0"); + const amountUsd = calculateTokenValueInCurrency(amountValue, balanceDetails?.price ?? 0, { + zeroDisplay: "$0.00", + }); + + const handleInputChange = (e: React.ChangeEvent) => { + setValue(fieldName, e.target.value, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + } + }; + + return ( + +
+
+ {currencyName} +
{currencyName}
+
+ +
+ + + {balanceDetails && displayBalance ? ( +
+
+ Stakable:{" "} + + {maxDecimals(Number(balanceDetails.balance), balanceDetails.decimals ?? 8)} + {" "} + {balanceDetails.symbol} +
+ {balanceDetails.displayUSD && balanceDetails.price !== undefined &&
{amountUsd} USD
} +
+ ) : null} +
+ ); +}; diff --git a/src/widgets/new-design/AmountSubsection/index.tsx b/src/widgets/new-design/AmountSubsection/index.tsx new file mode 100644 index 0000000..63de84a --- /dev/null +++ b/src/widgets/new-design/AmountSubsection/index.tsx @@ -0,0 +1 @@ +export { AmountSubsection } from "./AmountSubsection"; diff --git a/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx b/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx new file mode 100644 index 0000000..d2695e8 --- /dev/null +++ b/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx @@ -0,0 +1,20 @@ +import { FeeItem } from "./FeeItem"; + +interface BBNFeeAmountProps { + amount: number | string; + coinSymbol: string; + hint?: string; + title?: string; + className?: string; + decimals?: number; +} + +export function BBNFeeAmount({ amount, coinSymbol, hint, title, className, decimals = 5 }: BBNFeeAmountProps) { + const formattedAmount = typeof amount === "number" ? amount.toFixed(decimals) : amount; + + return ( + + {formattedAmount} {coinSymbol} + + ); +} diff --git a/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx b/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx new file mode 100644 index 0000000..bee0a01 --- /dev/null +++ b/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx @@ -0,0 +1,28 @@ +import { FeeItem } from "./FeeItem"; + +interface BTCFeeAmountProps { + amount: number | string; + coinSymbol: string; + hint?: string; + title?: string; + className?: string; + decimals?: number; +} + +export function BTCFeeAmount({ amount, coinSymbol, hint, title, className, decimals = 8 }: BTCFeeAmountProps) { + const formattedAmount = + typeof amount === "number" + ? amount === 0 + ? "0" + : (() => { + const str = amount.toFixed(decimals); + return str.replace(/0+$/, "").replace(/\.$/, ""); + })() + : amount; + + return ( + + {formattedAmount} {coinSymbol} + + ); +} diff --git a/src/widgets/new-design/FeesSection/BTCFeeRate.tsx b/src/widgets/new-design/FeesSection/BTCFeeRate.tsx new file mode 100644 index 0000000..6b09bd3 --- /dev/null +++ b/src/widgets/new-design/FeesSection/BTCFeeRate.tsx @@ -0,0 +1,24 @@ +import { FeeItem } from "./FeeItem"; +import { Button } from "../../../components/Button"; +import { FaPen } from "react-icons/fa6"; + +interface BTCFeeRateProps { + value: number | string; + onEdit?: () => void; + title?: string; + className?: string; +} + +export function BTCFeeRate({ value, onEdit, title = "Network Fee Rate", className }: BTCFeeRateProps) { + return ( + + {value} sats/vB + + {onEdit && ( + + )} + + ); +} diff --git a/src/widgets/new-design/FeesSection/FeeItem.tsx b/src/widgets/new-design/FeesSection/FeeItem.tsx new file mode 100644 index 0000000..ef763f9 --- /dev/null +++ b/src/widgets/new-design/FeesSection/FeeItem.tsx @@ -0,0 +1,40 @@ +import { Text } from "../../../components/Text"; +import { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface FeeItemProps extends PropsWithChildren { + title: string; + className?: string; + hint?: string; +} + +export function FeeItem({ title, children, className, hint }: FeeItemProps) { + return ( +
+ + {title} + + + {!hint ? ( + + {children} + + ) : ( +
+ + {children} + + + {hint} + +
+ )} +
+ ); +} diff --git a/src/widgets/new-design/FeesSection/FeesSection.stories.tsx b/src/widgets/new-design/FeesSection/FeesSection.stories.tsx new file mode 100644 index 0000000..5c770e3 --- /dev/null +++ b/src/widgets/new-design/FeesSection/FeesSection.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FeesSection } from "./FeesSection"; + +const meta: Meta = { + component: FeesSection, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + feeRate: 5, + feeAmount: "0.0001", + coinSymbol: "BTC", + total: "0.0001", + }, +}; diff --git a/src/widgets/new-design/FeesSection/FeesSection.tsx b/src/widgets/new-design/FeesSection/FeesSection.tsx new file mode 100644 index 0000000..222106e --- /dev/null +++ b/src/widgets/new-design/FeesSection/FeesSection.tsx @@ -0,0 +1,57 @@ +import { twMerge } from "tailwind-merge"; + +import { SubSection } from "../../../components/SubSection"; +import { BTCFeeRate } from "./BTCFeeRate"; +import { BTCFeeAmount } from "./BTCFeeAmount"; +import { BBNFeeAmount } from "./BBNFeeAmount"; +import { Total } from "./Total"; + +interface FeesSectionProps { + className?: string; + feeRate: number | string; + onFeeRateEdit?: () => void; + feeAmount: number | string; + coinSymbol: string; + feeAmountHint?: string; + total: number | string; + totalHint?: string; + bbnFeeAmount?: number | string; + bbnCoinSymbol?: string; + bbnFeeAmountHint?: string; + bbnFeeDecimals?: number; +} + +export function FeesSection({ + className, + feeRate, + onFeeRateEdit, + feeAmount, + coinSymbol, + feeAmountHint, + total, + totalHint, + + bbnFeeAmount, + bbnCoinSymbol, + bbnFeeAmountHint, + bbnFeeDecimals, +}: FeesSectionProps) { + return ( + +
+ + + {bbnFeeAmount !== undefined && bbnCoinSymbol ? ( + + ) : null} +
+ +
+ + ); +} diff --git a/src/widgets/new-design/FeesSection/Total.tsx b/src/widgets/new-design/FeesSection/Total.tsx new file mode 100644 index 0000000..8e6c591 --- /dev/null +++ b/src/widgets/new-design/FeesSection/Total.tsx @@ -0,0 +1,42 @@ +import { Text } from "../../../components/Text"; +import { twMerge } from "tailwind-merge"; + +interface TotalProps { + total: number | string; + coinSymbol: string; + hint?: string; + title?: string; + className?: string; + decimals?: number; +} + +export function Total({ total, coinSymbol, hint, title = "Total", className, decimals = 8 }: TotalProps) { + const formattedTotal = + typeof total === "number" + ? total === 0 + ? "0" + : (() => { + const str = total.toFixed(decimals); + return str.replace(/0+$/, "").replace(/\.$/, ""); + })() + : total; + + return ( +
+ + {title} + + +
+ + {formattedTotal} {coinSymbol} + + {hint && ( + + {hint} + + )} +
+
+ ); +} diff --git a/src/widgets/new-design/FeesSection/index.ts b/src/widgets/new-design/FeesSection/index.ts new file mode 100644 index 0000000..a699366 --- /dev/null +++ b/src/widgets/new-design/FeesSection/index.ts @@ -0,0 +1,6 @@ +export * from "./FeesSection"; +export * from "./FeeItem"; +export * from "./Total"; +export * from "./BTCFeeRate"; +export * from "./BTCFeeAmount"; +export * from "./BBNFeeAmount"; diff --git a/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.stories.tsx b/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.stories.tsx new file mode 100644 index 0000000..cf55c93 --- /dev/null +++ b/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FinalityProviderSubsection } from "./FinalityProviderSubsection"; +import type { ProviderItem } from "../../../components/ProvidersList/ProvidersList"; + +const sampleItems: ProviderItem[] = [ + { + bsnId: "babylon", + bsnName: "Babylon Genesis", + bsnLogoUrl: "https://placehold.co/24x24?text=B", + provider: { + rank: 1, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Provider 1" }, + }, + }, +]; + +const multipleItems: ProviderItem[] = [ + { + bsnId: "babylon", + bsnName: "Babylon Genesis", + bsnLogoUrl: "https://placehold.co/24x24?text=B", + provider: { + rank: 1, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Babylon Provider" }, + }, + }, + { + bsnId: "ethereum", + bsnName: "Ethereum Bridge", + bsnLogoUrl: "https://placehold.co/24x24?text=E", + provider: { + rank: 2, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Ethereum Provider" }, + }, + }, +]; + +const maxCapacityItems: ProviderItem[] = [ + { + bsnId: "babylon", + bsnName: "Babylon Genesis", + bsnLogoUrl: "https://placehold.co/24x24?text=B", + provider: { + rank: 1, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Babylon Provider" }, + }, + }, + { + bsnId: "ethereum", + bsnName: "Ethereum Bridge", + bsnLogoUrl: "https://placehold.co/24x24?text=E", + provider: { + rank: 2, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Ethereum Provider" }, + }, + }, + { + bsnId: "polygon", + bsnName: "Polygon Network", + bsnLogoUrl: "https://placehold.co/24x24?text=P", + provider: { + rank: 3, + logo_url: "https://placehold.co/40x40", + description: { moniker: "Polygon Provider" }, + }, + }, +]; + +const meta: Meta = { + component: FinalityProviderSubsection, + tags: ["autodocs"], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + max: 3, + items: sampleItems, + onAdd: () => alert("Add clicked"), + onRemove: () => alert("Remove clicked"), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const Empty: Story = { + args: { + max: 3, + items: [], + onAdd: () => alert("Add clicked"), + onRemove: () => alert("Remove clicked"), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const MultipleItems: Story = { + args: { + max: 5, + items: multipleItems, + onAdd: () => alert("Add clicked"), + onRemove: (bsnId?: string) => alert(`Remove clicked for ${bsnId}`), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const AtMaximumCapacity: Story = { + args: { + max: 3, + items: maxCapacityItems, + onAdd: () => alert("Add clicked"), + onRemove: (bsnId?: string) => alert(`Remove clicked for ${bsnId}`), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const SingleProviderMode: Story = { + args: { + max: 1, + items: [], + onAdd: () => alert("Add clicked"), + onRemove: () => alert("Remove clicked"), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const SingleProviderModeWithItem: Story = { + args: { + max: 1, + items: [sampleItems[0]], + onAdd: () => alert("Add clicked"), + onRemove: (bsnId?: string) => alert(`Remove clicked for ${bsnId}`), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const ProvidersWithoutLogos: Story = { + args: { + max: 3, + items: [ + { + bsnId: "nologo1", + bsnName: "Provider Without Logo", + provider: { + rank: 1, + description: { moniker: "No Logo Provider" }, + }, + }, + { + bsnId: "nologo2", + bsnName: "Another Provider", + bsnLogoUrl: "https://placehold.co/24x24?text=A", + provider: { + rank: 2, + description: { moniker: "Provider with BSN logo only" }, + }, + }, + ], + onAdd: () => alert("Add clicked"), + onRemove: (bsnId?: string) => alert(`Remove clicked for ${bsnId}`), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; + +export const ProvidersWithoutDescriptions: Story = { + args: { + max: 3, + items: [ + { + bsnId: "nodesc1", + bsnName: "Provider Without Description", + bsnLogoUrl: "https://placehold.co/24x24?text=N", + provider: { + rank: 1, + logo_url: "https://placehold.co/40x40", + }, + }, + { + bsnId: "nodesc2", + bsnName: "Another Provider", + bsnLogoUrl: "https://placehold.co/24x24?text=A", + provider: { + rank: 2, + logo_url: "https://placehold.co/40x40", + description: {}, + }, + }, + ], + onAdd: () => alert("Add clicked"), + onRemove: (bsnId?: string) => alert(`Remove clicked for ${bsnId}`), + }, + render: (props: { max: number; items: ProviderItem[]; onAdd: () => void; onRemove: (bsnId?: string) => void }) => ( + + ), +}; diff --git a/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.tsx b/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.tsx new file mode 100644 index 0000000..d09a087 --- /dev/null +++ b/src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.tsx @@ -0,0 +1,34 @@ +import { SubSection } from "../../../components/SubSection"; + +import { useMemo } from "react"; + +import { CounterButton } from "../../../components/CounterButton/CounterButton"; +import { ProvidersList, ProviderItem } from "../../../components/ProvidersList/ProvidersList"; + +interface Props { + max: number; + items: ProviderItem[]; + onAdd: () => void; + onRemove: (bsnId?: string) => void; +} + +export function FinalityProviderSubsection({ max, items = [], onAdd, onRemove }: Props) { + const count = useMemo(() => items.length, [items]); + + const allowsMultipleBsns = max > 1; + const actionText = allowsMultipleBsns ? "Add BSN and Finality Provider" : "Add Finality Provider"; + + return ( + +
+
+
+ {actionText} + +
+
+ {count > 0 && } +
+
+ ); +} diff --git a/src/widgets/new-design/FinalityProviderSubsection/index.tsx b/src/widgets/new-design/FinalityProviderSubsection/index.tsx new file mode 100644 index 0000000..ab7334b --- /dev/null +++ b/src/widgets/new-design/FinalityProviderSubsection/index.tsx @@ -0,0 +1 @@ +export { FinalityProviderSubsection } from "./FinalityProviderSubsection"; diff --git a/src/widgets/new-design/PreviewModal/PreviewModal.stories.tsx b/src/widgets/new-design/PreviewModal/PreviewModal.stories.tsx new file mode 100644 index 0000000..fa5209e --- /dev/null +++ b/src/widgets/new-design/PreviewModal/PreviewModal.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { PreviewModal } from "./PreviewModal"; + +const meta: Meta = { + component: PreviewModal, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + processing: false, + onClose: () => {}, + onProceed: () => {}, + bsns: [ + { + icon: , + name: "BSN 1", + }, + { + icon: , + name: "BSN 2", + }, + ], + finalityProviders: [ + { + icon: , + name: "FP 1", + }, + { + icon: , + name: "FP 2", + }, + ], + details: { + stakeAmount: "0.5 BTC", + feeRate: "5 sat/vB", + transactionFees: "0.0001 BTC", + term: { + blocks: "100", + duration: "~20 hours", + }, + unbonding: "1 day", + unbondingFee: "0 BTC", + }, + }, +}; diff --git a/src/widgets/new-design/PreviewModal/PreviewModal.tsx b/src/widgets/new-design/PreviewModal/PreviewModal.tsx new file mode 100644 index 0000000..e613c76 --- /dev/null +++ b/src/widgets/new-design/PreviewModal/PreviewModal.tsx @@ -0,0 +1,173 @@ +import { Button } from "@/components/Button"; +import { Card } from "@/components/Card"; +import { Dialog, MobileDialog, DialogBody, DialogFooter, DialogHeader } from "@/components/Dialog"; +import { Heading } from "@/components/Heading"; +import { Text } from "@/components/Text"; +import { PropsWithChildren, ReactNode, useEffect, useState } from "react"; +import { twMerge } from "tailwind-merge"; + +type DialogComponentProps = Parameters[0]; + +interface ResponsiveDialogProps extends DialogComponentProps { + children?: ReactNode; +} + +function useIsMobileView() { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const update = () => setIsMobile(window.innerWidth <= 640); + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + + return isMobile; +} + +function ResponsiveDialog({ className, ...restProps }: ResponsiveDialogProps) { + const isMobileView = useIsMobileView(); + const DialogComponent = isMobileView ? MobileDialog : Dialog; + + return ; +} + +interface Info { + icon: ReactNode; + name: string; +} + +interface StakingTerm { + blocks: string; + duration: string; +} + +interface StakingDetails { + stakeAmount: string; + feeRate: string; + transactionFees: string; + term: StakingTerm; + unbonding: string; + unbondingFee: string; +} + +interface PreviewModalProps { + open: boolean; + processing?: boolean; + onClose: () => void; + onProceed: () => void; + bsns: Info[]; + finalityProviders: Info[]; + details: StakingDetails; +} + +export const PreviewModal = ({ + open, + processing = false, + onClose, + onProceed, + bsns, + finalityProviders, + details, +}: PropsWithChildren) => { + const fields = [ + { label: "Stake Amount", value: details.stakeAmount }, + { label: "Fee Rate", value: details.feeRate }, + { label: "Transaction Fees", value: details.transactionFees }, + { + label: "Term", + value: ( + <> + {details.term.blocks} +
+ {details.term.duration} + + ), + }, + { label: "Unbonding", value: details.unbonding }, + { label: "Unbonding Fee", value: details.unbondingFee }, + ]; + + return ( + + + + +
+ {bsns.length > 1 ? ( +
+ + BSNs + + + Finality Provider + +
+ ) : null} +
+
+ {bsns.map((bsnItem, index) => ( +
+ {bsnItem.icon} + + {bsnItem.name} + +
+ ))} +
+
+ {finalityProviders.map((fpItem, index) => ( +
+ {fpItem.icon} + + {fpItem.name} + +
+ ))} +
+
+
+
+ +
+ {fields.map((field, index) => ( +
+ + {field.label} + + + {field.value} + +
+ ))} +
+ +
+ +
+ + Attention! + + + 1. No third party possesses your staked BTC. You are the only one who can unbond and withdraw your stake. +
+
+
+ + 2. Your stake will first be sent to Babylon Genesis for verification (~20 seconds), then you will be + prompted to submit it to the Bitcoin ledger. It will be marked as 'Pending' until it receives 10 + Bitcoin confirmations. + +
+ + + + + + + ); +}; diff --git a/src/widgets/new-design/PreviewModal/index.tsx b/src/widgets/new-design/PreviewModal/index.tsx new file mode 100644 index 0000000..dabd291 --- /dev/null +++ b/src/widgets/new-design/PreviewModal/index.tsx @@ -0,0 +1 @@ +export * from "./PreviewModal";