From 499e6839b1aae3d4b9671faf53a82a9aa1d71f23 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Wed, 16 Jul 2025 01:54:32 +0100 Subject: [PATCH 1/5] Add new design components --- package-lock.json | 7 ++ package.json | 3 +- .../SubSection/SubSection.stories.tsx | 19 ++++ src/components/SubSection/SubSection.tsx | 16 ++++ src/components/SubSection/index.ts | 1 + src/components/SubSection/index.tsx | 1 + src/index.tsx | 4 + src/utils/helpers.ts | 62 ++++++++++++ .../AmountSubsection.stories.tsx | 46 +++++++++ .../AmountSubsection/AmountSubsection.tsx | 95 +++++++++++++++++++ src/widgets/form/AmountSubsection/index.tsx | 1 + src/widgets/form/FeesSection/BBNFeeAmount.tsx | 44 +++++++++ src/widgets/form/FeesSection/BTCFeeAmount.tsx | 53 +++++++++++ src/widgets/form/FeesSection/BTCFeeRate.tsx | 42 ++++++++ src/widgets/form/FeesSection/FeeItem.tsx | 49 ++++++++++ .../form/FeesSection/FeesSection.stories.tsx | 21 ++++ src/widgets/form/FeesSection/FeesSection.tsx | 78 +++++++++++++++ src/widgets/form/FeesSection/Total.tsx | 56 +++++++++++ src/widgets/form/FeesSection/index.ts | 6 ++ 19 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 src/components/SubSection/SubSection.stories.tsx create mode 100644 src/components/SubSection/SubSection.tsx create mode 100644 src/components/SubSection/index.ts create mode 100644 src/components/SubSection/index.tsx create mode 100644 src/utils/helpers.ts create mode 100644 src/widgets/form/AmountSubsection/AmountSubsection.stories.tsx create mode 100644 src/widgets/form/AmountSubsection/AmountSubsection.tsx create mode 100644 src/widgets/form/AmountSubsection/index.tsx create mode 100644 src/widgets/form/FeesSection/BBNFeeAmount.tsx create mode 100644 src/widgets/form/FeesSection/BTCFeeAmount.tsx create mode 100644 src/widgets/form/FeesSection/BTCFeeRate.tsx create mode 100644 src/widgets/form/FeesSection/FeeItem.tsx create mode 100644 src/widgets/form/FeesSection/FeesSection.stories.tsx create mode 100644 src/widgets/form/FeesSection/FeesSection.tsx create mode 100644 src/widgets/form/FeesSection/Total.tsx create mode 100644 src/widgets/form/FeesSection/index.ts 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/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..0b227d8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ export * from "./components/Card"; export * from "./components/Toggle"; export * from "./components/List"; export * from "./components/Badge"; +export * from "./components/SubSection"; export * from "./widgets/form/Form"; export * from "./widgets/form/NumberField"; @@ -25,8 +26,11 @@ export * from "./widgets/form/RadioField"; export * from "./widgets/form/SelectField"; export * from "./widgets/form/HiddenField"; export * from "./widgets/form/hooks"; +export * from "./widgets/form/AmountSubsection"; +export * from "./widgets/form/FeesSection"; export * from "./components/ListLegacy"; export { ScrollLocker } from "./context/Dialog.context"; export { useFormContext, useFormState, useWatch } from "react-hook-form"; +export { SubSection } from "./components/SubSection"; 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/form/AmountSubsection/AmountSubsection.stories.tsx b/src/widgets/form/AmountSubsection/AmountSubsection.stories.tsx new file mode 100644 index 0000000..cfe0b56 --- /dev/null +++ b/src/widgets/form/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://via.placeholder.com/40", + 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/form/AmountSubsection/AmountSubsection.tsx b/src/widgets/form/AmountSubsection/AmountSubsection.tsx new file mode 100644 index 0000000..fac22c2 --- /dev/null +++ b/src/widgets/form/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/form/AmountSubsection/index.tsx b/src/widgets/form/AmountSubsection/index.tsx new file mode 100644 index 0000000..63de84a --- /dev/null +++ b/src/widgets/form/AmountSubsection/index.tsx @@ -0,0 +1 @@ +export { AmountSubsection } from "./AmountSubsection"; diff --git a/src/widgets/form/FeesSection/BBNFeeAmount.tsx b/src/widgets/form/FeesSection/BBNFeeAmount.tsx new file mode 100644 index 0000000..63bbfb6 --- /dev/null +++ b/src/widgets/form/FeesSection/BBNFeeAmount.tsx @@ -0,0 +1,44 @@ +import { FeeItem } from "./FeeItem"; + +interface BBNFeeAmountProps { + /** + * Fee amount expressed in token units (e.g. BBN, BABY, etc.). + * Can be a pre-formatted string or a raw numeric value. + */ + amount: number | string; + /** + * Symbol of the token (e.g. BBN, BABY). + */ + coinSymbol: string; + /** + * Optional secondary hint displayed below the amount (e.g. fiat equivalent). + */ + hint?: string; + /** + * Optional custom title. Defaults to " Network Fee". + */ + title?: string; + /** + * Optional additional Tailwind classes. + */ + className?: string; + /** + * Decimals used when `amount` is numeric. Defaults to 5. + */ + decimals?: number; +} + +/** + * Display-only component that renders a network fee amount row for the Babylon + * (BBN/tBABY) network. All calculations (fiat conversion, etc.) must be + * handled by the consumer and provided via props. + */ +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/form/FeesSection/BTCFeeAmount.tsx b/src/widgets/form/FeesSection/BTCFeeAmount.tsx new file mode 100644 index 0000000..42c26a0 --- /dev/null +++ b/src/widgets/form/FeesSection/BTCFeeAmount.tsx @@ -0,0 +1,53 @@ +import { FeeItem } from "./FeeItem"; + +interface BTCFeeAmountProps { + /** + * Fee amount expressed in token units (e.g. BTC). + * Can be a pre-formatted string or a raw numeric value. + */ + amount: number | string; + /** + * Symbol of the token (e.g. BTC, ETH, BBN). + */ + coinSymbol: string; + /** + * Optional secondary hint displayed below the amount (e.g. fiat equivalent). + */ + hint?: string; + /** + * Optional custom title. Defaults to " Network Fee". + */ + title?: string; + /** + * Optional additional Tailwind classes. + */ + className?: string; + /** + * Decimals used when `amount` is numeric. Defaults to 8. + */ + decimals?: number; +} + +/** + * Display-only component that renders a network fee amount row. + * All calculations (satoshi ⇢ BTC, fiat conversion, etc.) must be handled by + * the consumer and provided via props. + */ +export function BTCFeeAmount({ amount, coinSymbol, hint, title, className, decimals = 8 }: BTCFeeAmountProps) { + const formattedAmount = + typeof amount === "number" + ? amount === 0 + ? "0" + : (() => { + const str = amount.toFixed(decimals); + // Remove unnecessary trailing zeros and possible trailing decimal point + return str.replace(/0+$/, "").replace(/\.$/, ""); + })() + : amount; + + return ( + + {formattedAmount} {coinSymbol} + + ); +} diff --git a/src/widgets/form/FeesSection/BTCFeeRate.tsx b/src/widgets/form/FeesSection/BTCFeeRate.tsx new file mode 100644 index 0000000..90fe4d2 --- /dev/null +++ b/src/widgets/form/FeesSection/BTCFeeRate.tsx @@ -0,0 +1,42 @@ +import { FeeItem } from "./FeeItem"; +import { Button } from "../../../components/Button"; +import { FaPen } from "react-icons/fa6"; + +interface BTCFeeRateProps { + /** + * Current fee rate expressed in sats/vB. + */ + value: number | string; + /** + * Called when the user presses the edit button. If omitted the button is not rendered. + */ + onEdit?: () => void; + /** + * Custom label shown on the left. Defaults to "Network Fee Rate". + */ + title?: string; + /** + * Optional additional Tailwind classes. + */ + className?: string; +} + +/** + * Pure UI component that renders the fee rate row inside a FeesSection. + * All business-logic is expected to live in the consuming application – this + * component merely receives the already computed values through props and + * renders them. + */ +export function BTCFeeRate({ value, onEdit, title = "Network Fee Rate", className }: BTCFeeRateProps) { + return ( + + {value} sats/vB + + {onEdit && ( + + )} + + ); +} diff --git a/src/widgets/form/FeesSection/FeeItem.tsx b/src/widgets/form/FeesSection/FeeItem.tsx new file mode 100644 index 0000000..a6460d0 --- /dev/null +++ b/src/widgets/form/FeesSection/FeeItem.tsx @@ -0,0 +1,49 @@ +import { Text } from "../../../components/Text"; +import { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +interface FeeItemProps extends PropsWithChildren { + /** + * Label to show on the left side of the row (e.g. "Network Fee"). + */ + title: string; + /** + * Optional additional Tailwind classes. + */ + className?: string; + /** + * Optional hint rendered below the value on the right (e.g. USD equivalent). + */ + hint?: string; +} + +export function FeeItem({ title, children, className, hint }: FeeItemProps) { + return ( +
+ + {title} + + + {!hint ? ( + + {children} + + ) : ( +
+ + {children} + + + {hint} + +
+ )} +
+ ); +} diff --git a/src/widgets/form/FeesSection/FeesSection.stories.tsx b/src/widgets/form/FeesSection/FeesSection.stories.tsx new file mode 100644 index 0000000..5c770e3 --- /dev/null +++ b/src/widgets/form/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/form/FeesSection/FeesSection.tsx b/src/widgets/form/FeesSection/FeesSection.tsx new file mode 100644 index 0000000..ff932db --- /dev/null +++ b/src/widgets/form/FeesSection/FeesSection.tsx @@ -0,0 +1,78 @@ +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 { + /** Wrapper className override. */ + className?: string; + + /** Current fee rate (sats/vB). */ + feeRate: number | string; + /** Handler invoked when the edit button is pressed. */ + onFeeRateEdit?: () => void; + + /** Network fee amount in token units. */ + feeAmount: number | string; + /** Symbol of the token (e.g. BTC). */ + coinSymbol: string; + /** Optional fiat/equivalent hint for the fee amount. */ + feeAmountHint?: string; + + /** Total amount (token units). */ + total: number | string; + /** Optional fiat/equivalent hint for total. */ + totalHint?: string; + + /** Babylon network fee amount. Optional – row will be hidden if not provided. */ + bbnFeeAmount?: number | string; + /** Symbol of the Babylon token (e.g. BBN, BABY). Required if bbnFeeAmount is provided. */ + bbnCoinSymbol?: string; + /** Optional fiat/equivalent hint for Babylon fee amount. */ + bbnFeeAmountHint?: string; + /** Optional decimals override for Babylon fee amount (defaults to 5). */ + bbnFeeDecimals?: number; +} + +/** + * Wrapper that displays the three standard fee rows (network fee rate, + * network fee amount and total). All data is injected via props so that the + * consuming application retains full control over business-logic. + */ +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/form/FeesSection/Total.tsx b/src/widgets/form/FeesSection/Total.tsx new file mode 100644 index 0000000..35b44a0 --- /dev/null +++ b/src/widgets/form/FeesSection/Total.tsx @@ -0,0 +1,56 @@ +import { Text } from "../../../components/Text"; +import { twMerge } from "tailwind-merge"; + +interface TotalProps { + /** + * Total amount expressed in token units. + * Can be pre-formatted string or numeric value. + */ + total: number | string; + /** Symbol of the token, e.g. BTC. */ + coinSymbol: string; + /** Optional secondary hint (fiat equivalent). */ + hint?: string; + /** Optional row title. Defaults to "Total". */ + title?: string; + /** Additional Tailwind classes. */ + className?: string; + /** When `total` is numeric, number of decimals to format with. Defaults to 8. */ + decimals?: number; +} + +/** + * Pure UI component that displays the total row inside a FeesSection. All + * numerical calculations as well as currency conversions must be performed by + * the consumer. + */ +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/form/FeesSection/index.ts b/src/widgets/form/FeesSection/index.ts new file mode 100644 index 0000000..a699366 --- /dev/null +++ b/src/widgets/form/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"; From 42cb226f0689a475d2b40a13f73d8cfb0ef469c4 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Wed, 16 Jul 2025 02:18:11 +0100 Subject: [PATCH 2/5] Move components to new-design folder --- src/index.tsx | 6 +- .../AmountSubsection.stories.tsx | 0 .../AmountSubsection/AmountSubsection.tsx | 0 .../AmountSubsection/index.tsx | 0 .../FeesSection/BBNFeeAmount.tsx | 0 .../FeesSection/BTCFeeAmount.tsx | 0 .../FeesSection/BTCFeeRate.tsx | 0 .../FeesSection/FeeItem.tsx | 0 .../FeesSection/FeesSection.stories.tsx | 0 .../FeesSection/FeesSection.tsx | 0 .../FeesSection/Total.tsx | 0 .../{form => new-design}/FeesSection/index.ts | 0 .../PreviewModal/PreviewModal.stories.tsx | 52 ++++++ .../new-design/PreviewModal/PreviewModal.tsx | 173 ++++++++++++++++++ src/widgets/new-design/PreviewModal/index.tsx | 1 + 15 files changed, 230 insertions(+), 2 deletions(-) rename src/widgets/{form => new-design}/AmountSubsection/AmountSubsection.stories.tsx (100%) rename src/widgets/{form => new-design}/AmountSubsection/AmountSubsection.tsx (100%) rename src/widgets/{form => new-design}/AmountSubsection/index.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/BBNFeeAmount.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/BTCFeeAmount.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/BTCFeeRate.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/FeeItem.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/FeesSection.stories.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/FeesSection.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/Total.tsx (100%) rename src/widgets/{form => new-design}/FeesSection/index.ts (100%) create mode 100644 src/widgets/new-design/PreviewModal/PreviewModal.stories.tsx create mode 100644 src/widgets/new-design/PreviewModal/PreviewModal.tsx create mode 100644 src/widgets/new-design/PreviewModal/index.tsx diff --git a/src/index.tsx b/src/index.tsx index 0b227d8..a150ba8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,8 +26,10 @@ export * from "./widgets/form/RadioField"; export * from "./widgets/form/SelectField"; export * from "./widgets/form/HiddenField"; export * from "./widgets/form/hooks"; -export * from "./widgets/form/AmountSubsection"; -export * from "./widgets/form/FeesSection"; + +export * from "./widgets/new-design/AmountSubsection"; +export * from "./widgets/new-design/FeesSection"; +export * from "./widgets/new-design/PreviewModal"; export * from "./components/ListLegacy"; diff --git a/src/widgets/form/AmountSubsection/AmountSubsection.stories.tsx b/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx similarity index 100% rename from src/widgets/form/AmountSubsection/AmountSubsection.stories.tsx rename to src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx diff --git a/src/widgets/form/AmountSubsection/AmountSubsection.tsx b/src/widgets/new-design/AmountSubsection/AmountSubsection.tsx similarity index 100% rename from src/widgets/form/AmountSubsection/AmountSubsection.tsx rename to src/widgets/new-design/AmountSubsection/AmountSubsection.tsx diff --git a/src/widgets/form/AmountSubsection/index.tsx b/src/widgets/new-design/AmountSubsection/index.tsx similarity index 100% rename from src/widgets/form/AmountSubsection/index.tsx rename to src/widgets/new-design/AmountSubsection/index.tsx diff --git a/src/widgets/form/FeesSection/BBNFeeAmount.tsx b/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx similarity index 100% rename from src/widgets/form/FeesSection/BBNFeeAmount.tsx rename to src/widgets/new-design/FeesSection/BBNFeeAmount.tsx diff --git a/src/widgets/form/FeesSection/BTCFeeAmount.tsx b/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx similarity index 100% rename from src/widgets/form/FeesSection/BTCFeeAmount.tsx rename to src/widgets/new-design/FeesSection/BTCFeeAmount.tsx diff --git a/src/widgets/form/FeesSection/BTCFeeRate.tsx b/src/widgets/new-design/FeesSection/BTCFeeRate.tsx similarity index 100% rename from src/widgets/form/FeesSection/BTCFeeRate.tsx rename to src/widgets/new-design/FeesSection/BTCFeeRate.tsx diff --git a/src/widgets/form/FeesSection/FeeItem.tsx b/src/widgets/new-design/FeesSection/FeeItem.tsx similarity index 100% rename from src/widgets/form/FeesSection/FeeItem.tsx rename to src/widgets/new-design/FeesSection/FeeItem.tsx diff --git a/src/widgets/form/FeesSection/FeesSection.stories.tsx b/src/widgets/new-design/FeesSection/FeesSection.stories.tsx similarity index 100% rename from src/widgets/form/FeesSection/FeesSection.stories.tsx rename to src/widgets/new-design/FeesSection/FeesSection.stories.tsx diff --git a/src/widgets/form/FeesSection/FeesSection.tsx b/src/widgets/new-design/FeesSection/FeesSection.tsx similarity index 100% rename from src/widgets/form/FeesSection/FeesSection.tsx rename to src/widgets/new-design/FeesSection/FeesSection.tsx diff --git a/src/widgets/form/FeesSection/Total.tsx b/src/widgets/new-design/FeesSection/Total.tsx similarity index 100% rename from src/widgets/form/FeesSection/Total.tsx rename to src/widgets/new-design/FeesSection/Total.tsx diff --git a/src/widgets/form/FeesSection/index.ts b/src/widgets/new-design/FeesSection/index.ts similarity index 100% rename from src/widgets/form/FeesSection/index.ts rename to src/widgets/new-design/FeesSection/index.ts 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"; From 36b26b5451455e87b0a5748f5fd7c4e0569ad7e7 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Wed, 16 Jul 2025 02:31:56 +0100 Subject: [PATCH 3/5] Remove comments --- .../new-design/FeesSection/BBNFeeAmount.tsx | 24 ------------------ .../new-design/FeesSection/BTCFeeAmount.tsx | 25 ------------------- .../new-design/FeesSection/BTCFeeRate.tsx | 18 ------------- .../new-design/FeesSection/FeeItem.tsx | 9 ------- .../new-design/FeesSection/FeesSection.tsx | 21 ---------------- src/widgets/new-design/FeesSection/Total.tsx | 14 ----------- 6 files changed, 111 deletions(-) diff --git a/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx b/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx index 63bbfb6..d2695e8 100644 --- a/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx +++ b/src/widgets/new-design/FeesSection/BBNFeeAmount.tsx @@ -1,38 +1,14 @@ import { FeeItem } from "./FeeItem"; interface BBNFeeAmountProps { - /** - * Fee amount expressed in token units (e.g. BBN, BABY, etc.). - * Can be a pre-formatted string or a raw numeric value. - */ amount: number | string; - /** - * Symbol of the token (e.g. BBN, BABY). - */ coinSymbol: string; - /** - * Optional secondary hint displayed below the amount (e.g. fiat equivalent). - */ hint?: string; - /** - * Optional custom title. Defaults to " Network Fee". - */ title?: string; - /** - * Optional additional Tailwind classes. - */ className?: string; - /** - * Decimals used when `amount` is numeric. Defaults to 5. - */ decimals?: number; } -/** - * Display-only component that renders a network fee amount row for the Babylon - * (BBN/tBABY) network. All calculations (fiat conversion, etc.) must be - * handled by the consumer and provided via props. - */ export function BBNFeeAmount({ amount, coinSymbol, hint, title, className, decimals = 5 }: BBNFeeAmountProps) { const formattedAmount = typeof amount === "number" ? amount.toFixed(decimals) : amount; diff --git a/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx b/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx index 42c26a0..bee0a01 100644 --- a/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx +++ b/src/widgets/new-design/FeesSection/BTCFeeAmount.tsx @@ -1,38 +1,14 @@ import { FeeItem } from "./FeeItem"; interface BTCFeeAmountProps { - /** - * Fee amount expressed in token units (e.g. BTC). - * Can be a pre-formatted string or a raw numeric value. - */ amount: number | string; - /** - * Symbol of the token (e.g. BTC, ETH, BBN). - */ coinSymbol: string; - /** - * Optional secondary hint displayed below the amount (e.g. fiat equivalent). - */ hint?: string; - /** - * Optional custom title. Defaults to " Network Fee". - */ title?: string; - /** - * Optional additional Tailwind classes. - */ className?: string; - /** - * Decimals used when `amount` is numeric. Defaults to 8. - */ decimals?: number; } -/** - * Display-only component that renders a network fee amount row. - * All calculations (satoshi ⇢ BTC, fiat conversion, etc.) must be handled by - * the consumer and provided via props. - */ export function BTCFeeAmount({ amount, coinSymbol, hint, title, className, decimals = 8 }: BTCFeeAmountProps) { const formattedAmount = typeof amount === "number" @@ -40,7 +16,6 @@ export function BTCFeeAmount({ amount, coinSymbol, hint, title, className, decim ? "0" : (() => { const str = amount.toFixed(decimals); - // Remove unnecessary trailing zeros and possible trailing decimal point return str.replace(/0+$/, "").replace(/\.$/, ""); })() : amount; diff --git a/src/widgets/new-design/FeesSection/BTCFeeRate.tsx b/src/widgets/new-design/FeesSection/BTCFeeRate.tsx index 90fe4d2..6b09bd3 100644 --- a/src/widgets/new-design/FeesSection/BTCFeeRate.tsx +++ b/src/widgets/new-design/FeesSection/BTCFeeRate.tsx @@ -3,30 +3,12 @@ import { Button } from "../../../components/Button"; import { FaPen } from "react-icons/fa6"; interface BTCFeeRateProps { - /** - * Current fee rate expressed in sats/vB. - */ value: number | string; - /** - * Called when the user presses the edit button. If omitted the button is not rendered. - */ onEdit?: () => void; - /** - * Custom label shown on the left. Defaults to "Network Fee Rate". - */ title?: string; - /** - * Optional additional Tailwind classes. - */ className?: string; } -/** - * Pure UI component that renders the fee rate row inside a FeesSection. - * All business-logic is expected to live in the consuming application – this - * component merely receives the already computed values through props and - * renders them. - */ export function BTCFeeRate({ value, onEdit, title = "Network Fee Rate", className }: BTCFeeRateProps) { return ( diff --git a/src/widgets/new-design/FeesSection/FeeItem.tsx b/src/widgets/new-design/FeesSection/FeeItem.tsx index a6460d0..ef763f9 100644 --- a/src/widgets/new-design/FeesSection/FeeItem.tsx +++ b/src/widgets/new-design/FeesSection/FeeItem.tsx @@ -3,17 +3,8 @@ import { PropsWithChildren } from "react"; import { twMerge } from "tailwind-merge"; interface FeeItemProps extends PropsWithChildren { - /** - * Label to show on the left side of the row (e.g. "Network Fee"). - */ title: string; - /** - * Optional additional Tailwind classes. - */ className?: string; - /** - * Optional hint rendered below the value on the right (e.g. USD equivalent). - */ hint?: string; } diff --git a/src/widgets/new-design/FeesSection/FeesSection.tsx b/src/widgets/new-design/FeesSection/FeesSection.tsx index ff932db..222106e 100644 --- a/src/widgets/new-design/FeesSection/FeesSection.tsx +++ b/src/widgets/new-design/FeesSection/FeesSection.tsx @@ -7,41 +7,20 @@ import { BBNFeeAmount } from "./BBNFeeAmount"; import { Total } from "./Total"; interface FeesSectionProps { - /** Wrapper className override. */ className?: string; - - /** Current fee rate (sats/vB). */ feeRate: number | string; - /** Handler invoked when the edit button is pressed. */ onFeeRateEdit?: () => void; - - /** Network fee amount in token units. */ feeAmount: number | string; - /** Symbol of the token (e.g. BTC). */ coinSymbol: string; - /** Optional fiat/equivalent hint for the fee amount. */ feeAmountHint?: string; - - /** Total amount (token units). */ total: number | string; - /** Optional fiat/equivalent hint for total. */ totalHint?: string; - - /** Babylon network fee amount. Optional – row will be hidden if not provided. */ bbnFeeAmount?: number | string; - /** Symbol of the Babylon token (e.g. BBN, BABY). Required if bbnFeeAmount is provided. */ bbnCoinSymbol?: string; - /** Optional fiat/equivalent hint for Babylon fee amount. */ bbnFeeAmountHint?: string; - /** Optional decimals override for Babylon fee amount (defaults to 5). */ bbnFeeDecimals?: number; } -/** - * Wrapper that displays the three standard fee rows (network fee rate, - * network fee amount and total). All data is injected via props so that the - * consuming application retains full control over business-logic. - */ export function FeesSection({ className, feeRate, diff --git a/src/widgets/new-design/FeesSection/Total.tsx b/src/widgets/new-design/FeesSection/Total.tsx index 35b44a0..8e6c591 100644 --- a/src/widgets/new-design/FeesSection/Total.tsx +++ b/src/widgets/new-design/FeesSection/Total.tsx @@ -2,28 +2,14 @@ import { Text } from "../../../components/Text"; import { twMerge } from "tailwind-merge"; interface TotalProps { - /** - * Total amount expressed in token units. - * Can be pre-formatted string or numeric value. - */ total: number | string; - /** Symbol of the token, e.g. BTC. */ coinSymbol: string; - /** Optional secondary hint (fiat equivalent). */ hint?: string; - /** Optional row title. Defaults to "Total". */ title?: string; - /** Additional Tailwind classes. */ className?: string; - /** When `total` is numeric, number of decimals to format with. Defaults to 8. */ decimals?: number; } -/** - * Pure UI component that displays the total row inside a FeesSection. All - * numerical calculations as well as currency conversions must be performed by - * the consumer. - */ export function Total({ total, coinSymbol, hint, title = "Total", className, decimals = 8 }: TotalProps) { const formattedTotal = typeof total === "number" From db9236de0e8fb6dc401a8378917830c08532231a Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Wed, 16 Jul 2025 03:02:15 +0100 Subject: [PATCH 4/5] Add validators field --- .../CounterButton/CounterButton.stories.tsx | 74 ++++++ .../CounterButton/CounterButton.tsx | 37 +++ src/components/CounterButton/index.tsx | 1 + .../FinalityProviderItem.tsx | 60 +++++ src/components/FinalityProviderItem/index.ts | 1 + .../FinalityProviderLogo.tsx | 53 +++++ src/components/FinalityProviderLogo/index.ts | 1 + .../ProvidersList/ProvidersList.tsx | 46 ++++ src/components/ProvidersList/index.ts | 1 + src/index.tsx | 4 +- .../AmountSubsection.stories.tsx | 2 +- .../FinalityProviderSubsection.stories.tsx | 215 ++++++++++++++++++ .../FinalityProviderSubsection.tsx | 34 +++ .../FinalityProviderSubsection/index.tsx | 1 + 14 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 src/components/CounterButton/CounterButton.stories.tsx create mode 100644 src/components/CounterButton/CounterButton.tsx create mode 100644 src/components/CounterButton/index.tsx create mode 100644 src/components/FinalityProviderItem/FinalityProviderItem.tsx create mode 100644 src/components/FinalityProviderItem/index.ts create mode 100644 src/components/FinalityProviderLogo/FinalityProviderLogo.tsx create mode 100644 src/components/FinalityProviderLogo/index.ts create mode 100644 src/components/ProvidersList/ProvidersList.tsx create mode 100644 src/components/ProvidersList/index.ts create mode 100644 src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.stories.tsx create mode 100644 src/widgets/new-design/FinalityProviderSubsection/FinalityProviderSubsection.tsx create mode 100644 src/widgets/new-design/FinalityProviderSubsection/index.tsx 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.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.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.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/index.tsx b/src/index.tsx index a150ba8..3fc3eae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,8 @@ 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"; @@ -28,6 +30,7 @@ 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"; @@ -35,4 +38,3 @@ export * from "./components/ListLegacy"; export { ScrollLocker } from "./context/Dialog.context"; export { useFormContext, useFormState, useWatch } from "react-hook-form"; -export { SubSection } from "./components/SubSection"; diff --git a/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx b/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx index cfe0b56..31ebef7 100644 --- a/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx +++ b/src/widgets/new-design/AmountSubsection/AmountSubsection.stories.tsx @@ -24,7 +24,7 @@ const schema = yup export const Default: Story = { args: { fieldName: "amount", - currencyIcon: "https://via.placeholder.com/40", + currencyIcon: "https://placehold.co/40x40", currencyName: "BBN", placeholder: "Enter Amount", displayBalance: true, 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"; From c56a853f5ef7a9707ce9b7c762d22d9475c41e40 Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Wed, 16 Jul 2025 03:05:01 +0100 Subject: [PATCH 5/5] Add further stories --- .../FinalityProviderItem.stories.tsx | 93 +++++++++++ .../FinalityProviderLogo.stories.tsx | 75 +++++++++ .../ProvidersList/ProvidersList.stories.tsx | 151 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/components/FinalityProviderItem/FinalityProviderItem.stories.tsx create mode 100644 src/components/FinalityProviderLogo/FinalityProviderLogo.stories.tsx create mode 100644 src/components/ProvidersList/ProvidersList.stories.tsx 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/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/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}`), + }, +};