diff --git a/CLAUDE.md b/CLAUDE.md index 1a5cd774..ae4bf074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,15 @@ packages/ **Validation**: Zod-first. Schemas in domain, derive types with `z.infer`. Use `z.coerce.*` for query params. +**shadcn/ui Component Migration**: When creating or migrating a component to shadcn/ui, always: + +1. Place the raw shadcn primitive in `components/ui/` (install via `pnpm dlx shadcn@latest add `) +2. Create an enhanced atom wrapper in `components/atoms/` that preserves the existing public API +3. Save the old implementation as `.legacy.tsx` for reference +4. Add a `ComparisonWithLegacy` story in the component's `.stories.tsx` that renders Legacy (left) vs shadcn/ui (right) in a 2-column grid +5. Export the new ui component from `components/ui/index.ts` +6. Use `text-destructive` instead of `text-danger`/`text-red-*` for error colors (shadcn convention) + ## Docs | Topic | Location | diff --git a/apps/portal/.storybook/main.ts b/apps/portal/.storybook/main.ts index b98c0f89..211927b2 100644 --- a/apps/portal/.storybook/main.ts +++ b/apps/portal/.storybook/main.ts @@ -29,6 +29,19 @@ const config: StorybookConfig = { "next/navigation": path.resolve(__dirname, "mocks/next-navigation.tsx"), }; + // Pre-bundle ESM dependencies so Vite can resolve their exports + config.optimizeDeps = config.optimizeDeps || {}; + config.optimizeDeps.include = [ + ...(config.optimizeDeps.include || []), + "framer-motion", + "@heroicons/react/24/outline", + "@heroicons/react/24/solid", + "radix-ui", + "lucide-react", + "class-variance-authority", + "input-otp", + ]; + // Disable PostCSS — @tailwindcss/vite handles CSS directly config.css = config.css || {}; config.css.postcss = { plugins: [] }; diff --git a/apps/portal/package.json b/apps/portal/package.json index 2e907e82..b0028c18 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -23,6 +23,7 @@ "dependencies": { "@customer-portal/domain": "workspace:*", "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.90.20", "@xstate/react": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -35,10 +36,11 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-hook-form": "^7.71.2", "tailwind-merge": "^3.4.0", "world-countries": "^5.1.0", "xstate": "^5.28.0", - "zod": "^4.3.6", + "zod": "catalog:", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 365e8214..d076771c 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -270,6 +270,10 @@ --color-danger-bg: var(--danger-bg); --color-danger-border: var(--danger-border); + /* shadcn/ui uses "destructive" — alias to our "danger" tokens */ + --color-destructive: var(--danger); + --color-destructive-foreground: var(--danger-foreground); + --color-neutral: var(--neutral); --color-neutral-foreground: var(--neutral-foreground); --color-neutral-bg: var(--neutral-bg); diff --git a/apps/portal/src/components/atoms/badge.stories.tsx b/apps/portal/src/components/atoms/badge.stories.tsx index dbbdf884..a139b9ca 100644 --- a/apps/portal/src/components/atoms/badge.stories.tsx +++ b/apps/portal/src/components/atoms/badge.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Badge } from "./badge"; +import { Badge as LegacyBadge } from "./badge.legacy"; const meta: Meta = { title: "Atoms/Badge", @@ -69,3 +70,105 @@ export const WithDot: Story = { export const Removable: Story = { args: { children: "Removable", removable: true, onRemove: () => alert("Removed!") }, }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

Badge — Legacy vs shadcn/ui

+ + {/* Variants */} +
+
+

Legacy

+
+ Default + Secondary + Success + Warning + Error + Info + Outline +
+
+
+

shadcn/ui

+
+ Default + Secondary + Success + Warning + Error + Info + Outline +
+
+
+ + {/* Sizes */} +
+
+

Legacy — Sizes

+
+ Small + Default + Large +
+
+
+

shadcn/ui — Sizes

+
+ Small + Default + Large +
+
+
+ + {/* With Dot */} +
+
+

Legacy — Dot

+
+ + Active + + + Failed + +
+
+
+

shadcn/ui — Dot

+
+ + Active + + + Failed + +
+
+
+ + {/* Removable */} +
+
+

Legacy — Removable

+ {}}> + Remove me + +
+
+

+ shadcn/ui — Removable +

+ {}}> + Remove me + +
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/checkbox.stories.tsx b/apps/portal/src/components/atoms/checkbox.stories.tsx index 9cb0cf7b..b971ac4a 100644 --- a/apps/portal/src/components/atoms/checkbox.stories.tsx +++ b/apps/portal/src/components/atoms/checkbox.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Checkbox } from "./checkbox"; +import { Checkbox as LegacyCheckbox } from "./checkbox.legacy"; const meta: Meta = { title: "Atoms/Checkbox", @@ -37,3 +38,36 @@ export const AllStates: Story = { ), }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

Checkbox — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ + + + + +
+
+
+

shadcn/ui

+
+ + + + + +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/error-message.stories.tsx b/apps/portal/src/components/atoms/error-message.stories.tsx index ad3855a6..bd279e7c 100644 --- a/apps/portal/src/components/atoms/error-message.stories.tsx +++ b/apps/portal/src/components/atoms/error-message.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ErrorMessage } from "./error-message"; +import { ErrorMessage as LegacyErrorMessage } from "./error-message.legacy"; const meta: Meta = { title: "Atoms/ErrorMessage", @@ -30,3 +31,34 @@ export const AllVariants: Story = { export const WithoutIcon: Story = { args: { children: "Error without icon", showIcon: false }, }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

ErrorMessage — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ Default error + Inline error + Subtle error + Without icon +
+
+
+

shadcn/ui

+
+ Default error + Inline error + Subtle error + Without icon +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/error-state.stories.tsx b/apps/portal/src/components/atoms/error-state.stories.tsx index c0ff6e6a..ea4c2639 100644 --- a/apps/portal/src/components/atoms/error-state.stories.tsx +++ b/apps/portal/src/components/atoms/error-state.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ErrorState } from "./error-state"; +import { ErrorState as LegacyErrorState } from "./error-state.legacy"; const meta: Meta = { title: "Atoms/ErrorState", @@ -46,3 +47,47 @@ export const AllVariants: Story = { ), }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

ErrorState — Legacy vs shadcn/ui

+ + {/* Card variant */} +
+
+

Legacy — Card

+ {}} + /> +
+
+

shadcn/ui — Card

+ {}} + /> +
+
+ + {/* Inline variant */} +
+
+

Legacy — Inline

+ +
+
+

shadcn/ui — Inline

+ +
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/inline-toast.stories.tsx b/apps/portal/src/components/atoms/inline-toast.stories.tsx index 40a8578a..ea0682ee 100644 --- a/apps/portal/src/components/atoms/inline-toast.stories.tsx +++ b/apps/portal/src/components/atoms/inline-toast.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { InlineToast } from "./inline-toast"; +import { InlineToast as LegacyInlineToast } from "./inline-toast.legacy"; const meta: Meta = { title: "Atoms/InlineToast", @@ -34,3 +35,34 @@ export const AllTones: Story = { ), }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

InlineToast — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ + + + +
+
+
+

shadcn/ui

+
+ + + + +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/input.stories.tsx b/apps/portal/src/components/atoms/input.stories.tsx index b43665fb..81dcfedc 100644 --- a/apps/portal/src/components/atoms/input.stories.tsx +++ b/apps/portal/src/components/atoms/input.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Input } from "./input"; +import { Input as LegacyInput } from "./input.legacy"; const meta: Meta = { title: "Atoms/Input", @@ -35,3 +36,34 @@ export const AllStates: Story = { ), }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

Input — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ + + + +
+
+
+

shadcn/ui

+
+ + + + +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/label.stories.tsx b/apps/portal/src/components/atoms/label.stories.tsx index 9cf475b6..969454c0 100644 --- a/apps/portal/src/components/atoms/label.stories.tsx +++ b/apps/portal/src/components/atoms/label.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Label } from "./label"; +import { Label as LegacyLabel } from "./label.legacy"; const meta: Meta = { title: "Atoms/Label", @@ -20,3 +21,34 @@ export const Required: Story = { ), }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

Label — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ Default label + + Required * + +
+
+
+

shadcn/ui

+
+ + +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/skeleton.stories.tsx b/apps/portal/src/components/atoms/skeleton.stories.tsx index ba8681f0..db238c3a 100644 --- a/apps/portal/src/components/atoms/skeleton.stories.tsx +++ b/apps/portal/src/components/atoms/skeleton.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Skeleton } from "./skeleton"; +import { Skeleton as LegacySkeleton } from "./skeleton.legacy"; const meta: Meta = { title: "Atoms/Skeleton", @@ -28,3 +29,36 @@ export const CommonPatterns: Story = { export const NoAnimation: Story = { args: { className: "h-4 w-48", animate: false }, }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+

Skeleton — Legacy vs shadcn/ui

+ +
+
+

Legacy

+
+ + + + + +
+
+
+

shadcn/ui

+
+ + + + + +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/view-toggle.stories.tsx b/apps/portal/src/components/atoms/view-toggle.stories.tsx index 782db064..0339b0f3 100644 --- a/apps/portal/src/components/atoms/view-toggle.stories.tsx +++ b/apps/portal/src/components/atoms/view-toggle.stories.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { ViewToggle, type ViewMode } from "./view-toggle"; +import { ViewToggle as LegacyViewToggle } from "./view-toggle.legacy"; const meta: Meta = { title: "Atoms/ViewToggle", @@ -23,3 +24,31 @@ export const ListView: Story = { return ; }, }; + +/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */ + +export const ComparisonWithLegacy: Story = { + render: function Render() { + const [legacyMode, setLegacyMode] = useState("grid"); + const [newMode, setNewMode] = useState("grid"); + + return ( +
+

ViewToggle — Legacy vs shadcn/ui

+ +
+
+

Legacy

+ +

Selected: {legacyMode}

+
+
+

shadcn/ui

+ +

Selected: {newMode}

+
+
+
+ ); + }, +}; diff --git a/apps/portal/src/components/fancy/text/text-rotate.tsx b/apps/portal/src/components/fancy/text/text-rotate.tsx new file mode 100644 index 00000000..a2d41683 --- /dev/null +++ b/apps/portal/src/components/fancy/text/text-rotate.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AnimatePresence, motion, type MotionProps, type Transition } from "framer-motion"; +import { cn } from "@/shared/utils"; + +interface TextRotateProps { + /** Array of strings to rotate through */ + texts: string[]; + /** Interval in ms between rotations */ + rotationInterval?: number; + /** Class for the outer wrapper that contains the AnimatePresence */ + mainClassName?: string; + /** Class applied to each character-level split wrapper */ + splitLevelClassName?: string; + /** Stagger delay between characters (seconds) */ + staggerDuration?: number; + /** Which end to stagger from */ + staggerFrom?: "first" | "last" | "center"; + /** Motion initial state */ + initial?: MotionProps["initial"]; + /** Motion animate state */ + animate?: MotionProps["animate"]; + /** Motion exit state */ + exit?: MotionProps["exit"]; + /** Spring / tween transition config */ + transition?: Transition; +} + +export default function TextRotate({ + texts, + rotationInterval = 2000, + mainClassName, + splitLevelClassName, + staggerDuration = 0.025, + staggerFrom = "first", + initial = { y: "100%" }, + animate = { y: 0 }, + exit = { y: "-120%" }, + transition = { type: "spring", damping: 30, stiffness: 400 }, +}: TextRotateProps) { + const [index, setIndex] = useState(0); + + useEffect(() => { + const id = setInterval(() => setIndex(prev => (prev + 1) % texts.length), rotationInterval); + return () => clearInterval(id); + }, [texts.length, rotationInterval]); + + const getStaggerDelay = useCallback( + (i: number, total: number) => { + switch (staggerFrom) { + case "last": + return (total - 1 - i) * staggerDuration; + case "center": { + const center = Math.floor(total / 2); + return Math.abs(i - center) * staggerDuration; + } + default: + return i * staggerDuration; + } + }, + [staggerFrom, staggerDuration] + ); + + const chars = texts[index]!.split(""); + + return ( + + + {chars.map((char, i) => ( + + + {char === " " ? "\u00A0" : char} + + + ))} + + + ); +} diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.legacy.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.legacy.tsx new file mode 100644 index 00000000..0aa2125e --- /dev/null +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.legacy.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React from "react"; +import { + CheckCircleIcon, + InformationCircleIcon, + ExclamationTriangleIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; + +type Variant = "success" | "info" | "warning" | "error"; +type IconType = React.ComponentType>; + +const variantClasses: Record< + Variant, + { bg: string; border: string; text: string; icon: string; Icon: IconType } +> = { + success: { + bg: "bg-success-soft", + border: "border-success/30", + text: "text-success", + icon: "text-success", + Icon: CheckCircleIcon, + }, + info: { + bg: "bg-info-soft", + border: "border-info/30", + text: "text-info", + icon: "text-info", + Icon: InformationCircleIcon, + }, + warning: { + bg: "bg-warning-soft", + border: "border-warning/35", + text: "text-foreground", + icon: "text-warning", + Icon: ExclamationTriangleIcon, + }, + error: { + bg: "bg-danger-soft", + border: "border-danger/30", + text: "text-danger", + icon: "text-danger", + Icon: XCircleIcon, + }, +}; + +interface AlertBannerProps extends React.HTMLAttributes { + variant?: Variant; + title?: string; + children?: React.ReactNode; + icon?: React.ReactNode; + size?: "sm" | "md"; + elevated?: boolean; + onClose?: () => void; +} + +export function AlertBanner({ + variant = "info", + title, + children, + icon, + size = "md", + elevated = false, + onClose, + className, + ...rest +}: AlertBannerProps) { + const styles = variantClasses[variant]; + const Icon = styles.Icon; + const padding = size === "sm" ? "p-3" : "p-4"; + const radius = "rounded-xl"; + const shadow = elevated ? "shadow-sm" : ""; + const role = variant === "error" || variant === "warning" ? "alert" : "status"; + + return ( +
+
+
+ {icon ? icon : } +
+
+ {title &&

{title}

} + {children && ( +
{children}
+ )} +
+ {onClose && ( + + )} +
+
+ ); +} + +export type { AlertBannerProps }; diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx index 58702d67..b48c70ba 100644 --- a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AlertBanner } from "./AlertBanner"; +import { AlertBanner as AlertBannerLegacy } from "./AlertBanner.legacy"; const meta: Meta = { title: "Molecules/AlertBanner", @@ -77,3 +78,61 @@ export const Closable: Story = { export const Small: Story = { args: { variant: "warning", title: "Heads up", size: "sm" }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+ {(["info", "success", "warning", "error"] as const).map(variant => ( +
+

{variant}

+
+
+

New (shadcn Alert)

+ + This is the {variant} message body. + +
+
+

Legacy

+ + This is the {variant} message body. + +
+
+
+ ))} + +
+

closable

+
+
+

New (shadcn Alert)

+ {}}> + Click the X to close. + +
+
+

Legacy

+ {}}> + Click the X to close. + +
+
+
+ +
+

small + elevated

+
+
+

New (shadcn Alert)

+ +
+
+

Legacy

+ +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx index 0aa2125e..735e3765 100644 --- a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.tsx @@ -7,11 +7,13 @@ import { ExclamationTriangleIcon, XCircleIcon, } from "@heroicons/react/24/outline"; +import { cn } from "@/shared/utils"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; type Variant = "success" | "info" | "warning" | "error"; type IconType = React.ComponentType>; -const variantClasses: Record< +const variantStyles: Record< Variant, { bg: string; border: string; text: string; icon: string; Icon: IconType } > = { @@ -66,42 +68,39 @@ export function AlertBanner({ className, ...rest }: AlertBannerProps) { - const styles = variantClasses[variant]; + const styles = variantStyles[variant]; const Icon = styles.Icon; - const padding = size === "sm" ? "p-3" : "p-4"; - const radius = "rounded-xl"; - const shadow = elevated ? "shadow-sm" : ""; - const role = variant === "error" || variant === "warning" ? "alert" : "status"; + const padding = size === "sm" ? "px-3 py-2" : "px-4 py-3"; return ( -
-
-
- {icon ? icon : } -
-
- {title &&

{title}

} - {children && ( -
{children}
- )} -
- {onClose && ( - - )} -
-
+ {icon ? ( + {icon} + ) : ( + + )} + {title && {title}} + {children && {children}} + {onClose && ( + + )} + ); } diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx new file mode 100644 index 00000000..78603431 --- /dev/null +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.legacy.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ReactNode } from "react"; + +interface AnimatedCardProps { + children: ReactNode; + className?: string | undefined; + variant?: "default" | "highlighted" | "success" | "static" | undefined; + onClick?: (() => void) | undefined; + disabled?: boolean | undefined; +} + +const SHADOW_BASE = "0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06)"; +const SHADOW_ELEVATED = "0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.07)"; + +export function AnimatedCard({ + children, + className = "", + variant = "default", + onClick, + disabled = false, +}: AnimatedCardProps) { + const baseClasses = "bg-card text-card-foreground rounded-xl border"; + + const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = { + default: "border-border", + highlighted: "border-primary/35 ring-1 ring-primary/15", + success: "border-success/25 ring-1 ring-success/15", + static: "border-border", + }; + + const interactiveClasses = onClick && !disabled ? "cursor-pointer" : ""; + const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : ""; + + const isStatic = variant === "static" || disabled; + + return ( + + {children} + + ); +} + +export type { AnimatedCardProps }; diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx index 72e722a4..cc7aeac9 100644 --- a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AnimatedCard } from "./AnimatedCard"; +import { AnimatedCard as AnimatedCardLegacy } from "./AnimatedCard.legacy"; const meta: Meta = { title: "Molecules/AnimatedCard", @@ -52,3 +53,46 @@ export const Disabled: Story = { children:
Disabled card
, }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

+ Current (shadcn/ui Card classes) +

+
+ +
Default
+
+ +
Highlighted
+
+ +
Success
+
+ +
Static
+
+
+
+
+

Legacy

+
+ +
Default
+
+ +
Highlighted
+
+ +
Success
+
+ +
Static
+
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx index 78603431..741e3561 100644 --- a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.tsx @@ -2,6 +2,7 @@ import { motion } from "framer-motion"; import { ReactNode } from "react"; +import { cn } from "@/shared/utils"; interface AnimatedCardProps { children: ReactNode; @@ -21,8 +22,6 @@ export function AnimatedCard({ onClick, disabled = false, }: AnimatedCardProps) { - const baseClasses = "bg-card text-card-foreground rounded-xl border"; - const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = { default: "border-border", highlighted: "border-primary/35 ring-1 ring-primary/15", @@ -37,7 +36,13 @@ export function AnimatedCard({ return ( { + key: string; + header: string; + render: (item: T) => ReactNode; + className?: string; + /** If true, this column will be emphasized in mobile card view (shown first/larger) */ + primary?: boolean; + /** If true, this column will be hidden in mobile card view */ + hideOnMobile?: boolean; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + emptyState?: { + icon: ReactNode; + title: string; + description: string; + }; + onRowClick?: (item: T) => void; + className?: string; + /** Force table view even on mobile (not recommended for UX) */ + forceTableView?: boolean; +} + +function MobileCardView({ + data, + columns, + onRowClick, +}: { + data: T[]; + columns: Column[]; + onRowClick: ((item: T) => void) | undefined; +}) { + const primaryColumn = columns.find(col => col.primary); + const mobileColumns = columns.filter(col => !col.hideOnMobile && !col.primary); + + return ( +
+ {data.map((item, index) => ( +
onRowClick?.(item)} + role={onRowClick ? "button" : undefined} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={e => { + if (onRowClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onRowClick(item); + } + }} + style={{ animationDelay: `${index * 50}ms` }} + > +
+
+
+ {(primaryColumn ?? columns[0])?.render(item)} +
+
+ {onRowClick && ( + + )} +
+
+ {mobileColumns.map((column, colIndex) => { + if (!primaryColumn && colIndex === 0 && column === columns[0]) return null; + return ( +
+ + {column.header} + + + {column.render(item)} + +
+ ); + })} +
+
+ ))} +
+ ); +} + +function DesktopTableView({ + data, + columns, + onRowClick, + className = "", + forceTableView = false, +}: { + data: T[]; + columns: Column[]; + onRowClick: ((item: T) => void) | undefined; + className: string; + forceTableView: boolean; +}) { + return ( +
+ + + + {columns.map(column => ( + + ))} + + + + {data.map(item => ( + onRowClick?.(item)} + > + {columns.map(column => ( + + ))} + + ))} + +
+ {column.header} +
+ {column.render(item)} +
+
+ ); +} + +export function DataTable({ + data, + columns, + emptyState, + onRowClick, + className = "", + forceTableView = false, +}: DataTableProps) { + if (data.length === 0 && emptyState) { + return ( + + ); + } + + return ( + <> + {!forceTableView && } + + + ); +} + +export type { DataTableProps, Column }; diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx index b41befb6..2acd4074 100644 --- a/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx +++ b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { DataTable } from "./DataTable"; +import { DataTable as LegacyDataTable } from "./DataTable.legacy"; import { InboxIcon } from "@heroicons/react/24/outline"; interface SampleRow { @@ -80,3 +81,18 @@ export const Empty: Story = { }, }, }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

Current (shadcn/ui Table)

+ +
+
+

Legacy (custom table)

+ +
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.tsx index 3c1628d3..fde5db2f 100644 --- a/apps/portal/src/components/molecules/DataTable/DataTable.tsx +++ b/apps/portal/src/components/molecules/DataTable/DataTable.tsx @@ -1,5 +1,14 @@ import type { ReactNode } from "react"; import { EmptyState } from "@/components/atoms/empty-state"; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@/components/ui/table"; +import { cn } from "@/shared/utils"; import { ChevronRightIcon } from "@heroicons/react/24/outline"; interface Column { @@ -44,13 +53,13 @@ function MobileCardView({ {data.map((item, index) => (
onRowClick?.(item)} role={onRowClick ? "button" : undefined} tabIndex={onRowClick ? 0 : undefined} @@ -107,39 +116,39 @@ function DesktopTableView({ forceTableView: boolean; }) { return ( -
- - - +
+
+ + {columns.map(column => ( - + ))} - - - + + + {data.map(item => ( - onRowClick?.(item)} > {columns.map(column => ( - + ))} - + ))} - -
{column.header} -
+ {column.render(item)} -
+ +
); } diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx new file mode 100644 index 00000000..5fb34980 --- /dev/null +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.legacy.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { FunnelIcon } from "@heroicons/react/24/outline"; +import { cn } from "@/shared/utils"; + +export interface FilterOption { + value: string; + label: string; +} + +export interface FilterDropdownProps { + /** Current selected value */ + value: string; + /** Callback when value changes */ + onChange: (value: string) => void; + /** Array of filter options */ + options: FilterOption[]; + /** Accessible label for the dropdown */ + label: string; + /** Optional width class (default: "w-40") */ + width?: string; + /** Optional additional class names */ + className?: string; +} + +/** + * FilterDropdown (Legacy) - Reusable filter dropdown component with consistent styling. + * + * Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc. + */ +export function FilterDropdown({ + value, + onChange, + options, + label, + width = "w-40", + className, +}: FilterDropdownProps) { + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx index 4baca8ae..92faca9a 100644 --- a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { FilterDropdown } from "./FilterDropdown"; +import { FilterDropdown as FilterDropdownLegacy } from "./FilterDropdown.legacy"; const meta: Meta = { title: "Molecules/FilterDropdown", @@ -10,6 +11,20 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const statusOptions = [ + { value: "all", label: "All Statuses" }, + { value: "active", label: "Active" }, + { value: "pending", label: "Pending" }, + { value: "cancelled", label: "Cancelled" }, +]; + +const categoryOptions = [ + { value: "all", label: "All Categories" }, + { value: "billing", label: "Billing" }, + { value: "technical", label: "Technical" }, + { value: "general", label: "General" }, +]; + export const Default: Story = { render: function Render() { const [value, setValue] = useState("all"); @@ -17,12 +32,7 @@ export const Default: Story = { ); @@ -36,15 +46,39 @@ export const CustomWidth: Story = { ); }, }; + +export const ComparisonWithLegacy: Story = { + render: function Render() { + const [value, setValue] = useState("all"); + const [legacyValue, setLegacyValue] = useState("all"); + return ( +
+
+ shadcn/ui (new) + +
+
+ Legacy + +
+
+ ); + }, +}; diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx index a7c47c5b..2ec8932a 100644 --- a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.tsx @@ -2,6 +2,13 @@ import { FunnelIcon } from "@heroicons/react/24/outline"; import { cn } from "@/shared/utils"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; export interface FilterOption { value: string; @@ -24,7 +31,7 @@ export interface FilterDropdownProps { } /** - * FilterDropdown - Reusable filter dropdown component with consistent styling. + * FilterDropdown - Reusable filter dropdown component built on shadcn/ui Select. * * Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc. */ @@ -37,28 +44,18 @@ export function FilterDropdown({ className, }: FilterDropdownProps) { return ( -
- + + + + + {options.map(option => ( - + ))} - -
- -
-
+ + ); } diff --git a/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx b/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx new file mode 100644 index 00000000..85c98761 --- /dev/null +++ b/apps/portal/src/components/molecules/FormField/FormField.legacy.tsx @@ -0,0 +1,109 @@ +import { forwardRef, cloneElement, isValidElement, useId } from "react"; +import { cn } from "@/shared/utils"; +import { Label, type LabelProps } from "@/components/atoms/label"; +import { Input, type InputProps } from "@/components/atoms/input"; +import { ErrorMessage } from "@/components/atoms/error-message"; + +interface FormFieldProps extends Omit< + InputProps, + "id" | "aria-describedby" | "aria-invalid" | "children" | "dangerouslySetInnerHTML" +> { + label?: string | undefined; + error?: string | undefined; + helperText?: string | undefined; + required?: boolean | undefined; + labelProps?: Omit | undefined; + fieldId?: string | undefined; + children?: React.ReactNode | undefined; + containerClassName?: string | undefined; + inputClassName?: string | undefined; +} + +const FormField = forwardRef( + ( + { + label, + error, + helperText, + required, + labelProps, + fieldId, + containerClassName, + inputClassName, + children, + ...inputProps + }, + ref + ) => { + const generatedId = useId(); + const id = fieldId || generatedId; + const errorId = error ? `${id}-error` : undefined; + const helperTextId = helperText ? `${id}-helper` : undefined; + const describedBy = cn(errorId, helperTextId) || undefined; + + const { className: inputPropsClassName, ...restInputProps } = inputProps; + + const renderInput = () => { + if (!children) { + return ( + + ); + } + + if (isValidElement(children)) { + return cloneElement(children, { + id, + "aria-invalid": error ? "true" : undefined, + "aria-describedby": describedBy, + } as Record); + } + + return children; + }; + + return ( +
+ {label && ( + + )} + {renderInput()} + {error && {error}} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); + } +); +FormField.displayName = "FormField"; + +export { FormField }; +export type { FormFieldProps }; diff --git a/apps/portal/src/components/molecules/FormField/FormField.stories.tsx b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx index 2ff82b8e..64b26df1 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.stories.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { FormField } from "./FormField"; +import { FormField as FormFieldLegacy } from "./FormField.legacy"; const meta: Meta = { title: "Molecules/FormField", @@ -41,3 +42,71 @@ export const FormExample: Story = {
), }; + +export const ComparisonWithLegacy: Story = { + render: () => ( +
+
+

default

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+ +
+

with error

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+ +
+

required with helper

+
+
+

New (destructive tokens)

+ +
+
+

Legacy (danger tokens)

+ +
+
+
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/FormField/FormField.tsx b/apps/portal/src/components/molecules/FormField/FormField.tsx index 85c98761..371c2d70 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.tsx @@ -52,7 +52,8 @@ const FormField = forwardRef( aria-invalid={error ? "true" : undefined} aria-describedby={describedBy} className={cn( - error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2", + error && + "border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2", inputClassName, inputPropsClassName )} @@ -79,14 +80,14 @@ const FormField = forwardRef( htmlFor={id} className={cn( "block text-sm font-medium text-muted-foreground", - error && "text-danger", + error && "text-destructive", labelProps?.className )} {...(labelProps ? { ...labelProps, className: undefined } : undefined)} > {label} {required && ( -