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

View File

@@ -1,22 +1,45 @@
"use client"; "use client";
import { useApp } from "@/contexts/AppContext"; import { useApp } from "@/contexts/AppContext";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@heroui/react";
export default function LanguageSwitch() { export default function LanguageSwitch() {
const { language, setLanguage } = useApp(); const { language, setLanguage } = useApp();
const toggleLanguage = () => { const languages = [
setLanguage(language === "zh" ? "en" : "zh"); { key: "zh", label: "中文" },
{ key: "en", label: "English" },
];
const handleSelectionChange = (key: React.Key) => {
setLanguage(key as "zh" | "en");
}; };
return ( return (
<button <Dropdown>
onClick={toggleLanguage} <DropdownTrigger>
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
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"> <span className="text-sm font-medium text-text-primary dark:text-white">
{language === "zh" ? "中" : "EN"} {language === "zh" ? "中" : "EN"}
</span> </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 { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useApp } from "@/contexts/AppContext"; import { useApp } from "@/contexts/AppContext";
import { Tabs, Tab, Button } from "@heroui/react";
export default function MintSwapPanel() { export default function MintSwapPanel() {
const { t } = useApp(); const { t } = useApp();
@@ -10,56 +11,42 @@ export default function MintSwapPanel() {
const [activeAction, setActiveAction] = useState<"deposit" | "withdraw">("deposit"); const [activeAction, setActiveAction] = useState<"deposit" | "withdraw">("deposit");
return ( 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 */} {/* Mint/Swap Tabs */}
<div className="flex border-b border-border-gray dark:border-gray-700"> <Tabs
<button selectedKey={activeMode}
onClick={() => setActiveMode("mint")} onSelectionChange={(key) => setActiveMode(key as "mint" | "swap")}
className={`flex-1 h-[53px] flex items-center justify-center text-body-small font-bold rounded-tl-3xl transition-colors ${ variant="underlined"
activeMode === "mint" classNames={{
? "bg-bg-subtle dark:bg-gray-700 border-b-2 border-text-primary dark:border-blue-500 text-[#0f172b] dark:text-white" base: "w-full",
: "text-text-tertiary dark:text-gray-400" 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")} <Tab key="mint" title={t("mintSwap.mint")} />
</button> <Tab key="swap" title={t("mintSwap.swap")} />
<button </Tabs>
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>
{/* Content */} {/* Content */}
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
{/* Deposit/Withdraw Toggle */} {/* Deposit/Withdraw Toggle */}
<div className="bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 flex gap-0"> <Tabs
<button selectedKey={activeAction}
onClick={() => setActiveAction("deposit")} onSelectionChange={(key) => setActiveAction(key as "deposit" | "withdraw")}
className={`flex-1 h-8 px-4 rounded-lg text-body-small transition-all ${ variant="solid"
activeAction === "deposit" classNames={{
? "bg-bg-surface dark:bg-gray-600 font-bold text-text-primary dark:text-white shadow-sm" base: "w-full",
: "font-medium text-text-tertiary dark:text-gray-400" 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")} <Tab key="deposit" title={t("mintSwap.deposit")} />
</button> <Tab key="withdraw" title={t("mintSwap.withdraw")} />
<button </Tabs>
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>
{/* Input Area */} {/* Input Area */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -166,15 +153,13 @@ export default function MintSwapPanel() {
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<button <Button
className="rounded-xl h-12 flex items-center justify-center gap-2 bg-[#9ca1af] dark:bg-gray-600" isDisabled
disabled 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")} {t("mintSwap.approveDeposit")}
</span> </Button>
<Image src="/icon8.svg" alt="" width={20} height={20} />
</button>
{/* Terms */} {/* Terms */}
<div className="flex flex-col gap-0 text-center"> <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"; "use client";
import { useState } from "react"; import { Tabs, Tab } from "@heroui/react";
interface Tab { interface TabItem {
id: string; id: string;
label: string; label: string;
} }
interface TabNavigationProps { interface TabNavigationProps {
tabs: Tab[]; tabs: TabItem[];
defaultActiveId?: string; defaultActiveId?: string;
onTabChange?: (tabId: string) => void; onTabChange?: (tabId: string) => void;
} }
@@ -18,38 +18,26 @@ export default function TabNavigation({
defaultActiveId, defaultActiveId,
onTabChange, onTabChange,
}: TabNavigationProps) { }: TabNavigationProps) {
const [activeTab, setActiveTab] = useState(defaultActiveId || tabs[0]?.id); const handleSelectionChange = (key: React.Key) => {
onTabChange?.(key.toString());
const handleTabClick = (tabId: string) => {
setActiveTab(tabId);
onTabChange?.(tabId);
}; };
return ( return (
<div className="flex items-center gap-8"> <Tabs
{tabs.map((tab) => { selectedKey={defaultActiveId || tabs[0]?.id}
const isActive = activeTab === tab.id; onSelectionChange={handleSelectionChange}
return ( variant="underlined"
<button classNames={{
key={tab.id} base: "w-auto",
onClick={() => handleTabClick(tab.id)} tabList: "gap-8 w-auto p-0",
className="flex flex-col gap-2 items-start" 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 {tabs.map((tab) => (
className={`text-sm font-bold leading-[150%] ${ <Tab key={tab.id} title={tab.label} />
isActive ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400" ))}
}`} </Tabs>
>
{tab.label}
</span>
<div
className={`self-stretch border-t-2 -mt-[2px] ${
isActive ? "border-text-primary dark:border-white" : "border-transparent"
}`}
/>
</button>
);
})}
</div>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useApp } from "@/contexts/AppContext"; import { useApp } from "@/contexts/AppContext";
import { Button } from "@heroui/react";
export default function ThemeSwitch() { export default function ThemeSwitch() {
const { theme, setTheme } = useApp(); const { theme, setTheme } = useApp();
@@ -10,37 +11,43 @@ export default function ThemeSwitch() {
}; };
return ( return (
<button <Button
onClick={toggleTheme} isIconOnly
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" 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" ? ( {theme === "light" ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path <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" 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" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-text-primary dark:text-white"
/> />
<path <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" 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" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
className="text-text-primary dark:text-white"
/> />
</svg> </svg>
) : ( ) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path <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" 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" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="text-text-primary dark:text-white"
/> />
</svg> </svg>
)} )}
</button> </Button>
); );
} }

View File

@@ -1,4 +1,7 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import { Button } from "@heroui/react";
import Breadcrumb from "./Breadcrumb"; import Breadcrumb from "./Breadcrumb";
import LanguageSwitch from "./LanguageSwitch"; import LanguageSwitch from "./LanguageSwitch";
import ThemeSwitch from "./ThemeSwitch"; import ThemeSwitch from "./ThemeSwitch";
@@ -26,7 +29,12 @@ export default function TopBar() {
<ThemeSwitch /> <ThemeSwitch />
{/* Wallet Button */} {/* 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-wallet.svg" alt="Wallet" width={20} height={20} />
<Image <Image
src="/icon-notification.svg" src="/icon-notification.svg"
@@ -35,15 +43,16 @@ export default function TopBar() {
height={14} height={14}
className="ml-1" className="ml-1"
/> />
</button> </div>
</Button>
{/* Address 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"> <Button
<Image src="/icon-copy.svg" alt="Copy" width={16} height={16} /> className="bg-text-primary text-white font-bold font-jetbrains text-sm h-10 px-4 rounded-lg"
<span className="text-white text-sm font-bold font-jetbrains"> startContent={<Image src="/icon-copy.svg" alt="Copy" width={16} height={16} />}
>
0x12...4F82 0x12...4F82
</span> </Button>
</button>
</div> </div>
</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" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@heroui/react": "^2.8.8",
"@heroui/theme": "^2.4.26",
"framer-motion": "^12.29.2",
"next": "^15.1.4", "next": "^15.1.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View File

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