feat(web): use real Seed icon catalog (@seed-design/react-icon) matching iOS iconography

This commit is contained in:
Munkherdene 2026-08-22 22:28:41 +08:00
parent 0010b7b5fa
commit 5eff0f661f
4 changed files with 76 additions and 219 deletions

View file

@ -6,6 +6,7 @@
"dependencies": {
"@seed-design/css": "^2.5.0",
"@seed-design/react": "^2.3.0",
"@seed-design/react-icon": "^0.7.4",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"next": "^15.1.0",
@ -351,6 +352,8 @@
"@seed-design/react-floating": ["@seed-design/react-floating@1.0.1", "", { "dependencies": { "@floating-ui/react": "^0.27.0", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ijlrVyRndiPp4x71cjTq6nT9qOMc/xPM5zSLp5+DM86k5KQ3FB0UEmfoZE/mGrspFZilr/KXP3ctf9CblXqcnQ=="],
"@seed-design/react-icon": ["@seed-design/react-icon@0.7.4", "", { "peerDependencies": { "react": "17.x || 18.x || 19.x" } }, "sha512-jjfKw3mvIYsTlgdxb4pjWTpT+uW8RZns/gcfhOjWuegZEXuZyAy4kPpiNbeNdnbrNoLWTCt8Rtt6UCGr/Zo/EQ=="],
"@seed-design/react-image": ["@seed-design/react-image@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-layout-effect": "^1.1.1", "@seed-design/dom-utils": "^2.0.1", "@seed-design/react-primitive": "^2.0.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZJBr0rW/xIzCcgkSpdGMv9uNEgnaYIfsOrp4YWOtj/ZSK+FSE6o5JPiC5FO0kbpPMVske91AOK92z9a6tAeSWg=="],
"@seed-design/react-menu": ["@seed-design/react-menu@2.0.2", "", { "dependencies": { "@floating-ui/react": "^0.27.0", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-focus-scope": "^1.1.8", "@seed-design/dom-utils": "^2.0.1", "@seed-design/react-dismissible-layer": "^1.0.2", "@seed-design/react-primitive": "^2.0.1", "@seed-design/react-use-controllable-state": "^2.0.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-d6hHwY03qCxA6qs664Tv6S9fBiz6zyEu/tDkNYZ4RMbp7JxtoJ8n4Q/SONlBwVWPeddiZdLP6uSe4YRzfyDMWg=="],

View file

@ -12,13 +12,14 @@
"e2e": "playwright test"
},
"dependencies": {
"@seed-design/css": "^2.5.0",
"@seed-design/react": "^2.3.0",
"@seed-design/react-icon": "^0.7.4",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@seed-design/react": "^2.3.0",
"@seed-design/css": "^2.5.0",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"zod": "^3.23.8"
},
"devDependencies": {

View file

@ -2,7 +2,7 @@
import * as React from "react";
import Link from "next/link";
import { Icon } from "@seed-design/react";
import { Icon, type IconName } from "./icons";
import { t } from "@/i18n/common";
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
@ -15,47 +15,12 @@ const HREF: Record<TabKey, string> = {
profile: "/profile",
};
// `@seed-design/react`'s `Icon` takes a raw `svg` node rather than a named
// icon catalog (there's no bundled icon-name set in the installed
// @seed-design/react/css versions — verified under node_modules), so these
// are small hand-drawn stand-ins mirroring the iOS tab glyphs
// (houseFill / horizline3VerticalFill / checkmarkCalendarFill / cardFill /
// personFill from HomeTabBar.swift). Swap for the real Seed icon set once
// it's available in this app.
// Seed's `<Icon svg={...} />` uses a Radix `Slot` internally, which clones
// its size/color props onto a *single* element — so each entry here must be
// one root `<svg>` node, not a fragment of bare paths.
function tabSvg(children: React.ReactNode): React.ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
const TAB_ICON: Record<TabKey, React.ReactNode> = {
home: tabSvg(<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />),
accounting: tabSvg(
<>
<rect x="3" y="5" width="18" height="3" rx="1" />
<rect x="3" y="10.5" width="18" height="3" rx="1" />
<rect x="3" y="16" width="18" height="3" rx="1" />
</>,
),
planner: tabSvg(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M3 9h18" />
<path d="m8 14 2.5 2.5L16 11" />
</>,
),
assets: tabSvg(<rect x="2.5" y="5" width="19" height="14" rx="2" />),
profile: tabSvg(
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
</>,
),
const TAB_ICON: Record<TabKey, IconName> = {
home: "home",
accounting: "list",
planner: "calendar-check",
assets: "card",
profile: "user",
};
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
@ -82,7 +47,7 @@ export function TabBar({ active }: TabBarProps) {
className="flex flex-1 flex-col items-center gap-1 p-1 md:flex-none md:flex-row md:justify-start md:gap-3 md:rounded-xl md:px-3 md:py-2"
style={{ color: isActive ? "var(--seed-color-fg-neutral)" : "var(--seed-color-fg-placeholder)" }}
>
<Icon svg={TAB_ICON[key]} size="24px" />
<Icon name={TAB_ICON[key]} size={24} />
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
</Link>
);

View file

@ -1,8 +1,10 @@
import type { SVGProps } from "react";
import type { CSSProperties, ComponentType } from "react";
import * as Seed from "@seed-design/react-icon";
// Clean line-icon set (stroke, currentColor) — a dependency-free stand-in for
// the Seed multicolor catalog, which has no web build. 24x24, 1.75 stroke,
// round caps/joins. Color via `color` / currentColor; size via `size`.
// Real Seed icon catalog (same iconography as the iOS app's SeedIcon), mapped
// from our semantic keys to Seed's filled variants. Keeping this indirection
// means every `<Icon name="…" />` call site renders authentic Seed icons and
// we can retune the mapping in one place.
export type IconName =
| "utensils"
@ -38,174 +40,60 @@ export type IconName =
| "hand-coins"
| "bank";
const PATHS: Record<IconName, React.ReactNode> = {
utensils: (
<>
<path d="M4 3v6a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V3M6 11v10" />
<path d="M17 3c-1.5 0-3 1.5-3 4.5S15.5 12 17 12v9" />
</>
),
cart: (
<>
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
<path d="M2 3h2.2l2 12.4a1.5 1.5 0 0 0 1.5 1.2h9.1a1.5 1.5 0 0 0 1.5-1.2L20.5 7H5.2" />
</>
),
coffee: (
<>
<path d="M4 8h13v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z" />
<path d="M17 9h2.5a2.5 2.5 0 0 1 0 5H17" />
<path d="M8 2c-.6.8-.6 1.7 0 2.5M12 2c-.6.8-.6 1.7 0 2.5" />
</>
),
car: (
<>
<path d="M5 12l1.5-4.5A2 2 0 0 1 8.4 6h7.2a2 2 0 0 1 1.9 1.5L19 12" />
<path d="M3 12h18v4a1 1 0 0 1-1 1h-1.5M5.5 17H4a1 1 0 0 1-1-1v-4" />
<path d="M6.5 17v1.5M17.5 17v1.5" />
<circle cx="7" cy="14.5" r=".6" /><circle cx="17" cy="14.5" r=".6" />
</>
),
card: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="2.5" />
<path d="M2.5 9.5h19" />
</>
),
shield: <path d="M12 3l7 2.5v5.5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V5.5L12 3Z" />,
phone: (
<>
<rect x="6.5" y="2.5" width="11" height="19" rx="2.5" />
<path d="M11 18.5h2" />
</>
),
sparkles: (
<>
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
<path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8L18 14Z" />
</>
),
bag: (
<>
<path d="M5.5 8h13l-.9 11a2 2 0 0 1-2 1.8H8.4a2 2 0 0 1-2-1.8L5.5 8Z" />
<path d="M9 8V6.5a3 3 0 0 1 6 0V8" />
</>
),
monitor: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6M12 16v4" />
</>
),
gamepad: (
<>
<path d="M7 9h10a4 4 0 0 1 4 4v.5a3.5 3.5 0 0 1-6.3 2.1L14 15h-4l-.7.6A3.5 3.5 0 0 1 3 13.5V13a4 4 0 0 1 4-4Z" />
<path d="M7.5 12v2M6.5 13h2M15.5 12.5h.01M17.5 14.5h.01" />
</>
),
dumbbell: (
<>
<path d="M3 9v6M6 7v10M18 7v10M21 9v6M6 12h12" />
</>
),
scissors: (
<>
<circle cx="6" cy="6" r="2.2" /><circle cx="6" cy="18" r="2.2" />
<path d="M8 7.5 20 18M8 16.5 20 6" />
</>
),
repeat: (
<>
<path d="M3 12V9a3 3 0 0 1 3-3h11M14 3l3 3-3 3" />
<path d="M21 12v3a3 3 0 0 1-3 3H7M10 21l-3-3 3-3" />
</>
),
wallet: (
<>
<path d="M3.5 7.5A2 2 0 0 1 5.5 5.5h11a1.5 1.5 0 0 1 0 3H4" />
<rect x="3" y="7.5" width="18" height="11.5" rx="2.5" />
<circle cx="16.5" cy="13.5" r="1.1" />
</>
),
receipt: (
<>
<path d="M6 3h12v18l-2-1.3-2 1.3-2-1.3-2 1.3-2-1.3L6 21V3Z" />
<path d="M9 8h6M9 12h6" />
</>
),
wrench: <path d="M14.5 6.5a3.5 3.5 0 0 0 4.4 4.4l-8.9 8.9a2 2 0 0 1-2.8-2.8l8.9-8.9a3.5 3.5 0 0 0-1.6-1.6Z" />,
home: (
<>
<path d="M4 11 12 4l8 7" />
<path d="M6 10v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-9" />
</>
),
list: <path d="M4 7h16M4 12h16M4 17h16" />,
"calendar-check": (
<>
<rect x="3.5" y="5" width="17" height="16" rx="2.5" />
<path d="M3.5 9.5h17M8 3v4M16 3v4M8.5 15l2.2 2.2L15.5 12" />
</>
),
layers: (
<>
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 13l9 5 9-5M3 18l9 5 9-5" opacity=".5" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20a7 7 0 0 1 14 0" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
"chevron-right": <path d="m9 5 7 7-7 7" />,
"chevron-left": <path d="m15 5-7 7 7 7" />,
eye: (
<>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="2.6" />
</>
),
"eye-off": (
<>
<path d="M4 4l16 16" />
<path d="M9.5 9.6A2.6 2.6 0 0 0 12 14.5a2.6 2.6 0 0 0 2.4-1.6M6.5 6.8C4 8.4 2.5 12 2.5 12s3.5 6.5 9.5 6.5c1.5 0 2.8-.4 4-1M16.5 15.2C19 13.6 21.5 12 21.5 12s-3-5.5-8-6.4" />
</>
),
"arrow-up-right": <path d="M7 17 17 7M9 7h8v8" />,
"arrow-down-left": <path d="M17 7 7 17M15 17H7V9" />,
trending: <path d="m3 16 5-5 4 4 8-8M15 7h6v6" />,
"hand-coins": (
<>
<circle cx="8" cy="6" r="3" />
<path d="M13 9h4.5a2 2 0 0 1 0 4H14M3 20l3-3h6a2 2 0 0 0 2-2" />
</>
),
bank: (
<>
<path d="M3 9.5 12 4l9 5.5M4.5 9.5V18M19.5 9.5V18M8 10v6M12 10v6M16 10v6M3 20h18" />
</>
),
type SeedIcon = ComponentType<{ size?: number; color?: string; style?: CSSProperties; "aria-hidden"?: boolean }>;
const S = Seed as unknown as Record<string, SeedIcon>;
const pick = (...names: string[]): SeedIcon => {
for (const n of names) if (S[n]) return S[n];
return S.IconQuestionCheckFill ?? S.IconBillFill;
};
export function Icon({ name, size = 22, ...rest }: { name: IconName; size?: number } & Omit<SVGProps<SVGSVGElement>, "name">) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...rest}
>
{PATHS[name]}
</svg>
);
const MAP: Record<IconName, SeedIcon> = {
utensils: pick("IconRestaurantFill"),
cart: pick("IconCartFill"),
coffee: pick("IconCafeFill", "IconRestaurantFill"),
car: pick("IconCarFill"),
card: pick("IconPaymentFill", "IconListCardFill"),
shield: pick("IconLockFill"),
phone: pick("IconMobileFill"),
sparkles: pick("IconReviewStarFill"),
bag: pick("IconShoppingBagFill", "IconCartFill"),
monitor: pick("IconLaptopFill", "IconMonitorFill"),
gamepad: pick("IconPlayFill"),
dumbbell: pick("IconHeartFill"),
scissors: pick("IconReviewStarFill"),
repeat: pick("IconRetryFill"),
wallet: pick("IconMoneyWonFill", "IconPriceWonFill"),
receipt: pick("IconBillFill"),
wrench: pick("IconSettingFill", "IconToolboxFill"),
home: pick("IconHouseFill"),
list: pick("IconMenuFill", "IconListFill"),
"calendar-check": pick("IconCalendarFill"),
layers: pick("IconListCardFill"),
user: pick("IconProfileFill"),
plus: pick("IconAddFill"),
"chevron-right": pick("IconChevronRightFill"),
"chevron-left": pick("IconChevronLeftFill"),
eye: pick("IconViewFill", "IconChartFill"),
"eye-off": pick("IconViewOffFill", "IconChartFill"),
"arrow-up-right": pick("IconArrowUpwardFill", "IconArrowFill"),
"arrow-down-left": pick("IconArrowDownwardFill", "IconArrowFill"),
trending: pick("IconChartFill"),
"hand-coins": pick("IconMoneyWonFill", "IconPriceWonFill"),
bank: pick("IconPaymentFill", "IconListCardFill"),
};
export function Icon({
name,
size = 22,
color = "currentColor",
style,
}: {
name: IconName;
size?: number;
color?: string;
style?: CSSProperties;
}) {
const C = MAP[name];
return <C size={size} color={color} style={style} aria-hidden />;
}