diff --git a/src/problem1/README.md b/src/problem1/README.md new file mode 100644 index 000000000..42c230107 --- /dev/null +++ b/src/problem1/README.md @@ -0,0 +1,50 @@ +# Problem 1 — Sum to n + +**Input:** `n` (integer). **Output:** `1 + 2 + ... + n`. Result < `Number.MAX_SAFE_INTEGER`. + +## Run + +```bash +npm install +npm run start +``` + +## Implementations + +**A — Formula** + +```ts +var sum_to_n_a = function(n: number): number { + if (n <= 0) return 0; + return n * (n + 1) / 2; +}; +``` + +**B — Loop** + +```ts +var sum_to_n_b = function(n: number): number { + if (n <= 0) return 0; + let sum = 0; + for (let i = 1; i <= n; i++) sum += i; + return sum; +}; +``` + +**C — Recursion** + +```ts +var sum_to_n_c = function(n: number): number { + if (n <= 0) return 0; + return n + sum_to_n_c(n - 1); +}; +``` + +## Structure + +``` +problem1/ +├── index.ts +├── package.json +└── README.md +``` diff --git a/src/problem1/index.ts b/src/problem1/index.ts new file mode 100644 index 000000000..67665be01 --- /dev/null +++ b/src/problem1/index.ts @@ -0,0 +1,40 @@ + +/* +Provide 3 unique implementations of the following function in JavaScript. +Input: n - any integer +Assuming this input will always produce a result lesser than Number.MAX_SAFE_INTEGER. +Output: return - summation to n, i.e. sum_to_n(5) === 1 + 2 + 3 + 4 + 5 === 15. +*/ + +var sum_to_n_a = function(n: number): number { + // using the formula for the sum of an arithmetic series + if (n <= 0) return 0 + return n * (n + 1) / 2; +}; + +var sum_to_n_b = function(n: number): number { + // using a loop + if (n <= 0) return 0; + + let sum = 0; + for (let i = 1; i <= n; i++) { + sum += i; + } + + return sum; +}; + +var sum_to_n_c = function(n: number): number { + // using recursion + if (n <= 0) return 0; + + return n + sum_to_n_c(n - 1); +}; + +const numbers = 5; + +console.log(sum_to_n_a(numbers)); +console.log(sum_to_n_b(numbers)); +console.log(sum_to_n_c(numbers)); + +// RUN: npm run start \ No newline at end of file diff --git a/src/problem1/package.json b/src/problem1/package.json new file mode 100644 index 000000000..42dd50c4f --- /dev/null +++ b/src/problem1/package.json @@ -0,0 +1,14 @@ +{ + "name": "problem1", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "npx tsx index.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs" +} diff --git a/src/problem2/.gitignore b/src/problem2/.gitignore new file mode 100644 index 000000000..5e5b0beb5 --- /dev/null +++ b/src/problem2/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/src/problem2/README.md b/src/problem2/README.md new file mode 100644 index 000000000..1619fb055 --- /dev/null +++ b/src/problem2/README.md @@ -0,0 +1,56 @@ +# Currency Swap + +Token exchange at best rates — convert between tokens with live prices. + +**Demo:** https://fe-code-challenge-9ixy.onrender.com/ + +## Screenshots + +**Desktop (light)** + +![Desktop](src/assets/imgs/problem2-pc.png) + +**Dark mode** + +![Dark mode](src/assets/imgs/problem2-darkmode.png) + +**Mobile** + +![Mobile](src/assets/imgs/problem2-mb.png) + +## Tech + +- React 18 + TypeScript +- Vite 5 +- Vanilla CSS (no Tailwind) + +## Run locally + +```bash +yarn install +yarn dev +``` + +Open http://localhost:3000 + +Build: `yarn build` → output in `dist/` + +## Features + +- **Landing** — Hero, swap section, feature cards (“Why swap with us”), footer +- **Swap form** — Pick From/To token, enter amount; see estimated output and “1 FROM ≈ X TO” rate +- **Swap direction** — One-click button to swap From and To tokens +- **Max** — Button to fill a demo max amount +- **Token modal** — Open from token dropdown: search, token icons, price and last updated per token +- **Result modal** — Success or error with swap summary; form resets on close +- **Validation** — Positive amount only; From and To must differ; errors after input or submit +- **Loading** — Skeleton while prices load; retry button if fetch fails; “Last updated” relative time +- **Dark/light theme** — Toggle in header; preference saved in browser; respects system preference +- **Animations** — Hero entrance, scroll-triggered sections, button and modal transitions +- **Accessibility** — ARIA, keyboard support, focus management +- **Performance** — Lazy-loaded swap form, single price API call (deduped) + +## Data + +- Prices: https://interview.switcheo.com/prices.json +- Icons: Switcheo token-icons repo (fallback to letter if missing) diff --git a/src/problem2/index.html b/src/problem2/index.html index 4058a68bf..bad179d59 100644 --- a/src/problem2/index.html +++ b/src/problem2/index.html @@ -1,27 +1,24 @@ - - - - - Fancy Form - - - - - - - - -
-
Swap
- - - - - - - -
- - - + + + + + + + Swap — Token exchange at best rates + + + + + + +
+ + diff --git a/src/problem2/package.json b/src/problem2/package.json new file mode 100644 index 000000000..a04c1b8e4 --- /dev/null +++ b/src/problem2/package.json @@ -0,0 +1,25 @@ +{ + "name": "problem2", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "build:preview": "vite build && vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.0", + "vite": "^5.0.0" + }, + "main": "index.js", + "license": "MIT" +} diff --git a/src/problem2/script.js b/src/problem2/script.js index e69de29bb..29f003cb0 100644 --- a/src/problem2/script.js +++ b/src/problem2/script.js @@ -0,0 +1,459 @@ +const PRICES_API_URL = "https://interview.switcheo.com/prices.json"; +const ICON_BASE_URL = + "https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens"; +const FAKE_DELAY_MS = 1500; + +const fetchTokenPrices = async () => { + const response = await fetch(PRICES_API_URL); + if (!response.ok) throw new Error("Failed to fetch prices"); + const data = await response.json(); + + const priceMap = new Map(); + for (const item of data) { + if (!item.currency || typeof item.price !== "number" || item.price <= 0) + continue; + const existing = priceMap.get(item.currency); + if (!existing || new Date(item.date) > new Date(existing.date)) { + priceMap.set(item.currency, { price: item.price, date: item.date }); + } + } + return priceMap; +}; + +const getTokenIconUrl = (symbol) => `${ICON_BASE_URL}/${symbol}.svg`; + +// Converts amount from one token to another based on USD prices. +// Formula: (amount * fromPrice) / toPrice + +const convertAmount = (amount, fromPrice, toPrice) => { + if (!fromPrice || !toPrice || toPrice === 0) return null; + return (Number(amount) * fromPrice) / toPrice; +}; + +// Validates the swap form state. +// Returns { valid: boolean, errors: { amount?: string, tokens?: string } } + +const validateForm = (amount, fromToken, toToken) => { + const errors = {}; + + const numAmount = Number(amount); + if (amount === "" || amount === null || amount === undefined) { + errors.amount = "Please enter an amount"; + } else if (Number.isNaN(numAmount)) { + errors.amount = "Amount must be a valid number"; + } else if (numAmount <= 0) { + errors.amount = "Amount must be a positive number"; + } + + if (fromToken && toToken && fromToken === toToken) { + errors.tokens = "From and To tokens must be different"; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +}; + +// Parses user input as a number, allowing decimals. + +const parseAmount = (value) => { + const cleaned = String(value).replace(/[^\d.]/g, ""); + const num = parseFloat(cleaned); + return Number.isNaN(num) ? null : num; +}; + +// Creates and populates token select dropdowns and custom dropdown options. + +const renderTokenSelects = (priceMap, fromSelect, toSelect) => { + const tokens = Array.from(priceMap.keys()).sort(); + const optionsHtml = tokens + .map((symbol) => ``) + .join(""); + + fromSelect.innerHTML = `${optionsHtml}`; + toSelect.innerHTML = `${optionsHtml}`; + + if (tokens.length >= 2) { + fromSelect.value = tokens[0]; + toSelect.value = tokens[1]; + } + + // Populate custom dropdowns + const fromWrapper = fromSelect.closest(".token-select-wrapper"); + const toWrapper = toSelect.closest(".token-select-wrapper"); + + const renderDropdown = (wrapper, selectEl) => { + const dropdown = wrapper.querySelector(".token-dropdown"); + const triggerLabel = wrapper.querySelector(".token-trigger-label"); + if (!dropdown || !triggerLabel) return; + + dropdown.innerHTML = tokens + .map( + (symbol) => { + const isSelected = symbol === selectEl.value; + return ` +
  • + + ${symbol} +
  • + `; + } + ) + .join(""); + + triggerLabel.textContent = selectEl.value || "Select token"; + }; + + renderDropdown(fromWrapper, fromSelect); + renderDropdown(toWrapper, toSelect); +}; + +// Syncs custom trigger label and option selected state with select value. +const syncTriggerLabel = (selectEl) => { + const wrapper = selectEl.closest(".token-select-wrapper"); + const triggerLabel = wrapper?.querySelector(".token-trigger-label"); + if (triggerLabel) triggerLabel.textContent = selectEl.value || "Select token"; + + const dropdown = wrapper?.querySelector(".token-dropdown"); + if (dropdown) { + dropdown.querySelectorAll(".token-option").forEach((opt) => { + opt.setAttribute("aria-selected", opt.dataset.value === selectEl.value ? "true" : "false"); + }); + } +}; + +// Renders token icon in the given container. + +const renderTokenIcon = (container, symbol) => { + container.classList.remove("fallback"); + container.innerHTML = ""; + if (!symbol) return; + + const img = document.createElement("img"); + img.src = getTokenIconUrl(symbol); + img.alt = ""; + img.onerror = () => { + img.remove(); + container.textContent = symbol.charAt(0); + container.classList.add("fallback"); + }; + container.appendChild(img); +}; + +// Formats a number for display (max 8 decimal places, trim trailing zeros). + +const formatDisplayAmount = (value) => { + if (value === null || value === undefined || Number.isNaN(value)) return ""; + if (value === 0) return "0"; + const formatted = Number(value).toLocaleString("en-US", { + maximumFractionDigits: 8, + minimumFractionDigits: 0, + }); + return formatted.replace(/\.?0+$/, "") || "0"; +}; + +// Simulates a backend request with loading state. + +const simulateSwapRequest = () => + new Promise((resolve) => setTimeout(resolve, FAKE_DELAY_MS)); + +// Application State & Initialization + +const init = () => { + const form = document.getElementById("swap-form"); + const fromSelect = document.getElementById("from-token"); + const toSelect = document.getElementById("to-token"); + const inputAmount = document.getElementById("input-amount"); + const outputAmount = document.getElementById("output-amount"); + const amountError = document.getElementById("amount-error"); + const toTokenError = document.getElementById("to-token-error"); + const submitBtn = document.getElementById("submit-btn"); + const resultModal = document.getElementById("result-modal"); + const modalIcon = document.getElementById("modal-icon"); + const modalTitle = document.getElementById("modal-title"); + const modalMessage = document.getElementById("modal-message"); + const modalCloseBtn = document.getElementById("modal-close-btn"); + const modalOverlay = document.getElementById("modal-overlay"); + const loadError = document.getElementById("load-error"); + const swapDirectionBtn = document.getElementById("swap-direction-btn"); + const fromTokenIcon = document.getElementById("from-token-icon"); + const toTokenIcon = document.getElementById("to-token-icon"); + + let priceMap = new Map(); + let formTouched = false; + + // Updates validation state and submit button. + // Only shows error messages when user has interacted (input or submit). + + const updateValidation = (showErrors = formTouched) => { + const amount = inputAmount.value.trim(); + const from = fromSelect.value; + const to = toSelect.value; + const { valid, errors } = validateForm(amount, from, to); + + if (showErrors) { + amountError.textContent = errors.amount || ""; + amountError.style.display = errors.amount ? "block" : "none"; + inputAmount.setAttribute( + "aria-invalid", + errors.amount ? "true" : "false", + ); + toTokenError.textContent = errors.tokens || ""; + toTokenError.style.display = errors.tokens ? "block" : "none"; + } else { + amountError.textContent = ""; + amountError.style.display = "none"; + inputAmount.setAttribute("aria-invalid", "false"); + toTokenError.textContent = ""; + toTokenError.style.display = "none"; + } + + submitBtn.disabled = !valid; + }; + + // Updates the converted amount display. + + const updateConversion = () => { + const from = fromSelect.value; + const to = toSelect.value; + const amount = parseAmount(inputAmount.value); + + if (!from || !to || amount === null || amount < 0) { + outputAmount.value = ""; + return; + } + + const fromData = priceMap.get(from); + const toData = priceMap.get(to); + if (!fromData || !toData) { + outputAmount.value = ""; + return; + } + + const converted = convertAmount(amount, fromData.price, toData.price); + outputAmount.value = formatDisplayAmount(converted); + }; + + // Updates token icons when selection changes. + + const updateIcons = () => { + renderTokenIcon(fromTokenIcon, fromSelect.value); + renderTokenIcon(toTokenIcon, toSelect.value); + }; + + // Handles token selector change: update icons and conversion, no validation. + + const handleTokenChange = () => { + updateIcons(); + updateConversion(); + updateValidation(); + }; + + // Handles amount input: validate and update conversion. + + const handleInputChange = () => { + formTouched = true; + updateConversion(); + updateValidation(); + }; + + // Swaps From and To tokens. + + const handleSwapDirection = () => { + const from = fromSelect.value; + const to = toSelect.value; + if (!from || !to) return; + + fromSelect.value = to; + toSelect.value = from; + syncTriggerLabel(fromSelect); + syncTriggerLabel(toSelect); + updateIcons(); + updateConversion(); + updateValidation(); + }; + + // Shows result modal (success or error), then clears form when closed. + + const showResultModal = (success, message) => { + modalIcon.className = `modal-icon ${success ? "success" : "error"}`; + modalIcon.innerHTML = success + ? '' + : ''; + modalTitle.textContent = success ? "Success" : "Error"; + modalMessage.textContent = message; + resultModal.hidden = false; + modalCloseBtn.focus(); + }; + + // Closes modal and clears the form. + + const closeModalAndClearForm = () => { + resultModal.hidden = true; + + const tokens = Array.from(priceMap.keys()).sort(); + inputAmount.value = ""; + outputAmount.value = ""; + amountError.textContent = ""; + amountError.style.display = "none"; + inputAmount.setAttribute("aria-invalid", "false"); + toTokenError.textContent = ""; + toTokenError.style.display = "none"; + + if (tokens.length >= 2) { + fromSelect.value = tokens[0]; + toSelect.value = tokens[1]; + syncTriggerLabel(fromSelect); + syncTriggerLabel(toSelect); + } + formTouched = false; + updateIcons(); + updateConversion(); + updateValidation(); + }; + + // Handles form submit with loading state. + + const handleSubmit = async (e) => { + e.preventDefault(); + + formTouched = true; + const amount = inputAmount.value.trim(); + const from = fromSelect.value; + const to = toSelect.value; + const { valid } = validateForm(amount, from, to); + if (!valid) { + updateValidation(true); + return; + } + + submitBtn.disabled = true; + submitBtn.setAttribute("aria-busy", "true"); + + try { + await simulateSwapRequest(); + const converted = outputAmount.value; + showResultModal(true, `You will receive ${converted} ${to}`); + } catch (err) { + showResultModal(false, "Something went wrong. Please try again."); + } finally { + submitBtn.disabled = false; + submitBtn.setAttribute("aria-busy", "false"); + updateValidation(); + } + }; + + // Load prices and initialize UI. + + const loadAndInit = async () => { + try { + priceMap = await fetchTokenPrices(); + if (priceMap.size === 0) { + fromSelect.innerHTML = ''; + toSelect.innerHTML = ''; + syncTriggerLabel(fromSelect); + syncTriggerLabel(toSelect); + return; + } + renderTokenSelects(priceMap, fromSelect, toSelect); + setupTokenDropdown(fromSelect, handleTokenChange); + setupTokenDropdown(toSelect, handleTokenChange); + updateIcons(); + updateConversion(); + updateValidation(); + loadError.textContent = ""; + loadError.className = "status-message"; + } catch (err) { + fromSelect.innerHTML = ''; + toSelect.innerHTML = ''; + syncTriggerLabel(fromSelect); + syncTriggerLabel(toSelect); + loadError.textContent = "Failed to load token prices. Please refresh."; + loadError.className = "status-message error"; + } + }; + + // Setup custom dropdown for token selectors + const setupTokenDropdown = (selectEl, onSelect) => { + const wrapper = selectEl.closest(".token-select-wrapper"); + const trigger = wrapper?.querySelector(".token-trigger"); + const dropdown = wrapper?.querySelector(".token-dropdown"); + if (!wrapper || !trigger || !dropdown) return; + + const open = () => { + document.querySelectorAll(".token-select-wrapper.is-open").forEach((w) => w.classList.remove("is-open")); + wrapper.classList.add("is-open"); + trigger.setAttribute("aria-expanded", "true"); + }; + + const close = () => { + wrapper.classList.remove("is-open"); + trigger.setAttribute("aria-expanded", "false"); + }; + + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + if (wrapper.classList.contains("is-open")) close(); + else open(); + }); + + dropdown.addEventListener("click", (e) => { + const opt = e.target.closest(".token-option"); + if (!opt) return; + e.stopPropagation(); + const value = opt.dataset.value; + selectEl.value = value; + syncTriggerLabel(selectEl); + close(); + selectEl.dispatchEvent(new Event("change", { bubbles: true })); + onSelect?.(); + }); + dropdown.addEventListener("keydown", (e) => { + const opt = e.target.closest(".token-option"); + if (opt && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + opt.click(); + } + }); + + document.addEventListener("click", (e) => { + if (!wrapper.contains(e.target)) close(); + }); + }; + + /* Event listeners */ + fromSelect.addEventListener("change", handleTokenChange); + toSelect.addEventListener("change", handleTokenChange); + inputAmount.addEventListener("input", handleInputChange); + inputAmount.addEventListener("blur", handleInputChange); + + swapDirectionBtn.addEventListener("click", handleSwapDirection); + swapDirectionBtn.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSwapDirection(); + } + }); + + form.addEventListener("submit", handleSubmit); + + const handleModalKeydown = (e) => { + if (e.key === "Escape") { + if (!resultModal.hidden) { + closeModalAndClearForm(); + } else { + document.querySelectorAll(".token-select-wrapper.is-open").forEach((w) => w.classList.remove("is-open")); + document.querySelectorAll(".token-select-wrapper .token-trigger[aria-expanded='true']").forEach((t) => t.setAttribute("aria-expanded", "false")); + } + } + }; + + modalCloseBtn.addEventListener("click", closeModalAndClearForm); + modalOverlay.addEventListener("click", closeModalAndClearForm); + document.addEventListener("keydown", handleModalKeydown); + + loadAndInit(); +}; + +document.addEventListener("DOMContentLoaded", init); diff --git a/src/problem2/src/App.tsx b/src/problem2/src/App.tsx new file mode 100644 index 000000000..3641e63cd --- /dev/null +++ b/src/problem2/src/App.tsx @@ -0,0 +1,156 @@ +import { lazy, Suspense } from 'react'; +import { useTheme } from './hooks/useTheme'; +import { useInView } from './hooks/useInView'; + +const SwapForm = lazy(() => import('./components/SwapForm').then((m) => ({ default: m.SwapForm }))); + +function SwapFormSkeleton() { + return ( +
    +
    +
    +
    +
    +

    + Loading… +

    +
    + ); +} + +const features = [ + { + title: 'Real-time rates', + description: 'Live prices from multiple sources. See your quote before you swap.', + icon: '📊', + }, + { + title: 'Simple & fast', + description: 'Select tokens, enter amount, confirm. No sign-up required to get started.', + icon: '⚡', + }, + { + title: 'Transparent', + description: 'Clear exchange rate and estimated output. No hidden fees.', + icon: '🔍', + }, +]; + +function App() { + const { theme, toggleTheme } = useTheme(); + const [swapRef, swapInView] = useInView(); + const [featuresRef, featuresInView] = useInView(); + + return ( +
    + + ); +} + +export default App; diff --git a/src/problem2/src/assets/imgs/problem2-darkmode.png b/src/problem2/src/assets/imgs/problem2-darkmode.png new file mode 100644 index 000000000..7f7da1c46 Binary files /dev/null and b/src/problem2/src/assets/imgs/problem2-darkmode.png differ diff --git a/src/problem2/src/assets/imgs/problem2-mb.png b/src/problem2/src/assets/imgs/problem2-mb.png new file mode 100644 index 000000000..b95e2b16e Binary files /dev/null and b/src/problem2/src/assets/imgs/problem2-mb.png differ diff --git a/src/problem2/src/assets/imgs/problem2-pc.png b/src/problem2/src/assets/imgs/problem2-pc.png new file mode 100644 index 000000000..f0e33c570 Binary files /dev/null and b/src/problem2/src/assets/imgs/problem2-pc.png differ diff --git a/src/problem2/src/components/ResultModal.tsx b/src/problem2/src/components/ResultModal.tsx new file mode 100644 index 000000000..8c1ecf10e --- /dev/null +++ b/src/problem2/src/components/ResultModal.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +const CheckIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + +export interface SwapSummary { + fromAmount: string; + fromToken: string; + toAmount: string; + toToken: string; + rate?: string; +} + +interface ResultModalProps { + isOpen: boolean; + success: boolean; + message: string; + summary?: SwapSummary | null; + onClose: () => void; +} + +export const ResultModal = ({ isOpen, success, message, summary, onClose }: ResultModalProps) => { + const closeBtnRef = useRef(null); + + useEffect(() => { + if (isOpen) closeBtnRef.current?.focus(); + }, [isOpen]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + if (!isOpen) return null; + + const modalContent = ( +
    + + ); + + return createPortal(modalContent, document.body); +}; diff --git a/src/problem2/src/components/SwapForm.tsx b/src/problem2/src/components/SwapForm.tsx new file mode 100644 index 000000000..c35be3c9f --- /dev/null +++ b/src/problem2/src/components/SwapForm.tsx @@ -0,0 +1,290 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useTokenPrices } from '../hooks/useTokenPrices'; +import { TokenSelector } from './TokenSelector'; +import { ResultModal, type SwapSummary } from './ResultModal'; +import { validateForm, parseAmount } from '../utils/validation'; +import { convertAmount, formatDisplayAmount, getRateLabel, formatRelativeTime } from '../utils/format'; +import { simulateSwapRequest } from '../utils/api'; + +const MOCK_BALANCE = 1000; + +export const SwapForm = () => { + const { priceMap, tokens, loading, error, lastUpdated, retry } = useTokenPrices(); + + const [fromToken, setFromToken] = useState(''); + const [toToken, setToToken] = useState(''); + const [amount, setAmount] = useState(''); + const [formTouched, setFormTouched] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [modal, setModal] = useState<{ + isOpen: boolean; + success: boolean; + message: string; + summary?: SwapSummary | null; + }>({ isOpen: false, success: false, message: '' }); + + const convertedAmount = useCallback(() => { + const numAmount = parseAmount(amount); + if (!fromToken || !toToken || numAmount === null || numAmount < 0) return ''; + const fromData = priceMap.get(fromToken); + const toData = priceMap.get(toToken); + if (!fromData || !toData) return ''; + const converted = convertAmount(numAmount, fromData.price, toData.price); + return formatDisplayAmount(converted); + }, [amount, fromToken, toToken, priceMap]); + + const rateLabel = useCallback(() => { + if (!fromToken || !toToken) return ''; + const fromData = priceMap.get(fromToken); + const toData = priceMap.get(toToken); + if (!fromData || !toData) return ''; + return getRateLabel(fromToken, toToken, fromData.price, toData.price); + }, [fromToken, toToken, priceMap]); + + const { valid, errors } = validateForm(amount, fromToken, toToken); + const showErrors = formTouched; + + useEffect(() => { + if (tokens.length >= 2 && !fromToken && !toToken) { + setFromToken(tokens[0]); + setToToken(tokens[1]); + } + }, [tokens, fromToken, toToken]); + + const handleSwapDirection = () => { + if (!fromToken || !toToken) return; + setFromToken(toToken); + setToToken(fromToken); + }; + + const handleCloseModal = () => { + setModal((m) => ({ ...m, isOpen: false })); + setAmount(''); + if (tokens.length >= 2) { + setFromToken(tokens[0]); + setToToken(tokens[1]); + } + setFormTouched(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFormTouched(true); + if (!valid) return; + + setIsSubmitting(true); + const fromData = priceMap.get(fromToken); + const toData = priceMap.get(toToken); + const rate = fromData && toData ? getRateLabel(fromToken, toToken, fromData.price, toData.price) : undefined; + + try { + await simulateSwapRequest(); + const converted = convertedAmount(); + setModal({ + isOpen: true, + success: true, + message: `You will receive ${converted} ${toToken}`, + summary: { + fromAmount: amount.trim(), + fromToken, + toAmount: converted, + toToken, + rate, + }, + }); + } catch (err) { + setModal({ + isOpen: true, + success: false, + message: 'Something went wrong. Please try again.', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleAmountChange = (e: React.ChangeEvent) => { + setAmount(e.target.value); + setFormTouched(true); + }; + + const handleMaxClick = () => { + setAmount(String(MOCK_BALANCE)); + setFormTouched(true); + }; + + if (loading) { + return ( +
    +
    +
    +
    +
    +

    + Loading token prices... +

    +
    + ); + } + + if (error) { + return ( +
    +

    + {error} +

    + +
    + ); + } + + if (tokens.length === 0) { + return ( +

    + No tokens available. +

    + ); + } + + return ( + <> +
    +
    +
    + + +
    +
    + +
    + +
    +
    + {showErrors && errors.amount && ( + + )} +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    + {showErrors && errors.tokens && ( + + )} +
    + + {rateLabel() && ( +

    + {rateLabel()} +

    + )} + + +
    + + + + {lastUpdated && ( +

    + {formatRelativeTime(lastUpdated)} +

    + )} + + ); +}; diff --git a/src/problem2/src/components/TokenIcon.tsx b/src/problem2/src/components/TokenIcon.tsx new file mode 100644 index 000000000..7a49681e3 --- /dev/null +++ b/src/problem2/src/components/TokenIcon.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { getTokenIconUrl } from '../utils/api'; + +interface TokenIconProps { + symbol: string | null; + className?: string; +} + +export const TokenIcon = ({ symbol, className = '' }: TokenIconProps) => { + const [failed, setFailed] = useState(false); + + if (!symbol) return ; + + if (failed) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/src/problem2/src/components/TokenSelectModal.tsx b/src/problem2/src/components/TokenSelectModal.tsx new file mode 100644 index 000000000..418e1d563 --- /dev/null +++ b/src/problem2/src/components/TokenSelectModal.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { TokenIcon } from './TokenIcon'; +import { formatDisplayAmount, formatRelativeTime } from '../utils/format'; +import type { PriceData } from '../types'; + +interface TokenSelectModalProps { + isOpen: boolean; + onClose: () => void; + tokens: string[]; + priceMap: Map; + selectedSymbol: string; + onSelect: (symbol: string) => void; + title: string; +} + +export const TokenSelectModal = ({ + isOpen, + onClose, + tokens, + priceMap, + selectedSymbol, + onSelect, + title, +}: TokenSelectModalProps) => { + const [search, setSearch] = useState(''); + const searchInputRef = useRef(null); + const listRef = useRef(null); + + const filteredTokens = search.trim() + ? tokens.filter((s) => s.toLowerCase().includes(search.toLowerCase().trim())) + : tokens; + + useEffect(() => { + if (isOpen) { + setSearch(''); + setTimeout(() => searchInputRef.current?.focus(), 80); + } + }, [isOpen]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose]); + + const handleSelect = (symbol: string) => { + onSelect(symbol); + onClose(); + }; + + if (!isOpen) return null; + + const modalContent = ( +
    + + ); + + return createPortal(modalContent, document.body); +}; diff --git a/src/problem2/src/components/TokenSelector.tsx b/src/problem2/src/components/TokenSelector.tsx new file mode 100644 index 000000000..0320c9585 --- /dev/null +++ b/src/problem2/src/components/TokenSelector.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { TokenIcon } from './TokenIcon'; +import { TokenSelectModal } from './TokenSelectModal'; +import type { PriceData } from '../types'; + +interface TokenSelectorProps { + tokens: string[]; + value: string; + onChange: (symbol: string) => void; + ariaLabel: string; + priceMap: Map; +} + +export const TokenSelector = ({ tokens, value, onChange, ariaLabel, priceMap }: TokenSelectorProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsModalOpen(true); + }; + + const handleSelect = (symbol: string) => { + onChange(symbol); + setIsModalOpen(false); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + }; + + const title = 'Select a token'; + + return ( + <> +
    + +
    + +
    +
    + + + + ); +}; diff --git a/src/problem2/src/hooks/useInView.ts b/src/problem2/src/hooks/useInView.ts new file mode 100644 index 000000000..efe08a3a8 --- /dev/null +++ b/src/problem2/src/hooks/useInView.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef, useState } from 'react'; + +export const useInView = (): [React.RefObject, boolean] => { + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) setInView(true); + }, + { root: null, rootMargin: '0px 0px -6% 0px', threshold: 0.1 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return [ref, inView]; +}; diff --git a/src/problem2/src/hooks/useTheme.ts b/src/problem2/src/hooks/useTheme.ts new file mode 100644 index 000000000..cc5281db6 --- /dev/null +++ b/src/problem2/src/hooks/useTheme.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from 'react'; + +const STORAGE_KEY = 'swap-theme'; +type Theme = 'light' | 'dark'; + +const getInitialTheme = (): Theme => { + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +}; + +export const useTheme = () => { + const [theme, setThemeState] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const toggleTheme = () => { + setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + }; + + return { theme, setTheme: setThemeState, toggleTheme }; +}; diff --git a/src/problem2/src/hooks/useTokenPrices.ts b/src/problem2/src/hooks/useTokenPrices.ts new file mode 100644 index 000000000..bb04a59ce --- /dev/null +++ b/src/problem2/src/hooks/useTokenPrices.ts @@ -0,0 +1,65 @@ +import { useState, useCallback, useEffect } from 'react'; +import { fetchTokenPrices } from '../utils/api'; + +type PriceMap = Map; + +/** Cache the in-flight or last successful request so effect re-runs (e.g. Strict Mode) don't duplicate the API call. */ +let cachedPricesPromise: Promise | null = null; + +export const useTokenPrices = () => { + const [priceMap, setPriceMap] = useState(new Map()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + const load = useCallback(async (isRetry = false) => { + if (isRetry) cachedPricesPromise = null; + setLoading(true); + setError(null); + + if (cachedPricesPromise) { + try { + const map = await cachedPricesPromise; + setPriceMap(map); + setLastUpdated(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + return; + } + + const promise = fetchTokenPrices(); + cachedPricesPromise = promise; + try { + const map = await promise; + setPriceMap(map); + setLastUpdated(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + cachedPricesPromise = null; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let id: number; + const useIdle = typeof window.requestIdleCallback === 'function'; + if (useIdle) { + id = window.requestIdleCallback(() => load(), { timeout: 300 }); + } else { + id = window.setTimeout(load, 0) as unknown as number; + } + return () => { + if (useIdle) window.cancelIdleCallback!(id); + else clearTimeout(id); + }; + }, [load]); + + const retry = useCallback(() => load(true), [load]); + + const tokens = Array.from(priceMap.keys()).sort(); + return { priceMap, tokens, loading, error, lastUpdated, retry }; +}; diff --git a/src/problem2/src/index.css b/src/problem2/src/index.css new file mode 100644 index 000000000..af37d2703 --- /dev/null +++ b/src/problem2/src/index.css @@ -0,0 +1,1686 @@ +:root, +[data-theme="dark"] { + --color-bg: #0d0e12; + --color-surface: #16181e; + --color-surface-elevated: #1e2128; + --color-input-bg: #1e2128; + --color-border: #2a2d36; + --color-border-focus: #5261f6; + --color-text: #f0f1f3; + --color-text-muted: #8b8f99; + --color-accent: #5261f6; + --color-accent-hover: #6b7aff; + --color-accent-subtle: rgba(82, 97, 246, 0.15); + --color-success: #22c55e; + --color-error: #ef4444; + --color-disabled: #4b4f58; + --color-btn-disabled: #4b4f58; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 0, 0, 0.05); + --shadow-soft: 0 2px 8px rgba(0, 0, 0, 0.25); + --transition-fast: 150ms ease; + --transition-normal: 200ms ease; + --duration-entrance: 0.55s; + --ease-out-smooth: cubic-bezier(0.22, 1, 0.36, 1); +} + +[data-theme="light"] { + --color-bg: #f5f5f7; + --color-surface: #ffffff; + --color-surface-elevated: #ffffff; + --color-input-bg: #ebebef; + --color-border: #dcdce0; + --color-border-focus: #5261f6; + --color-text: #1a1a1d; + --color-text-muted: #5c5c66; + --color-accent: #5261f6; + --color-accent-hover: #4352e8; + --color-accent-subtle: rgba(82, 97, 246, 0.1); + --color-success: #16a34a; + --color-error: #dc2626; + --color-disabled: #999999; + --color-btn-disabled: #b8b8be; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-soft: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; + min-width: 360px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + width: 100%; + min-height: 100vh; +} + +html { + scroll-behavior: smooth; +} + +[id="swap"], +[id="features"] { + scroll-margin-top: 4rem; +} + +/* ========== Landing Page ========== */ +.landing { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Background animation */ +.landing-bg { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + background: var(--color-bg); +} + +.landing-bg-blob { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.4; + animation: bgBlob 20s ease-in-out infinite; +} + +.landing-bg-blob--1 { + width: 60vmax; + height: 60vmax; + background: radial-gradient(circle, rgba(82, 97, 246, 0.28) 0%, transparent 70%); + top: -20%; + left: -10%; + animation-duration: 22s; + animation-delay: 0s; +} + +.landing-bg-blob--2 { + width: 50vmax; + height: 50vmax; + background: radial-gradient(circle, rgba(107, 122, 255, 0.2) 0%, transparent 70%); + top: 40%; + right: -15%; + animation-duration: 25s; + animation-delay: -5s; +} + +.landing-bg-blob--3 { + width: 40vmax; + height: 40vmax; + background: radial-gradient(circle, rgba(82, 97, 246, 0.18) 0%, transparent 70%); + bottom: -10%; + left: 20%; + animation-duration: 18s; + animation-delay: -10s; +} + +.landing-bg-blob--4 { + width: 35vmax; + height: 35vmax; + background: radial-gradient(circle, rgba(82, 97, 246, 0.12) 0%, transparent 70%); + top: 60%; + left: -5%; + animation-duration: 24s; + animation-delay: -2s; +} + +.landing-bg-blob--5 { + width: 45vmax; + height: 45vmax; + background: radial-gradient(circle, rgba(82, 97, 246, 0.15) 0%, transparent 70%); + top: 10%; + right: 30%; + animation-duration: 20s; + animation-delay: -7s; +} + +/* ---------- Entrance keyframes ---------- */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes bgBlob { + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 25% { + transform: translate(5%, -5%) scale(1.05); + } + 50% { + transform: translate(-5%, 5%) scale(0.95); + } + 75% { + transform: translate(-3%, -3%) scale(1.02); + } +} + +[data-theme="light"] .landing-bg-blob { + opacity: 0.22; +} + +.landing-bg-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(82, 97, 246, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(82, 97, 246, 0.035) 1px, transparent 1px); + background-size: 64px 64px; + mask-image: radial-gradient(ellipse 90% 90% at 50% 40%, black 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 90% 90% at 50% 40%, black 30%, transparent 75%); + animation: bgGrid 40s linear infinite; +} + +@keyframes bgGrid { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(64px); + } +} + +[data-theme="light"] .landing-bg-grid { + background-image: + linear-gradient(rgba(0, 0, 0, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 0, 0, 0.06) 1px, transparent 1px); + background-size: 48px 48px; +} + +.landing-header { + position: sticky; + top: 0; + z-index: 50; + background: rgba(13, 14, 18, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .landing-header { + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--color-border); + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.06); +} + +.landing-header-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0.875rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text); + text-decoration: none; + font-weight: 600; + font-size: 1.25rem; + transition: color var(--transition-fast), transform var(--transition-fast); +} + +.logo:hover { + color: var(--color-text); + transform: scale(1.02); +} + +.logo-icon { + font-size: 1.5rem; + display: inline-block; + transition: transform var(--transition-normal) var(--ease-out-smooth); +} + +.logo:hover .logo-icon { + transform: rotate(-12deg); +} + +.logo-text { + letter-spacing: -0.02em; +} + +.landing-nav { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.landing-nav a { + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.5; + padding: 0.375rem 0; + transition: color var(--transition-fast), transform var(--transition-fast); +} + +.landing-nav a:hover { + color: var(--color-accent); + transform: translateY(-1px); +} + +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + margin: 0; + margin-left: 0.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-elevated); + color: var(--color-text); + cursor: pointer; + flex-shrink: 0; + transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.theme-toggle:hover { + background: var(--color-border); + border-color: var(--color-text-muted); + transform: scale(1.05); +} + +.theme-toggle:active { + transform: scale(0.95); +} + +.theme-toggle-icon { + transition: transform var(--transition-normal); +} + +.theme-toggle:hover .theme-toggle-icon { + transform: rotate(12deg); +} + +.theme-toggle:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(82, 97, 246, 0.25); +} + +.theme-toggle .theme-toggle-icon { + font-size: 1.125rem; + line-height: 1; + display: block; + transition: transform var(--transition-normal); +} + +.landing-hero { + padding: 4rem 1.5rem 3rem; + text-align: center; +} + +.landing-hero-inner { + max-width: 640px; + margin: 0 auto; +} + +.landing-hero-title { + margin: 0 0 1rem; + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.12; + opacity: 0; + animation: fadeInUp var(--duration-entrance) var(--ease-out-smooth) 0.1s forwards; +} + +.landing-hero-accent { + color: var(--color-accent); + background: linear-gradient(135deg, var(--color-accent), var(--color-accent-hover)); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: accentShimmer 6s ease-in-out infinite; +} + +@keyframes accentShimmer { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.landing-hero-subtitle { + margin: 0 0 1.5rem; + font-size: 1.125rem; + color: var(--color-text-muted); + line-height: 1.6; + opacity: 0; + animation: fadeInUp var(--duration-entrance) var(--ease-out-smooth) 0.25s forwards; +} + +.landing-hero-cta { + display: inline-block; + padding: 0.75rem 1.5rem; + background: var(--color-accent); + color: white; + font-weight: 600; + font-size: 0.9375rem; + letter-spacing: 0.02em; + text-decoration: none; + border-radius: var(--radius-md); + transition: background var(--transition-fast), transform var(--transition-normal), box-shadow var(--transition-normal); + opacity: 0; + animation: fadeInUp var(--duration-entrance) var(--ease-out-smooth) 0.4s forwards; + box-shadow: 0 1px 3px rgba(82, 97, 246, 0.25); +} + +.landing-hero-cta:hover { + background: var(--color-accent-hover); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(82, 97, 246, 0.35); +} + +.landing-hero-cta:active { + transform: translateY(0) scale(0.98); +} + +.landing-swap { + padding: 2rem 1.5rem 4rem; + flex: 1; +} + +.landing-swap .swap-card { + margin: 0; + opacity: 0; + transform: translateY(28px); + transition: opacity var(--duration-entrance) var(--ease-out-smooth), transform var(--duration-entrance) var(--ease-out-smooth), border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.landing-swap.in-view .swap-card { + opacity: 1; + transform: translateY(0); +} + +.landing-swap-inner { + max-width: 420px; + margin: 0 auto; +} + +.landing-swap .swap-container { + max-width: none; +} + +.landing-swap .swap-card { + margin: 0; +} + +.landing-features { + padding: 4rem 1.5rem; + background: rgba(0, 0, 0, 0.2); + border-top: 1px solid var(--color-border); +} + +[data-theme="light"] .landing-features { + background: #ebebef; + border-top: 1px solid var(--color-border); +} + +.landing-features-inner { + max-width: 1000px; + margin: 0 auto; +} + +.landing-features-title { + margin: 0 0 2rem; + font-size: 1.375rem; + font-weight: 600; + text-align: center; + letter-spacing: -0.03em; + color: var(--color-text); + opacity: 0; + transform: translateY(16px); + transition: opacity var(--duration-entrance) var(--ease-out-smooth), transform var(--duration-entrance) var(--ease-out-smooth); +} + +.landing-features.in-view .landing-features-title { + opacity: 1; + transform: translateY(0); +} + +.landing-features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.5rem; +} + +.feature-card { + padding: 1.5rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + opacity: 0; + transform: translateY(24px); + transition: transform var(--transition-normal) var(--ease-out-smooth), border-color var(--transition-fast), box-shadow var(--transition-fast); + box-shadow: var(--shadow-soft); +} + +.landing-features.in-view .feature-card { + animation: fadeInUp var(--duration-entrance) var(--ease-out-smooth) forwards; +} + +.landing-features.in-view .feature-card:nth-child(1) { animation-delay: 0.05s; } +.landing-features.in-view .feature-card:nth-child(2) { animation-delay: 0.15s; } +.landing-features.in-view .feature-card:nth-child(3) { animation-delay: 0.25s; } + +.feature-card:hover { + border-color: var(--color-border-focus); + box-shadow: var(--shadow-soft), 0 0 0 1px rgba(82, 97, 246, 0.12); + transform: translateY(-3px); +} + +[data-theme="light"] .feature-card:hover { + border-color: rgba(82, 97, 246, 0.3); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(82, 97, 246, 0.08); +} + +.feature-card-icon { + display: block; + font-size: 2rem; + margin-bottom: 1rem; +} + +.feature-card-title { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; +} + +.feature-card-desc { + margin: 0; + font-size: 0.9375rem; + color: var(--color-text-muted); + line-height: 1.5; +} + +.landing-footer { + margin-top: auto; + padding: 1.25rem 1.5rem; + border-top: 1px solid var(--color-border); +} + +.landing-footer-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 1.5rem; +} + +.landing-footer-copy { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.landing-footer-link { + color: var(--color-accent); + font-size: 0.8125rem; + text-decoration: none; +} + +.landing-footer-link:hover { + text-decoration: underline; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.swap-container { + width: 100%; + max-width: 420px; +} + +.swap-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.swap-card:hover { + border-color: rgba(82, 97, 246, 0.22); + box-shadow: var(--shadow); +} + +.swap-title { + margin: 0 0 1.25rem; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.swap-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.token-row-header { + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: 0.25rem; +} + +.max-btn { + padding: 0.25rem 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-accent); + background: transparent; + border: none; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.max-btn:hover { + background: var(--color-accent-subtle); + color: var(--color-accent-hover); +} + +.swap-rate { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted); + text-align: center; + letter-spacing: 0.01em; +} + +.swap-footer-meta { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: var(--color-text-muted); + text-align: center; +} + +.swap-card-footer { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.swap-disclaimer { + margin: 0 0 0.5rem; + font-size: 0.6875rem; + color: var(--color-text-muted); + line-height: 1.45; + letter-spacing: 0.01em; +} + + +.swap-source { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.swap-source a { + color: var(--color-accent); + text-decoration: none; +} + +.swap-source a:hover { + text-decoration: underline; +} + +.retry-btn { + margin-top: 0.75rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-accent); + background: var(--color-accent-subtle); + border: 1px solid var(--color-accent); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.retry-btn:hover { + background: var(--color-accent); + color: white; +} + +.swap-form-error { + text-align: center; +} + +.swap-form--skeleton .skeleton-row, +.swap-form--skeleton .skeleton-btn { + height: 56px; + background: linear-gradient(90deg, var(--color-border) 25%, var(--color-surface-elevated) 50%, var(--color-border) 75%); + background-size: 200% 100%; + animation: skeleton 1.2s ease-in-out infinite; + border-radius: var(--radius-md); +} + +.swap-form--skeleton .skeleton-swap-btn { + width: 36px; + height: 36px; + margin: 0 auto; + border-radius: 50%; + background: linear-gradient(90deg, var(--color-border) 25%, var(--color-surface-elevated) 50%, var(--color-border) 75%); + background-size: 200% 100%; + animation: skeleton 1.2s ease-in-out infinite; +} + +.swap-form--skeleton .skeleton-btn { + margin-top: 0.5rem; + height: 48px; +} + +@keyframes skeleton { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.token-row { + display: flex; + align-items: stretch; + gap: 0.75rem; + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.625rem 0.875rem; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +[data-theme="light"] .token-row { + background: var(--color-surface); + border-color: var(--color-border); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.token-row:focus-within { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(82, 97, 246, 0.18); +} + +[data-theme="light"] .token-row:focus-within { + box-shadow: 0 0 0 2px rgba(82, 97, 246, 0.2), inset 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.token-select-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex: 0 0 auto; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; + background: var(--color-input-bg); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); + position: relative; +} + +[data-theme="dark"] .token-select-wrapper { + background: rgba(0, 0, 0, 0.25); + border-color: rgba(255, 255, 255, 0.06); +} + +[data-theme="light"] .token-select-wrapper { + background: var(--color-input-bg); + border-color: rgba(0, 0, 0, 0.08); +} + +.token-select-wrapper:hover { + border-color: rgba(82, 97, 246, 0.3); +} + +[data-theme="dark"] .token-select-wrapper:hover { + background: rgba(0, 0, 0, 0.35); +} + +.token-select-wrapper:focus-within, +.token-select-wrapper.is-open { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(82, 97, 246, 0.2); +} + +.token-select-custom { + position: relative; +} + +.token-trigger { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0; + background: transparent; + border: none; + color: var(--color-text); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + outline: none; + min-width: 72px; + transition: color var(--transition-fast); +} + +.token-trigger:hover { + color: var(--color-accent-hover); +} + +.token-chevron { + width: 14px; + height: 14px; + color: var(--color-text-muted); + flex-shrink: 0; + transition: transform var(--transition-fast), color var(--transition-fast); +} + +.token-select-wrapper.is-open .token-chevron { + transform: rotate(180deg); + color: var(--color-accent); +} + +.token-dropdown-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow); + z-index: 100; + overflow: hidden; + animation: dropdownOpen 0.2s ease-out; +} + +[data-theme="light"] .token-dropdown-panel { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +@keyframes dropdownOpen { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.token-dropdown-search { + padding: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.token-search-input { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: 0.875rem; + outline: none; +} + +[data-theme="dark"] .token-search-input { + background: rgba(0, 0, 0, 0.25); +} + +.token-search-input::placeholder { + color: var(--color-text-muted); +} + +.token-search-input:focus { + border-color: var(--color-border-focus); +} + +.token-dropdown { + margin: 0; + padding: 0.5rem; + list-style: none; + max-height: 240px; + overflow-y: auto; +} + +.token-dropdown { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +.token-dropdown::-webkit-scrollbar { + width: 6px; +} + +.token-dropdown::-webkit-scrollbar-track { + background: transparent; + border-radius: 3px; +} + +.token-dropdown::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +.token-dropdown::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +.token-dropdown::-webkit-scrollbar-thumb:active { + background: var(--color-accent); +} + +.token-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); +} + +.token-option:hover { + background: var(--color-accent-subtle); +} + +.token-option[aria-selected="true"] { + background: var(--color-accent-subtle); + border: 1px solid rgba(82, 97, 246, 0.35); +} + +.token-option[aria-selected="true"]::after { + content: ""; + margin-left: auto; + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235261f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + flex-shrink: 0; +} + +.token-option-icon { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.token-option-icon .token-icon { + width: 100%; + height: 100%; +} + +.token-option-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.token-option-symbol { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text); +} + +.token-option--empty { + cursor: default; + color: var(--color-text-muted); + justify-content: center; + padding: 1rem; +} + +.token-option--empty:hover { + background: transparent; +} + +.token-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); + box-shadow: var(--shadow-soft); +} + +[data-theme="light"] .token-icon { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.token-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.token-icon.fallback { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.amount-wrapper { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.amount-wrapper input { + width: 100%; + min-width: 0; + background: transparent; + border: none; + color: var(--color-text); + font-size: 1.0625rem; + font-weight: 500; + letter-spacing: 0.02em; + text-align: right; + outline: none; +} + +.amount-wrapper input::placeholder { + color: var(--color-text-muted); +} + +.amount-wrapper input:read-only { + cursor: default; + color: var(--color-text-muted); +} + +.amount-wrapper input[aria-invalid="true"] { + color: var(--color-error); +} + +.swap-direction { + display: flex; + justify-content: center; + margin: -0.5rem 0; + position: relative; + z-index: 1; +} + +.swap-direction-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: var(--color-surface-elevated); + border: 2px solid var(--color-border); + border-radius: 50%; + color: var(--color-text); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform 0.4s var(--ease-out-smooth); +} + +.swap-direction-btn:hover { + background: var(--color-accent); + border-color: var(--color-accent); + color: white; + transform: rotate(180deg); +} + +.swap-direction-btn:active { + transform: scale(0.92); +} + +.swap-direction-btn:hover:active { + transform: rotate(180deg) scale(0.92); +} + +.swap-icon { + width: 20px; + height: 20px; +} + +.error-message { + margin: 0; + font-size: 0.8125rem; + color: var(--color-error); + min-height: 1.25rem; +} + +.submit-btn { + margin-top: 0.5rem; + padding: 0.875rem 1.5rem; + background: var(--color-accent); + border: none; + border-radius: var(--radius-md); + color: white; + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background var(--transition-normal), transform var(--transition-fast), box-shadow var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 48px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +[data-theme="dark"] .submit-btn { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.submit-btn:hover:not(:disabled) { + background: var(--color-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(82, 97, 246, 0.35); +} + +.submit-btn:active:not(:disabled) { + transform: translateY(0) scale(0.99); +} + +.submit-btn:disabled { + background: var(--color-btn-disabled); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.submit-btn[aria-busy="true"] .btn-text { + display: none; +} + +.submit-btn[aria-busy="true"] .btn-loading { + display: flex; +} + +.btn-loading { + display: none; + align-items: center; + gap: 0.5rem; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.status-message { + margin: 0.5rem 0 0; + font-size: 0.875rem; + min-height: 1.25rem; + text-align: center; +} + +.status-message.error { + color: var(--color-error); +} + +/* ---------- Modal base (shared) ---------- */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + animation: modalFadeIn 0.25s var(--ease-out-smooth); + overflow-y: auto; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +[data-theme="light"] .modal-overlay { + background: rgba(0, 0, 0, 0.35); +} + +.modal-content { + position: relative; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 0; + max-width: 360px; + width: 100%; + text-align: center; + box-shadow: var(--shadow); + animation: modalSlideIn 0.3s var(--ease-out-smooth); + overflow: hidden; +} + +/* ---------- Result modal (success/error) ---------- */ +.modal-content:not(.token-select-modal-content) { + padding: 1.75rem 1.5rem; +} + +.modal-icon { + width: 52px; + height: 52px; + margin: 0 auto 1.25rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; +} + +.modal-icon.success { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.25); + color: var(--color-success); +} + +.modal-icon.error { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.25); + color: var(--color-error); +} + +.modal-icon svg { + width: 26px; + height: 26px; +} + +.modal-title { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-text); +} + +.modal-message { + margin: 0 0 1.25rem; + font-size: 0.875rem; + color: var(--color-text-muted); + line-height: 1.5; + letter-spacing: 0.01em; +} + +.modal-summary { + margin: 0 0 1.25rem; + padding: 1rem 1.125rem; + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-align: left; +} + +[data-theme="dark"] .modal-summary { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.06); +} + +[data-theme="light"] .modal-summary { + background: var(--color-input-bg); + border-color: var(--color-border); +} + +.modal-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.375rem 0; + gap: 1rem; +} + +.modal-summary-row:first-child { + padding-top: 0; +} + +.modal-summary-row.modal-summary-rate { + padding-top: 0.75rem; + margin-top: 0.5rem; + border-top: 1px solid var(--color-border); +} + +.modal-summary-label { + font-size: 0.8125rem; + color: var(--color-text-muted); + letter-spacing: 0.01em; + flex-shrink: 0; +} + +.modal-summary-value { + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--color-text); + text-align: right; +} + +.modal-close-btn { + width: 100%; + padding: 0.75rem 1.5rem; + background: var(--color-accent); + border: none; + border-radius: var(--radius-md); + color: white; + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); + box-shadow: 0 1px 2px rgba(82, 97, 246, 0.2); +} + +.modal-close-btn:hover { + background: var(--color-accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(82, 97, 246, 0.3); +} + +.modal-close-btn:active { + transform: translateY(0); +} + +.modal-close-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px white, 0 0 0 4px var(--color-border-focus); +} + +/* ---------- Token select modal ---------- */ +.token-select-modal-content { + max-width: 400px; + width: 100%; + max-height: min(70vh, 480px); + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + border: 1px solid var(--color-border); + background: var(--color-surface); + flex-shrink: 0; +} + +.token-select-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.token-select-modal-header-text { + min-width: 0; +} + +.token-select-modal-title { + margin: 0 0 0.25rem; + font-size: 1.125rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-text); +} + +.token-select-modal-subtitle { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted); + letter-spacing: 0.01em; +} + +.token-select-modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + margin: -0.25rem -0.25rem 0 0; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.token-select-modal-close:hover { + background: var(--color-input-bg); + color: var(--color-text); +} + +.token-select-modal-close:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-border-focus); +} + +.token-select-modal-close svg { + width: 18px; + height: 18px; +} + +.token-select-modal-search { + padding: 0.5rem 1.25rem 0.75rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.token-select-modal-search-input { + width: 100%; + padding: 0.625rem 1rem; + background: var(--color-input-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: 0.875rem; + letter-spacing: 0.01em; + outline: none; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.token-select-modal-search-input::placeholder { + color: var(--color-text-muted); +} + +.token-select-modal-search-input:hover { + border-color: rgba(82, 97, 246, 0.2); +} + +.token-select-modal-search-input:focus { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 3px rgba(82, 97, 246, 0.12); +} + +[data-theme="dark"] .token-select-modal-search-input { + background: rgba(0, 0, 0, 0.2); +} + +.token-select-modal-list { + margin: 0; + padding: 0.5rem 1rem 0.75rem; + list-style: none; + overflow-y: auto; + flex: 1; + min-height: 0; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +.token-select-modal-list::-webkit-scrollbar { + width: 6px; +} + +.token-select-modal-list::-webkit-scrollbar-track { + background: transparent; + border-radius: 3px; +} + +.token-select-modal-list::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +.token-select-modal-item { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1rem 0.75rem 1rem; + margin-bottom: 2px; + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast); + border: 1px solid transparent; + border-left: 3px solid transparent; +} + +.token-select-modal-item:hover { + background: var(--color-accent-subtle); +} + +.token-select-modal-item[aria-selected="true"] { + background: var(--color-accent-subtle); + border-color: rgba(82, 97, 246, 0.25); + border-left-color: var(--color-accent); +} + +.token-select-modal-item--empty { + cursor: default; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.75rem; + color: var(--color-text-muted); + padding: 2rem 1.5rem; + font-size: 0.875rem; + border-left-color: transparent; + margin-bottom: 0; +} + +.token-select-modal-item--empty:hover { + background: transparent; +} + +.token-select-modal-empty-icon { + font-size: 1.75rem; + opacity: 0.7; +} + +.token-select-modal-item-icon { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-border); +} + +.token-select-modal-item-icon .token-icon { + width: 100%; + height: 100%; +} + +.token-select-modal-item-icon .token-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.token-select-modal-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.token-select-modal-item-symbol { + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--color-text); +} + +.token-select-modal-item-price { + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0.01em; + color: var(--color-text); +} + +.token-select-modal-item-date { + font-size: 0.6875rem; + color: var(--color-text-muted); + letter-spacing: 0.01em; +} + +.token-select-modal-item-meta { + font-size: 0.75rem; + color: var(--color-text-muted); + letter-spacing: 0.01em; +} + +.token-select-modal-item-check { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--color-accent); +} + +.token-select-modal-item-check svg { + width: 100%; + height: 100%; +} + +@keyframes modalFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 480px) { + .swap-card { + padding: 1.25rem; + } + + .token-row { + flex-wrap: wrap; + } + + .token-select-wrapper { + flex: 1 1 100%; + justify-content: flex-start; + } + + .token-trigger { + min-width: 80px; + } + + .token-dropdown { + min-width: 100%; + } + + .amount-wrapper { + flex: 1 1 100%; + } +} diff --git a/src/problem2/src/main.tsx b/src/problem2/src/main.tsx new file mode 100644 index 000000000..8557342e4 --- /dev/null +++ b/src/problem2/src/main.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +const stored = localStorage.getItem('swap-theme'); +const theme = + stored === 'light' || stored === 'dark' + ? stored + : window.matchMedia('(prefers-color-scheme: light)').matches + ? 'light' + : 'dark'; +document.documentElement.setAttribute('data-theme', theme); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/problem2/src/types/index.ts b/src/problem2/src/types/index.ts new file mode 100644 index 000000000..2345db345 --- /dev/null +++ b/src/problem2/src/types/index.ts @@ -0,0 +1,20 @@ +export interface PriceData { + price: number; + date: string; +} + +export interface ValidationErrors { + amount?: string; + tokens?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationErrors; +} + +export interface ModalState { + isOpen: boolean; + success: boolean; + message: string; +} diff --git a/src/problem2/src/utils/api.ts b/src/problem2/src/utils/api.ts new file mode 100644 index 000000000..6ea5b36a8 --- /dev/null +++ b/src/problem2/src/utils/api.ts @@ -0,0 +1,32 @@ +import type { PriceData } from '../types'; + +const PRICES_API_URL = 'https://interview.switcheo.com/prices.json'; +const ICON_BASE_URL = 'https://raw.githubusercontent.com/Switcheo/token-icons/main/tokens'; + +export const getTokenIconUrl = (symbol: string): string => + `${ICON_BASE_URL}/${symbol}.svg`; + +interface PriceItem { + currency: string; + price: number; + date: string; +} + +export const fetchTokenPrices = async (): Promise> => { + const response = await fetch(PRICES_API_URL); + if (!response.ok) throw new Error('Failed to fetch prices'); + const data: PriceItem[] = await response.json(); + + const priceMap = new Map(); + for (const item of data) { + if (!item.currency || typeof item.price !== 'number' || item.price <= 0) continue; + const existing = priceMap.get(item.currency); + if (!existing || new Date(item.date) > new Date(existing.date)) { + priceMap.set(item.currency, { price: item.price, date: item.date }); + } + } + return priceMap; +}; + +export const simulateSwapRequest = (): Promise => + new Promise((resolve) => setTimeout(resolve, 1500)); diff --git a/src/problem2/src/utils/format.ts b/src/problem2/src/utils/format.ts new file mode 100644 index 000000000..e70588fbb --- /dev/null +++ b/src/problem2/src/utils/format.ts @@ -0,0 +1,41 @@ +export const convertAmount = ( + amount: number, + fromPrice: number, + toPrice: number +): number | null => { + if (!fromPrice || !toPrice || toPrice === 0) return null; + return (Number(amount) * fromPrice) / toPrice; +}; + +export const formatDisplayAmount = (value: number | null | undefined): string => { + if (value === null || value === undefined || Number.isNaN(value)) return ''; + if (value === 0) return '0'; + const formatted = Number(value).toLocaleString('en-US', { + maximumFractionDigits: 8, + minimumFractionDigits: 0, + }); + return formatted.replace(/\.?0+$/, '') || '0'; +}; + +/** Returns exchange rate string: "1 FROM ≈ X TO" */ +export const getRateLabel = ( + fromSymbol: string, + toSymbol: string, + fromPrice: number, + toPrice: number +): string => { + if (!toPrice) return ''; + const rate = fromPrice / toPrice; + const formatted = rate >= 1e6 ? rate.toExponential(2) : formatDisplayAmount(rate) || '0'; + return `1 ${fromSymbol} ≈ ${formatted} ${toSymbol}`; +}; + +/** Format relative time (e.g. "Updated 2 min ago") */ +export const formatRelativeTime = (date: Date): string => { + const now = new Date(); + const sec = Math.floor((now.getTime() - date.getTime()) / 1000); + if (sec < 60) return 'Updated just now'; + if (sec < 3600) return `Updated ${Math.floor(sec / 60)} min ago`; + if (sec < 86400) return `Updated ${Math.floor(sec / 3600)} hr ago`; + return `Updated ${Math.floor(sec / 86400)} day(s) ago`; +}; diff --git a/src/problem2/src/utils/validation.ts b/src/problem2/src/utils/validation.ts new file mode 100644 index 000000000..627599358 --- /dev/null +++ b/src/problem2/src/utils/validation.ts @@ -0,0 +1,33 @@ +import type { ValidationErrors, ValidationResult } from '../types'; + +export const validateForm = ( + amount: string, + fromToken: string, + toToken: string +): ValidationResult => { + const errors: ValidationErrors = {}; + + const numAmount = Number(amount); + if (amount === '' || amount == null) { + errors.amount = 'Please enter an amount'; + } else if (Number.isNaN(numAmount)) { + errors.amount = 'Amount must be a valid number'; + } else if (numAmount <= 0) { + errors.amount = 'Amount must be a positive number'; + } + + if (fromToken && toToken && fromToken === toToken) { + errors.tokens = 'From and To tokens must be different'; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +}; + +export const parseAmount = (value: string): number | null => { + const cleaned = String(value).replace(/[^\d.]/g, ''); + const num = parseFloat(cleaned); + return Number.isNaN(num) ? null : num; +}; diff --git a/src/problem2/src/vite-env.d.ts b/src/problem2/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/src/problem2/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/problem2/style.css b/src/problem2/style.css index 915af91c7..890ae2916 100644 --- a/src/problem2/style.css +++ b/src/problem2/style.css @@ -1,8 +1,647 @@ +:root { + --color-bg: #0f0f12; + --color-surface: #1a1a1f; + --color-surface-elevated: #232329; + --color-border: #2d2d35; + --color-border-focus: #6366f1; + --color-text: #f4f4f5; + --color-text-muted: #a1a1aa; + --color-accent: #6366f1; + --color-accent-hover: #818cf8; + --color-success: #22c55e; + --color-error: #ef4444; + --color-disabled: #52525b; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + --transition-fast: 150ms ease; + --transition-normal: 200ms ease; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + body { + margin: 0; + min-height: 100vh; display: flex; - flex-direction: row; align-items: center; justify-content: center; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.5; + padding: 1rem; min-width: 360px; - font-family: Arial, Helvetica, sans-serif; +} + +/* Visually hidden but accessible to screen readers */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Main container */ +.swap-container { + width: 100%; + max-width: 420px; +} + +.swap-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.swap-card:hover { + border-color: rgba(99, 102, 241, 0.3); +} + +.swap-title { + margin: 0 0 1.25rem; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +/* Form layout */ +.swap-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.token-row { + display: flex; + align-items: stretch; + gap: 0.75rem; + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.token-row:focus-within { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} + +.token-select-wrapper { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; + flex: 0 0 auto; + padding: 0.375rem 0.5rem 0.375rem 0.375rem; + background: rgba(0, 0, 0, 0.25); + border-radius: var(--radius-md); + border: 1px solid transparent; + transition: all var(--transition-fast); + position: relative; +} + +.token-select-wrapper:hover { + background: rgba(0, 0, 0, 0.35); + border-color: rgba(99, 102, 241, 0.25); +} + +.token-select-wrapper:focus-within, +.token-select-wrapper.is-open { + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.15); +} + +/* Custom token trigger (replaces native select visually) */ +.token-select-custom { + position: relative; +} + +.token-trigger { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0; + background: transparent; + border: none; + color: var(--color-text); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + outline: none; + min-width: 72px; + transition: color var(--transition-fast); +} + +.token-trigger:hover { + color: var(--color-accent-hover); +} + +.token-trigger:focus-visible { + outline: none; +} + +.token-chevron { + width: 14px; + height: 14px; + color: var(--color-text-muted); + flex-shrink: 0; + transition: transform var(--transition-fast), color var(--transition-fast); +} + +.token-select-wrapper.is-open .token-chevron { + transform: rotate(180deg); + color: var(--color-accent); +} + +/* Custom dropdown options */ +.token-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + margin: 0; + padding: 0.5rem; + list-style: none; + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 100; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: opacity var(--transition-normal), transform var(--transition-normal), visibility var(--transition-normal); +} + +.token-select-wrapper.is-open .token-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +/* Custom scrollbar for token dropdown */ +.token-dropdown { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +.token-dropdown::-webkit-scrollbar { + width: 6px; +} + +.token-dropdown::-webkit-scrollbar-track { + background: transparent; + border-radius: 3px; +} + +.token-dropdown::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +.token-dropdown::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +.token-dropdown::-webkit-scrollbar-thumb:active { + background: var(--color-accent); +} + +.token-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); +} + +.token-option:hover { + background: rgba(99, 102, 241, 0.15); +} + +.token-option:focus { + outline: none; +} + +.token-option[aria-selected="true"] { + background: rgba(99, 102, 241, 0.2); + border: 1px solid rgba(99, 102, 241, 0.4); +} + +.token-option[aria-selected="true"]::after { + content: ""; + margin-left: auto; + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236366f1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + flex-shrink: 0; +} + +.token-option-icon { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.token-option-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.token-option-icon.fallback { + font-size: 0.75rem; +} + +.token-option-symbol { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text); +} + +/* Hidden native select (kept for form value) */ +.token-select-native { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.token-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.token-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.token-icon.fallback { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.amount-wrapper { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.amount-wrapper input { + width: 100%; + min-width: 0; + background: transparent; + border: none; + color: var(--color-text); + font-size: 1.125rem; + font-weight: 500; + text-align: right; + outline: none; +} + +.amount-wrapper input::placeholder { + color: var(--color-text-muted); +} + +.amount-wrapper input:read-only { + cursor: default; + color: var(--color-text-muted); +} + +.amount-wrapper input[aria-invalid="true"] { + color: var(--color-error); +} + +/* Swap direction button */ +.swap-direction { + display: flex; + justify-content: center; + margin: -0.5rem 0; + position: relative; + z-index: 1; +} + +.swap-direction-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: var(--color-surface-elevated); + border: 2px solid var(--color-border); + border-radius: 50%; + color: var(--color-text); + cursor: pointer; + transition: all var(--transition-fast); +} + +.swap-direction-btn:hover { + background: var(--color-accent); + border-color: var(--color-accent); + color: white; + transform: rotate(180deg); +} + +.swap-direction-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent); +} + +.swap-icon { + width: 20px; + height: 20px; +} + +/* Error messages */ +.error-message { + margin: 0; + font-size: 0.8125rem; + color: var(--color-error); + min-height: 1.25rem; +} + +/* Submit button */ +.submit-btn { + margin-top: 0.5rem; + padding: 1rem 1.5rem; + background: var(--color-accent); + border: none; + border-radius: var(--radius-md); + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 48px; +} + +.submit-btn:hover:not(:disabled) { + background: var(--color-accent-hover); + transform: translateY(-1px); +} + +.submit-btn:active:not(:disabled) { + transform: translateY(0); +} + +.submit-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent); +} + +.submit-btn:disabled { + background: var(--color-disabled); + cursor: not-allowed; + transform: none; +} + +.submit-btn[aria-busy="true"] .btn-text { + display: none; +} + +.submit-btn[aria-busy="true"] .btn-loading { + display: flex; +} + +.btn-loading { + display: none; + align-items: center; + gap: 0.5rem; +} + +/* Spinner animation */ +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Status message (success/error) */ +.status-message { + margin: 0.5rem 0 0; + font-size: 0.875rem; + min-height: 1.25rem; + text-align: center; +} + +.status-message.success { + color: var(--color-success); +} + +.status-message.error { + color: var(--color-error); +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + animation: modalFadeIn var(--transition-normal) ease-out; +} + +.modal[hidden] { + display: none; +} + +.modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; + max-width: 360px; + width: 100%; + text-align: center; + box-shadow: var(--shadow); + animation: modalSlideIn var(--transition-normal) ease-out; +} + +.modal-icon { + width: 48px; + height: 48px; + margin: 0 auto 1rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-icon.success { + background: rgba(34, 197, 94, 0.2); + color: var(--color-success); +} + +.modal-icon.error { + background: rgba(239, 68, 68, 0.2); + color: var(--color-error); +} + +.modal-icon svg { + width: 24px; + height: 24px; +} + +.modal-title { + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: 600; +} + +.modal-message { + margin: 0 0 1.25rem; + font-size: 0.9375rem; + color: var(--color-text-muted); + line-height: 1.5; +} + +.modal-close-btn { + width: 100%; + padding: 0.75rem 1.5rem; + background: var(--color-accent); + border: none; + border-radius: var(--radius-md); + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background var(--transition-fast); +} + +.modal-close-btn:hover { + background: var(--color-accent-hover); +} + +.modal-close-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent); +} + +@keyframes modalFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .swap-card { + padding: 1.25rem; + } + + .token-row { + flex-wrap: wrap; + } + + .token-select-wrapper { + flex: 1 1 100%; + justify-content: flex-start; + } + + .token-trigger { + min-width: 80px; + } + + .token-dropdown { + min-width: 100%; + } + + .amount-wrapper { + flex: 1 1 100%; + } } diff --git a/src/problem2/tsconfig.json b/src/problem2/tsconfig.json new file mode 100644 index 000000000..c65882367 --- /dev/null +++ b/src/problem2/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/src/problem2/tsconfig.node.json b/src/problem2/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/src/problem2/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/src/problem2/vite.config.ts b/src/problem2/vite.config.ts new file mode 100644 index 000000000..506e98b77 --- /dev/null +++ b/src/problem2/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => ({ + plugins: [react()], + root: '.', + server: { port: 3000 }, + esbuild: { + drop: mode === 'production' ? ['console', 'debugger'] : undefined, + }, + build: { + target: 'es2020', + minify: 'esbuild', + cssMinify: true, + sourcemap: false, + reportCompressedSize: true, + rollupOptions: { + output: { + manualChunks: (id: string) => { + if (id.indexOf('node_modules/react/') !== -1 || id.indexOf('node_modules/react-dom/') !== -1) return 'vendor-react'; + }, + chunkFileNames: 'assets/[name]-[hash].js', + entryFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, +})); diff --git a/src/problem2/yarn.lock b/src/problem2/yarn.lock new file mode 100644 index 000000000..fc3366c2a --- /dev/null +++ b/src/problem2/yarn.lock @@ -0,0 +1,753 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + +"@babel/core@^7.28.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" + integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + +"@babel/helper-plugin-utils@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== + dependencies: + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" + integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== + dependencies: + "@babel/types" "^7.29.0" + +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + 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/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@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", "@jridgewell/trace-mapping@^0.3.28": + 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" + +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + +"@rollup/rollup-android-arm-eabi@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" + integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== + +"@rollup/rollup-android-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" + integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== + +"@rollup/rollup-darwin-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz#1e99ab04c0b8c619dd7bbde725ba2b87b55bfd81" + integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== + +"@rollup/rollup-darwin-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" + integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== + +"@rollup/rollup-freebsd-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" + integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== + +"@rollup/rollup-freebsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" + integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" + integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== + +"@rollup/rollup-linux-arm-musleabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" + integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== + +"@rollup/rollup-linux-arm64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" + integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== + +"@rollup/rollup-linux-arm64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" + integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== + +"@rollup/rollup-linux-loong64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" + integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== + +"@rollup/rollup-linux-loong64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" + integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== + +"@rollup/rollup-linux-ppc64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" + integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== + +"@rollup/rollup-linux-ppc64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" + integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== + +"@rollup/rollup-linux-riscv64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" + integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== + +"@rollup/rollup-linux-riscv64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" + integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== + +"@rollup/rollup-linux-s390x-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" + integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== + +"@rollup/rollup-linux-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" + integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== + +"@rollup/rollup-linux-x64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" + integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== + +"@rollup/rollup-openbsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" + integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== + +"@rollup/rollup-openharmony-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" + integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== + +"@rollup/rollup-win32-arm64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" + integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== + +"@rollup/rollup-win32-ia32-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" + integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== + +"@rollup/rollup-win32-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" + integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== + +"@rollup/rollup-win32-x64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" + integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== + +"@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/prop-types@*": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + +"@types/react-dom@^18.2.0": + version "18.3.7" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + +"@types/react@^18.2.0": + version "18.3.28" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781" + integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw== + dependencies: + "@types/prop-types" "*" + csstype "^3.2.2" + +"@vitejs/plugin-react@^4.2.1": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + +baseline-browser-mapping@^2.9.0: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +caniuse-lite@^1.0.30001759: + version "1.0.30001769" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" + integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +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== + +debug@^4.1.0, debug@^4.3.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +electron-to-chromium@^1.5.263: + version "1.5.286" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" + integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +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== + +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== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +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== + +postcss@^8.4.43: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +react-dom@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + +react@^18.2.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +rollup@^4.20.0: + version "4.57.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" + integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.1" + "@rollup/rollup-android-arm64" "4.57.1" + "@rollup/rollup-darwin-arm64" "4.57.1" + "@rollup/rollup-darwin-x64" "4.57.1" + "@rollup/rollup-freebsd-arm64" "4.57.1" + "@rollup/rollup-freebsd-x64" "4.57.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.1" + "@rollup/rollup-linux-arm-musleabihf" "4.57.1" + "@rollup/rollup-linux-arm64-gnu" "4.57.1" + "@rollup/rollup-linux-arm64-musl" "4.57.1" + "@rollup/rollup-linux-loong64-gnu" "4.57.1" + "@rollup/rollup-linux-loong64-musl" "4.57.1" + "@rollup/rollup-linux-ppc64-gnu" "4.57.1" + "@rollup/rollup-linux-ppc64-musl" "4.57.1" + "@rollup/rollup-linux-riscv64-gnu" "4.57.1" + "@rollup/rollup-linux-riscv64-musl" "4.57.1" + "@rollup/rollup-linux-s390x-gnu" "4.57.1" + "@rollup/rollup-linux-x64-gnu" "4.57.1" + "@rollup/rollup-linux-x64-musl" "4.57.1" + "@rollup/rollup-openbsd-x64" "4.57.1" + "@rollup/rollup-openharmony-arm64" "4.57.1" + "@rollup/rollup-win32-arm64-msvc" "4.57.1" + "@rollup/rollup-win32-ia32-msvc" "4.57.1" + "@rollup/rollup-win32-x64-gnu" "4.57.1" + "@rollup/rollup-win32-x64-msvc" "4.57.1" + fsevents "~2.3.2" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +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== + +typescript@^5.3.0: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +update-browserslist-db@^1.2.0: + 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" + +vite@^5.0.0: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/src/problem3/readme.md b/src/problem3/readme.md new file mode 100644 index 000000000..bf3388be5 --- /dev/null +++ b/src/problem3/readme.md @@ -0,0 +1,142 @@ +# Problem 3 — Wallet balance refactor + +This document describes the issues in the original wallet code and how the refactor fixes them. + +--- + +## 1. Filter logic bug + +The filter uses a variable `lhsPriority` that is never defined, so the code crashes at runtime. The condition also keeps balances with amount <= 0, which is wrong for a wallet (we only want positive balances). + +**Fix:** Use the correct priority variable and filter with `balancePriority > -99 && balance.amount > 0`. + +--- + +## 2. Type safety + +`getPriority` takes `blockchain: any`, so TypeScript does not check the value. Invalid blockchains can slip through and you lose autocomplete and safety. + +**Fix:** Define a union type, e.g. `type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'`, and use it instead of `any`. + +--- + +## 3. Repeated priority calculation + +`getPriority` is called many times in filter and sort even though the result does not change. That wastes work and gets slower with more items. + +**Fix:** Use a constant map like `PRIORITY_MAP: Record` and look up the priority from it. + +--- + +## 4. useMemo dependencies + +The useMemo dependency array includes `prices` even when the memo does not use it (in the original). That causes unnecessary recalculations and is misleading. + +**Fix:** Only list dependencies that the memo actually uses. Add `prices` only when the computation needs it. + +--- + +## 5. Mutating the array with sort + +`Array.prototype.sort` changes the array in place. If `balances` comes from props or a hook, you are mutating shared data, which can cause bugs elsewhere and breaks React’s expectation of immutability. + +**Fix:** Sort a copy: `balances.slice().sort(...)` so the original array is not changed. + +--- + +## 6. Unused formattedBalances + +`formattedBalances` is computed but never used. It adds noise and extra work. + +**Fix:** Remove it, or move the formatting into the same pipeline where the data is actually used (e.g. inside the same useMemo that produces the list you render). + +--- + +## 7. Wrong type assertion + +`sortedBalances` is asserted as `FormattedWalletBalance` before formatting is done. TypeScript then assumes fields like `formatted` exist when they might not, which can cause runtime errors. + +**Fix:** Use `FormattedWalletBalance` (or an extended type) only after the step that adds `formatted` and any other derived fields. + +--- + +## 8. Using index as React key + +Using the array index as `key` is problematic when the list can be reordered or items removed. React can update the wrong DOM nodes and you get subtle bugs. + +**Fix:** Use a stable unique value, e.g. `key={balance.currency}`. + +--- + +## 9. Business logic in render + +USD value is computed inside the render loop, so it runs on every render and mixes business logic with presentation. + +**Fix:** Do all calculations (filter, sort, format, USD value) inside useMemo and keep the component’s render only for displaying the result. + +--- + +## Refactored code + +```ts +type Blockchain = 'Osmosis' | 'Ethereum' | 'Arbitrum' | 'Zilliqa' | 'Neo'; + +interface WalletBalance { + currency: string; + amount: number; + blockchain: string; +} + +interface EnhancedWalletBalance extends WalletBalance { + formatted: string; + usdValue: number; +} + +const PRIORITY_MAP: Record = { + Osmosis: 100, + Ethereum: 50, + Arbitrum: 30, + Zilliqa: 20, + Neo: 20, +}; + +interface Props {} + +const WalletPage: React.FC = ({ children, ...rest }) => { + const balances = useWalletBalances(); + const prices = usePrices(); + + const sortedBalances = useMemo(() => { + return balances + .filter((b: WalletBalance) => { + const priority = PRIORITY_MAP[b.blockchain as Blockchain] ?? -99; + return priority > -99 && b.amount > 0; + }) + .slice() + .sort((a: WalletBalance, b: WalletBalance) => { + const pa = PRIORITY_MAP[a.blockchain as Blockchain] ?? -99; + const pb = PRIORITY_MAP[b.blockchain as Blockchain] ?? -99; + return pb - pa; + }) + .map((b: WalletBalance) => ({ + ...b, + formatted: b.amount.toFixed(2), + usdValue: (prices[b.currency] ?? 0) * b.amount, + })); + }, [balances, prices]); + + return ( +
    + {sortedBalances.map((balance) => ( + + ))} + {children} +
    + ); +}; +``` diff --git a/src/problem4/.keep b/src/problem4/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/problem5/.keep b/src/problem5/.keep deleted file mode 100644 index e69de29bb..000000000