refactor: complete shadcn/ui migration and unify raw HTML with component library
Some checks failed
Pull Request Checks / Code Quality & Security (push) Has been cancelled
Security Audit / Security Vulnerability Audit (push) Has been cancelled
Security Audit / Dependency Review (push) Has been cancelled
Security Audit / CodeQL Security Analysis (push) Has been cancelled
Security Audit / Check Outdated Dependencies (push) Has been cancelled

- Migrate all molecule components (DataTable, PaginationBar, FilterDropdown,
  AlertBanner, FormField, SectionCard, SubCard, MetricCard, AnimatedCard,
  OtpInput) to shadcn/ui primitives with legacy backups and comparison stories
- Install 24 shadcn/ui primitives (accordion, alert, badge, button, card,
  checkbox, collapsible, dialog, dropdown-menu, input-otp, input, label,
  pagination, popover, radio-group, select, separator, sheet, skeleton,
  table, tabs, toggle-group, toggle, tooltip) with barrel exports
- Replace 69 raw HTML elements across all features with shadcn components:
  35+ <button> → Button, 5 <select> → Select, 15+ <label> → Label,
  6 <input type=checkbox> → Checkbox, 7 <input type=radio> → RadioGroup
- Add TextRotate animation component and integrate into hero section
  with rotating service names (Internet, Phone Plans, VPN, IT Support, Business)
- Add destructive color token aliases for error state consistency
- Add CLAUDE.md rules for shadcn migration process

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-03-09 17:21:36 +09:00
parent c8d0dfe230
commit d7efede122
108 changed files with 4304 additions and 597 deletions

View File

@ -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 <component>`)
2. Create an enhanced atom wrapper in `components/atoms/` that preserves the existing public API
3. Save the old implementation as `<component>.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 |

View File

@ -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: [] };

View File

@ -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": {

View File

@ -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);

View File

@ -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<typeof Badge> = {
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: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Badge Legacy vs shadcn/ui</h2>
{/* Variants */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-wrap gap-2">
<LegacyBadge variant="default">Default</LegacyBadge>
<LegacyBadge variant="secondary">Secondary</LegacyBadge>
<LegacyBadge variant="success">Success</LegacyBadge>
<LegacyBadge variant="warning">Warning</LegacyBadge>
<LegacyBadge variant="error">Error</LegacyBadge>
<LegacyBadge variant="info">Info</LegacyBadge>
<LegacyBadge variant="outline">Outline</LegacyBadge>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-wrap gap-2">
<Badge variant="default">Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="outline">Outline</Badge>
</div>
</div>
</div>
{/* Sizes */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Sizes</h3>
<div className="flex gap-2 items-center">
<LegacyBadge size="sm">Small</LegacyBadge>
<LegacyBadge size="default">Default</LegacyBadge>
<LegacyBadge size="lg">Large</LegacyBadge>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Sizes</h3>
<div className="flex gap-2 items-center">
<Badge size="sm">Small</Badge>
<Badge size="default">Default</Badge>
<Badge size="lg">Large</Badge>
</div>
</div>
</div>
{/* With Dot */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Dot</h3>
<div className="flex gap-2">
<LegacyBadge variant="success" dot>
Active
</LegacyBadge>
<LegacyBadge variant="error" dot>
Failed
</LegacyBadge>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Dot</h3>
<div className="flex gap-2">
<Badge variant="success" dot>
Active
</Badge>
<Badge variant="error" dot>
Failed
</Badge>
</div>
</div>
</div>
{/* Removable */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Removable</h3>
<LegacyBadge removable onRemove={() => {}}>
Remove me
</LegacyBadge>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
shadcn/ui Removable
</h3>
<Badge removable onRemove={() => {}}>
Remove me
</Badge>
</div>
</div>
</div>
),
};

View File

@ -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<typeof Checkbox> = {
title: "Atoms/Checkbox",
@ -37,3 +38,36 @@ export const AllStates: Story = {
</div>
),
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Checkbox Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-4">
<LegacyCheckbox label="Default" />
<LegacyCheckbox label="Checked" defaultChecked />
<LegacyCheckbox label="With helper" helperText="Additional info" />
<LegacyCheckbox label="With error" error="Required field" />
<LegacyCheckbox label="Disabled" disabled />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-4">
<Checkbox label="Default" />
<Checkbox label="Checked" defaultChecked />
<Checkbox label="With helper" helperText="Additional info" />
<Checkbox label="With error" error="Required field" />
<Checkbox label="Disabled" disabled />
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof ErrorMessage> = {
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: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">ErrorMessage Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-3">
<LegacyErrorMessage variant="default">Default error</LegacyErrorMessage>
<LegacyErrorMessage variant="inline">Inline error</LegacyErrorMessage>
<LegacyErrorMessage variant="subtle">Subtle error</LegacyErrorMessage>
<LegacyErrorMessage showIcon={false}>Without icon</LegacyErrorMessage>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-3">
<ErrorMessage variant="default">Default error</ErrorMessage>
<ErrorMessage variant="inline">Inline error</ErrorMessage>
<ErrorMessage variant="subtle">Subtle error</ErrorMessage>
<ErrorMessage showIcon={false}>Without icon</ErrorMessage>
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof ErrorState> = {
title: "Atoms/ErrorState",
@ -46,3 +47,47 @@ export const AllVariants: Story = {
</div>
),
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">ErrorState Legacy vs shadcn/ui</h2>
{/* Card variant */}
<div className="grid grid-cols-2 gap-8">
<div className="max-w-sm">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Card</h3>
<LegacyErrorState
variant="card"
title="Something went wrong"
message="An error occurred."
onRetry={() => {}}
/>
</div>
<div className="max-w-sm">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Card</h3>
<ErrorState
variant="card"
title="Something went wrong"
message="An error occurred."
onRetry={() => {}}
/>
</div>
</div>
{/* Inline variant */}
<div className="grid grid-cols-2 gap-8">
<div className="max-w-sm">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Inline</h3>
<LegacyErrorState variant="inline" title="Inline error" message="Something failed." />
</div>
<div className="max-w-sm">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Inline</h3>
<ErrorState variant="inline" title="Inline error" message="Something failed." />
</div>
</div>
</div>
),
};

View File

@ -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<typeof InlineToast> = {
title: "Atoms/InlineToast",
@ -34,3 +35,34 @@ export const AllTones: Story = {
</div>
),
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">InlineToast Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-3">
<LegacyInlineToast visible text="Info" tone="info" className="!fixed !relative" />
<LegacyInlineToast visible text="Success" tone="success" className="!fixed !relative" />
<LegacyInlineToast visible text="Warning" tone="warning" className="!fixed !relative" />
<LegacyInlineToast visible text="Error" tone="error" className="!fixed !relative" />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-3">
<InlineToast visible text="Info" tone="info" className="!fixed !relative" />
<InlineToast visible text="Success" tone="success" className="!fixed !relative" />
<InlineToast visible text="Warning" tone="warning" className="!fixed !relative" />
<InlineToast visible text="Error" tone="error" className="!fixed !relative" />
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof Input> = {
title: "Atoms/Input",
@ -35,3 +36,34 @@ export const AllStates: Story = {
</div>
),
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Input Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div className="w-80">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-4">
<LegacyInput placeholder="Default" />
<LegacyInput placeholder="With value" defaultValue="Some text" />
<LegacyInput placeholder="Error state" error="Required" />
<LegacyInput placeholder="Disabled" disabled />
</div>
</div>
<div className="w-80">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-4">
<Input placeholder="Default" />
<Input placeholder="With value" defaultValue="Some text" />
<Input placeholder="Error state" error="Required" />
<Input placeholder="Disabled" disabled />
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof Label> = {
title: "Atoms/Label",
@ -20,3 +21,34 @@ export const Required: Story = {
</Label>
),
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Label Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-3">
<LegacyLabel>Default label</LegacyLabel>
<LegacyLabel>
Required <span className="text-danger">*</span>
</LegacyLabel>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-3">
<Label>Default label</Label>
<Label>
Required <span className="text-danger">*</span>
</Label>
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof Skeleton> = {
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: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Skeleton Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div className="w-80">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-col gap-4">
<LegacySkeleton className="h-4 w-3/4" />
<LegacySkeleton className="h-4 w-full" />
<LegacySkeleton className="h-4 w-1/2" />
<LegacySkeleton className="h-10 w-10 rounded-full" />
<LegacySkeleton className="h-32 w-full rounded-xl" />
</div>
</div>
<div className="w-80">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-col gap-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-10 w-10 rounded-full" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</div>
</div>
</div>
),
};

View File

@ -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<typeof ViewToggle> = {
title: "Atoms/ViewToggle",
@ -23,3 +24,31 @@ export const ListView: Story = {
return <ViewToggle value={mode} onChange={setMode} />;
},
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: function Render() {
const [legacyMode, setLegacyMode] = useState<ViewMode>("grid");
const [newMode, setNewMode] = useState<ViewMode>("grid");
return (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">ViewToggle Legacy vs shadcn/ui</h2>
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<LegacyViewToggle value={legacyMode} onChange={setLegacyMode} />
<p className="text-xs text-muted-foreground mt-2">Selected: {legacyMode}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<ViewToggle value={newMode} onChange={setNewMode} />
<p className="text-xs text-muted-foreground mt-2">Selected: {newMode}</p>
</div>
</div>
</div>
);
},
};

View File

@ -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 (
<AnimatePresence mode="wait">
<motion.span key={texts[index]} className={cn("inline-flex", mainClassName)}>
{chars.map((char, i) => (
<span key={`${texts[index]}-${i}`} className={cn("inline-block", splitLevelClassName)}>
<motion.span
className="inline-block"
initial={initial}
animate={animate}
exit={exit}
transition={{
...transition,
delay: getStaggerDelay(i, chars.length),
}}
>
{char === " " ? "\u00A0" : char}
</motion.span>
</span>
))}
</motion.span>
</AnimatePresence>
);
}

View File

@ -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<React.SVGProps<SVGSVGElement>>;
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<HTMLDivElement> {
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 (
<div
className={[radius, padding, "border", shadow, styles.bg, styles.border, className]
.filter(Boolean)
.join(" ")}
role={role}
{...rest}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
{icon ? icon : <Icon className={["h-5 w-5", styles.icon].join(" ")} />}
</div>
<div className="flex-1">
{title && <p className={["font-medium", styles.text].join(" ")}>{title}</p>}
{children && (
<div className={["text-sm mt-1 text-foreground/80"].join(" ")}>{children}</div>
)}
</div>
{onClose && (
<button
onClick={onClose}
aria-label="Close alert"
className="text-muted-foreground hover:text-foreground transition-colors"
>
×
</button>
)}
</div>
</div>
);
}
export type { AlertBannerProps };

View File

@ -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<typeof AlertBanner> = {
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: () => (
<div className="flex flex-col gap-8 w-[500px]">
{(["info", "success", "warning", "error"] as const).map(variant => (
<div key={variant} className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">{variant}</p>
<div className="flex flex-col gap-2">
<div>
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
<AlertBanner variant={variant} title={`${variant} title`}>
This is the {variant} message body.
</AlertBanner>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
<AlertBannerLegacy variant={variant} title={`${variant} title`}>
This is the {variant} message body.
</AlertBannerLegacy>
</div>
</div>
</div>
))}
<div className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">closable</p>
<div className="flex flex-col gap-2">
<div>
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
<AlertBanner variant="info" title="Dismissible" onClose={() => {}}>
Click the X to close.
</AlertBanner>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
<AlertBannerLegacy variant="info" title="Dismissible" onClose={() => {}}>
Click the X to close.
</AlertBannerLegacy>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">small + elevated</p>
<div className="flex flex-col gap-2">
<div>
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
<AlertBanner variant="warning" title="Heads up" size="sm" elevated />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
<AlertBannerLegacy variant="warning" title="Heads up" size="sm" elevated />
</div>
</div>
</div>
</div>
),
};

View File

@ -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<React.SVGProps<SVGSVGElement>>;
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 (
<div
className={[radius, padding, "border", shadow, styles.bg, styles.border, className]
.filter(Boolean)
.join(" ")}
role={role}
<Alert
className={cn(
"rounded-xl",
padding,
styles.bg,
styles.border,
elevated && "shadow-sm",
className
)}
{...rest}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
{icon ? icon : <Icon className={["h-5 w-5", styles.icon].join(" ")} />}
</div>
<div className="flex-1">
{title && <p className={["font-medium", styles.text].join(" ")}>{title}</p>}
{children && (
<div className={["text-sm mt-1 text-foreground/80"].join(" ")}>{children}</div>
{icon ? (
<span className={cn("size-5", styles.icon)}>{icon}</span>
) : (
<Icon className={cn("size-5", styles.icon)} />
)}
</div>
{title && <AlertTitle className={cn("font-medium", styles.text)}>{title}</AlertTitle>}
{children && <AlertDescription className="text-foreground/80">{children}</AlertDescription>}
{onClose && (
<button
onClick={onClose}
aria-label="Close alert"
className="text-muted-foreground hover:text-foreground transition-colors"
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
>
×
</button>
)}
</div>
</div>
</Alert>
);
}

View File

@ -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 (
<motion.div
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
initial={{ boxShadow: SHADOW_BASE }}
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
transition={{ duration: 0.2 }}
onClick={disabled ? undefined : onClick}
>
{children}
</motion.div>
);
}
export type { AnimatedCardProps };

View File

@ -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<typeof AnimatedCard> = {
title: "Molecules/AnimatedCard",
@ -52,3 +53,46 @@ export const Disabled: Story = {
children: <div className="p-6">Disabled card</div>,
},
};
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="flex flex-col gap-8 w-[600px]">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Current (shadcn/ui Card classes)
</h3>
<div className="grid grid-cols-2 gap-4">
<AnimatedCard variant="default">
<div className="p-6">Default</div>
</AnimatedCard>
<AnimatedCard variant="highlighted">
<div className="p-6">Highlighted</div>
</AnimatedCard>
<AnimatedCard variant="success">
<div className="p-6">Success</div>
</AnimatedCard>
<AnimatedCard variant="static">
<div className="p-6">Static</div>
</AnimatedCard>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
<div className="grid grid-cols-2 gap-4">
<AnimatedCardLegacy variant="default">
<div className="p-6">Default</div>
</AnimatedCardLegacy>
<AnimatedCardLegacy variant="highlighted">
<div className="p-6">Highlighted</div>
</AnimatedCardLegacy>
<AnimatedCardLegacy variant="success">
<div className="p-6">Success</div>
</AnimatedCardLegacy>
<AnimatedCardLegacy variant="static">
<div className="p-6">Static</div>
</AnimatedCardLegacy>
</div>
</div>
</div>
),
};

View File

@ -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 (
<motion.div
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
variantClasses[variant],
interactiveClasses,
disabledClasses,
className
)}
initial={{ boxShadow: SHADOW_BASE }}
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
transition={{ duration: 0.2 }}

View File

@ -0,0 +1,180 @@
import type { ReactNode } from "react";
import { EmptyState } from "@/components/atoms/empty-state";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
interface Column<T> {
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<T> {
data: T[];
columns: Column<T>[];
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<T extends { id: number | string }>({
data,
columns,
onRowClick,
}: {
data: T[];
columns: Column<T>[];
onRowClick: ((item: T) => void) | undefined;
}) {
const primaryColumn = columns.find(col => col.primary);
const mobileColumns = columns.filter(col => !col.hideOnMobile && !col.primary);
return (
<div className="md:hidden space-y-3">
{data.map((item, index) => (
<div
key={item.id}
className={`
bg-card border border-border rounded-xl p-4
shadow-[var(--cp-shadow-1)]
transition-all duration-[var(--cp-duration-fast)]
active:scale-[0.98] active:shadow-none
${onRowClick ? "cursor-pointer active:bg-muted/50" : ""}
`}
onClick={() => 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` }}
>
<div className="flex items-center justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
<div className="font-semibold text-foreground">
{(primaryColumn ?? columns[0])?.render(item)}
</div>
</div>
{onRowClick && (
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/50 flex-shrink-0" />
)}
</div>
<div className="space-y-2">
{mobileColumns.map((column, colIndex) => {
if (!primaryColumn && colIndex === 0 && column === columns[0]) return null;
return (
<div key={column.key} className="flex items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground font-medium flex-shrink-0">
{column.header}
</span>
<span className="text-foreground text-right min-w-0 truncate">
{column.render(item)}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
function DesktopTableView<T extends { id: number | string }>({
data,
columns,
onRowClick,
className = "",
forceTableView = false,
}: {
data: T[];
columns: Column<T>[];
onRowClick: ((item: T) => void) | undefined;
className: string;
forceTableView: boolean;
}) {
return (
<div className={`${forceTableView ? "" : "hidden md:block"} overflow-x-auto`}>
<table className={`min-w-full divide-y divide-border ${className}`}>
<thead className="bg-muted/50">
<tr>
{columns.map(column => (
<th
key={column.key}
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider ${column.className || ""}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{data.map(item => (
<tr
key={item.id}
className={`hover:bg-muted/30 transition-colors duration-[var(--cp-transition-fast)] ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map(column => (
<td
key={column.key}
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`}
>
{column.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function DataTable<T extends { id: number | string }>({
data,
columns,
emptyState,
onRowClick,
className = "",
forceTableView = false,
}: DataTableProps<T>) {
if (data.length === 0 && emptyState) {
return (
<EmptyState
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
variant="compact"
/>
);
}
return (
<>
{!forceTableView && <MobileCardView data={data} columns={columns} onRowClick={onRowClick} />}
<DesktopTableView
data={data}
columns={columns}
onRowClick={onRowClick}
className={className}
forceTableView={forceTableView}
/>
</>
);
}
export type { DataTableProps, Column };

View File

@ -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: () => (
<div className="space-y-8">
<div>
<h3 className="text-lg font-semibold mb-2">Current (shadcn/ui Table)</h3>
<DataTable data={sampleData} columns={columns} forceTableView />
</div>
<div>
<h3 className="text-lg font-semibold mb-2">Legacy (custom table)</h3>
<LegacyDataTable data={sampleData} columns={columns} forceTableView />
</div>
</div>
),
};

View File

@ -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<T> {
@ -44,13 +53,13 @@ function MobileCardView<T extends { id: number | string }>({
{data.map((item, index) => (
<div
key={item.id}
className={`
bg-card border border-border rounded-xl p-4
shadow-[var(--cp-shadow-1)]
transition-all duration-[var(--cp-duration-fast)]
active:scale-[0.98] active:shadow-none
${onRowClick ? "cursor-pointer active:bg-muted/50" : ""}
`}
className={cn(
"bg-card border border-border rounded-xl p-4",
"shadow-[var(--cp-shadow-1)]",
"transition-all duration-[var(--cp-duration-fast)]",
"active:scale-[0.98] active:shadow-none",
onRowClick && "cursor-pointer active:bg-muted/50"
)}
onClick={() => onRowClick?.(item)}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
@ -107,39 +116,39 @@ function DesktopTableView<T extends { id: number | string }>({
forceTableView: boolean;
}) {
return (
<div className={`${forceTableView ? "" : "hidden md:block"} overflow-x-auto`}>
<table className={`min-w-full divide-y divide-border ${className}`}>
<thead className="bg-muted/50">
<tr>
<div className={cn(!forceTableView && "hidden md:block")}>
<Table className={className}>
<TableHeader>
<TableRow>
{columns.map(column => (
<th
<TableHead
key={column.key}
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider ${column.className || ""}`}
className={cn(
"text-xs uppercase tracking-wider text-muted-foreground",
column.className
)}
>
{column.header}
</th>
</TableHead>
))}
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
</TableRow>
</TableHeader>
<TableBody>
{data.map(item => (
<tr
<TableRow
key={item.id}
className={`hover:bg-muted/30 transition-colors duration-[var(--cp-transition-fast)] ${onRowClick ? "cursor-pointer" : ""}`}
className={cn(onRowClick && "cursor-pointer")}
onClick={() => onRowClick?.(item)}
>
{columns.map(column => (
<td
key={column.key}
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`}
>
<TableCell key={column.key} className={column.className}>
{column.render(item)}
</td>
</TableCell>
))}
</tr>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}

View File

@ -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 (
<div className={cn("relative", className)}>
<select
value={value}
onChange={event => onChange(event.target.value)}
className={cn(
"block pl-3 pr-8 py-2.5 text-sm border border-border",
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary",
"rounded-lg appearance-none bg-card text-foreground",
"shadow-sm cursor-pointer transition-colors",
width
)}
aria-label={label}
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
);
}

View File

@ -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<typeof FilterDropdown> = {
title: "Molecules/FilterDropdown",
@ -10,6 +11,20 @@ const meta: Meta<typeof FilterDropdown> = {
export default meta;
type Story = StoryObj<typeof FilterDropdown>;
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 = {
<FilterDropdown
value={value}
onChange={setValue}
options={[
{ value: "all", label: "All Statuses" },
{ value: "active", label: "Active" },
{ value: "pending", label: "Pending" },
{ value: "cancelled", label: "Cancelled" },
]}
options={statusOptions}
label="Filter by status"
/>
);
@ -36,15 +46,39 @@ export const CustomWidth: Story = {
<FilterDropdown
value={value}
onChange={setValue}
options={[
{ value: "all", label: "All Categories" },
{ value: "billing", label: "Billing" },
{ value: "technical", label: "Technical" },
{ value: "general", label: "General" },
]}
options={categoryOptions}
label="Filter by category"
width="w-48"
/>
);
},
};
export const ComparisonWithLegacy: Story = {
render: function Render() {
const [value, setValue] = useState("all");
const [legacyValue, setLegacyValue] = useState("all");
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">shadcn/ui (new)</span>
<FilterDropdown
value={value}
onChange={setValue}
options={statusOptions}
label="Filter by status"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Legacy</span>
<FilterDropdownLegacy
value={legacyValue}
onChange={setLegacyValue}
options={statusOptions}
label="Filter by status"
/>
</div>
</div>
);
},
};

View File

@ -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 (
<div className={cn("relative", className)}>
<select
value={value}
onChange={event => onChange(event.target.value)}
className={cn(
"block pl-3 pr-8 py-2.5 text-sm border border-border",
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary",
"rounded-lg appearance-none bg-card text-foreground",
"shadow-sm cursor-pointer transition-colors",
width
)}
aria-label={label}
>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={cn(width, className)} aria-label={label}>
<FunnelIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<option key={option.value} value={option.value}>
<SelectItem key={option.value} value={option.value}>
{option.label}
</option>
</SelectItem>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</SelectContent>
</Select>
);
}

View File

@ -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<LabelProps, "htmlFor"> | undefined;
fieldId?: string | undefined;
children?: React.ReactNode | undefined;
containerClassName?: string | undefined;
inputClassName?: string | undefined;
}
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
(
{
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 (
<Input
id={id}
ref={ref}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy}
className={cn(
error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2",
inputClassName,
inputPropsClassName
)}
{...restInputProps}
/>
);
}
if (isValidElement(children)) {
return cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": describedBy,
} as Record<string, unknown>);
}
return children;
};
return (
<div className={cn("space-y-1", containerClassName)}>
{label && (
<Label
htmlFor={id}
className={cn(
"block text-sm font-medium text-muted-foreground",
error && "text-danger",
labelProps?.className
)}
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
>
<span>{label}</span>
{required && (
<span aria-hidden="true" className="ml-1 text-danger">
*
</span>
)}
</Label>
)}
{renderInput()}
{error && <ErrorMessage id={errorId}>{error}</ErrorMessage>}
{helperText && !error && (
<p id={helperTextId} className="text-sm text-muted-foreground">
{helperText}
</p>
)}
</div>
);
}
);
FormField.displayName = "FormField";
export { FormField };
export type { FormFieldProps };

View File

@ -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<typeof FormField> = {
title: "Molecules/FormField",
@ -41,3 +42,71 @@ export const FormExample: Story = {
</div>
),
};
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="flex flex-col gap-8 w-80">
<div className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">default</p>
<div className="flex flex-col gap-4">
<div>
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
<FormField label="Email" placeholder="you@example.com" />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
<FormFieldLegacy label="Email" placeholder="you@example.com" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">with error</p>
<div className="flex flex-col gap-4">
<div>
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
<FormField
label="Email"
placeholder="you@example.com"
error="Invalid email address"
required
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
<FormFieldLegacy
label="Email"
placeholder="you@example.com"
error="Invalid email address"
required
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-mono text-muted-foreground uppercase">required with helper</p>
<div className="flex flex-col gap-4">
<div>
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
<FormField
label="Phone"
placeholder="+81 90-1234-5678"
helperText="Include country code"
required
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
<FormFieldLegacy
label="Phone"
placeholder="+81 90-1234-5678"
helperText="Include country code"
required
/>
</div>
</div>
</div>
</div>
),
};

View File

@ -52,7 +52,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
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<HTMLInputElement, FormFieldProps>(
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)}
>
<span>{label}</span>
{required && (
<span aria-hidden="true" className="ml-1 text-danger">
<span aria-hidden="true" className="ml-1 text-destructive">
*
</span>
)}

View File

@ -0,0 +1,89 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
const toneStyles: Record<MetricTone, { icon: string; accent: string }> = {
primary: { icon: "text-primary bg-primary/10", accent: "text-primary" },
success: { icon: "text-success bg-success/10", accent: "text-success" },
warning: { icon: "text-warning bg-warning/10", accent: "text-warning" },
danger: { icon: "text-danger bg-danger/10", accent: "text-danger" },
info: { icon: "text-info bg-info/10", accent: "text-info" },
neutral: { icon: "text-muted-foreground bg-muted", accent: "text-muted-foreground" },
};
export interface MetricCardProps {
icon?: ReactNode;
label: string;
value: string | number;
subtitle?: string;
tone?: MetricTone;
trend?: { value: string; positive?: boolean };
className?: string;
}
export function MetricCard({
icon,
label,
value,
subtitle,
tone = "primary",
trend,
className,
}: MetricCardProps) {
const styles = toneStyles[tone];
return (
<div
className={cn(
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
"transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
className
)}
>
{icon && (
<div
className={cn(
"flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
styles.icon
)}
>
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-2 mt-0.5">
<p className="text-2xl font-bold text-foreground tabular-nums font-heading tracking-tight">
{value}
</p>
{trend && (
<span
className={cn("text-xs font-medium", trend.positive ? "text-success" : "text-danger")}
>
{trend.value}
</span>
)}
</div>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
);
}
export function MetricCardSkeleton({ className }: { className?: string }) {
return (
<div
className={cn(
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
className
)}
>
<div className="flex-shrink-0 h-10 w-10 rounded-lg cp-skeleton-shimmer" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 cp-skeleton-shimmer rounded w-16" />
<div className="h-7 cp-skeleton-shimmer rounded w-12" />
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MetricCard, MetricCardSkeleton } from "./MetricCard";
import { MetricCard as MetricCardLegacy } from "./MetricCard.legacy";
import {
CurrencyYenIcon,
UsersIcon,
@ -81,3 +82,46 @@ export const LoadingSkeleton: Story = {
</div>
),
};
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="flex flex-col gap-8 w-[600px]">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
<div className="grid grid-cols-2 gap-4">
<MetricCard
icon={<CurrencyYenIcon className="h-5 w-5" />}
label="Revenue"
value="¥1.2M"
tone="primary"
/>
<MetricCard
icon={<UsersIcon className="h-5 w-5" />}
label="Users"
value="2,847"
tone="success"
trend={{ value: "+5%", positive: true }}
/>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
<div className="grid grid-cols-2 gap-4">
<MetricCardLegacy
icon={<CurrencyYenIcon className="h-5 w-5" />}
label="Revenue"
value="¥1.2M"
tone="primary"
/>
<MetricCardLegacy
icon={<UsersIcon className="h-5 w-5" />}
label="Users"
value="2,847"
tone="success"
trend={{ value: "+5%", positive: true }}
/>
</div>
</div>
</div>
),
};

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
import { Card, CardContent } from "@/components/ui/card";
type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
@ -34,13 +35,14 @@ export function MetricCard({
const styles = toneStyles[tone];
return (
<div
<Card
className={cn(
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
"gap-0 py-0 border-border/60",
"transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
className
)}
>
<CardContent className="flex items-start gap-3.5 p-4">
{icon && (
<div
className={cn(
@ -59,7 +61,10 @@ export function MetricCard({
</p>
{trend && (
<span
className={cn("text-xs font-medium", trend.positive ? "text-success" : "text-danger")}
className={cn(
"text-xs font-medium",
trend.positive ? "text-success" : "text-danger"
)}
>
{trend.value}
</span>
@ -67,7 +72,8 @@ export function MetricCard({
</div>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,109 @@
/**
* Legacy OtpInput preserved for Storybook comparison.
* Uses the old InputOTPSlot styling (ring-2 ring-primary, bg-card, rounded-lg).
*/
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext, REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/shared/utils";
/* ── Inline legacy InputOTP primitives ──────────────────────────── */
function LegacyInputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
return (
<OTPInput
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function LegacyInputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex items-center", className)} {...props} />;
}
function LegacyInputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & { index: number }) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
return (
<div
className={cn(
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
"first:rounded-l-lg first:border-l last:rounded-r-lg",
"bg-card text-foreground",
isActive && "z-10 ring-2 ring-primary border-primary",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
}
/* ── Legacy OtpInput molecule ───────────────────────────────────── */
interface OtpInputProps {
length?: number;
value: string;
onChange: (value: string) => void;
onComplete?: (value: string) => void;
disabled?: boolean;
error?: string;
autoFocus?: boolean;
}
export function LegacyOtpInput({
length = 6,
value,
onChange,
onComplete,
disabled = false,
error,
autoFocus = true,
}: OtpInputProps) {
return (
<div className="space-y-2">
<div className="flex justify-center">
<LegacyInputOTP
maxLength={length}
value={value}
onChange={onChange}
{...(onComplete && { onComplete })}
disabled={disabled}
autoFocus={autoFocus}
pattern={REGEXP_ONLY_DIGITS}
containerClassName="justify-center"
>
<LegacyInputOTPGroup>
{Array.from({ length }, (_, i) => (
<LegacyInputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
))}
</LegacyInputOTPGroup>
</LegacyInputOTP>
</div>
{error && (
<p className="text-sm text-danger text-center" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { OtpInput } from "./OtpInput";
import { LegacyOtpInput } from "./OtpInput.legacy";
const meta: Meta<typeof OtpInput> = {
title: "Molecules/OtpInput",
@ -44,3 +45,70 @@ export const FourDigit: Story = {
return <OtpInput value={value} onChange={setValue} length={4} autoFocus={false} />;
},
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: function Render() {
const [legacyValue, setLegacyValue] = useState("123");
const [newValue, setNewValue] = useState("123");
const [legacyErrorValue, setLegacyErrorValue] = useState("999999");
const [newErrorValue, setNewErrorValue] = useState("999999");
return (
<div className="space-y-10">
<h2 className="text-lg font-bold text-foreground">OtpInput Legacy vs shadcn/ui</h2>
{/* Default state */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Default</h3>
<LegacyOtpInput value={legacyValue} onChange={setLegacyValue} autoFocus={false} />
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
shadcn/ui Default
</h3>
<OtpInput value={newValue} onChange={setNewValue} autoFocus={false} />
</div>
</div>
{/* Error state */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Error</h3>
<LegacyOtpInput
value={legacyErrorValue}
onChange={setLegacyErrorValue}
error="Invalid code. Please try again."
autoFocus={false}
/>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Error</h3>
<OtpInput
value={newErrorValue}
onChange={setNewErrorValue}
error="Invalid code. Please try again."
autoFocus={false}
/>
</div>
</div>
{/* Disabled state */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Disabled</h3>
<LegacyOtpInput value="123" onChange={() => {}} disabled autoFocus={false} />
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
shadcn/ui Disabled
</h3>
<OtpInput value="123" onChange={() => {}} disabled autoFocus={false} />
</div>
</div>
</div>
);
},
};

View File

@ -8,7 +8,6 @@
"use client";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/shared/utils";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
interface OtpInputProps {
@ -45,13 +44,18 @@ export function OtpInput({
>
<InputOTPGroup>
{Array.from({ length }, (_, i) => (
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
<InputOTPSlot
key={i}
index={i}
aria-invalid={!!error || undefined}
className="h-14 w-12 text-xl font-semibold"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
{error && (
<p className="text-sm text-danger text-center" role="alert">
<p className="text-sm text-destructive text-center" role="alert">
{error}
</p>
)}

View File

@ -0,0 +1,76 @@
"use client";
import React from "react";
interface PaginationBarProps {
currentPage: number;
pageSize: number;
totalItems: number;
onPageChange: (page: number) => void;
className?: string;
}
export function PaginationBar({
currentPage,
pageSize,
totalItems,
onPageChange,
className,
}: PaginationBarProps) {
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const canPrev = currentPage > 1;
const canNext = currentPage < totalPages;
return (
<div className={`px-1 sm:px-0 py-1 flex items-center justify-between ${className || ""}`}>
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "}
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> of{" "}
<span className="font-medium">{totalItems}</span> results
</p>
</div>
<div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</nav>
</div>
</div>
</div>
);
}
export type { PaginationBarProps };

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { PaginationBar } from "./PaginationBar";
import { PaginationBar as PaginationBarLegacy } from "./PaginationBar.legacy";
const meta: Meta<typeof PaginationBar> = {
title: "Molecules/PaginationBar",
@ -37,3 +38,27 @@ export const LastPage: Story = {
export const SinglePage: Story = {
args: { currentPage: 1, pageSize: 10, totalItems: 5, onPageChange: () => {} },
};
export const ComparisonWithLegacy: Story = {
render: function Render() {
const [page, setPage] = useState(3);
return (
<div className="flex flex-col gap-8 w-[700px]">
<div>
<h3 className="text-sm font-semibold mb-2">New (shadcn/ui)</h3>
<PaginationBar currentPage={page} pageSize={10} totalItems={100} onPageChange={setPage} />
</div>
<hr />
<div>
<h3 className="text-sm font-semibold mb-2">Legacy</h3>
<PaginationBarLegacy
currentPage={page}
pageSize={10}
totalItems={100}
onPageChange={setPage}
/>
</div>
</div>
);
},
};

View File

@ -1,6 +1,16 @@
"use client";
import React from "react";
import React, { useMemo } from "react";
import { cn } from "@/shared/utils";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface PaginationBarProps {
currentPage: number;
@ -10,6 +20,37 @@ interface PaginationBarProps {
className?: string;
}
/**
* Compute which page numbers to display, up to 5 visible pages with ellipsis.
*/
function getPageRange(
currentPage: number,
totalPages: number
): (number | "ellipsis-start" | "ellipsis-end")[] {
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// Always show first and last page
if (currentPage <= 3) {
return [1, 2, 3, 4, "ellipsis-end", totalPages];
}
if (currentPage >= totalPages - 2) {
return [1, "ellipsis-start", totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
}
return [
1,
"ellipsis-start",
currentPage - 1,
currentPage,
currentPage + 1,
"ellipsis-end",
totalPages,
];
}
export function PaginationBar({
currentPage,
pageSize,
@ -21,54 +62,79 @@ export function PaginationBar({
const canPrev = currentPage > 1;
const canNext = currentPage < totalPages;
const pageRange = useMemo(() => getPageRange(currentPage, totalPages), [currentPage, totalPages]);
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
return (
<div className={`px-1 sm:px-0 py-1 flex items-center justify-between ${className || ""}`}>
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
<div
className={cn(
"flex flex-col items-center gap-4 py-1 sm:flex-row sm:justify-between",
className
)}
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "}
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> of{" "}
Showing <span className="font-medium">{startItem}</span> to{" "}
<span className="font-medium">{endItem}</span> of{" "}
<span className="font-medium">{totalItems}</span> results
</p>
</div>
<div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={e => {
e.preventDefault();
if (canPrev) onPageChange(currentPage - 1);
}}
aria-disabled={!canPrev}
className={cn(!canPrev && "pointer-events-none opacity-50")}
role="button"
tabIndex={canPrev ? 0 : -1}
/>
</PaginationItem>
{pageRange.map(page => {
if (page === "ellipsis-start" || page === "ellipsis-end") {
return (
<PaginationItem key={page}>
<PaginationEllipsis />
</PaginationItem>
);
}
return (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={e => {
e.preventDefault();
onPageChange(page);
}}
role="button"
tabIndex={0}
>
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</nav>
</div>
</div>
{page}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
onClick={e => {
e.preventDefault();
if (canNext) onPageChange(currentPage + 1);
}}
aria-disabled={!canNext}
className={cn(!canNext && "pointer-events-none opacity-50")}
role="button"
tabIndex={canNext ? 0 : -1}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}

View File

@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
type SectionTone = "primary" | "success" | "info" | "warning" | "danger" | "neutral";
const toneStyles: Record<SectionTone, string> = {
primary: "bg-primary/10 text-primary",
success: "bg-success/10 text-success",
info: "bg-info/10 text-info",
warning: "bg-warning/10 text-warning",
danger: "bg-danger/10 text-danger",
neutral: "bg-neutral/10 text-neutral",
};
interface SectionCardProps {
icon: ReactNode;
title: string;
subtitle?: string | undefined;
tone?: SectionTone;
actions?: ReactNode | undefined;
children: ReactNode;
className?: string | undefined;
}
export function SectionCard({
icon,
title,
subtitle,
tone = "primary",
actions,
children,
className,
}: SectionCardProps) {
return (
<div
className={cn(
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden",
className
)}
>
<div className="bg-muted/40 px-6 py-4 border-b border-border/40">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
"h-9 w-9 rounded-lg flex items-center justify-center flex-shrink-0",
toneStyles[tone]
)}
>
{icon}
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
</div>
<div className="px-6 py-5">{children}</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SectionCard } from "./SectionCard";
import { SectionCard as SectionCardLegacy } from "./SectionCard.legacy";
import { CreditCardIcon, UserIcon } from "@heroicons/react/24/outline";
import { Button } from "../../atoms/button";
@ -70,3 +71,56 @@ export const AllTones: Story = {
</div>
),
};
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="flex flex-col gap-8 w-[500px]">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
<SectionCard
icon={<UserIcon className="h-5 w-5" />}
title="Account Details"
subtitle="Your personal information"
tone="info"
actions={
<Button size="sm" variant="outline">
Edit
</Button>
}
>
<div className="space-y-2 text-sm">
<p>
<span className="text-muted-foreground">Name:</span> John Doe
</p>
<p>
<span className="text-muted-foreground">Email:</span> john@example.com
</p>
</div>
</SectionCard>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
<SectionCardLegacy
icon={<UserIcon className="h-5 w-5" />}
title="Account Details"
subtitle="Your personal information"
tone="info"
actions={
<Button size="sm" variant="outline">
Edit
</Button>
}
>
<div className="space-y-2 text-sm">
<p>
<span className="text-muted-foreground">Name:</span> John Doe
</p>
<p>
<span className="text-muted-foreground">Email:</span> john@example.com
</p>
</div>
</SectionCardLegacy>
</div>
</div>
),
};

View File

@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
import { Card, CardHeader, CardAction, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
type SectionTone = "primary" | "success" | "info" | "warning" | "danger" | "neutral";
@ -32,14 +34,8 @@ export function SectionCard({
className,
}: SectionCardProps) {
return (
<div
className={cn(
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden",
className
)}
>
<div className="bg-muted/40 px-6 py-4 border-b border-border/40">
<div className="flex items-center justify-between gap-3">
<Card className={cn("gap-0 py-0 shadow-[var(--cp-shadow-1)] overflow-hidden", className)}>
<CardHeader className="bg-muted/40 px-6 py-4 gap-0">
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
@ -54,10 +50,14 @@ export function SectionCard({
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
</div>
<div className="px-6 py-5">{children}</div>
</div>
{actions && (
<CardAction>
<div className="flex items-center gap-2 flex-shrink-0">{actions}</div>
</CardAction>
)}
</CardHeader>
<Separator className="bg-border/40" />
<CardContent className="px-6 py-5">{children}</CardContent>
</Card>
);
}

View File

@ -0,0 +1,81 @@
import { forwardRef, type ReactNode } from "react";
import { cn } from "@/shared/utils";
export interface SubCardProps {
title?: string;
icon?: ReactNode;
right?: ReactNode;
header?: ReactNode;
footer?: ReactNode;
children: ReactNode;
className?: string;
headerClassName?: string;
bodyClassName?: string;
/** Enable hover effects for interactive cards */
interactive?: boolean;
}
interface SubCardHeaderOptions {
header: ReactNode | undefined;
title: string | undefined;
icon: ReactNode | undefined;
right: ReactNode | undefined;
headerClassName: string;
}
function renderSubCardHeader({
header,
title,
icon,
right,
headerClassName,
}: SubCardHeaderOptions): ReactNode {
if (header) {
return <div className={`${headerClassName || "mb-5"}`}>{header}</div>;
}
if (title) {
return (
<div className={`flex items-center justify-between mb-5 ${headerClassName}`}>
<div className="flex items-center gap-3">
{icon && <div className="text-primary">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
</div>
{right}
</div>
);
}
return null;
}
export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
(
{
title,
icon,
right,
header,
footer,
children,
className = "",
headerClassName = "",
bodyClassName = "",
interactive = false,
},
ref
) => (
<div
ref={ref}
className={cn(
"border border-border bg-card text-card-foreground shadow-sm rounded-2xl p-5 sm:p-6",
interactive &&
"transition-all duration-200 hover:shadow-md hover:border-border/80 cursor-pointer",
className
)}
>
{renderSubCardHeader({ header, title, icon, right, headerClassName })}
<div className={bodyClassName}>{children}</div>
{footer ? <div className="mt-5 pt-5 border-t border-border/60">{footer}</div> : null}
</div>
)
);
SubCard.displayName = "SubCard";

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SubCard } from "./SubCard";
import { SubCard as SubCardLegacy } from "./SubCard.legacy";
import { CreditCardIcon, CogIcon } from "@heroicons/react/24/outline";
import { Button } from "../../atoms/button";
@ -72,3 +73,54 @@ export const Interactive: Story = {
children: <p className="text-sm text-muted-foreground">This card has hover effects.</p>,
},
};
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="flex flex-col gap-8 w-[500px]">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
<SubCard
title="Subscription"
icon={<CreditCardIcon className="h-5 w-5" />}
right={
<Button size="sm" variant="outline">
Manage
</Button>
}
footer={
<div className="flex justify-between items-center w-full">
<span className="text-sm text-muted-foreground">Next billing: April 1</span>
<Button size="sm" variant="outline">
Renew
</Button>
</div>
}
>
<p className="text-sm">Fiber Internet 1Gbps - Active</p>
</SubCard>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
<SubCardLegacy
title="Subscription"
icon={<CreditCardIcon className="h-5 w-5" />}
right={
<Button size="sm" variant="outline">
Manage
</Button>
}
footer={
<div className="flex justify-between items-center w-full">
<span className="text-sm text-muted-foreground">Next billing: April 1</span>
<Button size="sm" variant="outline">
Renew
</Button>
</div>
}
>
<p className="text-sm">Fiber Internet 1Gbps - Active</p>
</SubCardLegacy>
</div>
</div>
),
};

View File

@ -1,5 +1,7 @@
import { forwardRef, type ReactNode } from "react";
import { cn } from "@/shared/utils";
import { Card, CardHeader, CardAction, CardContent, CardFooter } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export interface SubCardProps {
title?: string;
@ -31,17 +33,17 @@ function renderSubCardHeader({
headerClassName,
}: SubCardHeaderOptions): ReactNode {
if (header) {
return <div className={`${headerClassName || "mb-5"}`}>{header}</div>;
return <CardHeader className={cn("px-0 pt-0", headerClassName || "pb-5")}>{header}</CardHeader>;
}
if (title) {
return (
<div className={`flex items-center justify-between mb-5 ${headerClassName}`}>
<CardHeader className={cn("px-0 pt-0 pb-5 gap-0", headerClassName)}>
<div className="flex items-center gap-3">
{icon && <div className="text-primary">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
</div>
{right}
</div>
{right && <CardAction>{right}</CardAction>}
</CardHeader>
);
}
return null;
@ -63,19 +65,24 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
},
ref
) => (
<div
<Card
ref={ref}
className={cn(
"border border-border bg-card text-card-foreground shadow-sm rounded-2xl p-5 sm:p-6",
"gap-0 rounded-2xl p-5 sm:p-6",
interactive &&
"transition-all duration-200 hover:shadow-md hover:border-border/80 cursor-pointer",
className
)}
>
{renderSubCardHeader({ header, title, icon, right, headerClassName })}
<div className={bodyClassName}>{children}</div>
{footer ? <div className="mt-5 pt-5 border-t border-border/60">{footer}</div> : null}
</div>
<CardContent className={cn("px-0 py-0", bodyClassName)}>{children}</CardContent>
{footer ? (
<>
<Separator className="mt-5 bg-border/60" />
<CardFooter className="px-0 pt-5 pb-0">{footer}</CardFooter>
</>
) : null}
</Card>
)
);
SubCard.displayName = "SubCard";

View File

@ -0,0 +1,64 @@
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,102 @@
import * as React from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@ -0,0 +1,21 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,144 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,228 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked ?? false}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@ -6,18 +6,165 @@
* import from "@/components/atoms" instead.
*/
// Form primitives
export { Button, buttonVariants } from "./button";
export { Input } from "./input";
export { Checkbox } from "./checkbox";
export { Label } from "./label";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
// Accordion
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
// Display primitives
export { Badge, badgeVariants } from "./badge";
export { Skeleton } from "./skeleton";
// Alert
export { Alert, AlertTitle, AlertDescription } from "./alert";
// Toggle primitives
// Badge
export { Badge, badgeVariants } from "./badge";
// Breadcrumb
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from "./breadcrumb";
// Button
export { Button, buttonVariants } from "./button";
// Card
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
} from "./card";
// Checkbox
export { Checkbox } from "./checkbox";
// Collapsible
export { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./collapsible";
// Dialog
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "./dialog";
// Dropdown Menu
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "./dropdown-menu";
// Input
export { Input } from "./input";
// Input OTP
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
// Label
export { Label } from "./label";
// Radio Group
export { RadioGroup, RadioGroupItem } from "./radio-group";
// Pagination
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from "./pagination";
// Popover
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
} from "./popover";
// Select
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "./select";
// Separator
export { Separator } from "./separator";
// Sheet
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
} from "./sheet";
// Skeleton
export { Skeleton } from "./skeleton";
// Table
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from "./table";
// Tabs
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } from "./tabs";
// Toggle
export { Toggle, toggleVariants } from "./toggle";
// Toggle Group
export { ToggleGroup, ToggleGroupItem } from "./toggle-group";
// Tooltip
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./tooltip";

View File

@ -3,13 +3,16 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/shared/utils";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
@ -30,20 +33,18 @@ function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & { index: number }) {
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
// Index is always valid — we control it via the length prop in OtpInput
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
"first:rounded-l-lg first:border-l last:rounded-r-lg",
"bg-card text-foreground",
isActive && "z-10 ring-2 ring-primary border-primary",
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
@ -51,7 +52,7 @@ function InputOTPSlot({
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>

View File

@ -0,0 +1,106 @@
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants, type Button } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@ -0,0 +1,74 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <div data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
}
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
};

View File

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,175 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@ -0,0 +1,81 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

View File

@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -3,6 +3,7 @@
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { Input } from "@/components/atoms/input";
import { Label } from "@/components/atoms/label";
/** Data required for displaying personal info card */
interface PersonalInfoData {
@ -41,7 +42,7 @@ function ReadOnlyField({
}) {
return (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">{label}</label>
<Label className="block text-sm font-medium text-muted-foreground mb-2">{label}</Label>
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium">
{value || <span className="text-muted-foreground italic">Not provided</span>}
@ -68,7 +69,7 @@ function EditableEmailField({
}) {
return (
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-muted-foreground mb-2">Email Address</label>
<Label className="block text-sm font-medium text-muted-foreground mb-2">Email Address</Label>
{isEditing ? (
<Input
type="email"
@ -101,7 +102,7 @@ function EditablePhoneField({
}) {
return (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">Phone Number</label>
<Label className="block text-sm font-medium text-muted-foreground mb-2">Phone Number</Label>
{isEditing ? (
<Input
type="tel"

View File

@ -13,6 +13,7 @@
*/
import { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { cn } from "@/shared/utils";
@ -185,12 +186,13 @@ function ResidenceTypeSelector({
return (
<div>
<div className="grid grid-cols-2 gap-3">
<button
<Button
type="button"
variant="outline"
onClick={() => onChange(RESIDENCE_TYPE.HOUSE)}
disabled={disabled}
className={cn(
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300 h-auto",
"hover:scale-[1.02] active:scale-[0.98]",
value === RESIDENCE_TYPE.HOUSE
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
@ -216,14 +218,15 @@ function ResidenceTypeSelector({
>
House
</span>
</button>
</Button>
<button
<Button
type="button"
variant="outline"
onClick={() => onChange(RESIDENCE_TYPE.APARTMENT)}
disabled={disabled}
className={cn(
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300 h-auto",
"hover:scale-[1.02] active:scale-[0.98]",
value === RESIDENCE_TYPE.APARTMENT
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
@ -249,7 +252,7 @@ function ResidenceTypeSelector({
>
Apartment
</span>
</button>
</Button>
</div>
{error && <p className="text-sm text-danger mt-2">{error}</p>}

View File

@ -13,7 +13,7 @@
import { useState, useCallback } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Button, Input, PasswordInput, ErrorMessage } from "@/components/atoms";
import { Button, Input, PasswordInput, ErrorMessage, Checkbox } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { LoginOtpStep } from "../LoginOtpStep";
import { useLoginWithOtp } from "../../hooks/use-auth";
@ -240,20 +240,14 @@ function CredentialsForm({
/>
</FormField>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
<Checkbox
id="remember-me"
name="remember-me"
type="checkbox"
checked={values.rememberMe}
onChange={e => setValue("rememberMe", e.target.checked)}
checked={values.rememberMe ?? false}
onCheckedChange={val => setValue("rememberMe", val === true)}
disabled={isDisabled}
className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary"
label="Remember me"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-muted-foreground">
Remember me
</label>
</div>
{showForgotPasswordLink && (
<div className="text-sm">
<Link

View File

@ -8,7 +8,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Button, ErrorMessage } from "@/components/atoms";
import { Button, ErrorMessage, Checkbox } from "@/components/atoms";
import { OtpInput, formatTimeRemaining } from "@/components/molecules";
import { ArrowLeft, Mail, Clock } from "lucide-react";
@ -124,18 +124,14 @@ export function LoginOtpStep({
timeRemaining={timeRemaining}
/>
<div className="flex items-center justify-center">
<input
<Checkbox
id="remember-device"
name="remember-device"
type="checkbox"
checked={rememberDevice}
onChange={e => setRememberDevice(e.target.checked)}
onCheckedChange={val => setRememberDevice(val === true)}
disabled={isSubmitting || isExpired}
className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary"
label="Remember this device for 7 days"
/>
<label htmlFor="remember-device" className="ml-2 block text-sm text-muted-foreground">
Remember this device for 7 days
</label>
</div>
<Button
type="button"
@ -147,15 +143,16 @@ export function LoginOtpStep({
>
Verify and Sign In
</Button>
<button
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSubmitting}
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 disabled:opacity-50"
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to login</span>
</button>
</Button>
<p className="text-xs text-center text-muted-foreground">
Didn&apos;t receive the code? Check your spam folder or go back to try again.
</p>

View File

@ -15,7 +15,7 @@ export function MarketingCheckbox({ checked, onChange, disabled }: MarketingChec
<Checkbox
id="marketingConsent"
checked={checked}
onChange={e => onChange(e.target.checked)}
onCheckedChange={val => onChange(val === true)}
disabled={disabled}
/>
<Label

View File

@ -17,7 +17,7 @@ export function TermsCheckbox({ checked, onChange, disabled, error }: TermsCheck
<Checkbox
id="acceptTerms"
checked={checked}
onChange={e => onChange(e.target.checked)}
onCheckedChange={val => onChange(val === true)}
disabled={disabled}
/>
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">

View File

@ -104,14 +104,15 @@ function EmailSentConfirmation({
</div>
<div className="space-y-2">
<button
<Button
type="button"
variant="ghost"
onClick={onBackToForm}
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
>
<ArrowLeft className="w-4 h-4" />
<span>Try a different email</span>
</button>
</Button>
<Link
href="/auth/login"
className="block text-center text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"

View File

@ -11,6 +11,7 @@ import {
ChevronRightIcon,
} from "@heroicons/react/24/outline";
import type { Activity } from "@customer-portal/domain/dashboard";
import { Button } from "@/components/atoms/button";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms";
@ -99,13 +100,14 @@ function ActivityItem({ activity, isLast = false }: ActivityItemProps) {
if (isClickable) {
return (
<button
<Button
type="button"
variant="ghost"
onClick={() => router.push(navPath!)}
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2"
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2 h-auto"
>
{content}
</button>
</Button>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import type { ComponentType, SVGProps } from "react";
import { Button } from "@/components/atoms/button";
import {
DocumentTextIcon,
CheckCircleIcon,
@ -74,13 +75,14 @@ export function DashboardActivityItem({
if (onClick) {
return (
<button
<Button
type="button"
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors cursor-pointer -mx-2 px-2"
variant="ghost"
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors cursor-pointer -mx-2 px-2 h-auto"
onClick={onClick}
>
{content}
</button>
</Button>
);
}

View File

@ -202,8 +202,8 @@ function MigrateTermsSection({
<Checkbox
id="acceptTerms"
checked={form.acceptTerms}
onChange={e => {
form.setAcceptTerms(e.target.checked);
onCheckedChange={val => {
form.setAcceptTerms(val === true);
form.clearError("acceptTerms");
}}
disabled={loading}
@ -237,7 +237,7 @@ function MigrateTermsSection({
<Checkbox
id="marketingConsent"
checked={form.marketingConsent}
onChange={e => form.setMarketingConsent(e.target.checked)}
onCheckedChange={val => form.setMarketingConsent(val === true)}
disabled={loading}
/>
<Label

View File

@ -1,6 +1,7 @@
"use client";
import { Input, Label } from "@/components/atoms";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { AccountFormErrors } from "./types";
type Gender = "male" | "female" | "other" | "";
@ -74,25 +75,22 @@ export function PersonalInfoFields({
<Label>
Gender <span className="text-danger">*</span>
</Label>
<div className="flex gap-4">
{GENDER_OPTIONS.map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="gender"
value={option}
checked={gender === option}
onChange={() => {
onGenderChange(option);
<RadioGroup
value={gender || ""}
onValueChange={(value: string) => {
onGenderChange(value as "male" | "female" | "other");
clearError("gender");
}}
disabled={loading}
className="h-4 w-4 text-primary focus:ring-primary"
/>
className="flex gap-4"
>
{GENDER_OPTIONS.map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value={option} />
<span className="text-sm capitalize">{option}</span>
</label>
))}
</div>
</RadioGroup>
{errors.gender && <p className="text-sm text-danger">{errors.gender}</p>}
</div>
</>

View File

@ -1,9 +1,12 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { LayoutGroup, motion, useInView } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import TextRotate from "@/components/fancy/text/text-rotate";
const SERVICE_WORDS = ["Internet", "Phone Plans", "VPN", "IT Support", "Business"];
interface HeroSectionProps {
heroCTARef: React.RefObject<HTMLDivElement | null>;
@ -45,10 +48,30 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
/>
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
<LayoutGroup>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground font-heading">
<span className="block">Seamless IT Solutions</span>
<span className="block text-primary mt-2">Built for Your Business</span>
<motion.span
className="flex items-center justify-center gap-2 sm:gap-3 mt-2"
layout
transition={{ type: "spring", damping: 30, stiffness: 400 }}
>
<span className="text-foreground">for</span>
<TextRotate
texts={SERVICE_WORDS}
mainClassName="text-white px-3 sm:px-4 bg-primary overflow-hidden py-1 sm:py-1.5 justify-center rounded-lg"
staggerFrom="last"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "-120%" }}
staggerDuration={0.025}
splitLevelClassName="overflow-hidden pb-0.5 sm:pb-1"
transition={{ type: "spring", damping: 30, stiffness: 400 }}
rotationInterval={2500}
/>
</motion.span>
</h1>
</LayoutGroup>
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto">
From connectivity to communication, we handle the complexity so you can focus on what
matters with dedicated English support across Japan.

View File

@ -4,6 +4,7 @@ import { memo, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { cn } from "@/shared/utils";
import { useCarousel, useInView } from "@/features/landing-page/hooks";
import {
@ -251,14 +252,15 @@ function CarouselHeader({
</div>
<div className="flex bg-muted rounded-full p-1 self-start relative">
{(["personal", "business"] as const).map(tab => (
<button
<Button
key={tab}
type="button"
variant="ghost"
onClick={() => onTabChange(tab)}
className={cn(
"relative z-10 px-5 py-2.5 text-sm font-semibold rounded-full transition-colors duration-300",
"relative z-10 px-5 py-2.5 text-sm font-semibold rounded-full transition-colors duration-300 h-auto",
activeTab === tab
? "text-background"
? "text-background hover:text-background"
: "text-muted-foreground hover:text-foreground"
)}
>
@ -272,7 +274,7 @@ function CarouselHeader({
<span className="relative z-10">
{tab === "personal" ? "For You" : "For Business"}
</span>
</button>
</Button>
))}
</div>
</div>
@ -298,25 +300,28 @@ function CarouselNav({
return (
<div className="mx-auto max-w-3xl px-6 sm:px-10">
<div className="flex items-center justify-center gap-6 mt-8">
<button
<Button
type="button"
variant="outline"
size="icon"
aria-label="Previous service"
onClick={goPrev}
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
>
<ChevronLeft className="h-5 w-5" />
</button>
</Button>
<div className="flex items-center gap-2">
{cards.map((card, i) => {
const styles = ACCENTS[card.accent];
return (
<button
<Button
key={`${card.title}-${i}`}
type="button"
variant="ghost"
aria-label={`Go to ${card.title}`}
onClick={() => goTo(i)}
className={cn(
"rounded-full transition-all duration-300 h-2.5",
"rounded-full transition-all duration-300 h-2.5 p-0 min-w-0",
i === activeIndex
? cn("w-8", styles.dotBg)
: "w-2.5 bg-border hover:bg-muted-foreground"
@ -325,14 +330,16 @@ function CarouselNav({
);
})}
</div>
<button
<Button
type="button"
variant="outline"
size="icon"
aria-label="Next service"
onClick={goNext}
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
>
<ChevronRight className="h-5 w-5" />
</button>
</Button>
</div>
</div>
);

View File

@ -3,6 +3,7 @@
import { memo, useState, useRef, useCallback, useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import { BellIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { useUnreadNotificationCount } from "../hooks/useNotifications";
import { NotificationDropdown } from "./NotificationDropdown";
import { cn } from "@/shared/utils";
@ -63,8 +64,10 @@ export const NotificationBell = memo(function NotificationBell({
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"relative p-2.5 rounded-xl transition-all duration-200",
"text-muted-foreground hover:text-foreground hover:bg-muted/60",
@ -83,7 +86,7 @@ export const NotificationBell = memo(function NotificationBell({
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
</Button>
<AnimatePresence>
{isOpen && (

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { CheckIcon } from "@heroicons/react/24/outline";
import { BellSlashIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/atoms/button";
import {
useNotifications,
useMarkNotificationAsRead,
@ -54,15 +55,17 @@ export const NotificationDropdown = memo(function NotificationDropdown({
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
{hasUnread && (
<button
<Button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
size="sm"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors h-auto px-2 py-1"
onClick={() => markAllAsRead.mutate()}
disabled={markAllAsRead.isPending}
>
<CheckIcon className="h-3.5 w-3.5" />
Mark all read
</button>
</Button>
)}
</div>

View File

@ -3,6 +3,7 @@
import { memo, useCallback } from "react";
import Link from "next/link";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import {
CheckCircleIcon,
ExclamationCircleIcon,
@ -84,14 +85,16 @@ export const NotificationItem = memo(function NotificationItem({
</div>
{/* Dismiss button */}
<button
<Button
type="button"
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity"
variant="ghost"
size="icon"
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity h-auto w-auto"
onClick={handleDismiss}
aria-label="Dismiss notification"
>
<XMarkIcon className="h-4 w-4 text-muted-foreground" />
</button>
</Button>
{/* Unread indicator */}
{!notification.read && (

View File

@ -1,6 +1,7 @@
"use client";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { Checkbox } from "@/components/atoms/checkbox";
// Local type that includes all properties we need
// This avoids type intersection issues with exactOptionalPropertyTypes
@ -154,11 +155,10 @@ export function AddonGroup({
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={() => handleGroupToggle(group)}
className="text-green-600 focus:ring-green-500 mt-1"
onCheckedChange={() => handleGroupToggle(group)}
className="mt-1"
/>
<div className="flex-1">
<div className="flex items-center justify-between">

View File

@ -9,6 +9,14 @@ import {
type AddressFormData,
type Address,
} from "@customer-portal/domain/customer";
import { Label } from "@/components/atoms/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export interface AddressFormProps {
// Initial values
@ -119,25 +127,28 @@ function AddressField({
return (
<div key={field} className={variant === "compact" ? "mb-3" : "mb-4"}>
<label className="block text-sm font-medium text-foreground mb-1">
<Label className="block text-sm font-medium text-foreground mb-1">
{labels[field]}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
</Label>
{type === "select" ? (
<select
<Select
value={formValues[field] || ""}
onChange={e => onFieldChange(field, e.target.value)}
onBlur={() => onBlur(field)}
className={baseInputClasses}
onValueChange={value => onFieldChange(field, value)}
disabled={disabled}
>
{SELECT_COUNTRY_OPTIONS.map(option => (
<option key={option.code} value={option.code}>
<SelectTrigger className={baseInputClasses} onBlur={() => onBlur(field)}>
<SelectValue placeholder="Select Country" />
</SelectTrigger>
<SelectContent>
{SELECT_COUNTRY_OPTIONS.filter(option => option.code !== "").map(option => (
<SelectItem key={option.code} value={option.code}>
{option.name}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
) : (
<input
type="text"

View File

@ -3,6 +3,7 @@
import { useState, type ElementType, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/atoms/button";
interface CollapsibleSectionProps {
title: string;
@ -21,10 +22,11 @@ export function CollapsibleSection({
return (
<div className="border border-border/60 rounded-xl overflow-hidden bg-card">
<button
<Button
variant="ghost"
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors h-auto rounded-none"
>
<div className="flex items-center gap-2.5">
<Icon className="w-4 h-4 text-primary" />
@ -33,7 +35,7 @@ export function CollapsibleSection({
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</motion.div>
</button>
</Button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div

View File

@ -6,6 +6,8 @@ import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain/payments";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/atoms/label";
export interface PaymentFormProps {
existingMethods?: PaymentMethod[];
@ -49,12 +51,10 @@ function PaymentMethodItem({
method,
isSelected,
disabled,
onSelect,
}: {
method: PaymentMethod;
isSelected: boolean;
disabled: boolean;
onSelect: (id: string) => void;
}) {
const methodId = String(method.id);
const cardLastFourDisplay = method.cardLastFour ? `•••• ${method.cardLastFour}` : "";
@ -63,9 +63,9 @@ function PaymentMethodItem({
: (method.description ?? method.type);
return (
<label
<Label
className={[
"flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-colors",
"flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-colors font-normal",
isSelected ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
disabled ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
@ -87,18 +87,12 @@ function PaymentMethodItem({
</div>
</div>
<input
type="radio"
name="paymentMethod"
<RadioGroupItem
value={methodId}
checked={isSelected}
disabled={disabled}
onChange={() => {
if (!disabled) onSelect(methodId);
}}
className="text-blue-600 focus:ring-blue-500"
/>
</label>
</Label>
);
}
@ -241,14 +235,17 @@ export function PaymentForm({
disabled={disabled}
/>
) : (
<div className="space-y-3">
<RadioGroup
value={selectedMethod}
onValueChange={value => handleSelect(value)}
className="space-y-3"
>
{methods.map(method => (
<PaymentMethodItem
key={method.id}
method={method}
isSelected={selectedMethod === String(method.id)}
disabled={disabled}
onSelect={handleSelect}
/>
))}
{allowNewMethod && onAddNewMethod ? (
@ -263,7 +260,7 @@ export function PaymentForm({
</Button>
</div>
) : null}
</div>
</RadioGroup>
)}
{children}

View File

@ -3,6 +3,7 @@
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/utils/cn";
import { Button } from "@/components/atoms/button";
export interface FAQItem {
question: string;
@ -30,10 +31,11 @@ function FAQItemComponent({
}) {
return (
<div className="border-b border-border/60 last:border-b-0">
<button
<Button
variant="ghost"
type="button"
onClick={onToggle}
className="w-full py-4 flex items-start justify-between gap-3 text-left group"
className="w-full py-4 flex items-start justify-between gap-3 text-left group h-auto rounded-none px-0"
aria-expanded={isOpen}
>
<span className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
@ -45,7 +47,7 @@ function FAQItemComponent({
isOpen && "rotate-180"
)}
/>
</button>
</Button>
<div
className={cn(
"overflow-hidden transition-all duration-300",

View File

@ -2,6 +2,7 @@
import { useRef, useState, useEffect } from "react";
import { cn } from "@/shared/utils";
import { Button } from "@/components/atoms/button";
export interface HighlightFeature {
icon: React.ReactNode;
@ -104,13 +105,15 @@ export function ServiceHighlights({ features, className = "" }: ServiceHighlight
{/* Dot indicators */}
<div className="flex justify-center gap-1.5 mt-1.5">
{features.map((_, index) => (
<button
<Button
key={index}
variant="ghost"
size="icon"
type="button"
onClick={() => scrollToIndex(index)}
aria-label={`Go to slide ${index + 1}`}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
"h-1.5 rounded-full transition-all duration-300 p-0 min-w-0",
activeIndex === index
? "w-5 bg-primary"
: "w-1.5 bg-muted-foreground/25 hover:bg-muted-foreground/40"

View File

@ -1,6 +1,7 @@
"use client";
import { Input, Label, ErrorMessage } from "@/components/atoms";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type Gender = "male" | "female" | "other" | "";
@ -77,25 +78,22 @@ export function PersonalInfoFields({
<Label>
Gender <span className="text-danger">*</span>
</Label>
<div className="flex gap-4">
{GENDER_OPTIONS.map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="gender"
value={option}
checked={gender === option}
onChange={() => {
onGenderChange(option);
<RadioGroup
value={gender || ""}
onValueChange={(value: string) => {
onGenderChange(value as "male" | "female" | "other");
clearError("gender");
}}
disabled={loading}
className="h-4 w-4 text-primary focus:ring-primary"
/>
className="flex gap-4"
>
{GENDER_OPTIONS.map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<RadioGroupItem value={option} />
<span className="text-sm capitalize">{option}</span>
</label>
))}
</div>
</RadioGroup>
<ErrorMessage>{errors.gender}</ErrorMessage>
</div>
</>

View File

@ -3,6 +3,7 @@
import { useState } from "react";
import { Wrench, Sparkles, Network, ChevronDown, ChevronUp, HelpCircle } from "lucide-react";
import { cn } from "@/shared/utils";
import { Button } from "@/components/atoms/button";
interface PlanGuideItemProps {
tier: "Silver" | "Gold" | "Platinum";
@ -85,10 +86,11 @@ export function PlanComparisonGuide() {
return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Collapsible header */}
<button
<Button
variant="ghost"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3.5 flex items-center justify-between gap-3 text-left hover:bg-muted/30 transition-colors"
className="w-full px-4 py-3.5 flex items-center justify-between gap-3 text-left hover:bg-muted/30 transition-colors h-auto rounded-none"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-info-soft/50 text-info border border-info/20 flex-shrink-0">
@ -111,7 +113,7 @@ export function PlanComparisonGuide() {
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
</Button>
{/* Expandable content */}
{isExpanded && (

View File

@ -1,5 +1,7 @@
"use client";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/atoms/label";
import { CardPricing } from "@/features/services/components/base/CardPricing";
interface ActivationFeeDetails {
@ -44,26 +46,23 @@ export function ActivationForm({
activationFee,
}: ActivationFormProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<RadioGroup
value={activationType}
onValueChange={value => onActivationTypeChange(value as "Immediate" | "Scheduled")}
className="grid grid-cols-1 md:grid-cols-2 gap-6"
>
{activationOptions.map(option => {
const isSelected = activationType === option.type;
return (
<label
<Label
key={option.type}
className={`p-6 rounded-xl border text-left transition-shadow duration-[var(--cp-duration-normal)] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background cursor-pointer flex flex-col gap-3 ${
className={`p-6 rounded-xl border text-left transition-shadow duration-[var(--cp-duration-normal)] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background cursor-pointer flex flex-col gap-3 font-normal ${
isSelected
? "border-primary bg-primary-soft shadow-[var(--cp-shadow-2)]"
: "border-border hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]"
}`}
>
<input
type="radio"
name="activationType"
value={option.type}
checked={isSelected}
onChange={e => onActivationTypeChange(e.target.value as "Immediate" | "Scheduled")}
className="sr-only"
/>
<RadioGroupItem value={option.type} className="sr-only" />
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
@ -99,12 +98,12 @@ export function ActivationForm({
aria-hidden={!isSelected}
>
<div className="mt-3">
<label
<Label
htmlFor="scheduledActivationDate"
className="block text-sm font-medium text-muted-foreground mb-1"
>
Preferred activation date *
</label>
</Label>
<input
type="date"
id="scheduledActivationDate"
@ -125,9 +124,9 @@ export function ActivationForm({
</div>
</div>
)}
</label>
</Label>
);
})}
</div>
</RadioGroup>
);
}

View File

@ -3,6 +3,7 @@
import { useState, useMemo } from "react";
import { Search, Smartphone, Check, X } from "lucide-react";
import { cn } from "@/shared/utils";
import { Button } from "@/components/atoms/button";
// Device categories with their devices
const DEVICE_CATEGORIES = [
@ -291,14 +292,15 @@ export function DeviceCompatibility() {
</div>
<div className="max-w-3xl mx-auto">
<button
<Button
variant="ghost"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:text-primary/80 transition-colors h-auto"
>
<Smartphone className="h-4 w-4" />
{isExpanded ? "Hide full device list" : "View all compatible devices"}
</button>
</Button>
{isExpanded && (
<div className="mt-4 rounded-xl border border-border bg-card overflow-hidden">
@ -325,10 +327,11 @@ function DeviceCategorySection({ category }: { category: (typeof DEVICE_CATEGORI
return (
<div>
<button
<Button
variant="ghost"
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
className="w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors h-auto rounded-none"
>
<span className="font-medium text-foreground">{category.name}</span>
<span
@ -339,7 +342,7 @@ function DeviceCategorySection({ category }: { category: (typeof DEVICE_CATEGORI
>
</span>
</button>
</Button>
{isOpen && (
<div className="px-4 pb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View File

@ -1,5 +1,14 @@
import React from "react";
import type { MnpData } from "@customer-portal/domain/sim";
import { Label } from "@/components/atoms/label";
import { Checkbox } from "@/components/atoms/checkbox";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
@ -103,20 +112,19 @@ function MnpGenderSelect({
}) {
return (
<div>
<label htmlFor="portingGender" className={LABEL_CLASS}>
<Label htmlFor="portingGender" className={LABEL_CLASS}>
Gender *
</label>
<select
id="portingGender"
value={value}
onChange={e => onChange(e.target.value)}
className={INPUT_CLASS}
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Corporate/Other">Corporate/Other</option>
</select>
</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="portingGender" className={INPUT_CLASS}>
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Male">Male</SelectItem>
<SelectItem value="Female">Female</SelectItem>
<SelectItem value="Corporate/Other">Corporate/Other</SelectItem>
</SelectContent>
</Select>
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
</div>
);
@ -179,12 +187,11 @@ export function MnpForm({
return (
<div>
<div className="mb-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
<Label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={wantsMnp}
onChange={e => onWantsMnpChange(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
onCheckedChange={checked => onWantsMnpChange(checked === true)}
className="mt-1 h-4 w-4"
/>
<div>
<span className="font-medium text-gray-900">
@ -195,7 +202,7 @@ export function MnpForm({
Additional fees may apply.
</p>
</div>
</label>
</Label>
</div>
{wantsMnp && (

View File

@ -3,6 +3,7 @@
import { useState } from "react";
import { PhoneIcon, ChatBubbleLeftIcon, GlobeAltIcon } from "@heroicons/react/24/outline";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/atoms/button";
const domesticRates = {
calling: { rate: 10, unit: "30 sec" },
@ -74,9 +75,10 @@ function InternationalRatesSection({
}) {
return (
<div className="border-t border-border">
<button
<Button
variant="ghost"
onClick={onToggle}
className="w-full p-4 flex items-center justify-between text-left hover:bg-muted/50 transition-colors"
className="w-full p-4 flex items-center justify-between text-left hover:bg-muted/50 transition-colors h-auto rounded-none"
>
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-5 h-5 text-primary" />
@ -85,7 +87,7 @@ function InternationalRatesSection({
<ChevronDownIcon
className={`w-5 h-5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</button>
</Button>
{isExpanded && (
<div className="px-6 pb-6">

View File

@ -288,12 +288,13 @@ function SimTabSwitcher({
<div className="flex justify-center">
<div className="inline-flex rounded-lg bg-muted/60 p-0.5 border border-border/60">
{SIM_TABS.map(tab => (
<button
<Button
key={tab.key}
variant="ghost"
type="button"
onClick={() => onTabChange(tab.key)}
className={cn(
"flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium transition-all",
"flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium transition-all h-auto",
activeTab === tab.key
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
@ -305,7 +306,7 @@ function SimTabSwitcher({
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/8 text-primary font-semibold">
{plansByType[tab.planTypeKey].length}
</span>
</button>
</Button>
))}
</div>
</div>

View File

@ -10,6 +10,7 @@ import {
CheckIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
const esimFeatures = [
{ text: "No physical card needed", included: true },
@ -53,12 +54,14 @@ function ESimCard({ onToggleEidInfo }: { onToggleEidInfo: () => void }) {
<span className="text-sm text-foreground">
{feature.text}
{feature.note && (
<button
<Button
variant="ghost"
size="icon"
onClick={onToggleEidInfo}
className="ml-1 text-primary hover:text-primary-hover"
className="ml-1 text-primary hover:text-primary-hover inline-flex h-auto w-auto p-0"
>
<QuestionMarkCircleIcon className="w-4 h-4 inline" />
</button>
</Button>
)}
</span>
</li>

View File

@ -9,6 +9,7 @@ import {
InformationCircleIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
const SIM_TYPE_PHYSICAL = "Physical SIM" as const;
@ -24,10 +25,11 @@ const compatibleEidPrefixes = ["89049032", "89033023", "89033024", "89043051", "
function ESimOption({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) {
return (
<button
<Button
variant="ghost"
type="button"
onClick={onSelect}
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 ${
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 h-auto whitespace-normal ${
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50"
@ -73,7 +75,7 @@ function ESimOption({ isSelected, onSelect }: { isSelected: boolean; onSelect: (
<span>EID number required</span>
</li>
</ul>
</button>
</Button>
);
}
@ -85,10 +87,11 @@ function PhysicalSimOption({
onSelect: () => void;
}) {
return (
<button
<Button
variant="ghost"
type="button"
onClick={onSelect}
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 ${
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 h-auto whitespace-normal ${
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50"
@ -134,7 +137,7 @@ function PhysicalSimOption({
<span>3-in-1 size (Nano/Micro/Standard)</span>
</li>
</ul>
</button>
</Button>
);
}
@ -197,13 +200,14 @@ function EidInput({
{hasError && <p className="text-destructive text-sm mt-2">{errors["eid"]}</p>}
{!hasError && warning && <p className="text-warning text-sm mt-2">{warning}</p>}
<button
<Button
variant="ghost"
type="button"
onClick={() => setShowEidInfo(!showEidInfo)}
className="text-sm text-primary hover:underline mt-2"
className="text-sm text-primary hover:underline mt-2 h-auto p-0"
>
{showEidInfo ? "Hide" : "Where to find your EID?"}
</button>
</Button>
{showEidInfo && (
<div className="mt-3 p-4 bg-card border border-border rounded-lg text-sm">

View File

@ -4,6 +4,15 @@ import { useState, type ReactNode } from "react";
import { PageLayout } from "@/components/templates/PageLayout";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { Label } from "@/components/atoms/label";
import { Checkbox } from "@/components/atoms/checkbox";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ============================================================================
@ -157,22 +166,24 @@ function Step1Content({
</div>
{serviceInfo}
<div>
<label htmlFor="cancelMonth" className="block text-sm font-medium text-foreground mb-2">
<Label htmlFor="cancelMonth" className="block text-sm font-medium text-foreground mb-2">
Cancellation Month
</label>
<select
</Label>
<Select value={selectedMonth || ""} onValueChange={onMonthChange}>
<SelectTrigger
id="cancelMonth"
value={selectedMonth}
onChange={e => onMonthChange(e.target.value)}
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value="">Select a month...</option>
<SelectValue placeholder="Select a month..." />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => (
<option key={month.value} value={month.value}>
<SelectItem key={month.value} value={month.value}>
{month.label}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-2">
Service will end at the end of the selected month.
</p>
@ -221,22 +232,20 @@ function Step2Content({
</div>
{termsContent}
<div className="space-y-4 p-4 bg-muted/50 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
<Label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={acceptTerms}
onChange={e => onAcceptTermsChange(e.target.checked)}
onCheckedChange={val => onAcceptTermsChange(val === true)}
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary"
/>
<span className="text-sm text-foreground">
I have read and understood the cancellation terms above.
</span>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
</Label>
<Label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={confirmMonthEnd}
onChange={e => onConfirmMonthEndChange(e.target.checked)}
onCheckedChange={val => onConfirmMonthEndChange(val === true)}
disabled={!selectedMonth}
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
@ -244,7 +253,7 @@ function Step2Content({
I confirm cancellation at the end of{" "}
<strong>{selectedMonthLabel || "the selected month"}</strong>.
</span>
</label>
</Label>
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack}>
@ -296,9 +305,9 @@ function Step3Content({
<div className="text-sm font-medium text-foreground">{customerEmail || "—"}</div>
</div>
<div>
<label htmlFor="comments" className="block text-sm font-medium text-foreground mb-2">
<Label htmlFor="comments" className="block text-sm font-medium text-foreground mb-2">
Additional Comments <span className="font-normal text-muted-foreground">(optional)</span>
</label>
</Label>
<textarea
id="comments"
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary resize-none"

View File

@ -5,6 +5,15 @@ import { motion } from "framer-motion";
import { apiClient } from "@/core/api";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { mapToSimplifiedFormat } from "../../utils/plan";
import { Button } from "@/components/atoms/button";
import { Label } from "@/components/atoms/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
interface ChangePlanModalProps {
subscriptionId: number;
@ -28,19 +37,19 @@ function PlanSelector({
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-700">Select New Plan</label>
<select
value={value}
onChange={e => onChange(e.target.value as PlanCode)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
>
<option value="">Choose a plan</option>
<Label className="block text-sm font-medium text-gray-700">Select New Plan</Label>
<Select value={value || ""} onValueChange={v => onChange(v as PlanCode)}>
<SelectTrigger className="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
<SelectValue placeholder="Choose a plan" />
</SelectTrigger>
<SelectContent>
{allowedPlans.map(code => (
<option key={code} value={code}>
<SelectItem key={code} value={code}>
{code}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500">
Only plans different from your current plan are listed. The change will be scheduled for the
1st of the next month.
@ -105,9 +114,14 @@ export function ChangePlanModal({
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<div className="flex items-center justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-5 w-5" />
</button>
</Button>
</div>
<div className="mt-4 space-y-4">
<PlanSelector
@ -120,22 +134,23 @@ export function ChangePlanModal({
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
<Button
type="button"
onClick={() => void submit()}
disabled={loading}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{loading ? "Processing..." : "Change Plan"}
</button>
<button
</Button>
<Button
variant="outline"
type="button"
onClick={onClose}
disabled={loading}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Back
</button>
</Button>
</div>
</motion.div>
</div>

View File

@ -5,6 +5,8 @@ import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { Label } from "@/components/atoms/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SimKind = "physical" | "esim";
@ -36,17 +38,14 @@ function SimTypeSelector({
}) {
return (
<div>
<label className="block text-sm font-medium text-muted-foreground">Select SIM type</label>
<div className="mt-3 space-y-2">
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
<input
type="radio"
name="sim-type"
value="physical"
checked={selectedSimType === "physical"}
onChange={() => onSelect("physical")}
className="mt-1 text-primary focus:ring-ring"
/>
<Label className="block text-sm font-medium text-muted-foreground">Select SIM type</Label>
<RadioGroup
value={selectedSimType}
onValueChange={v => onSelect(v as SimKind)}
className="mt-3 space-y-2"
>
<Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
<RadioGroupItem value="physical" className="mt-1 text-primary focus:ring-ring" />
<div>
<p className="text-sm font-medium text-foreground">Physical SIM</p>
<p className="text-xs text-muted-foreground">
@ -54,16 +53,9 @@ function SimTypeSelector({
contact support to proceed.
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
<input
type="radio"
name="sim-type"
value="esim"
checked={selectedSimType === "esim"}
onChange={() => onSelect("esim")}
className="mt-1 text-primary focus:ring-ring"
/>
</Label>
<Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
<RadioGroupItem value="esim" className="mt-1 text-primary focus:ring-ring" />
<div>
<p className="text-sm font-medium text-foreground">eSIM</p>
<p className="text-xs text-muted-foreground">
@ -71,8 +63,8 @@ function SimTypeSelector({
processing completes.
</p>
</div>
</label>
</div>
</Label>
</RadioGroup>
</div>
);
}
@ -149,14 +141,16 @@ export function ReissueSimModal({
</p>
</div>
</div>
<button
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Close reissue SIM modal"
type="button"
>
<XMarkIcon className="h-5 w-5" />
</button>
</Button>
</div>
<div className="mt-6">
<AlertBanner variant="warning" title="Important information" elevated>

View File

@ -69,7 +69,7 @@ function ActionButton({
}`;
return (
<button onClick={onClick} disabled={disabled} className={buttonClasses}>
<Button variant="ghost" onClick={onClick} disabled={disabled} className={buttonClasses}>
<div className="flex items-center">
{icon}
<div className="text-left">
@ -82,7 +82,7 @@ function ActionButton({
{disabledReason}
</div>
)}
</button>
</Button>
);
}
@ -110,7 +110,7 @@ function ReissueButton({
}`;
return (
<button onClick={onClick} disabled={disabled} className={buttonClasses}>
<Button variant="ghost" onClick={onClick} disabled={disabled} className={buttonClasses}>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<ArrowPathIcon className="h-4 w-4 mr-3" />
@ -127,7 +127,7 @@ function ReissueButton({
{disabledReason}
</div>
)}
</button>
</Button>
);
}

View File

@ -217,8 +217,9 @@ function FeatureToggleRow({
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<button
<Button
type="button"
variant="ghost"
role="switch"
aria-checked={checked}
onClick={onChange}
@ -229,7 +230,7 @@ function FeatureToggleRow({
animate={{ x: checked ? 20 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
</Button>
</div>
);
}
@ -258,9 +259,10 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
aria-label="Network Type"
>
{options.map(value => (
<button
<Button
key={value}
type="button"
variant="ghost"
role="radio"
aria-checked={nt === value}
onClick={() => setNt(value)}
@ -278,7 +280,7 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
>
{value}
</span>
</button>
</Button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">5G connectivity for enhanced speeds</p>

View File

@ -14,6 +14,7 @@ import { apiClient } from "@/core/api";
import { useSubscription } from "@/features/subscriptions/hooks";
import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms/button";
import { Checkbox } from "@/components/atoms/checkbox";
import { formatIsoDate } from "@/shared/utils";
const { formatCurrency } = Formatting;
@ -462,9 +463,10 @@ type ActionTileProps = {
function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProps) {
const isDanger = intent === "danger";
return (
<button
<Button
variant="ghost"
onClick={onClick}
className={`flex flex-col gap-2 items-center justify-center rounded-xl border transition-all duration-200 p-5 text-center ${
className={`flex flex-col gap-2 items-center justify-center rounded-xl border transition-all duration-200 p-5 text-center h-auto ${
isDanger
? "border-danger/25 bg-danger-soft/40 hover:bg-danger-soft hover:shadow-[var(--cp-shadow-2)]"
: "border-border bg-muted/40 hover:bg-background hover:shadow-[var(--cp-shadow-2)]"
@ -476,7 +478,7 @@ function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProp
<span className={`text-sm font-medium ${isDanger ? "text-danger" : "text-foreground"}`}>
{label}
</span>
</button>
</Button>
);
}
@ -514,20 +516,21 @@ function StatusToggle({
<p className="font-medium text-foreground">{label}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
<label
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input
type="checkbox"
<Checkbox
checked={checked}
onChange={handleClick}
onCheckedChange={val => {
if (!isDisabled && onChange) {
onChange(val === true);
}
}}
disabled={isDisabled}
className="sr-only peer"
/>
<div
className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}
role="presentation"
onClick={handleClick}
className={`relative w-11 h-6 bg-muted rounded-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all ${checked ? "bg-primary after:translate-x-full after:border-background" : ""} ${loading ? "animate-pulse" : ""} ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
></div>
</label>
</div>
</div>
);

View File

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { apiClient } from "@/core/api";
import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing";
import { Button } from "@/components/atoms/button";
interface TopUpModalProps {
subscriptionId: number;
@ -104,12 +105,14 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<p className="text-sm text-gray-500">Add data quota to your SIM service</p>
</div>
</div>
<button
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<XMarkIcon className="h-6 w-6" />
</button>
</Button>
</div>
<form onSubmit={e => void handleSubmit(e)}>
<div className="mb-6">
@ -150,21 +153,22 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div>
)}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 space-y-3 space-y-reverse sm:space-y-0">
<button
<Button
variant="outline"
type="button"
onClick={onClose}
disabled={loading}
className="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Back
</button>
<button
</Button>
<Button
type="submit"
disabled={loading || !isValidAmount()}
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
</button>
</Button>
</div>
</form>
</div>

View File

@ -15,6 +15,7 @@ import type {
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { Button } from "@/components/atoms/button";
const { formatCurrency } = Formatting;
@ -285,16 +286,17 @@ interface TabButtonProps {
function TabButton({ active, onClick, icon, label, count }: TabButtonProps): React.ReactElement {
const tabClasses = active ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES;
return (
<button
<Button
variant="ghost"
onClick={onClick}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${tabClasses}`}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors rounded-none ${tabClasses}`}
>
{icon}
{label}
{count !== undefined && (
<span className="ml-1 text-xs text-muted-foreground/70">({count})</span>
)}
</button>
</Button>
);
}

Some files were not shown because too many files have changed in this diff Show More