feat: integrate HeroUI component library

Implemented HeroUI migration plan with the following changes:

Stage 0: Environment Setup
- Installed @heroui/react@2.8.7, @heroui/theme@2.4.26, framer-motion@12.29.2
- Configured Tailwind with HeroUI plugin
- Added HeroUI content paths to Tailwind config

Stage 1: Provider Architecture
- Created Providers.tsx wrapper combining HeroUIProvider and AppProvider
- Updated app/layout.tsx to use new Providers component
- Preserved all AppContext functionality (theme, language, translations)

Stage 2: Component Migrations
- TabNavigation: Migrated to HeroUI Tabs with keyboard navigation support
- TopBar: Migrated buttons to HeroUI Button components
- LanguageSwitch: Migrated to HeroUI Dropdown for better UX
- ThemeSwitch: Migrated to HeroUI Button (isIconOnly variant)
- MintSwapPanel: Migrated to HeroUI Tabs and Button components

Benefits:
- Enhanced accessibility with ARIA attributes and keyboard navigation
- Smooth animations and transitions via Framer Motion
- Consistent component API across the application
- Maintained all existing design tokens and color system
- Preserved dark mode functionality

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 03:49:53 +00:00
parent 098a91f2ac
commit 9e0dd1d278
27 changed files with 11184 additions and 7928 deletions

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { AppProvider } from "@/contexts/AppContext";
import { Providers } from "@/components/Providers";
const inter = Inter({
subsets: ["latin"],
@@ -28,7 +28,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} ${jetbrainsMono.variable} ${inter.className}`}>
<AppProvider>{children}</AppProvider>
<Providers>{children}</Providers>
</body>
</html>
);

View File

@@ -1,22 +1,45 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@heroui/react";
export default function LanguageSwitch() {
const { language, setLanguage } = useApp();
const toggleLanguage = () => {
setLanguage(language === "zh" ? "en" : "zh");
const languages = [
{ key: "zh", label: "中文" },
{ key: "en", label: "English" },
];
const handleSelectionChange = (key: React.Key) => {
setLanguage(key as "zh" | "en");
};
return (
<button
onClick={toggleLanguage}
className="bg-bg-surface dark:bg-gray-800 rounded-lg border border-border-normal dark:border-gray-700 px-3 py-2 flex items-center justify-center h-10 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
<Dropdown>
<DropdownTrigger>
<Button
variant="bordered"
className="bg-bg-surface dark:bg-gray-800 border-border-normal dark:border-gray-700 min-w-10 h-10 px-3 rounded-lg"
>
<span className="text-sm font-medium text-text-primary dark:text-white">
{language === "zh" ? "中" : "EN"}
</span>
</button>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Language selection"
selectedKeys={new Set([language])}
selectionMode="single"
onSelectionChange={(keys) => {
const key = Array.from(keys)[0];
if (key) handleSelectionChange(key);
}}
>
{languages.map((lang) => (
<DropdownItem key={lang.key}>{lang.label}</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
);
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { Tabs, Tab, Button } from "@heroui/react";
export default function MintSwapPanel() {
const { t } = useApp();
@@ -10,56 +11,42 @@ export default function MintSwapPanel() {
const [activeAction, setActiveAction] = useState<"deposit" | "withdraw">("deposit");
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col">
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden">
{/* Mint/Swap Tabs */}
<div className="flex border-b border-border-gray dark:border-gray-700">
<button
onClick={() => setActiveMode("mint")}
className={`flex-1 h-[53px] flex items-center justify-center text-body-small font-bold rounded-tl-3xl transition-colors ${
activeMode === "mint"
? "bg-bg-subtle dark:bg-gray-700 border-b-2 border-text-primary dark:border-blue-500 text-[#0f172b] dark:text-white"
: "text-text-tertiary dark:text-gray-400"
}`}
<Tabs
selectedKey={activeMode}
onSelectionChange={(key) => setActiveMode(key as "mint" | "swap")}
variant="underlined"
classNames={{
base: "w-full",
tabList: "w-full gap-0 p-0 rounded-none border-b border-border-gray dark:border-gray-700",
cursor: "bg-text-primary dark:bg-blue-500 h-[2px]",
tab: "h-[53px] flex-1 rounded-none data-[selected=true]:bg-bg-subtle dark:data-[selected=true]:bg-gray-700",
tabContent: "text-body-small font-bold text-text-tertiary dark:text-gray-400 group-data-[selected=true]:text-[#0f172b] dark:group-data-[selected=true]:text-white",
}}
>
{t("mintSwap.mint")}
</button>
<button
onClick={() => setActiveMode("swap")}
className={`flex-1 h-[53px] flex items-center justify-center text-body-small font-bold transition-colors ${
activeMode === "swap"
? "bg-bg-subtle dark:bg-gray-700 border-b-2 border-text-primary dark:border-blue-500 text-[#0f172b] dark:text-white"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("mintSwap.swap")}
</button>
</div>
<Tab key="mint" title={t("mintSwap.mint")} />
<Tab key="swap" title={t("mintSwap.swap")} />
</Tabs>
{/* Content */}
<div className="flex flex-col gap-6 p-6">
{/* Deposit/Withdraw Toggle */}
<div className="bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 flex gap-0">
<button
onClick={() => setActiveAction("deposit")}
className={`flex-1 h-8 px-4 rounded-lg text-body-small transition-all ${
activeAction === "deposit"
? "bg-bg-surface dark:bg-gray-600 font-bold text-text-primary dark:text-white shadow-sm"
: "font-medium text-text-tertiary dark:text-gray-400"
}`}
<Tabs
selectedKey={activeAction}
onSelectionChange={(key) => setActiveAction(key as "deposit" | "withdraw")}
variant="solid"
classNames={{
base: "w-full",
tabList: "bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 gap-0 w-full",
cursor: "bg-bg-surface dark:bg-gray-600 shadow-sm",
tab: "h-8 px-4",
tabContent: "text-body-small font-medium text-text-tertiary dark:text-gray-400 group-data-[selected=true]:font-bold group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
{t("mintSwap.deposit")}
</button>
<button
onClick={() => setActiveAction("withdraw")}
className={`flex-1 h-8 px-4 rounded-lg text-body-small transition-all ${
activeAction === "withdraw"
? "bg-bg-surface dark:bg-gray-600 font-bold text-text-primary dark:text-white shadow-sm"
: "font-medium text-text-tertiary dark:text-gray-400"
}`}
>
{t("mintSwap.withdraw")}
</button>
</div>
<Tab key="deposit" title={t("mintSwap.deposit")} />
<Tab key="withdraw" title={t("mintSwap.withdraw")} />
</Tabs>
{/* Input Area */}
<div className="flex flex-col gap-2">
@@ -166,15 +153,13 @@ export default function MintSwapPanel() {
</div>
{/* Submit Button */}
<button
className="rounded-xl h-12 flex items-center justify-center gap-2 bg-[#9ca1af] dark:bg-gray-600"
disabled
<Button
isDisabled
className="rounded-xl h-12 bg-[#9ca1af] dark:bg-gray-600 text-lg font-bold text-white"
endContent={<Image src="/icon8.svg" alt="" width={20} height={20} />}
>
<span className="text-lg font-bold text-white leading-7">
{t("mintSwap.approveDeposit")}
</span>
<Image src="/icon8.svg" alt="" width={20} height={20} />
</button>
</Button>
{/* Terms */}
<div className="flex flex-col gap-0 text-center">

13
components/Providers.tsx Normal file
View File

@@ -0,0 +1,13 @@
"use client";
import { HeroUIProvider } from "@heroui/react";
import { AppProvider } from "@/contexts/AppContext";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return (
<HeroUIProvider>
<AppProvider>{children}</AppProvider>
</HeroUIProvider>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import { useState } from "react";
import { Tabs, Tab } from "@heroui/react";
interface Tab {
interface TabItem {
id: string;
label: string;
}
interface TabNavigationProps {
tabs: Tab[];
tabs: TabItem[];
defaultActiveId?: string;
onTabChange?: (tabId: string) => void;
}
@@ -18,38 +18,26 @@ export default function TabNavigation({
defaultActiveId,
onTabChange,
}: TabNavigationProps) {
const [activeTab, setActiveTab] = useState(defaultActiveId || tabs[0]?.id);
const handleTabClick = (tabId: string) => {
setActiveTab(tabId);
onTabChange?.(tabId);
const handleSelectionChange = (key: React.Key) => {
onTabChange?.(key.toString());
};
return (
<div className="flex items-center gap-8">
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className="flex flex-col gap-2 items-start"
<Tabs
selectedKey={defaultActiveId || tabs[0]?.id}
onSelectionChange={handleSelectionChange}
variant="underlined"
classNames={{
base: "w-auto",
tabList: "gap-8 w-auto p-0",
cursor: "bg-text-primary dark:bg-white",
tab: "px-0 h-auto",
tabContent: "text-sm font-bold text-text-tertiary dark:text-gray-400 group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<span
className={`text-sm font-bold leading-[150%] ${
isActive ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"
}`}
>
{tab.label}
</span>
<div
className={`self-stretch border-t-2 -mt-[2px] ${
isActive ? "border-text-primary dark:border-white" : "border-transparent"
}`}
/>
</button>
);
})}
</div>
{tabs.map((tab) => (
<Tab key={tab.id} title={tab.label} />
))}
</Tabs>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { Button } from "@heroui/react";
export default function ThemeSwitch() {
const { theme, setTheme } = useApp();
@@ -10,37 +11,43 @@ export default function ThemeSwitch() {
};
return (
<button
onClick={toggleTheme}
className="bg-bg-surface dark:bg-gray-800 rounded-lg border border-border-normal dark:border-gray-700 px-3 py-2 flex items-center justify-center h-10 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
<Button
isIconOnly
variant="bordered"
onPress={toggleTheme}
className="bg-bg-surface dark:bg-gray-800 border-border-normal dark:border-gray-700 min-w-10 h-10 rounded-lg"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
>
{theme === "light" ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M10 15C12.7614 15 15 12.7614 15 10C15 7.23858 12.7614 5 10 5C7.23858 5 5 7.23858 5 10C5 12.7614 7.23858 15 10 15Z"
stroke="#111827"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-text-primary dark:text-white"
/>
<path
d="M10 1V3M10 17V19M19 10H17M3 10H1M16.07 16.07L14.64 14.64M5.36 5.36L3.93 3.93M16.07 3.93L14.64 5.36M5.36 14.64L3.93 16.07"
stroke="#111827"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="text-text-primary dark:text-white"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M17 10.79C16.8427 12.4922 16.1039 14.0754 14.9116 15.2662C13.7192 16.4571 12.1503 17.1913 10.4501 17.3464C8.74989 17.5016 7.04992 17.0676 5.63182 16.1159C4.21372 15.1642 3.15973 13.7534 2.63564 12.1102C2.11155 10.467 2.14637 8.68739 2.73477 7.06725C3.32317 5.44711 4.43113 4.07931 5.88616 3.18637C7.3412 2.29343 9.05859 1.93047 10.7542 2.15507C12.4498 2.37967 13.9989 3.17747 15.16 4.41"
stroke="#111827"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-text-primary dark:text-white"
/>
</svg>
)}
</button>
</Button>
);
}

View File

@@ -1,4 +1,7 @@
"use client";
import Image from "next/image";
import { Button } from "@heroui/react";
import Breadcrumb from "./Breadcrumb";
import LanguageSwitch from "./LanguageSwitch";
import ThemeSwitch from "./ThemeSwitch";
@@ -26,7 +29,12 @@ export default function TopBar() {
<ThemeSwitch />
{/* Wallet Button */}
<button className="bg-bg-surface rounded-lg border border-border-normal px-2 py-2 flex items-center justify-center h-10">
<Button
isIconOnly
variant="bordered"
className="bg-bg-surface border-border-normal min-w-10 h-10 rounded-lg"
>
<div className="flex items-center justify-center">
<Image src="/icon-wallet.svg" alt="Wallet" width={20} height={20} />
<Image
src="/icon-notification.svg"
@@ -35,15 +43,16 @@ export default function TopBar() {
height={14}
className="ml-1"
/>
</button>
</div>
</Button>
{/* Address Button */}
<button className="bg-text-primary rounded-lg px-4 py-2 flex items-center justify-center gap-2 h-10 hover:bg-gray-800 transition-colors">
<Image src="/icon-copy.svg" alt="Copy" width={16} height={16} />
<span className="text-white text-sm font-bold font-jetbrains">
<Button
className="bg-text-primary text-white font-bold font-jetbrains text-sm h-10 px-4 rounded-lg"
startContent={<Image src="/icon-copy.svg" alt="Copy" width={16} height={16} />}
>
0x12...4F82
</span>
</button>
</Button>
</div>
</div>
);

3244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
"lint": "next lint"
},
"dependencies": {
"@heroui/react": "^2.8.8",
"@heroui/theme": "^2.4.26",
"framer-motion": "^12.29.2",
"next": "^15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View File

@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import { heroui } from "@heroui/theme";
export default {
darkMode: "class",
@@ -6,6 +7,7 @@ export default {
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
@@ -40,5 +42,5 @@ export default {
},
},
},
plugins: [],
plugins: [heroui()],
} satisfies Config;