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)**
+
+
+
+**Dark mode**
+
+
+
+**Mobile**
+
+
+
+## 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 — 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 (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
+ Swap tokens at best rates
+
+
+ Convert between tokens in seconds. Live prices, no sign-up needed.
+
+
+ Try it now
+
+
+
+
+
} className={`landing-swap${swapInView ? ' in-view' : ''}`}>
+
+
+
Swap
+
}>
+
+
+
+
+
+
+
+
} className={`landing-features${featuresInView ? ' in-view' : ''}`}>
+
+
Why swap with us
+
+ {features.map((f) => (
+
+
+ {f.icon}
+
+ {f.title}
+ {f.description}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+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 = (
+
+
+
+
+ {success ? : }
+
+
+ {success ? 'Success' : 'Error'}
+
+
+ {message}
+
+ {success && summary && (
+
+
+ You sent
+ {summary.fromAmount} {summary.fromToken}
+
+
+ You receive
+ {summary.toAmount} {summary.toToken}
+
+ {summary.rate && (
+
+ Rate
+ {summary.rate}
+
+ )}
+
+ )}
+
+
+
+ );
+
+ 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 (
+ <>
+
+
+
+
+ {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 (
+
+ {symbol.charAt(0)}
+
+ );
+ }
+
+ return (
+
+
setFailed(true)}
+ />
+
+ );
+};
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 = (
+
+
+
+
+
+
+ {title}
+
+
+ Search and select a token
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by symbol..."
+ aria-label="Search tokens"
+ className="token-select-modal-search-input"
+ />
+
+
+
+
+
+ );
+
+ 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