From 429c1755c158751aad0a7e4b8346accff68080e4 Mon Sep 17 00:00:00 2001 From: "thanhminh.uit" <230365528+thanhminhuit-ops@users.noreply.github.com> Date: Wed, 6 May 2026 19:14:14 +0200 Subject: [PATCH 1/3] feat: problem 1 --- src/problem1/sum-to-n.js | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/problem1/sum-to-n.js diff --git a/src/problem1/sum-to-n.js b/src/problem1/sum-to-n.js new file mode 100644 index 0000000000..bb5a3a60d6 --- /dev/null +++ b/src/problem1/sum-to-n.js @@ -0,0 +1,64 @@ +/** + * Problem 1: Three ways to sum to n + * + * Assumptions: + * - n is an integer. + * - n is non-negative. + * - Result is less than Number.MAX_SAFE_INTEGER. + */ + +var sum_to_n_a = function (n) { + // Iterative loop (O(n) time, O(1) space) + var sum = 0; + for (var i = 1; i <= n; i += 1) { + sum += i; + } + return sum; +}; + +var sum_to_n_b = function (n) { + // Mathematical formula (O(1) time, O(1) space) + return (n * (n + 1)) / 2; +}; + +var sum_to_n_c = function (n) { + // Recursion (O(n) time, O(n) call stack) + if (n <= 1) return n; + return n + sum_to_n_c(n - 1); +}; + + +const runTests = () => { + + const testCases = [ + { input: 0, expected: 0 }, + { input: 1, expected: 1 }, + { input: 5, expected: 15 }, + { input: 10, expected: 55 }, + { input: 100, expected: 5050 } +]; + +const implementations = [ + { name: "Iterative", fn: sum_to_n_a }, + { name: "Formula", fn: sum_to_n_b }, + { name: "Functional", fn: sum_to_n_c } +]; + +console.log("Running Tests...\n"); + +testCases.forEach(({ input, expected }) => { + console.log(`Test n = ${input} (expected: ${expected})`); + implementations.forEach(({ name, fn }) => { + const result = fn(input); + const pass = result === expected; + console.log( + ` ${name.padEnd(12)} → ${result} ${pass ? "✅ PASS" : "❌ FAIL"}` + ); + }); + console.log(""); +}); + +console.log("All tests completed."); +}; + +runTests(); \ No newline at end of file From 900c27cc28116d5f939e54f9fb072d76562c9f2b Mon Sep 17 00:00:00 2001 From: "thanhminh.uit" <230365528+thanhminhuit-ops@users.noreply.github.com> Date: Wed, 6 May 2026 19:14:14 +0200 Subject: [PATCH 2/3] feat: problem 2 --- src/problem2/.gitignore | 2 + src/problem2/App.tsx | 181 ++++ src/problem2/index.html | 50 +- src/problem2/main.tsx | 16 + src/problem2/package.json | 25 + src/problem2/postcss.config.js | 6 + src/problem2/script.js | 0 src/problem2/src/components/Button/Button.tsx | 109 +++ src/problem2/src/components/Input/Input.tsx | 25 + .../SearchableDropdown/SearchableDropdown.tsx | 170 ++++ .../components/SwapDetails/SwapDetails.tsx | 40 + .../src/components/SwapForm/SwapForm.tsx | 199 +++++ .../components/SwapForm/SwapFormSkeleton.tsx | 52 ++ .../components/ThemeToggle/ThemeToggle.tsx | 21 + .../src/components/Toast/ToastProvider.tsx | 193 ++++ .../src/components/TokenIcon/TokenIcon.tsx | 39 + .../TokenSelectField/TokenSelectField.tsx | 102 +++ src/problem2/src/form/SwapFormContext.tsx | 14 + src/problem2/src/form/createFormContext.tsx | 249 ++++++ src/problem2/src/form/formTypes.ts | 3 + src/problem2/src/form/swapFormTypes.ts | 25 + src/problem2/src/hooks/useTokenPrices.ts | 43 + src/problem2/src/lib/constants.ts | 4 + src/problem2/src/lib/defaultTokens.ts | 8 + src/problem2/src/lib/format.ts | 6 + src/problem2/src/lib/prices.ts | 56 ++ src/problem2/src/lib/tokenIcon.ts | 5 + src/problem2/src/theme/ThemeProvider.tsx | 97 ++ src/problem2/style.css | 8 - src/problem2/styles/tailwind.css | 100 +++ src/problem2/tailwind.config.js | 28 + src/problem2/tsconfig.json | 13 + src/problem2/yarn.lock | 837 ++++++++++++++++++ 33 files changed, 2696 insertions(+), 30 deletions(-) create mode 100644 src/problem2/.gitignore create mode 100644 src/problem2/App.tsx create mode 100644 src/problem2/main.tsx create mode 100644 src/problem2/package.json create mode 100644 src/problem2/postcss.config.js delete mode 100644 src/problem2/script.js create mode 100644 src/problem2/src/components/Button/Button.tsx create mode 100644 src/problem2/src/components/Input/Input.tsx create mode 100644 src/problem2/src/components/SearchableDropdown/SearchableDropdown.tsx create mode 100644 src/problem2/src/components/SwapDetails/SwapDetails.tsx create mode 100644 src/problem2/src/components/SwapForm/SwapForm.tsx create mode 100644 src/problem2/src/components/SwapForm/SwapFormSkeleton.tsx create mode 100644 src/problem2/src/components/ThemeToggle/ThemeToggle.tsx create mode 100644 src/problem2/src/components/Toast/ToastProvider.tsx create mode 100644 src/problem2/src/components/TokenIcon/TokenIcon.tsx create mode 100644 src/problem2/src/components/TokenSelectField/TokenSelectField.tsx create mode 100644 src/problem2/src/form/SwapFormContext.tsx create mode 100644 src/problem2/src/form/createFormContext.tsx create mode 100644 src/problem2/src/form/formTypes.ts create mode 100644 src/problem2/src/form/swapFormTypes.ts create mode 100644 src/problem2/src/hooks/useTokenPrices.ts create mode 100644 src/problem2/src/lib/constants.ts create mode 100644 src/problem2/src/lib/defaultTokens.ts create mode 100644 src/problem2/src/lib/format.ts create mode 100644 src/problem2/src/lib/prices.ts create mode 100644 src/problem2/src/lib/tokenIcon.ts create mode 100644 src/problem2/src/theme/ThemeProvider.tsx delete mode 100644 src/problem2/style.css create mode 100644 src/problem2/styles/tailwind.css create mode 100644 src/problem2/tailwind.config.js create mode 100644 src/problem2/tsconfig.json create mode 100644 src/problem2/yarn.lock diff --git a/src/problem2/.gitignore b/src/problem2/.gitignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/src/problem2/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/src/problem2/App.tsx b/src/problem2/App.tsx new file mode 100644 index 0000000000..40028efe92 --- /dev/null +++ b/src/problem2/App.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useToast } from "./src/components/Toast/ToastProvider"; +import SwapForm from "./src/components/SwapForm/SwapForm"; +import { SwapFormSkeleton } from "./src/components/SwapForm/SwapFormSkeleton"; +import { ThemeToggle } from "./src/components/ThemeToggle/ThemeToggle"; +import type { FormValues } from "./src/form/swapFormTypes"; +import { useTokenPrices } from "./src/hooks/useTokenPrices"; +import { getDefaultTokenPair } from "./src/lib/defaultTokens"; +import { useTheme } from "./src/theme/ThemeProvider"; + +const SUBMIT_DELAY_MS = 2000; + +const swapTokenApi = async (values: FormValues) => { + return new Promise((resolve, reject) => { + window.setTimeout(() => { + if (Math.random() > 0.5) { + console.log(values); + resolve(); + } else { + reject(new Error("Failed to submit swap.")); + } + }, SUBMIT_DELAY_MS); + }); +}; + +function App() { + const toast = useToast(); + const { resolved, toggleResolved } = useTheme(); + const { tokenPrices, loadError, isLoading } = useTokenPrices(); + const symbols = useMemo(() => Array.from(tokenPrices.keys()), [tokenPrices]); + + const { from, to } = getDefaultTokenPair(symbols); + const initialValues = useMemo( + () => ({ fromToken: from, toToken: to, fromAmount: "", toAmount: "" }), + [from, to], + ); + const [submitInProgress, setSubmitInProgress] = useState(false); + + const handleSubmit = useCallback( + async (values: FormValues) => { + setSubmitInProgress(true); + toast.dismissAll(); + + try { + await swapTokenApi(values); + toast.success("Swap submitted successfully. (Mock transaction)"); + setSubmitInProgress(false); + return { success: true }; + } catch (error) { + toast.error("Failed to submit swap."); + setSubmitInProgress(false); + return { success: false }; + } + }, + [toast], + ); + + const handleClearSubmitFeedback = useCallback(() => { + toast.dismissAll(); + }, [toast]); + + const pricesReady = symbols.length > 0; + /** + * Stagger real form fade-in one frame past mount so opacity transitions interpolate (prevents flicker). + * Skips extra frames when the user prefers reduced motion. + */ + const [formEnter, setFormEnter] = useState(false); + + useEffect(() => { + if (!pricesReady) { + setFormEnter(false); + return; + } + let canceled = false; + const prefersReduce = + typeof window.matchMedia !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (prefersReduce) { + queueMicrotask(() => { + if (!canceled) { + setFormEnter(true); + } + }); + return () => { + canceled = true; + }; + } + let idInner = 0; + const idOuter = window.requestAnimationFrame(() => { + idInner = window.requestAnimationFrame(() => { + if (!canceled) { + setFormEnter(true); + } + }); + }); + return () => { + canceled = true; + window.cancelAnimationFrame(idOuter); + window.cancelAnimationFrame(idInner); + }; + }, [pricesReady]); + + const revealForm = pricesReady && formEnter; + + return ( + <> +
+ +
+ +
+
+
+
+
+ +
+
+

+ + + + + Live feed +

+

+ Swap tokens +

+

+ Trade at market rates with a clean, production-style flow — powered by the Switcheo price API. +

+
+ + {loadError && ( +

+ {loadError || "Failed to load market data."} +

+ )} + + {!loadError ? ( +
+
+ +
+
+ {pricesReady ? ( + + ) : null} +
+
+ ) : null} +
+
+ + ); +} + +export default App; diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bff..4ce3ee077e 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,33 @@ - - + + - - Fancy Form - - - + + + Token Swap + + + + + + - - - -
-
Swap
- - - - - - - -
- +
+ - diff --git a/src/problem2/main.tsx b/src/problem2/main.tsx new file mode 100644 index 0000000000..c301d7e19d --- /dev/null +++ b/src/problem2/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { ThemeProvider } from "./src/theme/ThemeProvider"; +import { ToastProvider } from "./src/components/Toast/ToastProvider"; +import "./styles/tailwind.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/src/problem2/package.json b/src/problem2/package.json new file mode 100644 index 0000000000..532c5d415e --- /dev/null +++ b/src/problem2/package.json @@ -0,0 +1,25 @@ +{ + "name": "problem2-token-swap", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "latest", + "react-dom": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "latest", + "vite": "latest" + } +} diff --git a/src/problem2/postcss.config.js b/src/problem2/postcss.config.js new file mode 100644 index 0000000000..2aa7205d4b --- /dev/null +++ b/src/problem2/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/problem2/script.js b/src/problem2/script.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/problem2/src/components/Button/Button.tsx b/src/problem2/src/components/Button/Button.tsx new file mode 100644 index 0000000000..dcfa4c8196 --- /dev/null +++ b/src/problem2/src/components/Button/Button.tsx @@ -0,0 +1,109 @@ +import { ButtonHTMLAttributes, forwardRef } from "react"; + +export type ButtonVariant = "primary" | "iconRound" | "dropdownTrigger" | "listItem"; + +const VARIANT_CLASSES: Record = { + primary: + "inline-flex w-full items-center justify-center gap-2.5 rounded-2xl border-0 bg-gradient-to-br from-violet-600 via-indigo-600 to-cyan-500 px-4 py-[0.95rem] text-[0.95rem] font-bold tracking-wide text-white shadow-glow ring-1 ring-white/25 transition-[transform,box-shadow,filter] duration-200 hover:-translate-y-0.5 hover:brightness-105 hover:shadow-[0_16px_48px_-14px_rgba(99,102,241,0.55)] active:translate-y-0 disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:translate-y-0 disabled:hover:brightness-100", + iconRound: + "relative inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-slate-200/90 bg-white text-[1.05rem] text-indigo-600 shadow-[0_8px_20px_-8px_rgba(15,23,42,0.35)] ring-2 ring-white/80 transition-[transform,background-color,color,box-shadow,border-color] duration-300 ease-out hover:border-indigo-200 hover:bg-gradient-to-br hover:from-indigo-50 hover:to-teal-50 hover:text-indigo-700 hover:shadow-md active:scale-95 dark:border-slate-600 dark:bg-slate-800 dark:text-indigo-300 dark:shadow-black/50 dark:ring-white/12 dark:hover:border-indigo-500/55 dark:hover:from-indigo-950 dark:hover:to-slate-900 dark:hover:text-indigo-200 dark:hover:shadow-black/65", + dropdownTrigger: + "absolute right-[0.2rem] top-1/2 flex h-[1.85rem] w-[1.85rem] -translate-y-1/2 items-center justify-center rounded-lg border border-transparent bg-white/95 text-indigo-500 shadow-sm backdrop-blur-sm transition-[background,border,color,transform] duration-200 ease-out hover:border-indigo-200/85 hover:bg-indigo-50/95 hover:text-indigo-700 disabled:cursor-not-allowed disabled:opacity-45 dark:bg-slate-800/92 dark:border-slate-600/40 dark:text-indigo-300 dark:hover:bg-slate-700 dark:hover:border-indigo-500/45 dark:hover:text-indigo-200", + listItem: + "inline-flex w-full items-center gap-2 rounded-xl border border-transparent px-2.5 py-2 text-left text-[0.9rem] font-medium text-slate-800 transition-[background,color,border] duration-200 ease-out hover:border-indigo-200/90 hover:bg-gradient-to-r hover:from-indigo-50/92 hover:to-teal-50/55 hover:text-indigo-900 dark:border-transparent dark:text-slate-100 dark:hover:border-indigo-500/35 dark:hover:from-indigo-950 dark:hover:to-teal-950/40 dark:hover:text-indigo-100", +}; + +export type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant; + /** Shows a spinner and disables interaction while true. */ + inProgress?: boolean; + /** When true (and not `inProgress`), shows a check icon; does **not** disable the button — use alongside `disabled` if you want to block presses. */ + ready?: boolean; +}; + +function ButtonSpinner({ + variant, +}: { + variant: ButtonVariant; +}) { + const size = + variant === "dropdownTrigger" ? + "h-3 w-3 border-[2px]" + : variant === "iconRound" ? + "h-5 w-5 border-2" + : "h-[1.06em] w-[1.06em] border-2"; + + return ( + + ); +} + +function ReadyCheckGlyph({ variant }: { variant: ButtonVariant }) { + const size = + variant === "dropdownTrigger" ? "h-3 w-3" + : variant === "iconRound" ? "h-5 w-5" + : "h-[1.15em] w-[1.15em] shrink-0"; + return ( + + + + ); +} + +export const Button = forwardRef(function Button( + { + variant = "primary", + className, + type = "button", + inProgress = false, + ready = false, + disabled, + children, + ...rest + }, + ref, +) { + const variantClass = VARIANT_CLASSES[variant]; + const merged = [variantClass, className].filter(Boolean).join(" "); + const showReady = Boolean(ready && !inProgress); + + return ( + + ); +}); diff --git a/src/problem2/src/components/Input/Input.tsx b/src/problem2/src/components/Input/Input.tsx new file mode 100644 index 0000000000..e1454e85df --- /dev/null +++ b/src/problem2/src/components/Input/Input.tsx @@ -0,0 +1,25 @@ +import { InputHTMLAttributes, forwardRef } from "react"; + +export type InputVariant = "field" | "combobox"; + +const SHARED = + "rounded-xl bg-white/[0.97] text-[0.95rem] font-medium text-slate-800 shadow-panel-inset transition-[border-color,box-shadow,background-color,color] duration-200 ease-out placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-4 focus:ring-indigo-500/18 disabled:opacity-55 dark:bg-slate-900/93 dark:text-slate-100 dark:placeholder:text-slate-500 dark:shadow-none dark:focus:border-indigo-400 dark:focus:ring-indigo-400/26"; + +const VARIANT_CLASSES: Record = { + field: `${SHARED} border border-slate-200/95 px-[0.85rem] py-[0.7rem] dark:border-slate-600 read-only:bg-slate-50/95 read-only:text-slate-800 tabular-nums dark:read-only:bg-slate-900/82 dark:read-only:text-slate-200`, + combobox: `${SHARED} w-full border border-slate-200/95 px-[2.35rem] py-[0.68rem] pr-9 dark:border-slate-600`, +}; + +export type InputProps = InputHTMLAttributes & { + variant?: InputVariant; +}; + +export const Input = forwardRef(function Input( + { variant = "field", className, ...rest }, + ref, +) { + const variantClass = VARIANT_CLASSES[variant]; + const merged = [variantClass, className].filter(Boolean).join(" "); + + return ; +}); diff --git a/src/problem2/src/components/SearchableDropdown/SearchableDropdown.tsx b/src/problem2/src/components/SearchableDropdown/SearchableDropdown.tsx new file mode 100644 index 0000000000..5c684e4d6a --- /dev/null +++ b/src/problem2/src/components/SearchableDropdown/SearchableDropdown.tsx @@ -0,0 +1,170 @@ +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../Button/Button"; +import { Input } from "../Input/Input"; + +type DropdownOption = { + value: string; + label: string; +}; + +type Props = { + id: string; + ariaLabel: string; + value: string; + options: DropdownOption[]; + onChange: (nextValue: string) => void; + disabled?: boolean; + placeholder?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + noResultsText?: string; + className?: string; +}; + +export function SearchableDropdown({ + className, + id, + ariaLabel, + value, + options, + onChange, + disabled = false, + placeholder = "", + leftIcon, + rightIcon, + noResultsText = "No options found", +}: Props) { + const rootRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const listboxId = `${id}-listbox`; + + const selectedOption = useMemo( + () => options.find((option) => option.value === value), + [options, value], + ); + + useEffect(() => { + setQuery(selectedOption?.label ?? value); + }, [selectedOption, value]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleOutsideClick = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + setQuery(selectedOption?.label ?? value); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [isOpen, selectedOption, value]); + + const filteredOptions = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return options; + } + return options.filter((option) => + `${option.label} ${option.value}`.toLowerCase().includes(normalized), + ); + }, [options, query]); + + const selectOption = (nextValue: string) => { + const nextOption = options.find((option) => option.value === nextValue); + onChange(nextValue); + setQuery(nextOption?.label ?? nextValue); + setIsOpen(false); + }; + + return ( +
+ {leftIcon ? ( + + {leftIcon} + + ) : null} + setIsOpen(true)} + className={ + isOpen ? + "border-indigo-300 ring-4 ring-indigo-500/12 dark:border-indigo-400 dark:ring-indigo-400/25" + : "" + } + onChange={(event) => { + const nextValue = event.target.value; + setQuery(nextValue); + setIsOpen(true); + + const exactMatch = options.find( + (option) => option.value === nextValue || option.label === nextValue, + ); + if (exactMatch) { + onChange(exactMatch.value); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsOpen(false); + setQuery(selectedOption?.label ?? value); + } + if (event.key === "Enter" && filteredOptions.length > 0) { + event.preventDefault(); + selectOption(filteredOptions[0].value); + } + }} + /> + + + {isOpen ? ( +
    + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( +
  • + +
  • + )) + ) : ( +
  • {noResultsText}
  • + )} +
+ ) : null} +
+ ); +} diff --git a/src/problem2/src/components/SwapDetails/SwapDetails.tsx b/src/problem2/src/components/SwapDetails/SwapDetails.tsx new file mode 100644 index 0000000000..50bafbeff2 --- /dev/null +++ b/src/problem2/src/components/SwapDetails/SwapDetails.tsx @@ -0,0 +1,40 @@ +type Props = { + exchangeRateText: string; + networkFeeText: string; +}; + +const SwapDetails = ({ exchangeRateText, networkFeeText }: Props) => { + return ( +
+ {exchangeRateText} + {networkFeeText} +
+ ); +}; + +function DetailLine({ + children, + accent, +}: { + children: string; + accent: "violet" | "teal"; +}) { + const bar = + accent === "violet" + ? "from-violet-500 via-indigo-500 to-fuchsia-500" + : "from-teal-400 via-cyan-500 to-indigo-500"; + + return ( +
+
+ +

{children}

+
+
+ ); +} + +export default SwapDetails; diff --git a/src/problem2/src/components/SwapForm/SwapForm.tsx b/src/problem2/src/components/SwapForm/SwapForm.tsx new file mode 100644 index 0000000000..d2e8c0c111 --- /dev/null +++ b/src/problem2/src/components/SwapForm/SwapForm.tsx @@ -0,0 +1,199 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { Button } from "../Button/Button"; +import SwapDetails from "../SwapDetails/SwapDetails"; +import TokenSelectField from "../TokenSelectField/TokenSelectField"; +import { SwapFormProvider, useSwapForm } from "../../form/SwapFormContext"; +import { + type FormValues, + type SwapFormErrors, + type SwapFormValues, + type ValidateSwapForm, + doSwapValuesMatchSnapshot, +} from "../../form/swapFormTypes"; +import { formatAmount } from "../../lib/format"; + +export type { FormValues } from "../../form/swapFormTypes"; + +const EMPTY_VALUES: SwapFormValues = { + fromToken: "", + toToken: "", + fromAmount: "", + toAmount: "", +}; + +const validateSwap: ValidateSwapForm = (values) => { + const errors: SwapFormErrors = {}; + + if (!values.fromAmount) { + errors.fromAmount = "Enter an amount to swap."; + } else if (Number.isNaN(Number(values.fromAmount)) || Number(values.fromAmount) <= 0) { + errors.fromAmount = "Amount must be greater than 0."; + } + + if (values.fromToken === values.toToken) { + errors.fromAmount = "Please select two different tokens."; + } + + return errors; +}; + +type Props = { + tokenPrices: Map; + loadError: string; + onSubmit: (values: FormValues) => Promise<{ success: boolean }>; + initialValues?: FormValues; + submitInProgress: boolean; + onClearSubmitFeedback: () => void; +}; + +type InnerProps = Omit; + +const panelClass = + "rounded-[1.15rem] border border-slate-200/85 bg-gradient-to-b from-white to-slate-50/98 p-[1rem_1.05rem] shadow-[0_10px_30px_-18px_rgba(15,23,42,0.18)] ring-1 ring-white/70 transition-[border-color,box-shadow] duration-500 dark:border-slate-600/90 dark:bg-gradient-to-b dark:from-slate-900 dark:to-slate-950 dark:shadow-[0_14px_40px_-12px_rgba(0,0,0,0.52)] dark:ring-slate-700/85"; + +const SwapFormFields = ({ + tokenPrices, + loadError, + submitInProgress, +}: InnerProps) => { + const { values, handleSubmit, setValues, setFieldValue, isValid, lastSubmittedValues } = useSwapForm(); + + const submitMatchesLastSuccess = useMemo( + () => + lastSubmittedValues !== null && + doSwapValuesMatchSnapshot(values, lastSubmittedValues), + [lastSubmittedValues, values], + ); + + const tokenSymbols = useMemo(() => [...tokenPrices.keys()], [tokenPrices]); + + const fromPrice = tokenPrices.get(values.fromToken); + const toPrice = tokenPrices.get(values.toToken); + + const canComputeQuote = + typeof fromPrice === "number" && + typeof toPrice === "number" && + Number(values.fromAmount) > 0 && + values.fromToken !== values.toToken && + !loadError; + + const computedToAmount = useMemo(() => { + if ( + !canComputeQuote || + typeof fromPrice !== "number" || + typeof toPrice !== "number" + ) { + return ""; + } + const convertedAmount = (Number(values.fromAmount) * fromPrice) / toPrice; + return formatAmount(convertedAmount, 8); + }, [canComputeQuote, values.fromAmount, fromPrice, toPrice]); + + useEffect(() => { + if (computedToAmount === values.toAmount) { + return; + } + setFieldValue("toAmount", computedToAmount, { notify: false }); + }, [computedToAmount, values.toAmount, setFieldValue]); + + const exchangeRateText = useMemo(() => { + if (!canComputeQuote || typeof fromPrice !== "number" || typeof toPrice !== "number") { + return "Rate: —"; + } + return `Rate: 1 ${values.fromToken} = ${formatAmount(fromPrice / toPrice, 8)} ${values.toToken}`; + }, [canComputeQuote, fromPrice, values.fromToken, toPrice, values.toToken]); + + const networkFeeText = useMemo(() => { + if (!canComputeQuote) { + return "Estimated network fee: —"; + } + return `Estimated network fee: ${formatAmount(Number(values.fromAmount) * 0.0025, 6)} ${values.fromToken}`; + }, [canComputeQuote, values.fromAmount, values.fromToken]); + + const flipTokens = useCallback(() => { + setValues({ fromToken: values.toToken, toToken: values.fromToken }); + }, [values.toToken, values.fromToken, setValues]); + + return ( +
+
+ +
+ +
+ + +
+ +
+ +
+ + + + + + ); +}; + +const SwapForm = (props: Props) => { + const { initialValues, onSubmit, onClearSubmitFeedback, ...rest } = props; + + const mergedInitial = initialValues ?? EMPTY_VALUES; + + return ( + + + + ); +}; + +export default SwapForm; diff --git a/src/problem2/src/components/SwapForm/SwapFormSkeleton.tsx b/src/problem2/src/components/SwapForm/SwapFormSkeleton.tsx new file mode 100644 index 0000000000..715261a84a --- /dev/null +++ b/src/problem2/src/components/SwapForm/SwapFormSkeleton.tsx @@ -0,0 +1,52 @@ +/** Visual placeholder matched to SwapForm vertical rhythm — avoids CLS when prices load. */ +export function SwapFormSkeleton() { + const pulseBar = + "h-11 w-full animate-pulse rounded-lg bg-slate-200/85 motion-reduce:animate-none dark:bg-slate-700/95"; + + const panel = + "rounded-[1.15rem] border border-slate-200/65 bg-white/55 p-[1rem_1.05rem] shadow-[0_6px_20px_-12px_rgba(15,23,42,0.08)] backdrop-blur-sm dark:border-slate-700 dark:bg-slate-950/52"; + + const chip = + "mb-3 rounded-md bg-slate-200/95 dark:bg-slate-700"; + + const metric = + "flex min-h-[4.75rem] rounded-2xl border border-slate-200/65 bg-white/55 p-3 backdrop-blur-sm dark:border-slate-700 dark:bg-slate-950/52"; + + const metricInner = + "h-12 flex-1 self-center rounded-xl bg-slate-200/85 motion-safe:animate-pulse motion-reduce:animate-none dark:bg-slate-700/90"; + + return ( +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ ); +} diff --git a/src/problem2/src/components/ThemeToggle/ThemeToggle.tsx b/src/problem2/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 0000000000..3c0fb6dd4f --- /dev/null +++ b/src/problem2/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,21 @@ +type Props = { + isDark: boolean; + onToggle: () => void; +}; + +export function ThemeToggle({ isDark, onToggle }: Props) { + return ( + + ); +} diff --git a/src/problem2/src/components/Toast/ToastProvider.tsx b/src/problem2/src/components/Toast/ToastProvider.tsx new file mode 100644 index 0000000000..e4157582f8 --- /dev/null +++ b/src/problem2/src/components/Toast/ToastProvider.tsx @@ -0,0 +1,193 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +const AUTO_DISMISS_MS = 6200; +/** Keep in sync with `animate-toast-out` duration in tailwind.config.js */ +const TOAST_EXIT_MS = 300; + +type Variant = "success" | "error"; + +type ToastItem = { + id: string; + message: string; + variant: Variant; + exiting?: boolean; +}; + +export type ToastContextValue = { + success: (message: string) => void; + error: (message: string) => void; + dismiss: (id: string) => void; + dismissAll: () => void; +}; + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const idRef = useRef(0); + + const dismiss = useCallback((id: string) => { + let shouldExit = false; + setToasts((previous) => { + const found = previous.find((t) => t.id === id); + if (!found || found.exiting) { + return previous; + } + shouldExit = true; + return previous.map((t) => (t.id === id ? { ...t, exiting: true } : t)); + }); + if (!shouldExit) { + return; + } + window.setTimeout(() => { + setToasts((previous) => previous.filter((t) => t.id !== id)); + }, TOAST_EXIT_MS); + }, []); + + const dismissAll = useCallback(() => { + setToasts((previous) => { + if (previous.length === 0 || previous.every((toast) => toast.exiting)) { + return previous; + } + return previous.map((toast) => ({ ...toast, exiting: true })); + }); + window.setTimeout(() => { + setToasts((previous) => previous.filter((toast) => !toast.exiting)); + }, TOAST_EXIT_MS); + }, []); + + const push = useCallback((message: string, variant: Variant) => { + idRef.current += 1; + const id = `toast-${idRef.current}`; + setToasts((previous) => [...previous, { id, message, variant }]); + }, []); + + const success = useCallback((message: string) => push(message, "success"), [push]); + + const error = useCallback((message: string) => push(message, "error"), [push]); + + const value = useMemo( + () => ({ success, error, dismiss, dismissAll }), + [success, error, dismiss, dismissAll], + ); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used within ToastProvider."); + } + return ctx; +} + +function ToastViewport({ + toasts, + onDismiss, +}: { + toasts: ToastItem[]; + onDismiss: (id: string) => void; +}) { + const portalTarget = typeof document !== "undefined" ? document.body : null; + + if (!portalTarget) { + return null; + } + + return createPortal( +
+ {toasts.map((toast) => ( + + ))} +
, + portalTarget, + ); +} + +function ToastMessage({ + toast, + onDismiss, +}: { + toast: ToastItem; + onDismiss: (id: string) => void; +}) { + useEffect(() => { + if (toast.exiting) { + return; + } + const timeout = window.setTimeout(() => onDismiss(toast.id), AUTO_DISMISS_MS); + return () => window.clearTimeout(timeout); + }, [toast.id, toast.exiting, toast.message, onDismiss]); + + const isError = toast.variant === "error"; + + const surface = + isError ? + "relative overflow-hidden rounded-2xl border-[1.5px] border-red-600 bg-white text-neutral-950 shadow-[0_18px_50px_-20px_rgba(185,28,28,0.45)] ring-1 ring-neutral-950/[0.1] backdrop-blur-[2px] dark:border-red-400 dark:bg-red-950 dark:text-neutral-50 dark:shadow-[0_22px_50px_-16px_rgba(0,0,0,0.75)] dark:ring-white/22" + : "relative overflow-hidden rounded-2xl border-[1.5px] border-emerald-700 bg-white text-neutral-950 shadow-[0_18px_50px_-22px_rgba(5,80,62,0.32)] ring-1 ring-neutral-950/[0.08] backdrop-blur-[2px] dark:border-emerald-400 dark:bg-emerald-950 dark:text-emerald-50 dark:shadow-[0_22px_50px_-16px_rgba(0,0,0,0.65)] dark:ring-emerald-100/22"; + + const iconTone = + isError ? + "border border-red-800/35 bg-red-600 text-white shadow-md dark:bg-red-500 dark:text-white dark:border-transparent" + : "border border-emerald-900/30 bg-emerald-600 text-white shadow-md dark:bg-emerald-500 dark:text-white dark:border-transparent"; + + const motionClasses = toast.exiting ? "toast-motion-out pointer-events-none" : "toast-motion-in"; + + return ( +
+ + {isError ? "!" : "✓"} + +

+ {toast.message} +

+ +
+ ); +} diff --git a/src/problem2/src/components/TokenIcon/TokenIcon.tsx b/src/problem2/src/components/TokenIcon/TokenIcon.tsx new file mode 100644 index 0000000000..766707146c --- /dev/null +++ b/src/problem2/src/components/TokenIcon/TokenIcon.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { getTokenIconUrl } from "../../lib/tokenIcon"; + +type Props = { + symbol: string; +}; + +export function TokenIcon({ symbol }: Props) { + const [hidden, setHidden] = useState(false); + const [sourceIndex, setSourceIndex] = useState(0); + const iconSources = [...new Set([symbol, symbol.toUpperCase(), symbol.toLowerCase()])].map( + (tokenSymbol) => getTokenIconUrl(tokenSymbol), + ); + + useEffect(() => { + setHidden(false); + setSourceIndex(0); + }, [symbol]); + + if (!symbol) { + return null; + } + + return ( + { + if (sourceIndex < iconSources.length - 1) { + setSourceIndex((previous) => previous + 1); + return; + } + setHidden(true); + }} + /> + ); +} diff --git a/src/problem2/src/components/TokenSelectField/TokenSelectField.tsx b/src/problem2/src/components/TokenSelectField/TokenSelectField.tsx new file mode 100644 index 0000000000..f0c8779e38 --- /dev/null +++ b/src/problem2/src/components/TokenSelectField/TokenSelectField.tsx @@ -0,0 +1,102 @@ +import { ChangeEvent, ReactNode } from "react"; +import { useSwapFormField } from "../../form/SwapFormContext"; +import { Input } from "../Input/Input"; +import { SearchableDropdown } from "../SearchableDropdown/SearchableDropdown"; +import { TokenIcon } from "../TokenIcon/TokenIcon"; + +export type SwapTokenField = "fromToken" | "toToken"; +export type SwapAmountField = "fromAmount" | "toAmount"; + +type Props = { + id: string; + ariaLabel: string; + symbols: string[]; + disabled?: boolean; + tokenField: SwapTokenField; + amountField: SwapAmountField; + rightIcon?: ReactNode; + label?: string; + amountReadOnly?: boolean; +}; + +const TokenSelectField = ({ + id, + ariaLabel, + symbols, + disabled = false, + tokenField, + amountField, + rightIcon, + label, + amountReadOnly = false, +}: Props) => { + const tokenProps = useSwapFormField(tokenField); + const amountProps = useSwapFormField(amountField); + + const handleAmountChange = (event: ChangeEvent) => { + amountProps.setValue(event.target.value as string); + }; + + const handleTokenChange = (symbol: string) => { + tokenProps.setValue(symbol); + tokenProps.setTouched(true); + }; + + const message = amountProps.error ?? tokenProps.error; + const showMessage = + (amountProps.touched || tokenProps.touched) && typeof message === "string" && message.length > 0; + + const tokenSymbol = typeof tokenProps.value === "string" ? tokenProps.value : ""; + const amountString = typeof amountProps.value === "string" ? amountProps.value : ""; + + const optionMap = symbols.map((symbol) => ({ value: symbol, label: symbol })); + + return ( +
+ +
+ } + rightIcon={rightIcon ?? } + noResultsText="No tokens found" + /> + +
+ {showMessage ? ( +

+ {message} +

+ ) : null} +
+ ); +}; + +export default TokenSelectField; diff --git a/src/problem2/src/form/SwapFormContext.tsx b/src/problem2/src/form/SwapFormContext.tsx new file mode 100644 index 0000000000..51c59634ad --- /dev/null +++ b/src/problem2/src/form/SwapFormContext.tsx @@ -0,0 +1,14 @@ +import { createFormContext } from "./createFormContext"; +import type { SwapFormValues } from "./swapFormTypes"; + +const swapForm = createFormContext(); + +/** Swap-token form instance of the generic form context API. */ +export const SwapFormProvider = swapForm.FormProvider; + +export const useSwapForm = swapForm.useForm; + +export const useSwapFormField = swapForm.useFormField; + +export { createFormContext } from "./createFormContext"; +export type { FormErrors, ValidateForm } from "./formTypes"; diff --git a/src/problem2/src/form/createFormContext.tsx b/src/problem2/src/form/createFormContext.tsx new file mode 100644 index 0000000000..5e776a2fed --- /dev/null +++ b/src/problem2/src/form/createFormContext.tsx @@ -0,0 +1,249 @@ +import { + createContext, + FormEvent, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { FormErrors, ValidateForm } from "./formTypes"; + +/** + * Formik-style state (values, errors, touched) with a small API. + * Instantiate once per form shape: `const { FormProvider, useForm, useFormField } = createFormContext()`. + */ +export function createFormContext>() { + type FieldKey = keyof T; + + type FormContextApi = { + values: T; + errors: FormErrors; + touched: Partial>; + dirty: boolean; + isValid: boolean; + setFieldValue: ( + field: K, + value: T[K], + options?: { notify?: boolean }, + ) => void; + setFieldTouched: (field: FieldKey, touched?: boolean) => void; + setValues: (patch: Partial) => void; + resetForm: (nextInitial?: Partial) => void; + handleSubmit: (event: FormEvent) => void; + lastSubmittedValues: T | null; + }; + + const FormContextReact = createContext(null); + + type FormProviderProps = { + initialValues: T; + validate?: ValidateForm; + onSubmit: (values: T) => Promise<{ success: boolean }>; + /** Called after user-driven value updates (skipped when `setFieldValue(..., { notify: false })`). */ + onValuesChange?: () => void; + /** + * Keys to mark touched on submit before validation. + * If omitted, merges keys from initial values and current values so optional fields aren’t skipped. + */ + submitTouchKeys?: FieldKey[]; + children: ReactNode; + }; + + function mergeTouchKeys(currentValues: T, initialSnapshot: T, explicit?: FieldKey[]): FieldKey[] { + if (explicit && explicit.length > 0) { + return explicit; + } + const set = new Set( + [...Object.keys(currentValues), ...Object.keys(initialSnapshot)] as FieldKey[], + ); + return [...set]; + } + + function FormProvider({ + initialValues: initialValuesProp, + validate, + onSubmit, + onValuesChange, + submitTouchKeys, + children, + }: FormProviderProps) { + const initialRef = useRef(initialValuesProp); + + useEffect(() => { + initialRef.current = initialValuesProp; + }, [initialValuesProp]); + + const [values, setValuesState] = useState(initialValuesProp); + const [touched, setTouched] = useState>>({}); + const [errors, setErrors] = useState>({}); + const [lastSubmittedValues, setLastSubmittedValues] = useState(null); + + /** Avoid re-init / layout thrash when the parent passes a new object with the same logical initial values. */ + const serializedInitialRef = useRef(null); + + const validateRef = useRef(validate); + validateRef.current = validate; + + const submitTouchKeysRef = useRef(submitTouchKeys); + submitTouchKeysRef.current = submitTouchKeys; + + const runValidation = useCallback((nextValues: T): FormErrors => { + const fn = validateRef.current; + if (!fn) { + return {}; + } + return fn(nextValues); + }, []); + + useEffect(() => { + const serialized = JSON.stringify(initialValuesProp); + if ( + serializedInitialRef.current !== null && + serializedInitialRef.current === serialized + ) { + return; + } + serializedInitialRef.current = serialized; + setValuesState(initialValuesProp); + setTouched({}); + setErrors({}); + }, [initialValuesProp]); + + useEffect(() => { + const hasTouched = Object.values(touched).some(Boolean); + if (!validateRef.current || !hasTouched) { + return; + } + setErrors(runValidation(values)); + }, [values, touched, runValidation]); + + const dirty = useMemo( + () => JSON.stringify(values) !== JSON.stringify(initialValuesProp), + [values, initialValuesProp], + ); + + const isValid = useMemo( + () => Object.keys(runValidation(values)).length === 0, + [values, runValidation], + ); + + const setFieldValue = useCallback( + (field: K, value: T[K], options?: { notify?: boolean }) => { + setValuesState((previous) => ({ ...previous, [field]: value })); + if (options?.notify !== false) { + onValuesChange?.(); + } + }, + [onValuesChange], + ); + + const setValues = useCallback( + (patch: Partial) => { + setValuesState((previous) => ({ ...previous, ...patch })); + onValuesChange?.(); + }, + [onValuesChange], + ); + + const setFieldTouched = useCallback((field: FieldKey, isTouched = true) => { + setTouched((previous) => ({ ...previous, [field]: isTouched })); + }, []); + + const resetForm = useCallback((nextInitial?: Partial) => { + const base = initialRef.current; + const next = nextInitial ? { ...base, ...nextInitial } : base; + setValuesState(next); + setTouched({}); + setErrors({}); + }, []); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + + const keys = mergeTouchKeys(values, initialRef.current, submitTouchKeysRef.current); + setTouched( + keys.reduce>>((acc, key) => { + acc[key] = true; + return acc; + }, {}), + ); + + const nextErrors = runValidation(values); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + return; + } + + void onSubmit(values).then((result) => { + if (result.success) { + setLastSubmittedValues(values); + } + }); + }, + [values, onSubmit, runValidation], + ); + + const ctx = useMemo( + () => ({ + values, + errors, + touched, + dirty, + isValid, + setFieldValue, + setFieldTouched, + setValues, + resetForm, + handleSubmit, + lastSubmittedValues, + }), + [ + values, + errors, + touched, + dirty, + isValid, + setFieldValue, + setFieldTouched, + setValues, + resetForm, + lastSubmittedValues, + handleSubmit, + ], + ); + + return ( + {children} + ); + } + + function useForm(): FormContextApi { + const ctx = useContext(FormContextReact); + if (!ctx) { + throw new Error( + "useForm must be used within FormProvider from the same createFormContext() instance.", + ); + } + return ctx; + } + + function useFormField(name: K) { + const { values, errors, touched, setFieldValue, setFieldTouched } = useForm(); + + return { + name, + value: values[name], + error: errors[name], + touched: touched[name] ?? false, + setValue: (next: T[K]) => setFieldValue(name, next), + setTouched: (next = true) => setFieldTouched(name, next), + onBlur: () => setFieldTouched(name, true), + }; + } + + return { FormProvider, useForm, useFormField }; +} diff --git a/src/problem2/src/form/formTypes.ts b/src/problem2/src/form/formTypes.ts new file mode 100644 index 0000000000..858f458ba6 --- /dev/null +++ b/src/problem2/src/form/formTypes.ts @@ -0,0 +1,3 @@ +export type FormErrors = Partial>; + +export type ValidateForm = (values: T) => FormErrors; diff --git a/src/problem2/src/form/swapFormTypes.ts b/src/problem2/src/form/swapFormTypes.ts new file mode 100644 index 0000000000..a58cbf7b98 --- /dev/null +++ b/src/problem2/src/form/swapFormTypes.ts @@ -0,0 +1,25 @@ +import type { FormErrors, ValidateForm } from "./formTypes"; + +export type SwapFormValues = { + fromToken: string; + toToken: string; + fromAmount: string; + toAmount: string; +}; + +/** Convenience alias shared with callers (e.g. App). */ +export type FormValues = SwapFormValues; + +/** True when live form matches a prior successful-submit snapshot (all four fields compared as strings). */ +export function doSwapValuesMatchSnapshot(a: FormValues, b: FormValues): boolean { + return ( + a.fromToken === b.fromToken && + a.toToken === b.toToken && + a.fromAmount === b.fromAmount && + a.toAmount === b.toAmount + ); +} + +export type SwapFormErrors = FormErrors; + +export type ValidateSwapForm = ValidateForm; diff --git a/src/problem2/src/hooks/useTokenPrices.ts b/src/problem2/src/hooks/useTokenPrices.ts new file mode 100644 index 0000000000..6421bb5ded --- /dev/null +++ b/src/problem2/src/hooks/useTokenPrices.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { fetchTokenPrices } from "../lib/prices"; + +export function useTokenPrices() { + const [tokenPrices, setTokenPrices] = useState>( + new Map(), + ); + const [loadError, setLoadError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + + const initialize = async (): Promise => { + try { + setIsLoading(true); + const map = await fetchTokenPrices(); + if (cancelled) { + return; + } + setTokenPrices(map); + setLoadError(""); + } catch (error) { + if (cancelled) { + return; + } + const message = + error instanceof Error ? error.message : "Unknown error."; + setLoadError(`Failed to load market data. ${message}`); + } finally { + setIsLoading(false); + } + }; + + void initialize(); + + return () => { + cancelled = true; + }; + }, []); + + return { tokenPrices, loadError, isLoading }; +} diff --git a/src/problem2/src/lib/constants.ts b/src/problem2/src/lib/constants.ts new file mode 100644 index 0000000000..2c08956e66 --- /dev/null +++ b/src/problem2/src/lib/constants.ts @@ -0,0 +1,4 @@ +export const PRICE_URL = "https://interview.switcheo.com/prices.json"; + +export const TOKEN_ICON_BASE_URL = + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; diff --git a/src/problem2/src/lib/defaultTokens.ts b/src/problem2/src/lib/defaultTokens.ts new file mode 100644 index 0000000000..0fed36d20b --- /dev/null +++ b/src/problem2/src/lib/defaultTokens.ts @@ -0,0 +1,8 @@ +export function getDefaultTokenPair(symbols: string[]): { from: string; to: string } { + const from = symbols.includes("ETH") ? "ETH" : symbols[0]; + const to = symbols.includes("USDC") + ? "USDC" + : symbols.find((symbol) => symbol !== from) || symbols[0]; + + return { from, to }; +} diff --git a/src/problem2/src/lib/format.ts b/src/problem2/src/lib/format.ts new file mode 100644 index 0000000000..76d0788a1b --- /dev/null +++ b/src/problem2/src/lib/format.ts @@ -0,0 +1,6 @@ +export function formatAmount(value: number, maxFractionDigits = 6): string { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: maxFractionDigits, + }).format(value); +} diff --git a/src/problem2/src/lib/prices.ts b/src/problem2/src/lib/prices.ts new file mode 100644 index 0000000000..bc6f4f29f0 --- /dev/null +++ b/src/problem2/src/lib/prices.ts @@ -0,0 +1,56 @@ +import { PRICE_URL } from "./constants"; + +export type PriceRow = { + currency: string; + date: string; + price: number; +}; + +export function isPriceRow(value: unknown): value is PriceRow { + if (!value || typeof value !== "object") { + return false; + } + + const row = value as Partial; + return ( + typeof row.currency === "string" && + typeof row.date === "string" && + typeof row.price === "number" && + !Number.isNaN(row.price) + ); +} + +export function getLatestPriceMap(priceRows: PriceRow[]): Map { + const latestByCurrency = new Map(); + + for (const row of priceRows) { + const existing = latestByCurrency.get(row.currency); + if (!existing || new Date(row.date) > new Date(existing.date)) { + latestByCurrency.set(row.currency, { date: row.date, price: row.price }); + } + } + + const sortedEntries: Array<[string, number]> = [...latestByCurrency.entries()] + .filter(([, value]) => value.price > 0) + .map(([symbol, value]) => [symbol, value.price] as [string, number]) + .sort((a, b) => a[0].localeCompare(b[0])); + + return new Map(sortedEntries); +} + +export async function fetchTokenPrices(): Promise> { + const response = await fetch(PRICE_URL); + if (!response.ok) { + throw new Error(`Unable to load prices (${response.status}).`); + } + + const payload: unknown = await response.json(); + const priceRows = Array.isArray(payload) ? payload.filter(isPriceRow) : []; + const map = getLatestPriceMap(priceRows); + + if (map.size < 2) { + throw new Error("Not enough token prices available to perform a swap."); + } + + return map; +} diff --git a/src/problem2/src/lib/tokenIcon.ts b/src/problem2/src/lib/tokenIcon.ts new file mode 100644 index 0000000000..4774142575 --- /dev/null +++ b/src/problem2/src/lib/tokenIcon.ts @@ -0,0 +1,5 @@ +import { TOKEN_ICON_BASE_URL } from "./constants"; + +export function getTokenIconUrl(symbol: string): string { + return `${TOKEN_ICON_BASE_URL}/${encodeURIComponent(symbol)}.svg`; +} diff --git a/src/problem2/src/theme/ThemeProvider.tsx b/src/problem2/src/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..841c3884f0 --- /dev/null +++ b/src/problem2/src/theme/ThemeProvider.tsx @@ -0,0 +1,97 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +const STORAGE_KEY = "swap-ui-theme"; + +export type ThemeMode = "light" | "dark" | "system"; + +type ThemeContextValue = { + theme: ThemeMode; + resolved: "light" | "dark"; + setTheme: (mode: ThemeMode) => void; + toggleResolved: () => void; +}; + +const ThemeContext = createContext(null); + +function getSystemDark(): boolean { + if (typeof window === "undefined") { + return false; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { + if (typeof window === "undefined") { + return "system"; + } + const stored = window.localStorage.getItem(STORAGE_KEY) as ThemeMode | null; + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + return "system"; + }); + + const [systemDark, setSystemDark] = useState(getSystemDark); + + useEffect(() => { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const listener = (): void => { + setSystemDark(media.matches); + }; + listener(); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); + }, []); + + const resolved: "light" | "dark" = + theme === "system" ? (systemDark ? "dark" : "light") : theme; + + useEffect(() => { + document.documentElement.classList.toggle("dark", resolved === "dark"); + document.documentElement.dataset.theme = resolved; + }, [resolved]); + + const setTheme = useCallback((mode: ThemeMode) => { + setThemeState(mode); + window.localStorage.setItem(STORAGE_KEY, mode); + }, []); + + const toggleResolved = useCallback(() => { + setThemeState((previous) => { + const effective = + previous === "system" ? (systemDark ? "dark" : "light") : previous; + const next: ThemeMode = effective === "dark" ? "light" : "dark"; + window.localStorage.setItem(STORAGE_KEY, next); + return next; + }); + }, [systemDark]); + + const value = useMemo( + () => ({ + theme, + resolved, + setTheme, + toggleResolved, + }), + [theme, resolved, setTheme, toggleResolved], + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within ThemeProvider."); + } + return ctx; +} diff --git a/src/problem2/style.css b/src/problem2/style.css deleted file mode 100644 index 915af91c72..0000000000 --- a/src/problem2/style.css +++ /dev/null @@ -1,8 +0,0 @@ -body { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - min-width: 360px; - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/problem2/styles/tailwind.css b/src/problem2/styles/tailwind.css new file mode 100644 index 0000000000..aafe89f236 --- /dev/null +++ b/src/problem2/styles/tailwind.css @@ -0,0 +1,100 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/** + * Toast enter/exit — defined here (not via `motion-safe:*` utilities) so keyframes + * always apply: Tailwind nests `motion-safe:animate-*` inside a media query, which + * can prevent mount animations from running consistently. + */ +@layer utilities { + @keyframes app-toast-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes app-toast-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + } + @keyframes app-toast-in-reduce { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes app-toast-out-reduce { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + .toast-motion-in { + animation: app-toast-in 420ms cubic-bezier(0.16, 1, 0.3, 1) forwards; + } + .toast-motion-out { + animation: app-toast-out 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + + @media (prefers-reduced-motion: reduce) { + .toast-motion-in { + animation: app-toast-in-reduce 220ms ease-out forwards; + } + .toast-motion-out { + animation: app-toast-out-reduce 220ms ease-in forwards; + } + } +} + +@layer base { + html { + color-scheme: light; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + html.dark { + color-scheme: dark; + } + + body { + @apply m-0 min-h-screen font-sans text-slate-900 transition-[background,color] duration-500 ease-out; + background-color: #f1f5f9; + background-image: + radial-gradient(ellipse 90% 70% at 50% -40%, rgba(99, 102, 241, 0.2), transparent 55%), + radial-gradient(ellipse 55% 50% at 100% 30%, rgba(34, 211, 238, 0.12), transparent 50%), + linear-gradient(180deg, #f8fafc 0%, #e2e8f0 50%, #f1f5f9 100%); + background-attachment: fixed; + } + + html.dark body { + @apply text-slate-100; + background-color: #060a10; + background-image: + radial-gradient(ellipse 100% 85% at 50% -35%, rgba(99, 102, 241, 0.42), transparent 55%), + radial-gradient(ellipse 55% 55% at 105% 35%, rgba(34, 211, 238, 0.18), transparent 50%), + radial-gradient(ellipse 55% 50% at -5% 75%, rgba(244, 114, 182, 0.2), transparent 50%), + linear-gradient(165deg, #060a10 0%, #0c1220 38%, #0f172a 72%, #020617 100%); + background-attachment: fixed; + } + + ::selection { + @apply bg-indigo-400/35 text-inherit dark:bg-indigo-500/40 dark:text-white; + } +} diff --git a/src/problem2/tailwind.config.js b/src/problem2/tailwind.config.js new file mode 100644 index 0000000000..f2c7b6a333 --- /dev/null +++ b/src/problem2/tailwind.config.js @@ -0,0 +1,28 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: "class", + content: ["./index.html", "./**/*.{ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: ["Plus Jakarta Sans", "system-ui", "sans-serif"], + }, + boxShadow: { + card: "0 2px 24px -4px rgba(15, 23, 42, 0.12), 0 12px 48px -12px rgba(15, 23, 42, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.65)", + glow: "0 0 42px -8px rgba(99, 102, 241, 0.55), 0 0 86px -24px rgba(34, 211, 238, 0.35)", + "panel-inset": "inset 0 1px 1px rgba(15, 23, 42, 0.06)", + dropdown: "0 18px 42px -14px rgba(15, 23, 42, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.06)", + }, + animation: { + float: "float 5s ease-in-out infinite", + }, + keyframes: { + float: { + "0%, 100%": { transform: "translateY(0)" }, + "50%": { transform: "translateY(-6px)" }, + }, + }, + }, + }, + plugins: [], +}; diff --git a/src/problem2/tsconfig.json b/src/problem2/tsconfig.json new file mode 100644 index 0000000000..6a8c148daa --- /dev/null +++ b/src/problem2/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/src/problem2/yarn.lock b/src/problem2/yarn.lock new file mode 100644 index 0000000000..fb095b8fa8 --- /dev/null +++ b/src/problem2/yarn.lock @@ -0,0 +1,837 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + +"@emnapi/core@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" + integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + +"@emnapi/runtime@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@jridgewell/gen-mapping@^0.3.2": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@napi-rs/wasm-runtime@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" + integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== + dependencies: + "@tybys/wasm-util" "^0.10.1" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@oxc-project/types@=0.127.0": + version "0.127.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" + integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== + +"@rolldown/binding-android-arm64@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz#0a502a88c39d0ffa81aa30b561dade6f6217dcc5" + integrity sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz#8b7f05ac9000ab19161a79a0346b1b64a1bc7ba3" + integrity sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw== + +"@rolldown/binding-darwin-x64@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz#f8b465b3a4e992053890b162f1ae19e4f1719a6a" + integrity sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz#a8281e14fa9c243fe22dc2d0e54900e66b31935e" + integrity sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz#cd29cf869ddd4fac8d6929abf94b91ddb0494650" + integrity sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz#91c331236ec3728366218d61a62f0bd226546c6c" + integrity sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz#80108957db752e7826836e22240e56b8140e9684" + integrity sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz#1dce51148cbc6bab3c3f9157b5323d2a31aac924" + integrity sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz#d4a0d2e01d8d441e4ac3af3fa68eec17a7d0e9cd" + integrity sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz#0ac8b3139cefeea798ad147f30ea70572b133af1" + integrity sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz#2af61bee087571728f58f1c47734bbbd41dd7050" + integrity sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz#56c1afbf6c592819abf47b4a983987dc288b30c1" + integrity sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz#5d112ff4dd0d268a60fb4e0eb3077e3ea2531f0d" + integrity sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA== + dependencies: + "@emnapi/core" "1.10.0" + "@emnapi/runtime" "1.10.0" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz#5125a85222d64a543201d28e16a395cc45bf4d17" + integrity sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA== + +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz#fc6b78e759a0bb2054b5c0a3489da12b2cae54b4" + integrity sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg== + +"@rolldown/pluginutils@1.0.0-rc.17": + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz#a89b30833fb628bc834fe2e89fea93a2da9fa69a" + integrity sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg== + +"@tybys/wasm-util@^0.10.1": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== + dependencies: + tslib "^2.4.0" + +"@types/react-dom@latest": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + +"@types/react@latest": + version "19.2.14" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" + integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== + dependencies: + csstype "^3.2.2" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + +autoprefixer@^10.4.20: + version "10.5.0" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.0.tgz#33d87e443430f020a0f85319d6ff1593cb291be9" + integrity sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong== + dependencies: + browserslist "^4.28.2" + caniuse-lite "^1.0.30001787" + fraction.js "^5.3.4" + picocolors "^1.1.1" + postcss-value-parser "^4.2.0" + +baseline-browser-mapping@^2.10.12: + version "2.10.27" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3" + integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.28.2: + version "4.28.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" + integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== + dependencies: + baseline-browser-mapping "^2.10.12" + caniuse-lite "^1.0.30001782" + electron-to-chromium "^1.5.328" + node-releases "^2.0.36" + update-browserslist-db "^1.2.3" + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001787: + version "1.0.30001792" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5" + integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw== + +chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + +electron-to-chromium@^1.5.328: + version "1.5.351" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz#7314fbb5b4835a1869feaec09665541b6a84cd37" + integrity sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fastq@^1.6.0: + version "1.20.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + dependencies: + reusify "^1.0.4" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +fraction.js@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" + integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +glob-parent@^5.1.2, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +hasown@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" + integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== + dependencies: + function-bind "^1.1.2" + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-core-module@^2.16.1: + version "2.16.2" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" + integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== + dependencies: + hasown "^2.0.3" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +jiti@^1.21.7: + version "1.21.7" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" + integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== + +lightningcss-android-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" + integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + +lightningcss-darwin-arm64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5" + integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + +lightningcss-darwin-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e" + integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + +lightningcss-freebsd-x64@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575" + integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + +lightningcss-linux-arm-gnueabihf@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d" + integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + +lightningcss-linux-arm64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335" + integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + +lightningcss-linux-arm64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133" + integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss-win32-arm64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38" + integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + +lightningcss-win32-x64-msvc@1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a" + integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + +lightningcss@^1.32.0: + version "1.32.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" + +lilconfig@^3.1.1, lilconfig@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.11: + version "3.3.12" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" + integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== + +node-releases@^2.0.36: + version "2.0.38" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.38.tgz#791569b9e4424a044e12c3abfad418ed83ce9947" + integrity sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-assign@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +postcss-import@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.1.0.tgz#003b63c6edde948766e40f3daf7e997ae43a5ce6" + integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw== + dependencies: + camelcase-css "^2.0.1" + +"postcss-load-config@^4.0.2 || ^5.0 || ^6.0": + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + +postcss-nested@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" + integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ== + dependencies: + postcss-selector-parser "^6.1.1" + +postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" + integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.47, postcss@^8.4.49, postcss@^8.5.10: + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-dom@latest: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e" + integrity sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag== + dependencies: + scheduler "^0.27.0" + +react@latest: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#c888ab8b8ef33e2597fae8bdb2d77edbdb42858b" + integrity sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA== + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +resolve@^1.1.7, resolve@^1.22.8: + version "1.22.12" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" + integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== + dependencies: + es-errors "^1.3.0" + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rolldown@1.0.0-rc.17: + version "1.0.0-rc.17" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.17.tgz#c524fc22f6bb37b5588aec862ab1ee11382610f3" + integrity sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA== + dependencies: + "@oxc-project/types" "=0.127.0" + "@rolldown/pluginutils" "1.0.0-rc.17" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.0.0-rc.17" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.17" + "@rolldown/binding-darwin-x64" "1.0.0-rc.17" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.17" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.17" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.17" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.17" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.17" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.17" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.17" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.17" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.17" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.17" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.17" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.17" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +sucrase@^3.35.0: + version "3.35.1" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" + integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + tinyglobby "^0.2.11" + ts-interface-checker "^0.1.9" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tailwindcss@^3.4.17: + version "3.4.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2" + integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.6.0" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.3.2" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.21.7" + lilconfig "^3.1.3" + micromatch "^4.0.8" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.1.1" + postcss "^8.4.47" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.2 || ^5.0 || ^6.0" + postcss-nested "^6.2.0" + postcss-selector-parser "^6.1.2" + resolve "^1.22.8" + sucrase "^3.35.0" + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +tinyglobby@^0.2.11, tinyglobby@^0.2.16: + version "0.2.16" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +typescript@latest: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +update-browserslist-db@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vite@latest: + version "8.0.10" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.10.tgz#fb31868526ec874101fac084172a2cdc6776319b" + integrity sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw== + dependencies: + lightningcss "^1.32.0" + picomatch "^4.0.4" + postcss "^8.5.10" + rolldown "1.0.0-rc.17" + tinyglobby "^0.2.16" + optionalDependencies: + fsevents "~2.3.3" From 13101107d6696d42577d8b6c9842dbb5ec174b4d Mon Sep 17 00:00:00 2001 From: "thanhminh.uit" <230365528+thanhminhuit-ops@users.noreply.github.com> Date: Wed, 6 May 2026 19:14:14 +0200 Subject: [PATCH 3/3] feat: problem 3 --- src/problem3/README.md | 65 +++++++++++++++++++++ src/problem3/WalletPage.tsx | 112 ++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/problem3/README.md create mode 100644 src/problem3/WalletPage.tsx diff --git a/src/problem3/README.md b/src/problem3/README.md new file mode 100644 index 0000000000..3e8bbaf0aa --- /dev/null +++ b/src/problem3/README.md @@ -0,0 +1,65 @@ +# Problem 3 - Messy React (WalletPage) + +This document summarizes the issues found in the original component and the improvements applied in the refactored `WalletPage.tsx`. + +## Tech Context + +- React with TypeScript +- Functional components +- React Hooks (`useMemo`) + +## What Was Wrong (Original Version) + +### 1) Runtime and Logic Bugs + +- Used an undefined variable inside filtering logic (`lhsPriority`), which can break execution. +- Filtering condition was reversed (`amount <= 0`), keeping invalid/empty balances. +- Sort comparator did not always return a number (missing equal-case handling), causing unstable ordering. +- Mapped rows from `sortedBalances` but tried to read formatted fields that were not present. +- `formattedAmount` prop mismatch (`formatted` existed in data, `formattedAmount` was read in component). +- Potential `NaN` in USD value calculation when a token price is missing. + +### 2) TypeScript Anti-Patterns + +- `blockchain` property used but not declared on `WalletBalance`. +- `getPriority(blockchain: any)` weakened type safety. +- Callback parameter types were inaccurate (declared as richer type than actual values). +- Empty/weak prop typing patterns and inconsistent prop contracts across components. + +### 3) React / Performance Issues + +- Used `index` as React key for list rows (unstable when sorting/filtering). +- Derived arrays were created in multiple passes without clear alignment of data shape. +- Memoization dependencies were previously incorrect in the old implementation. +- Unused derived data (`formattedBalances`) added unnecessary work. + +## What Was Improved (Current Version) + +### 1) Correctness Fixes + +- Replaced broken filter/sort pipeline with correct logic: + - Keep only supported blockchains + - Keep only positive balances + - Sort by descending blockchain priority +- Unified row data shape so formatted values and USD value are created before rendering. +- Added safe fallback in price lookup: `(prices[b.currency] ?? 0)`. +- Fixed row prop mismatch by using a consistent `formatted` field end-to-end. + +### 2) Type Safety Improvements + +- Added strict `Blockchain` union type. +- Added required `blockchain` and `id` fields to `WalletBalance`. +- Replaced weak `any` usage with typed `getPriority(blockchain: Blockchain)`. +- Introduced explicit `WalletRowData` and `WalletRowProps` interfaces for stable contracts. + +### 3) React Best-Practice Improvements + +- Replaced index key with stable key (`row.id`). +- Consolidated derived list creation into a single `useMemo` pipeline (`filter -> sort -> map`). +- Kept render phase focused on presentation only (`rows.map(...)` into `WalletRow`). +- Switched to direct function component props typing for clearer component contracts. + +## Final Notes + +- The component now has a clearer data flow: **raw balances -> validated/sorted/formatted rows -> render**. +- The current version is significantly safer and easier to reason about. diff --git a/src/problem3/WalletPage.tsx b/src/problem3/WalletPage.tsx new file mode 100644 index 0000000000..017ed0ea59 --- /dev/null +++ b/src/problem3/WalletPage.tsx @@ -0,0 +1,112 @@ +import React, { memo, useMemo } from 'react'; +import type { BoxProps } from '@mui/material'; + +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface WalletBalance { + id: string; // stable unique id for key + currency: string; + amount: number; + blockchain: Blockchain; +} + +interface WalletRowData extends WalletBalance { + formatted: string; + usdValue: number; +} + +type WalletRowProps = WalletRowData & { className?: string }; + +const BLOCKCHAIN_PRIORITY: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + + +const DEFAULT_PRIORITY = -99; + +const getPriority = (blockchain: Blockchain): number => + BLOCKCHAIN_PRIORITY[blockchain] ?? DEFAULT_PRIORITY; + +const useWalletBalances = (): WalletBalance[] => { + return [ + { id: 'eth-usdc', currency: 'USDC', amount: 100, blockchain: 'Ethereum' }, + { id: 'osmo-usdc', currency: 'USDC', amount: 100, blockchain: 'Osmosis' }, + { id: 'arb-usdc', currency: 'USDC', amount: 100, blockchain: 'Arbitrum' }, + ]; +}; + +const usePrices = (): Record => { + return { + USDC: 1, + OSMO: 0.5, + ARB: 0.3, + ZIL: 0.2, + NEO: 0.1, + }; +}; + +const WalletRow = memo(function WalletRow({ + className, + blockchain, + currency, + amount, + usdValue, + formatted, +}: WalletRowProps) { + return ( +
+
{blockchain}
+
{currency}
+
{amount}
+
{usdValue}
+
{formatted}
+
+ ); +}); + +type Props = BoxProps; + +export default function WalletPage({ children, ...rest }: Props) { + const balances = useWalletBalances(); + const prices = usePrices(); + + const rows = useMemo(() => { + return balances + .filter((b) => getPriority(b.blockchain) > DEFAULT_PRIORITY && b.amount > 0) + .slice() + .sort((a, b) => { + const priorityDiff = getPriority(b.blockchain) - getPriority(a.blockchain); + if (priorityDiff !== 0) { + return priorityDiff; + } + // Stable tie-breaker so same-priority rows keep deterministic order. + return a.currency.localeCompare(b.currency); + }) + .map((b) => ({ + ...b, + formatted: b.amount.toFixed(2), + usdValue: (prices[b.currency] ?? 0) * b.amount, + })); + }, [balances, prices]); + + return ( +
+ {rows.length === 0 ? ( +
No balances available.
+ ) : ( + rows.map((row) => ( + + )) + )} + {children} +
+ ); +} \ No newline at end of file