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. **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 ## Docs
| Topic | Location | | Topic | Location |

View File

@ -29,6 +29,19 @@ const config: StorybookConfig = {
"next/navigation": path.resolve(__dirname, "mocks/next-navigation.tsx"), "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 // Disable PostCSS — @tailwindcss/vite handles CSS directly
config.css = config.css || {}; config.css = config.css || {};
config.css.postcss = { plugins: [] }; config.css.postcss = { plugins: [] };

View File

@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@customer-portal/domain": "workspace:*", "@customer-portal/domain": "workspace:*",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@xstate/react": "^6.0.0", "@xstate/react": "^6.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -35,10 +36,11 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"world-countries": "^5.1.0", "world-countries": "^5.1.0",
"xstate": "^5.28.0", "xstate": "^5.28.0",
"zod": "^4.3.6", "zod": "catalog:",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

View File

@ -270,6 +270,10 @@
--color-danger-bg: var(--danger-bg); --color-danger-bg: var(--danger-bg);
--color-danger-border: var(--danger-border); --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: var(--neutral);
--color-neutral-foreground: var(--neutral-foreground); --color-neutral-foreground: var(--neutral-foreground);
--color-neutral-bg: var(--neutral-bg); --color-neutral-bg: var(--neutral-bg);

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "./badge"; import { Badge } from "./badge";
import { Badge as LegacyBadge } from "./badge.legacy";
const meta: Meta<typeof Badge> = { const meta: Meta<typeof Badge> = {
title: "Atoms/Badge", title: "Atoms/Badge",
@ -69,3 +70,105 @@ export const WithDot: Story = {
export const Removable: Story = { export const Removable: Story = {
args: { children: "Removable", removable: true, onRemove: () => alert("Removed!") }, 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 type { Meta, StoryObj } from "@storybook/react";
import { Checkbox } from "./checkbox"; import { Checkbox } from "./checkbox";
import { Checkbox as LegacyCheckbox } from "./checkbox.legacy";
const meta: Meta<typeof Checkbox> = { const meta: Meta<typeof Checkbox> = {
title: "Atoms/Checkbox", title: "Atoms/Checkbox",
@ -37,3 +38,36 @@ export const AllStates: Story = {
</div> </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 type { Meta, StoryObj } from "@storybook/react";
import { ErrorMessage } from "./error-message"; import { ErrorMessage } from "./error-message";
import { ErrorMessage as LegacyErrorMessage } from "./error-message.legacy";
const meta: Meta<typeof ErrorMessage> = { const meta: Meta<typeof ErrorMessage> = {
title: "Atoms/ErrorMessage", title: "Atoms/ErrorMessage",
@ -30,3 +31,34 @@ export const AllVariants: Story = {
export const WithoutIcon: Story = { export const WithoutIcon: Story = {
args: { children: "Error without icon", showIcon: false }, 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 type { Meta, StoryObj } from "@storybook/react";
import { ErrorState } from "./error-state"; import { ErrorState } from "./error-state";
import { ErrorState as LegacyErrorState } from "./error-state.legacy";
const meta: Meta<typeof ErrorState> = { const meta: Meta<typeof ErrorState> = {
title: "Atoms/ErrorState", title: "Atoms/ErrorState",
@ -46,3 +47,47 @@ export const AllVariants: Story = {
</div> </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 type { Meta, StoryObj } from "@storybook/react";
import { InlineToast } from "./inline-toast"; import { InlineToast } from "./inline-toast";
import { InlineToast as LegacyInlineToast } from "./inline-toast.legacy";
const meta: Meta<typeof InlineToast> = { const meta: Meta<typeof InlineToast> = {
title: "Atoms/InlineToast", title: "Atoms/InlineToast",
@ -34,3 +35,34 @@ export const AllTones: Story = {
</div> </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 type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./input"; import { Input } from "./input";
import { Input as LegacyInput } from "./input.legacy";
const meta: Meta<typeof Input> = { const meta: Meta<typeof Input> = {
title: "Atoms/Input", title: "Atoms/Input",
@ -35,3 +36,34 @@ export const AllStates: Story = {
</div> </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 type { Meta, StoryObj } from "@storybook/react";
import { Label } from "./label"; import { Label } from "./label";
import { Label as LegacyLabel } from "./label.legacy";
const meta: Meta<typeof Label> = { const meta: Meta<typeof Label> = {
title: "Atoms/Label", title: "Atoms/Label",
@ -20,3 +21,34 @@ export const Required: Story = {
</Label> </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 type { Meta, StoryObj } from "@storybook/react";
import { Skeleton } from "./skeleton"; import { Skeleton } from "./skeleton";
import { Skeleton as LegacySkeleton } from "./skeleton.legacy";
const meta: Meta<typeof Skeleton> = { const meta: Meta<typeof Skeleton> = {
title: "Atoms/Skeleton", title: "Atoms/Skeleton",
@ -28,3 +29,36 @@ export const CommonPatterns: Story = {
export const NoAnimation: Story = { export const NoAnimation: Story = {
args: { className: "h-4 w-48", animate: false }, 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 { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { ViewToggle, type ViewMode } from "./view-toggle"; import { ViewToggle, type ViewMode } from "./view-toggle";
import { ViewToggle as LegacyViewToggle } from "./view-toggle.legacy";
const meta: Meta<typeof ViewToggle> = { const meta: Meta<typeof ViewToggle> = {
title: "Atoms/ViewToggle", title: "Atoms/ViewToggle",
@ -23,3 +24,31 @@ export const ListView: Story = {
return <ViewToggle value={mode} onChange={setMode} />; 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 type { Meta, StoryObj } from "@storybook/react";
import { AlertBanner } from "./AlertBanner"; import { AlertBanner } from "./AlertBanner";
import { AlertBanner as AlertBannerLegacy } from "./AlertBanner.legacy";
const meta: Meta<typeof AlertBanner> = { const meta: Meta<typeof AlertBanner> = {
title: "Molecules/AlertBanner", title: "Molecules/AlertBanner",
@ -77,3 +78,61 @@ export const Closable: Story = {
export const Small: Story = { export const Small: Story = {
args: { variant: "warning", title: "Heads up", size: "sm" }, 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, ExclamationTriangleIcon,
XCircleIcon, XCircleIcon,
} from "@heroicons/react/24/outline"; } 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 Variant = "success" | "info" | "warning" | "error";
type IconType = React.ComponentType<React.SVGProps<SVGSVGElement>>; type IconType = React.ComponentType<React.SVGProps<SVGSVGElement>>;
const variantClasses: Record< const variantStyles: Record<
Variant, Variant,
{ bg: string; border: string; text: string; icon: string; Icon: IconType } { bg: string; border: string; text: string; icon: string; Icon: IconType }
> = { > = {
@ -66,42 +68,39 @@ export function AlertBanner({
className, className,
...rest ...rest
}: AlertBannerProps) { }: AlertBannerProps) {
const styles = variantClasses[variant]; const styles = variantStyles[variant];
const Icon = styles.Icon; const Icon = styles.Icon;
const padding = size === "sm" ? "p-3" : "p-4"; const padding = size === "sm" ? "px-3 py-2" : "px-4 py-3";
const radius = "rounded-xl";
const shadow = elevated ? "shadow-sm" : "";
const role = variant === "error" || variant === "warning" ? "alert" : "status";
return ( return (
<div <Alert
className={[radius, padding, "border", shadow, styles.bg, styles.border, className] className={cn(
.filter(Boolean) "rounded-xl",
.join(" ")} padding,
role={role} styles.bg,
styles.border,
elevated && "shadow-sm",
className
)}
{...rest} {...rest}
> >
<div className="flex items-start gap-3"> {icon ? (
<div className="mt-0.5 flex-shrink-0"> <span className={cn("size-5", styles.icon)}>{icon}</span>
{icon ? icon : <Icon className={["h-5 w-5", styles.icon].join(" ")} />} ) : (
</div> <Icon className={cn("size-5", styles.icon)} />
<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> {title && <AlertTitle className={cn("font-medium", styles.text)}>{title}</AlertTitle>}
{children && <AlertDescription className="text-foreground/80">{children}</AlertDescription>}
{onClose && ( {onClose && (
<button <button
onClick={onClose} onClick={onClose}
aria-label="Close alert" 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> </button>
)} )}
</div> </Alert>
</div>
); );
} }

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 type { Meta, StoryObj } from "@storybook/react";
import { AnimatedCard } from "./AnimatedCard"; import { AnimatedCard } from "./AnimatedCard";
import { AnimatedCard as AnimatedCardLegacy } from "./AnimatedCard.legacy";
const meta: Meta<typeof AnimatedCard> = { const meta: Meta<typeof AnimatedCard> = {
title: "Molecules/AnimatedCard", title: "Molecules/AnimatedCard",
@ -52,3 +53,46 @@ export const Disabled: Story = {
children: <div className="p-6">Disabled card</div>, 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 { motion } from "framer-motion";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { cn } from "@/shared/utils";
interface AnimatedCardProps { interface AnimatedCardProps {
children: ReactNode; children: ReactNode;
@ -21,8 +22,6 @@ export function AnimatedCard({
onClick, onClick,
disabled = false, disabled = false,
}: AnimatedCardProps) { }: AnimatedCardProps) {
const baseClasses = "bg-card text-card-foreground rounded-xl border";
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = { const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
default: "border-border", default: "border-border",
highlighted: "border-primary/35 ring-1 ring-primary/15", highlighted: "border-primary/35 ring-1 ring-primary/15",
@ -37,7 +36,13 @@ export function AnimatedCard({
return ( return (
<motion.div <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 }} initial={{ boxShadow: SHADOW_BASE }}
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }} whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
transition={{ duration: 0.2 }} 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 type { Meta, StoryObj } from "@storybook/react";
import { DataTable } from "./DataTable"; import { DataTable } from "./DataTable";
import { DataTable as LegacyDataTable } from "./DataTable.legacy";
import { InboxIcon } from "@heroicons/react/24/outline"; import { InboxIcon } from "@heroicons/react/24/outline";
interface SampleRow { 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 type { ReactNode } from "react";
import { EmptyState } from "@/components/atoms/empty-state"; 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"; import { ChevronRightIcon } from "@heroicons/react/24/outline";
interface Column<T> { interface Column<T> {
@ -44,13 +53,13 @@ function MobileCardView<T extends { id: number | string }>({
{data.map((item, index) => ( {data.map((item, index) => (
<div <div
key={item.id} key={item.id}
className={` className={cn(
bg-card border border-border rounded-xl p-4 "bg-card border border-border rounded-xl p-4",
shadow-[var(--cp-shadow-1)] "shadow-[var(--cp-shadow-1)]",
transition-all duration-[var(--cp-duration-fast)] "transition-all duration-[var(--cp-duration-fast)]",
active:scale-[0.98] active:shadow-none "active:scale-[0.98] active:shadow-none",
${onRowClick ? "cursor-pointer active:bg-muted/50" : ""} onRowClick && "cursor-pointer active:bg-muted/50"
`} )}
onClick={() => onRowClick?.(item)} onClick={() => onRowClick?.(item)}
role={onRowClick ? "button" : undefined} role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined} tabIndex={onRowClick ? 0 : undefined}
@ -107,39 +116,39 @@ function DesktopTableView<T extends { id: number | string }>({
forceTableView: boolean; forceTableView: boolean;
}) { }) {
return ( return (
<div className={`${forceTableView ? "" : "hidden md:block"} overflow-x-auto`}> <div className={cn(!forceTableView && "hidden md:block")}>
<table className={`min-w-full divide-y divide-border ${className}`}> <Table className={className}>
<thead className="bg-muted/50"> <TableHeader>
<tr> <TableRow>
{columns.map(column => ( {columns.map(column => (
<th <TableHead
key={column.key} 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} {column.header}
</th> </TableHead>
))} ))}
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="bg-card divide-y divide-border"> <TableBody>
{data.map(item => ( {data.map(item => (
<tr <TableRow
key={item.id} 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)} onClick={() => onRowClick?.(item)}
> >
{columns.map(column => ( {columns.map(column => (
<td <TableCell key={column.key} className={column.className}>
key={column.key}
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`}
>
{column.render(item)} {column.render(item)}
</td> </TableCell>
))} ))}
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </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 { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { FilterDropdown } from "./FilterDropdown"; import { FilterDropdown } from "./FilterDropdown";
import { FilterDropdown as FilterDropdownLegacy } from "./FilterDropdown.legacy";
const meta: Meta<typeof FilterDropdown> = { const meta: Meta<typeof FilterDropdown> = {
title: "Molecules/FilterDropdown", title: "Molecules/FilterDropdown",
@ -10,6 +11,20 @@ const meta: Meta<typeof FilterDropdown> = {
export default meta; export default meta;
type Story = StoryObj<typeof FilterDropdown>; 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 = { export const Default: Story = {
render: function Render() { render: function Render() {
const [value, setValue] = useState("all"); const [value, setValue] = useState("all");
@ -17,12 +32,7 @@ export const Default: Story = {
<FilterDropdown <FilterDropdown
value={value} value={value}
onChange={setValue} onChange={setValue}
options={[ options={statusOptions}
{ value: "all", label: "All Statuses" },
{ value: "active", label: "Active" },
{ value: "pending", label: "Pending" },
{ value: "cancelled", label: "Cancelled" },
]}
label="Filter by status" label="Filter by status"
/> />
); );
@ -36,15 +46,39 @@ export const CustomWidth: Story = {
<FilterDropdown <FilterDropdown
value={value} value={value}
onChange={setValue} onChange={setValue}
options={[ options={categoryOptions}
{ value: "all", label: "All Categories" },
{ value: "billing", label: "Billing" },
{ value: "technical", label: "Technical" },
{ value: "general", label: "General" },
]}
label="Filter by category" label="Filter by category"
width="w-48" 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 { FunnelIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export interface FilterOption { export interface FilterOption {
value: string; 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. * Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc.
*/ */
@ -37,28 +44,18 @@ export function FilterDropdown({
className, className,
}: FilterDropdownProps) { }: FilterDropdownProps) {
return ( return (
<div className={cn("relative", className)}> <Select value={value} onValueChange={onChange}>
<select <SelectTrigger className={cn(width, className)} aria-label={label}>
value={value} <FunnelIcon className="h-4 w-4 text-muted-foreground shrink-0" />
onChange={event => onChange(event.target.value)} <SelectValue />
className={cn( </SelectTrigger>
"block pl-3 pr-8 py-2.5 text-sm border border-border", <SelectContent>
"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 => ( {options.map(option => (
<option key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none"> </Select>
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
); );
} }

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 type { Meta, StoryObj } from "@storybook/react";
import { FormField } from "./FormField"; import { FormField } from "./FormField";
import { FormField as FormFieldLegacy } from "./FormField.legacy";
const meta: Meta<typeof FormField> = { const meta: Meta<typeof FormField> = {
title: "Molecules/FormField", title: "Molecules/FormField",
@ -41,3 +42,71 @@ export const FormExample: Story = {
</div> </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-invalid={error ? "true" : undefined}
aria-describedby={describedBy} aria-describedby={describedBy}
className={cn( 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, inputClassName,
inputPropsClassName inputPropsClassName
)} )}
@ -79,14 +80,14 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
htmlFor={id} htmlFor={id}
className={cn( className={cn(
"block text-sm font-medium text-muted-foreground", "block text-sm font-medium text-muted-foreground",
error && "text-danger", error && "text-destructive",
labelProps?.className labelProps?.className
)} )}
{...(labelProps ? { ...labelProps, className: undefined } : undefined)} {...(labelProps ? { ...labelProps, className: undefined } : undefined)}
> >
<span>{label}</span> <span>{label}</span>
{required && ( {required && (
<span aria-hidden="true" className="ml-1 text-danger"> <span aria-hidden="true" className="ml-1 text-destructive">
* *
</span> </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 type { Meta, StoryObj } from "@storybook/react";
import { MetricCard, MetricCardSkeleton } from "./MetricCard"; import { MetricCard, MetricCardSkeleton } from "./MetricCard";
import { MetricCard as MetricCardLegacy } from "./MetricCard.legacy";
import { import {
CurrencyYenIcon, CurrencyYenIcon,
UsersIcon, UsersIcon,
@ -81,3 +82,46 @@ export const LoadingSkeleton: Story = {
</div> </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 type { ReactNode } from "react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { Card, CardContent } from "@/components/ui/card";
type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral"; type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
@ -34,13 +35,14 @@ export function MetricCard({
const styles = toneStyles[tone]; const styles = toneStyles[tone];
return ( return (
<div <Card
className={cn( 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)]", "transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
className className
)} )}
> >
<CardContent className="flex items-start gap-3.5 p-4">
{icon && ( {icon && (
<div <div
className={cn( className={cn(
@ -59,7 +61,10 @@ export function MetricCard({
</p> </p>
{trend && ( {trend && (
<span <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} {trend.value}
</span> </span>
@ -67,7 +72,8 @@ export function MetricCard({
</div> </div>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div> </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 { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { OtpInput } from "./OtpInput"; import { OtpInput } from "./OtpInput";
import { LegacyOtpInput } from "./OtpInput.legacy";
const meta: Meta<typeof OtpInput> = { const meta: Meta<typeof OtpInput> = {
title: "Molecules/OtpInput", title: "Molecules/OtpInput",
@ -44,3 +45,70 @@ export const FourDigit: Story = {
return <OtpInput value={value} onChange={setValue} length={4} autoFocus={false} />; 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"; "use client";
import { REGEXP_ONLY_DIGITS } from "input-otp"; import { REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/shared/utils";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
interface OtpInputProps { interface OtpInputProps {
@ -45,13 +44,18 @@ export function OtpInput({
> >
<InputOTPGroup> <InputOTPGroup>
{Array.from({ length }, (_, i) => ( {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> </InputOTPGroup>
</InputOTP> </InputOTP>
</div> </div>
{error && ( {error && (
<p className="text-sm text-danger text-center" role="alert"> <p className="text-sm text-destructive text-center" role="alert">
{error} {error}
</p> </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 { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { PaginationBar } from "./PaginationBar"; import { PaginationBar } from "./PaginationBar";
import { PaginationBar as PaginationBarLegacy } from "./PaginationBar.legacy";
const meta: Meta<typeof PaginationBar> = { const meta: Meta<typeof PaginationBar> = {
title: "Molecules/PaginationBar", title: "Molecules/PaginationBar",
@ -37,3 +38,27 @@ export const LastPage: Story = {
export const SinglePage: Story = { export const SinglePage: Story = {
args: { currentPage: 1, pageSize: 10, totalItems: 5, onPageChange: () => {} }, 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"; "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 { interface PaginationBarProps {
currentPage: number; currentPage: number;
@ -10,6 +20,37 @@ interface PaginationBarProps {
className?: string; 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({ export function PaginationBar({
currentPage, currentPage,
pageSize, pageSize,
@ -21,54 +62,79 @@ export function PaginationBar({
const canPrev = currentPage > 1; const canPrev = currentPage > 1;
const canNext = currentPage < totalPages; 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 ( return (
<div className={`px-1 sm:px-0 py-1 flex items-center justify-between ${className || ""}`}> <div
<div className="flex-1 flex justify-between sm:hidden"> className={cn(
<button "flex flex-col items-center gap-4 py-1 sm:flex-row sm:justify-between",
onClick={() => onPageChange(Math.max(1, currentPage - 1))} className
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"> <p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "} Showing <span className="font-medium">{startItem}</span> to{" "}
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> of{" "} <span className="font-medium">{endItem}</span> of{" "}
<span className="font-medium">{totalItems}</span> results <span className="font-medium">{totalItems}</span> results
</p> </p>
</div>
<div> <Pagination className="mx-0 w-auto">
<nav <PaginationContent>
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" <PaginationItem>
aria-label="Pagination" <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 {page}
onClick={() => onPageChange(Math.max(1, currentPage - 1))} </PaginationLink>
disabled={!canPrev} </PaginationItem>
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> <PaginationItem>
<button <PaginationNext
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))} onClick={e => {
disabled={!canNext} e.preventDefault();
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" if (canNext) onPageChange(currentPage + 1);
> }}
Next aria-disabled={!canNext}
</button> className={cn(!canNext && "pointer-events-none opacity-50")}
</nav> role="button"
</div> tabIndex={canNext ? 0 : -1}
</div> />
</PaginationItem>
</PaginationContent>
</Pagination>
</div> </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 type { Meta, StoryObj } from "@storybook/react";
import { SectionCard } from "./SectionCard"; import { SectionCard } from "./SectionCard";
import { SectionCard as SectionCardLegacy } from "./SectionCard.legacy";
import { CreditCardIcon, UserIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, UserIcon } from "@heroicons/react/24/outline";
import { Button } from "../../atoms/button"; import { Button } from "../../atoms/button";
@ -70,3 +71,56 @@ export const AllTones: Story = {
</div> </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 type { ReactNode } from "react";
import { cn } from "@/shared/utils"; 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"; type SectionTone = "primary" | "success" | "info" | "warning" | "danger" | "neutral";
@ -32,14 +34,8 @@ export function SectionCard({
className, className,
}: SectionCardProps) { }: SectionCardProps) {
return ( return (
<div <Card className={cn("gap-0 py-0 shadow-[var(--cp-shadow-1)] overflow-hidden", className)}>
className={cn( <CardHeader className="bg-muted/40 px-6 py-4 gap-0">
"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="flex items-center gap-3 min-w-0">
<div <div
className={cn( className={cn(
@ -54,10 +50,14 @@ export function SectionCard({
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div> </div>
</div> </div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>} {actions && (
</div> <CardAction>
</div> <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>
<div className="px-6 py-5">{children}</div> </CardAction>
</div> )}
</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 type { Meta, StoryObj } from "@storybook/react";
import { SubCard } from "./SubCard"; import { SubCard } from "./SubCard";
import { SubCard as SubCardLegacy } from "./SubCard.legacy";
import { CreditCardIcon, CogIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, CogIcon } from "@heroicons/react/24/outline";
import { Button } from "../../atoms/button"; 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>, 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 { forwardRef, type ReactNode } from "react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { Card, CardHeader, CardAction, CardContent, CardFooter } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export interface SubCardProps { export interface SubCardProps {
title?: string; title?: string;
@ -31,17 +33,17 @@ function renderSubCardHeader({
headerClassName, headerClassName,
}: SubCardHeaderOptions): ReactNode { }: SubCardHeaderOptions): ReactNode {
if (header) { 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) { if (title) {
return ( 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"> <div className="flex items-center gap-3">
{icon && <div className="text-primary">{icon}</div>} {icon && <div className="text-primary">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3> <h3 className="text-lg font-semibold text-foreground">{title}</h3>
</div> </div>
{right} {right && <CardAction>{right}</CardAction>}
</div> </CardHeader>
); );
} }
return null; return null;
@ -63,19 +65,24 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
}, },
ref ref
) => ( ) => (
<div <Card
ref={ref} ref={ref}
className={cn( 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 && interactive &&
"transition-all duration-200 hover:shadow-md hover:border-border/80 cursor-pointer", "transition-all duration-200 hover:shadow-md hover:border-border/80 cursor-pointer",
className className
)} )}
> >
{renderSubCardHeader({ header, title, icon, right, headerClassName })} {renderSubCardHeader({ header, title, icon, right, headerClassName })}
<div className={bodyClassName}>{children}</div> <CardContent className={cn("px-0 py-0", bodyClassName)}>{children}</CardContent>
{footer ? <div className="mt-5 pt-5 border-t border-border/60">{footer}</div> : null} {footer ? (
</div> <>
<Separator className="mt-5 bg-border/60" />
<CardFooter className="px-0 pt-5 pb-0">{footer}</CardFooter>
</>
) : null}
</Card>
) )
); );
SubCard.displayName = "SubCard"; 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. * import from "@/components/atoms" instead.
*/ */
// Form primitives // Accordion
export { Button, buttonVariants } from "./button"; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
export { Input } from "./input";
export { Checkbox } from "./checkbox";
export { Label } from "./label";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
// Display primitives // Alert
export { Badge, badgeVariants } from "./badge";
export { Skeleton } from "./skeleton";
export { Alert, AlertTitle, AlertDescription } from "./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"; export { Toggle, toggleVariants } from "./toggle";
// Toggle Group
export { ToggleGroup, ToggleGroupItem } from "./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 * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp"; import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react"; import { MinusIcon } from "lucide-react";
import { cn } from "@/shared/utils";
import { cn } from "@/lib/utils";
function InputOTP({ function InputOTP({
className, className,
containerClassName, containerClassName,
...props ...props
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) { }: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return ( return (
<OTPInput <OTPInput
data-slot="input-otp" data-slot="input-otp"
@ -30,20 +33,18 @@ function InputOTPSlot({
index, index,
className, className,
...props ...props
}: React.ComponentProps<"div"> & { index: number }) { }: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext); 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 ( return (
<div <div
data-slot="input-otp-slot" data-slot="input-otp-slot"
data-active={isActive} data-active={isActive}
className={cn( 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", "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",
"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 className
)} )}
{...props} {...props}
@ -51,7 +52,7 @@ function InputOTPSlot({
{char} {char}
{hasFakeCaret && ( {hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <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>
)} )}
</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 { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { Input } from "@/components/atoms/input"; import { Input } from "@/components/atoms/input";
import { Label } from "@/components/atoms/label";
/** Data required for displaying personal info card */ /** Data required for displaying personal info card */
interface PersonalInfoData { interface PersonalInfoData {
@ -41,7 +42,7 @@ function ReadOnlyField({
}) { }) {
return ( return (
<div> <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"> <div className="bg-card rounded-lg p-4 border border-border shadow-sm">
<p className="text-base text-foreground font-medium"> <p className="text-base text-foreground font-medium">
{value || <span className="text-muted-foreground italic">Not provided</span>} {value || <span className="text-muted-foreground italic">Not provided</span>}
@ -68,7 +69,7 @@ function EditableEmailField({
}) { }) {
return ( return (
<div className="sm:col-span-2"> <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 ? ( {isEditing ? (
<Input <Input
type="email" type="email"
@ -101,7 +102,7 @@ function EditablePhoneField({
}) { }) {
return ( return (
<div> <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 ? ( {isEditing ? (
<Input <Input
type="tel" type="tel"

View File

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

View File

@ -13,7 +13,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; 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 { FormField } from "@/components/molecules/FormField/FormField";
import { LoginOtpStep } from "../LoginOtpStep"; import { LoginOtpStep } from "../LoginOtpStep";
import { useLoginWithOtp } from "../../hooks/use-auth"; import { useLoginWithOtp } from "../../hooks/use-auth";
@ -240,20 +240,14 @@ function CredentialsForm({
/> />
</FormField> </FormField>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <Checkbox
<input
id="remember-me" id="remember-me"
name="remember-me" name="remember-me"
type="checkbox" checked={values.rememberMe ?? false}
checked={values.rememberMe} onCheckedChange={val => setValue("rememberMe", val === true)}
onChange={e => setValue("rememberMe", e.target.checked)}
disabled={isDisabled} 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 && ( {showForgotPasswordLink && (
<div className="text-sm"> <div className="text-sm">
<Link <Link

View File

@ -8,7 +8,7 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; 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 { OtpInput, formatTimeRemaining } from "@/components/molecules";
import { ArrowLeft, Mail, Clock } from "lucide-react"; import { ArrowLeft, Mail, Clock } from "lucide-react";
@ -124,18 +124,14 @@ export function LoginOtpStep({
timeRemaining={timeRemaining} timeRemaining={timeRemaining}
/> />
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<input <Checkbox
id="remember-device" id="remember-device"
name="remember-device" name="remember-device"
type="checkbox"
checked={rememberDevice} checked={rememberDevice}
onChange={e => setRememberDevice(e.target.checked)} onCheckedChange={val => setRememberDevice(val === true)}
disabled={isSubmitting || isExpired} 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> </div>
<Button <Button
type="button" type="button"
@ -147,15 +143,16 @@ export function LoginOtpStep({
> >
Verify and Sign In Verify and Sign In
</Button> </Button>
<button <Button
type="button" type="button"
variant="ghost"
onClick={onBack} onClick={onBack}
disabled={isSubmitting} 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" /> <ArrowLeft className="w-4 h-4" />
<span>Back to login</span> <span>Back to login</span>
</button> </Button>
<p className="text-xs text-center text-muted-foreground"> <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. Didn&apos;t receive the code? Check your spam folder or go back to try again.
</p> </p>

View File

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

View File

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

View File

@ -104,14 +104,15 @@ function EmailSentConfirmation({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<button <Button
type="button" type="button"
variant="ghost"
onClick={onBackToForm} onClick={onBackToForm}
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200" 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" /> <ArrowLeft className="w-4 h-4" />
<span>Try a different email</span> <span>Try a different email</span>
</button> </Button>
<Link <Link
href="/auth/login" href="/auth/login"
className="block text-center text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]" 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, ChevronRightIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { Activity } from "@customer-portal/domain/dashboard"; import type { Activity } from "@customer-portal/domain/dashboard";
import { Button } from "@/components/atoms/button";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils"; import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { motionVariants } from "@/components/atoms"; import { motionVariants } from "@/components/atoms";
@ -99,13 +100,14 @@ function ActivityItem({ activity, isLast = false }: ActivityItemProps) {
if (isClickable) { if (isClickable) {
return ( return (
<button <Button
type="button" type="button"
variant="ghost"
onClick={() => router.push(navPath!)} 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} {content}
</button> </Button>
); );
} }

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import type { ComponentType, SVGProps } from "react"; import type { ComponentType, SVGProps } from "react";
import { Button } from "@/components/atoms/button";
import { import {
DocumentTextIcon, DocumentTextIcon,
CheckCircleIcon, CheckCircleIcon,
@ -74,13 +75,14 @@ export function DashboardActivityItem({
if (onClick) { if (onClick) {
return ( return (
<button <Button
type="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} onClick={onClick}
> >
{content} {content}
</button> </Button>
); );
} }

View File

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

View File

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

View File

@ -1,9 +1,12 @@
"use client"; "use client";
import { useRef } from "react"; import { useRef } from "react";
import { motion, useInView } from "framer-motion"; import { LayoutGroup, motion, useInView } from "framer-motion";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button"; 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 { interface HeroSectionProps {
heroCTARef: React.RefObject<HTMLDivElement | null>; 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"> <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"> <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">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> </h1>
</LayoutGroup>
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto"> <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 From connectivity to communication, we handle the complexity so you can focus on what
matters with dedicated English support across Japan. matters with dedicated English support across Japan.

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { CheckIcon } from "@heroicons/react/24/outline"; import { CheckIcon } from "@heroicons/react/24/outline";
import { BellSlashIcon } from "@heroicons/react/24/solid"; import { BellSlashIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/atoms/button";
import { import {
useNotifications, useNotifications,
useMarkNotificationAsRead, 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"> <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> <h3 className="text-sm font-semibold text-foreground">Notifications</h3>
{hasUnread && ( {hasUnread && (
<button <Button
type="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()} onClick={() => markAllAsRead.mutate()}
disabled={markAllAsRead.isPending} disabled={markAllAsRead.isPending}
> >
<CheckIcon className="h-3.5 w-3.5" /> <CheckIcon className="h-3.5 w-3.5" />
Mark all read Mark all read
</button> </Button>
)} )}
</div> </div>

View File

@ -3,6 +3,7 @@
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { import {
CheckCircleIcon, CheckCircleIcon,
ExclamationCircleIcon, ExclamationCircleIcon,
@ -84,14 +85,16 @@ export const NotificationItem = memo(function NotificationItem({
</div> </div>
{/* Dismiss button */} {/* Dismiss button */}
<button <Button
type="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} onClick={handleDismiss}
aria-label="Dismiss notification" aria-label="Dismiss notification"
> >
<XMarkIcon className="h-4 w-4 text-muted-foreground" /> <XMarkIcon className="h-4 w-4 text-muted-foreground" />
</button> </Button>
{/* Unread indicator */} {/* Unread indicator */}
{!notification.read && ( {!notification.read && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { Wrench, Sparkles, Network, ChevronDown, ChevronUp, HelpCircle } from "lucide-react"; import { Wrench, Sparkles, Network, ChevronDown, ChevronUp, HelpCircle } from "lucide-react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { Button } from "@/components/atoms/button";
interface PlanGuideItemProps { interface PlanGuideItemProps {
tier: "Silver" | "Gold" | "Platinum"; tier: "Silver" | "Gold" | "Platinum";
@ -85,10 +86,11 @@ export function PlanComparisonGuide() {
return ( return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden"> <section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
{/* Collapsible header */} {/* Collapsible header */}
<button <Button
variant="ghost"
type="button" type="button"
onClick={() => setIsExpanded(!isExpanded)} 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 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"> <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" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
)} )}
</div> </div>
</button> </Button>
{/* Expandable content */} {/* Expandable content */}
{isExpanded && ( {isExpanded && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
InformationCircleIcon, InformationCircleIcon,
CheckIcon, CheckIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
const SIM_TYPE_PHYSICAL = "Physical SIM" as const; 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 }) { function ESimOption({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) {
return ( return (
<button <Button
variant="ghost"
type="button" type="button"
onClick={onSelect} 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 isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20" ? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50" : "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> <span>EID number required</span>
</li> </li>
</ul> </ul>
</button> </Button>
); );
} }
@ -85,10 +87,11 @@ function PhysicalSimOption({
onSelect: () => void; onSelect: () => void;
}) { }) {
return ( return (
<button <Button
variant="ghost"
type="button" type="button"
onClick={onSelect} 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 isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20" ? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50" : "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> <span>3-in-1 size (Nano/Micro/Standard)</span>
</li> </li>
</ul> </ul>
</button> </Button>
); );
} }
@ -197,13 +200,14 @@ function EidInput({
{hasError && <p className="text-destructive text-sm mt-2">{errors["eid"]}</p>} {hasError && <p className="text-destructive text-sm mt-2">{errors["eid"]}</p>}
{!hasError && warning && <p className="text-warning text-sm mt-2">{warning}</p>} {!hasError && warning && <p className="text-warning text-sm mt-2">{warning}</p>}
<button <Button
variant="ghost"
type="button" type="button"
onClick={() => setShowEidInfo(!showEidInfo)} 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?"} {showEidInfo ? "Hide" : "Where to find your EID?"}
</button> </Button>
{showEidInfo && ( {showEidInfo && (
<div className="mt-3 p-4 bg-card border border-border rounded-lg text-sm"> <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 { PageLayout } from "@/components/templates/PageLayout";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms"; 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"; import { CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ============================================================================ // ============================================================================
@ -157,22 +166,24 @@ function Step1Content({
</div> </div>
{serviceInfo} {serviceInfo}
<div> <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 Cancellation Month
</label> </Label>
<select <Select value={selectedMonth || ""} onValueChange={onMonthChange}>
<SelectTrigger
id="cancelMonth" 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" 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 => ( {availableMonths.map(month => (
<option key={month.value} value={month.value}> <SelectItem key={month.value} value={month.value}>
{month.label} {month.label}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Service will end at the end of the selected month. Service will end at the end of the selected month.
</p> </p>
@ -221,22 +232,20 @@ function Step2Content({
</div> </div>
{termsContent} {termsContent}
<div className="space-y-4 p-4 bg-muted/50 rounded-lg"> <div className="space-y-4 p-4 bg-muted/50 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer"> <Label className="flex items-start gap-3 cursor-pointer">
<input <Checkbox
type="checkbox"
checked={acceptTerms} 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" 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"> <span className="text-sm text-foreground">
I have read and understood the cancellation terms above. I have read and understood the cancellation terms above.
</span> </span>
</label> </Label>
<label className="flex items-start gap-3 cursor-pointer"> <Label className="flex items-start gap-3 cursor-pointer">
<input <Checkbox
type="checkbox"
checked={confirmMonthEnd} checked={confirmMonthEnd}
onChange={e => onConfirmMonthEndChange(e.target.checked)} onCheckedChange={val => onConfirmMonthEndChange(val === true)}
disabled={!selectedMonth} disabled={!selectedMonth}
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary disabled:opacity-50" 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{" "} I confirm cancellation at the end of{" "}
<strong>{selectedMonthLabel || "the selected month"}</strong>. <strong>{selectedMonthLabel || "the selected month"}</strong>.
</span> </span>
</label> </Label>
</div> </div>
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack}> <Button variant="ghost" onClick={onBack}>
@ -296,9 +305,9 @@ function Step3Content({
<div className="text-sm font-medium text-foreground">{customerEmail || "—"}</div> <div className="text-sm font-medium text-foreground">{customerEmail || "—"}</div>
</div> </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> Additional Comments <span className="font-normal text-muted-foreground">(optional)</span>
</label> </Label>
<textarea <textarea
id="comments" 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" 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 { apiClient } from "@/core/api";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { mapToSimplifiedFormat } from "../../utils/plan"; 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 { interface ChangePlanModalProps {
subscriptionId: number; subscriptionId: number;
@ -28,19 +37,19 @@ function PlanSelector({
}) { }) {
return ( return (
<div> <div>
<label className="block text-sm font-medium text-gray-700">Select New Plan</label> <Label className="block text-sm font-medium text-gray-700">Select New Plan</Label>
<select <Select value={value || ""} onValueChange={v => onChange(v as PlanCode)}>
value={value} <SelectTrigger className="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
onChange={e => onChange(e.target.value as PlanCode)} <SelectValue placeholder="Choose a plan" />
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" </SelectTrigger>
> <SelectContent>
<option value="">Choose a plan</option>
{allowedPlans.map(code => ( {allowedPlans.map(code => (
<option key={code} value={code}> <SelectItem key={code} value={code}>
{code} {code}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> <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 Only plans different from your current plan are listed. The change will be scheduled for the
1st of the next month. 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="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3> <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" /> <XMarkIcon className="h-5 w-5" />
</button> </Button>
</div> </div>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<PlanSelector <PlanSelector
@ -120,22 +134,23 @@ export function ChangePlanModal({
</div> </div>
</div> </div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button <Button
type="button" type="button"
onClick={() => void submit()} onClick={() => void submit()}
disabled={loading} 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" 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"} {loading ? "Processing..." : "Change Plan"}
</button> </Button>
<button <Button
variant="outline"
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={loading} 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" 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 Back
</button> </Button>
</div> </div>
</motion.div> </motion.div>
</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 { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { Label } from "@/components/atoms/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SimKind = "physical" | "esim"; type SimKind = "physical" | "esim";
@ -36,17 +38,14 @@ function SimTypeSelector({
}) { }) {
return ( return (
<div> <div>
<label className="block text-sm font-medium text-muted-foreground">Select SIM type</label> <Label className="block text-sm font-medium text-muted-foreground">Select SIM type</Label>
<div className="mt-3 space-y-2"> <RadioGroup
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3"> value={selectedSimType}
<input onValueChange={v => onSelect(v as SimKind)}
type="radio" className="mt-3 space-y-2"
name="sim-type" >
value="physical" <Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
checked={selectedSimType === "physical"} <RadioGroupItem value="physical" className="mt-1 text-primary focus:ring-ring" />
onChange={() => onSelect("physical")}
className="mt-1 text-primary focus:ring-ring"
/>
<div> <div>
<p className="text-sm font-medium text-foreground">Physical SIM</p> <p className="text-sm font-medium text-foreground">Physical SIM</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -54,16 +53,9 @@ function SimTypeSelector({
contact support to proceed. contact support to proceed.
</p> </p>
</div> </div>
</label> </Label>
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3"> <Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
<input <RadioGroupItem value="esim" className="mt-1 text-primary focus:ring-ring" />
type="radio"
name="sim-type"
value="esim"
checked={selectedSimType === "esim"}
onChange={() => onSelect("esim")}
className="mt-1 text-primary focus:ring-ring"
/>
<div> <div>
<p className="text-sm font-medium text-foreground">eSIM</p> <p className="text-sm font-medium text-foreground">eSIM</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -71,8 +63,8 @@ function SimTypeSelector({
processing completes. processing completes.
</p> </p>
</div> </div>
</label> </Label>
</div> </RadioGroup>
</div> </div>
); );
} }
@ -149,14 +141,16 @@ export function ReissueSimModal({
</p> </p>
</div> </div>
</div> </div>
<button <Button
variant="ghost"
size="icon"
onClick={onClose} onClick={onClose}
className="text-muted-foreground transition-colors hover:text-foreground" className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Close reissue SIM modal" aria-label="Close reissue SIM modal"
type="button" type="button"
> >
<XMarkIcon className="h-5 w-5" /> <XMarkIcon className="h-5 w-5" />
</button> </Button>
</div> </div>
<div className="mt-6"> <div className="mt-6">
<AlertBanner variant="warning" title="Important information" elevated> <AlertBanner variant="warning" title="Important information" elevated>

View File

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

View File

@ -217,8 +217,9 @@ function FeatureToggleRow({
<div className="text-sm font-medium text-foreground">{label}</div> <div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{description}</div> <div className="text-xs text-muted-foreground">{description}</div>
</div> </div>
<button <Button
type="button" type="button"
variant="ghost"
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
onClick={onChange} onClick={onChange}
@ -229,7 +230,7 @@ function FeatureToggleRow({
animate={{ x: checked ? 20 : 0 }} animate={{ x: checked ? 20 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }} transition={{ type: "spring", stiffness: 500, damping: 30 }}
/> />
</button> </Button>
</div> </div>
); );
} }
@ -258,9 +259,10 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
aria-label="Network Type" aria-label="Network Type"
> >
{options.map(value => ( {options.map(value => (
<button <Button
key={value} key={value}
type="button" type="button"
variant="ghost"
role="radio" role="radio"
aria-checked={nt === value} aria-checked={nt === value}
onClick={() => setNt(value)} onClick={() => setNt(value)}
@ -278,7 +280,7 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
> >
{value} {value}
</span> </span>
</button> </Button>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground mt-2">5G connectivity for enhanced speeds</p> <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 { useSubscription } from "@/features/subscriptions/hooks";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { Checkbox } from "@/components/atoms/checkbox";
import { formatIsoDate } from "@/shared/utils"; import { formatIsoDate } from "@/shared/utils";
const { formatCurrency } = Formatting; const { formatCurrency } = Formatting;
@ -462,9 +463,10 @@ type ActionTileProps = {
function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProps) { function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProps) {
const isDanger = intent === "danger"; const isDanger = intent === "danger";
return ( return (
<button <Button
variant="ghost"
onClick={onClick} 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 isDanger
? "border-danger/25 bg-danger-soft/40 hover:bg-danger-soft hover:shadow-[var(--cp-shadow-2)]" ? "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)]" : "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"}`}> <span className={`text-sm font-medium ${isDanger ? "text-danger" : "text-foreground"}`}>
{label} {label}
</span> </span>
</button> </Button>
); );
} }
@ -514,20 +516,21 @@ function StatusToggle({
<p className="font-medium text-foreground">{label}</p> <p className="font-medium text-foreground">{label}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div> </div>
<label <Checkbox
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={checked} checked={checked}
onChange={handleClick} onCheckedChange={val => {
if (!isDisabled && onChange) {
onChange(val === true);
}
}}
disabled={isDisabled} disabled={isDisabled}
className="sr-only peer" className="sr-only peer"
/> />
<div <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> ></div>
</label>
</div> </div>
</div> </div>
); );

View File

@ -4,6 +4,7 @@ import React, { useState } from "react";
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { apiClient } from "@/core/api"; import { apiClient } from "@/core/api";
import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing"; import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing";
import { Button } from "@/components/atoms/button";
interface TopUpModalProps { interface TopUpModalProps {
subscriptionId: number; 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> <p className="text-sm text-gray-500">Add data quota to your SIM service</p>
</div> </div>
</div> </div>
<button <Button
variant="ghost"
size="icon"
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none" className="text-gray-400 hover:text-gray-500 focus:outline-none"
> >
<XMarkIcon className="h-6 w-6" /> <XMarkIcon className="h-6 w-6" />
</button> </Button>
</div> </div>
<form onSubmit={e => void handleSubmit(e)}> <form onSubmit={e => void handleSubmit(e)}>
<div className="mb-6"> <div className="mb-6">
@ -150,21 +153,22 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div> </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"> <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" type="button"
onClick={onClose} onClick={onClose}
disabled={loading} 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" 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 Back
</button> </Button>
<button <Button
type="submit" type="submit"
disabled={loading || !isValidAmount()} 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" 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()}`} {loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -15,6 +15,7 @@ import type {
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar"; import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { Button } from "@/components/atoms/button";
const { formatCurrency } = Formatting; const { formatCurrency } = Formatting;
@ -285,16 +286,17 @@ interface TabButtonProps {
function TabButton({ active, onClick, icon, label, count }: TabButtonProps): React.ReactElement { function TabButton({ active, onClick, icon, label, count }: TabButtonProps): React.ReactElement {
const tabClasses = active ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES; const tabClasses = active ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES;
return ( return (
<button <Button
variant="ghost"
onClick={onClick} 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} {icon}
{label} {label}
{count !== undefined && ( {count !== undefined && (
<span className="ml-1 text-xs text-muted-foreground/70">({count})</span> <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