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
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:
parent
c8d0dfe230
commit
d7efede122
@ -53,6 +53,15 @@ packages/
|
||||
|
||||
**Validation**: Zod-first. Schemas in domain, derive types with `z.infer`. Use `z.coerce.*` for query params.
|
||||
|
||||
**shadcn/ui Component Migration**: When creating or migrating a component to shadcn/ui, always:
|
||||
|
||||
1. Place the raw shadcn primitive in `components/ui/` (install via `pnpm dlx shadcn@latest add <component>`)
|
||||
2. Create an enhanced atom wrapper in `components/atoms/` that preserves the existing public API
|
||||
3. Save the old implementation as `<component>.legacy.tsx` for reference
|
||||
4. Add a `ComparisonWithLegacy` story in the component's `.stories.tsx` that renders Legacy (left) vs shadcn/ui (right) in a 2-column grid
|
||||
5. Export the new ui component from `components/ui/index.ts`
|
||||
6. Use `text-destructive` instead of `text-danger`/`text-red-*` for error colors (shadcn convention)
|
||||
|
||||
## Docs
|
||||
|
||||
| Topic | Location |
|
||||
|
||||
@ -29,6 +29,19 @@ const config: StorybookConfig = {
|
||||
"next/navigation": path.resolve(__dirname, "mocks/next-navigation.tsx"),
|
||||
};
|
||||
|
||||
// Pre-bundle ESM dependencies so Vite can resolve their exports
|
||||
config.optimizeDeps = config.optimizeDeps || {};
|
||||
config.optimizeDeps.include = [
|
||||
...(config.optimizeDeps.include || []),
|
||||
"framer-motion",
|
||||
"@heroicons/react/24/outline",
|
||||
"@heroicons/react/24/solid",
|
||||
"radix-ui",
|
||||
"lucide-react",
|
||||
"class-variance-authority",
|
||||
"input-otp",
|
||||
];
|
||||
|
||||
// Disable PostCSS — @tailwindcss/vite handles CSS directly
|
||||
config.css = config.css || {};
|
||||
config.css.postcss = { plugins: [] };
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"dependencies": {
|
||||
"@customer-portal/domain": "workspace:*",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@xstate/react": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -35,10 +36,11 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"world-countries": "^5.1.0",
|
||||
"xstate": "^5.28.0",
|
||||
"zod": "^4.3.6",
|
||||
"zod": "catalog:",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -270,6 +270,10 @@
|
||||
--color-danger-bg: var(--danger-bg);
|
||||
--color-danger-border: var(--danger-border);
|
||||
|
||||
/* shadcn/ui uses "destructive" — alias to our "danger" tokens */
|
||||
--color-destructive: var(--danger);
|
||||
--color-destructive-foreground: var(--danger-foreground);
|
||||
|
||||
--color-neutral: var(--neutral);
|
||||
--color-neutral-foreground: var(--neutral-foreground);
|
||||
--color-neutral-bg: var(--neutral-bg);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Badge } from "./badge";
|
||||
import { Badge as LegacyBadge } from "./badge.legacy";
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "Atoms/Badge",
|
||||
@ -69,3 +70,105 @@ export const WithDot: Story = {
|
||||
export const Removable: Story = {
|
||||
args: { children: "Removable", removable: true, onRemove: () => alert("Removed!") },
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">Badge — Legacy vs shadcn/ui</h2>
|
||||
|
||||
{/* Variants */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LegacyBadge variant="default">Default</LegacyBadge>
|
||||
<LegacyBadge variant="secondary">Secondary</LegacyBadge>
|
||||
<LegacyBadge variant="success">Success</LegacyBadge>
|
||||
<LegacyBadge variant="warning">Warning</LegacyBadge>
|
||||
<LegacyBadge variant="error">Error</LegacyBadge>
|
||||
<LegacyBadge variant="info">Info</LegacyBadge>
|
||||
<LegacyBadge variant="outline">Outline</LegacyBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="success">Success</Badge>
|
||||
<Badge variant="warning">Warning</Badge>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<Badge variant="info">Info</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sizes */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Sizes</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<LegacyBadge size="sm">Small</LegacyBadge>
|
||||
<LegacyBadge size="default">Default</LegacyBadge>
|
||||
<LegacyBadge size="lg">Large</LegacyBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui — Sizes</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="default">Default</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* With Dot */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Dot</h3>
|
||||
<div className="flex gap-2">
|
||||
<LegacyBadge variant="success" dot>
|
||||
Active
|
||||
</LegacyBadge>
|
||||
<LegacyBadge variant="error" dot>
|
||||
Failed
|
||||
</LegacyBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui — Dot</h3>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="success" dot>
|
||||
Active
|
||||
</Badge>
|
||||
<Badge variant="error" dot>
|
||||
Failed
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Removable */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Removable</h3>
|
||||
<LegacyBadge removable onRemove={() => {}}>
|
||||
Remove me
|
||||
</LegacyBadge>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
shadcn/ui — Removable
|
||||
</h3>
|
||||
<Badge removable onRemove={() => {}}>
|
||||
Remove me
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Checkbox } from "./checkbox";
|
||||
import { Checkbox as LegacyCheckbox } from "./checkbox.legacy";
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: "Atoms/Checkbox",
|
||||
@ -37,3 +38,36 @@ export const AllStates: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">Checkbox — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LegacyCheckbox label="Default" />
|
||||
<LegacyCheckbox label="Checked" defaultChecked />
|
||||
<LegacyCheckbox label="With helper" helperText="Additional info" />
|
||||
<LegacyCheckbox label="With error" error="Required field" />
|
||||
<LegacyCheckbox label="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Checkbox label="Default" />
|
||||
<Checkbox label="Checked" defaultChecked />
|
||||
<Checkbox label="With helper" helperText="Additional info" />
|
||||
<Checkbox label="With error" error="Required field" />
|
||||
<Checkbox label="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ErrorMessage } from "./error-message";
|
||||
import { ErrorMessage as LegacyErrorMessage } from "./error-message.legacy";
|
||||
|
||||
const meta: Meta<typeof ErrorMessage> = {
|
||||
title: "Atoms/ErrorMessage",
|
||||
@ -30,3 +31,34 @@ export const AllVariants: Story = {
|
||||
export const WithoutIcon: Story = {
|
||||
args: { children: "Error without icon", showIcon: false },
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">ErrorMessage — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<LegacyErrorMessage variant="default">Default error</LegacyErrorMessage>
|
||||
<LegacyErrorMessage variant="inline">Inline error</LegacyErrorMessage>
|
||||
<LegacyErrorMessage variant="subtle">Subtle error</LegacyErrorMessage>
|
||||
<LegacyErrorMessage showIcon={false}>Without icon</LegacyErrorMessage>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ErrorMessage variant="default">Default error</ErrorMessage>
|
||||
<ErrorMessage variant="inline">Inline error</ErrorMessage>
|
||||
<ErrorMessage variant="subtle">Subtle error</ErrorMessage>
|
||||
<ErrorMessage showIcon={false}>Without icon</ErrorMessage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ErrorState } from "./error-state";
|
||||
import { ErrorState as LegacyErrorState } from "./error-state.legacy";
|
||||
|
||||
const meta: Meta<typeof ErrorState> = {
|
||||
title: "Atoms/ErrorState",
|
||||
@ -46,3 +47,47 @@ export const AllVariants: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">ErrorState — Legacy vs shadcn/ui</h2>
|
||||
|
||||
{/* Card variant */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="max-w-sm">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Card</h3>
|
||||
<LegacyErrorState
|
||||
variant="card"
|
||||
title="Something went wrong"
|
||||
message="An error occurred."
|
||||
onRetry={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-sm">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui — Card</h3>
|
||||
<ErrorState
|
||||
variant="card"
|
||||
title="Something went wrong"
|
||||
message="An error occurred."
|
||||
onRetry={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline variant */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="max-w-sm">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Inline</h3>
|
||||
<LegacyErrorState variant="inline" title="Inline error" message="Something failed." />
|
||||
</div>
|
||||
<div className="max-w-sm">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui — Inline</h3>
|
||||
<ErrorState variant="inline" title="Inline error" message="Something failed." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { InlineToast } from "./inline-toast";
|
||||
import { InlineToast as LegacyInlineToast } from "./inline-toast.legacy";
|
||||
|
||||
const meta: Meta<typeof InlineToast> = {
|
||||
title: "Atoms/InlineToast",
|
||||
@ -34,3 +35,34 @@ export const AllTones: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">InlineToast — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<LegacyInlineToast visible text="Info" tone="info" className="!fixed !relative" />
|
||||
<LegacyInlineToast visible text="Success" tone="success" className="!fixed !relative" />
|
||||
<LegacyInlineToast visible text="Warning" tone="warning" className="!fixed !relative" />
|
||||
<LegacyInlineToast visible text="Error" tone="error" className="!fixed !relative" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<InlineToast visible text="Info" tone="info" className="!fixed !relative" />
|
||||
<InlineToast visible text="Success" tone="success" className="!fixed !relative" />
|
||||
<InlineToast visible text="Warning" tone="warning" className="!fixed !relative" />
|
||||
<InlineToast visible text="Error" tone="error" className="!fixed !relative" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input } from "./input";
|
||||
import { Input as LegacyInput } from "./input.legacy";
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: "Atoms/Input",
|
||||
@ -35,3 +36,34 @@ export const AllStates: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">Input — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="w-80">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LegacyInput placeholder="Default" />
|
||||
<LegacyInput placeholder="With value" defaultValue="Some text" />
|
||||
<LegacyInput placeholder="Error state" error="Required" />
|
||||
<LegacyInput placeholder="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-80">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input placeholder="Default" />
|
||||
<Input placeholder="With value" defaultValue="Some text" />
|
||||
<Input placeholder="Error state" error="Required" />
|
||||
<Input placeholder="Disabled" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Label } from "./label";
|
||||
import { Label as LegacyLabel } from "./label.legacy";
|
||||
|
||||
const meta: Meta<typeof Label> = {
|
||||
title: "Atoms/Label",
|
||||
@ -20,3 +21,34 @@ export const Required: Story = {
|
||||
</Label>
|
||||
),
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">Label — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<LegacyLabel>Default label</LegacyLabel>
|
||||
<LegacyLabel>
|
||||
Required <span className="text-danger">*</span>
|
||||
</LegacyLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Default label</Label>
|
||||
<Label>
|
||||
Required <span className="text-danger">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { Skeleton as LegacySkeleton } from "./skeleton.legacy";
|
||||
|
||||
const meta: Meta<typeof Skeleton> = {
|
||||
title: "Atoms/Skeleton",
|
||||
@ -28,3 +29,36 @@ export const CommonPatterns: Story = {
|
||||
export const NoAnimation: Story = {
|
||||
args: { className: "h-4 w-48", animate: false },
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">Skeleton — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="w-80">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LegacySkeleton className="h-4 w-3/4" />
|
||||
<LegacySkeleton className="h-4 w-full" />
|
||||
<LegacySkeleton className="h-4 w-1/2" />
|
||||
<LegacySkeleton className="h-10 w-10 rounded-full" />
|
||||
<LegacySkeleton className="h-32 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-80">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<Skeleton className="h-32 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ViewToggle, type ViewMode } from "./view-toggle";
|
||||
import { ViewToggle as LegacyViewToggle } from "./view-toggle.legacy";
|
||||
|
||||
const meta: Meta<typeof ViewToggle> = {
|
||||
title: "Atoms/ViewToggle",
|
||||
@ -23,3 +24,31 @@ export const ListView: Story = {
|
||||
return <ViewToggle value={mode} onChange={setMode} />;
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: function Render() {
|
||||
const [legacyMode, setLegacyMode] = useState<ViewMode>("grid");
|
||||
const [newMode, setNewMode] = useState<ViewMode>("grid");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-bold text-foreground">ViewToggle — Legacy vs shadcn/ui</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
|
||||
<LegacyViewToggle value={legacyMode} onChange={setLegacyMode} />
|
||||
<p className="text-xs text-muted-foreground mt-2">Selected: {legacyMode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
|
||||
<ViewToggle value={newMode} onChange={setNewMode} />
|
||||
<p className="text-xs text-muted-foreground mt-2">Selected: {newMode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
89
apps/portal/src/components/fancy/text/text-rotate.tsx
Normal file
89
apps/portal/src/components/fancy/text/text-rotate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AlertBanner } from "./AlertBanner";
|
||||
import { AlertBanner as AlertBannerLegacy } from "./AlertBanner.legacy";
|
||||
|
||||
const meta: Meta<typeof AlertBanner> = {
|
||||
title: "Molecules/AlertBanner",
|
||||
@ -77,3 +78,61 @@ export const Closable: Story = {
|
||||
export const Small: Story = {
|
||||
args: { variant: "warning", title: "Heads up", size: "sm" },
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-[500px]">
|
||||
{(["info", "success", "warning", "error"] as const).map(variant => (
|
||||
<div key={variant} className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">{variant}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
|
||||
<AlertBanner variant={variant} title={`${variant} title`}>
|
||||
This is the {variant} message body.
|
||||
</AlertBanner>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
|
||||
<AlertBannerLegacy variant={variant} title={`${variant} title`}>
|
||||
This is the {variant} message body.
|
||||
</AlertBannerLegacy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">closable</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
|
||||
<AlertBanner variant="info" title="Dismissible" onClose={() => {}}>
|
||||
Click the X to close.
|
||||
</AlertBanner>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
|
||||
<AlertBannerLegacy variant="info" title="Dismissible" onClose={() => {}}>
|
||||
Click the X to close.
|
||||
</AlertBannerLegacy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">small + elevated</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (shadcn Alert)</p>
|
||||
<AlertBanner variant="warning" title="Heads up" size="sm" elevated />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy</p>
|
||||
<AlertBannerLegacy variant="warning" title="Heads up" size="sm" elevated />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -7,11 +7,13 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
type Variant = "success" | "info" | "warning" | "error";
|
||||
type IconType = React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
|
||||
const variantClasses: Record<
|
||||
const variantStyles: Record<
|
||||
Variant,
|
||||
{ bg: string; border: string; text: string; icon: string; Icon: IconType }
|
||||
> = {
|
||||
@ -66,42 +68,39 @@ export function AlertBanner({
|
||||
className,
|
||||
...rest
|
||||
}: AlertBannerProps) {
|
||||
const styles = variantClasses[variant];
|
||||
const styles = variantStyles[variant];
|
||||
const Icon = styles.Icon;
|
||||
const padding = size === "sm" ? "p-3" : "p-4";
|
||||
const radius = "rounded-xl";
|
||||
const shadow = elevated ? "shadow-sm" : "";
|
||||
const role = variant === "error" || variant === "warning" ? "alert" : "status";
|
||||
const padding = size === "sm" ? "px-3 py-2" : "px-4 py-3";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[radius, padding, "border", shadow, styles.bg, styles.border, className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role={role}
|
||||
<Alert
|
||||
className={cn(
|
||||
"rounded-xl",
|
||||
padding,
|
||||
styles.bg,
|
||||
styles.border,
|
||||
elevated && "shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{icon ? icon : <Icon className={["h-5 w-5", styles.icon].join(" ")} />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{title && <p className={["font-medium", styles.text].join(" ")}>{title}</p>}
|
||||
{children && (
|
||||
<div className={["text-sm mt-1 text-foreground/80"].join(" ")}>{children}</div>
|
||||
{icon ? (
|
||||
<span className={cn("size-5", styles.icon)}>{icon}</span>
|
||||
) : (
|
||||
<Icon className={cn("size-5", styles.icon)} />
|
||||
)}
|
||||
</div>
|
||||
{title && <AlertTitle className={cn("font-medium", styles.text)}>{title}</AlertTitle>}
|
||||
{children && <AlertDescription className="text-foreground/80">{children}</AlertDescription>}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close alert"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AnimatedCard } from "./AnimatedCard";
|
||||
import { AnimatedCard as AnimatedCardLegacy } from "./AnimatedCard.legacy";
|
||||
|
||||
const meta: Meta<typeof AnimatedCard> = {
|
||||
title: "Molecules/AnimatedCard",
|
||||
@ -52,3 +53,46 @@ export const Disabled: Story = {
|
||||
children: <div className="p-6">Disabled card</div>,
|
||||
},
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-[600px]">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Current (shadcn/ui Card classes)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AnimatedCard variant="default">
|
||||
<div className="p-6">Default</div>
|
||||
</AnimatedCard>
|
||||
<AnimatedCard variant="highlighted">
|
||||
<div className="p-6">Highlighted</div>
|
||||
</AnimatedCard>
|
||||
<AnimatedCard variant="success">
|
||||
<div className="p-6">Success</div>
|
||||
</AnimatedCard>
|
||||
<AnimatedCard variant="static">
|
||||
<div className="p-6">Static</div>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AnimatedCardLegacy variant="default">
|
||||
<div className="p-6">Default</div>
|
||||
</AnimatedCardLegacy>
|
||||
<AnimatedCardLegacy variant="highlighted">
|
||||
<div className="p-6">Highlighted</div>
|
||||
</AnimatedCardLegacy>
|
||||
<AnimatedCardLegacy variant="success">
|
||||
<div className="p-6">Success</div>
|
||||
</AnimatedCardLegacy>
|
||||
<AnimatedCardLegacy variant="static">
|
||||
<div className="p-6">Static</div>
|
||||
</AnimatedCardLegacy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
interface AnimatedCardProps {
|
||||
children: ReactNode;
|
||||
@ -21,8 +22,6 @@ export function AnimatedCard({
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: AnimatedCardProps) {
|
||||
const baseClasses = "bg-card text-card-foreground rounded-xl border";
|
||||
|
||||
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
|
||||
default: "border-border",
|
||||
highlighted: "border-primary/35 ring-1 ring-primary/15",
|
||||
@ -37,7 +36,13 @@ export function AnimatedCard({
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${disabledClasses} ${className}`}
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
variantClasses[variant],
|
||||
interactiveClasses,
|
||||
disabledClasses,
|
||||
className
|
||||
)}
|
||||
initial={{ boxShadow: SHADOW_BASE }}
|
||||
whileHover={isStatic ? {} : { boxShadow: SHADOW_ELEVATED }}
|
||||
transition={{ duration: 0.2 }}
|
||||
|
||||
@ -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 };
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { DataTable } from "./DataTable";
|
||||
import { DataTable as LegacyDataTable } from "./DataTable.legacy";
|
||||
import { InboxIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface SampleRow {
|
||||
@ -80,3 +81,18 @@ export const Empty: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Current (shadcn/ui Table)</h3>
|
||||
<DataTable data={sampleData} columns={columns} forceTableView />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Legacy (custom table)</h3>
|
||||
<LegacyDataTable data={sampleData} columns={columns} forceTableView />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface Column<T> {
|
||||
@ -44,13 +53,13 @@ function MobileCardView<T extends { id: number | string }>({
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
bg-card border border-border rounded-xl p-4
|
||||
shadow-[var(--cp-shadow-1)]
|
||||
transition-all duration-[var(--cp-duration-fast)]
|
||||
active:scale-[0.98] active:shadow-none
|
||||
${onRowClick ? "cursor-pointer active:bg-muted/50" : ""}
|
||||
`}
|
||||
className={cn(
|
||||
"bg-card border border-border rounded-xl p-4",
|
||||
"shadow-[var(--cp-shadow-1)]",
|
||||
"transition-all duration-[var(--cp-duration-fast)]",
|
||||
"active:scale-[0.98] active:shadow-none",
|
||||
onRowClick && "cursor-pointer active:bg-muted/50"
|
||||
)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
role={onRowClick ? "button" : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
@ -107,39 +116,39 @@ function DesktopTableView<T extends { id: number | string }>({
|
||||
forceTableView: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${forceTableView ? "" : "hidden md:block"} overflow-x-auto`}>
|
||||
<table className={`min-w-full divide-y divide-border ${className}`}>
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<div className={cn(!forceTableView && "hidden md:block")}>
|
||||
<Table className={className}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map(column => (
|
||||
<th
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider ${column.className || ""}`}
|
||||
className={cn(
|
||||
"text-xs uppercase tracking-wider text-muted-foreground",
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map(item => (
|
||||
<tr
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/30 transition-colors duration-[var(--cp-transition-fast)] ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
className={cn(onRowClick && "cursor-pointer")}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`}
|
||||
>
|
||||
<TableCell key={column.key} className={column.className}>
|
||||
{column.render(item)}
|
||||
</td>
|
||||
</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FilterDropdown } from "./FilterDropdown";
|
||||
import { FilterDropdown as FilterDropdownLegacy } from "./FilterDropdown.legacy";
|
||||
|
||||
const meta: Meta<typeof FilterDropdown> = {
|
||||
title: "Molecules/FilterDropdown",
|
||||
@ -10,6 +11,20 @@ const meta: Meta<typeof FilterDropdown> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FilterDropdown>;
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "All Statuses" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: "all", label: "All Categories" },
|
||||
{ value: "billing", label: "Billing" },
|
||||
{ value: "technical", label: "Technical" },
|
||||
{ value: "general", label: "General" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState("all");
|
||||
@ -17,12 +32,7 @@ export const Default: Story = {
|
||||
<FilterDropdown
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={[
|
||||
{ value: "all", label: "All Statuses" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]}
|
||||
options={statusOptions}
|
||||
label="Filter by status"
|
||||
/>
|
||||
);
|
||||
@ -36,15 +46,39 @@ export const CustomWidth: Story = {
|
||||
<FilterDropdown
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={[
|
||||
{ value: "all", label: "All Categories" },
|
||||
{ value: "billing", label: "Billing" },
|
||||
{ value: "technical", label: "Technical" },
|
||||
{ value: "general", label: "General" },
|
||||
]}
|
||||
options={categoryOptions}
|
||||
label="Filter by category"
|
||||
width="w-48"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState("all");
|
||||
const [legacyValue, setLegacyValue] = useState("all");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">shadcn/ui (new)</span>
|
||||
<FilterDropdown
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={statusOptions}
|
||||
label="Filter by status"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Legacy</span>
|
||||
<FilterDropdownLegacy
|
||||
value={legacyValue}
|
||||
onChange={setLegacyValue}
|
||||
options={statusOptions}
|
||||
label="Filter by status"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -2,6 +2,13 @@
|
||||
|
||||
import { FunnelIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/shared/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
@ -24,7 +31,7 @@ export interface FilterDropdownProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterDropdown - Reusable filter dropdown component with consistent styling.
|
||||
* FilterDropdown - Reusable filter dropdown component built on shadcn/ui Select.
|
||||
*
|
||||
* Used across list pages (Orders, Support, Invoices) for filtering by status, type, priority, etc.
|
||||
*/
|
||||
@ -37,28 +44,18 @@ export function FilterDropdown({
|
||||
className,
|
||||
}: FilterDropdownProps) {
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
className={cn(
|
||||
"block pl-3 pr-8 py-2.5 text-sm border border-border",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary",
|
||||
"rounded-lg appearance-none bg-card text-foreground",
|
||||
"shadow-sm cursor-pointer transition-colors",
|
||||
width
|
||||
)}
|
||||
aria-label={label}
|
||||
>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className={cn(width, className)} aria-label={label}>
|
||||
<FunnelIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
|
||||
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FormField } from "./FormField";
|
||||
import { FormField as FormFieldLegacy } from "./FormField.legacy";
|
||||
|
||||
const meta: Meta<typeof FormField> = {
|
||||
title: "Molecules/FormField",
|
||||
@ -41,3 +42,71 @@ export const FormExample: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-80">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">default</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
|
||||
<FormField label="Email" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
|
||||
<FormFieldLegacy label="Email" placeholder="you@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">with error</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
|
||||
<FormField
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
error="Invalid email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
|
||||
<FormFieldLegacy
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
error="Invalid email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-mono text-muted-foreground uppercase">required with helper</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">New (destructive tokens)</p>
|
||||
<FormField
|
||||
label="Phone"
|
||||
placeholder="+81 90-1234-5678"
|
||||
helperText="Include country code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Legacy (danger tokens)</p>
|
||||
<FormFieldLegacy
|
||||
label="Phone"
|
||||
placeholder="+81 90-1234-5678"
|
||||
helperText="Include country code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -52,7 +52,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
aria-invalid={error ? "true" : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2",
|
||||
error &&
|
||||
"border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2",
|
||||
inputClassName,
|
||||
inputPropsClassName
|
||||
)}
|
||||
@ -79,14 +80,14 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"block text-sm font-medium text-muted-foreground",
|
||||
error && "text-danger",
|
||||
error && "text-destructive",
|
||||
labelProps?.className
|
||||
)}
|
||||
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && (
|
||||
<span aria-hidden="true" className="ml-1 text-danger">
|
||||
<span aria-hidden="true" className="ml-1 text-destructive">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MetricCard, MetricCardSkeleton } from "./MetricCard";
|
||||
import { MetricCard as MetricCardLegacy } from "./MetricCard.legacy";
|
||||
import {
|
||||
CurrencyYenIcon,
|
||||
UsersIcon,
|
||||
@ -81,3 +82,46 @@ export const LoadingSkeleton: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-[600px]">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<MetricCard
|
||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
||||
label="Revenue"
|
||||
value="¥1.2M"
|
||||
tone="primary"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
label="Users"
|
||||
value="2,847"
|
||||
tone="success"
|
||||
trend={{ value: "+5%", positive: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<MetricCardLegacy
|
||||
icon={<CurrencyYenIcon className="h-5 w-5" />}
|
||||
label="Revenue"
|
||||
value="¥1.2M"
|
||||
tone="primary"
|
||||
/>
|
||||
<MetricCardLegacy
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
label="Users"
|
||||
value="2,847"
|
||||
tone="success"
|
||||
trend={{ value: "+5%", positive: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
@ -34,13 +35,14 @@ export function MetricCard({
|
||||
const styles = toneStyles[tone];
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
className={cn(
|
||||
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
|
||||
"gap-0 py-0 border-border/60",
|
||||
"transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-start gap-3.5 p-4">
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
@ -59,7 +61,10 @@ export function MetricCard({
|
||||
</p>
|
||||
{trend && (
|
||||
<span
|
||||
className={cn("text-xs font-medium", trend.positive ? "text-success" : "text-danger")}
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
trend.positive ? "text-success" : "text-danger"
|
||||
)}
|
||||
>
|
||||
{trend.value}
|
||||
</span>
|
||||
@ -67,7 +72,8 @@ export function MetricCard({
|
||||
</div>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { OtpInput } from "./OtpInput";
|
||||
import { LegacyOtpInput } from "./OtpInput.legacy";
|
||||
|
||||
const meta: Meta<typeof OtpInput> = {
|
||||
title: "Molecules/OtpInput",
|
||||
@ -44,3 +45,70 @@ export const FourDigit: Story = {
|
||||
return <OtpInput value={value} onChange={setValue} length={4} autoFocus={false} />;
|
||||
},
|
||||
};
|
||||
|
||||
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: function Render() {
|
||||
const [legacyValue, setLegacyValue] = useState("123");
|
||||
const [newValue, setNewValue] = useState("123");
|
||||
const [legacyErrorValue, setLegacyErrorValue] = useState("999999");
|
||||
const [newErrorValue, setNewErrorValue] = useState("999999");
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<h2 className="text-lg font-bold text-foreground">OtpInput — Legacy vs shadcn/ui</h2>
|
||||
|
||||
{/* Default state */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Default</h3>
|
||||
<LegacyOtpInput value={legacyValue} onChange={setLegacyValue} autoFocus={false} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
shadcn/ui — Default
|
||||
</h3>
|
||||
<OtpInput value={newValue} onChange={setNewValue} autoFocus={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Error</h3>
|
||||
<LegacyOtpInput
|
||||
value={legacyErrorValue}
|
||||
onChange={setLegacyErrorValue}
|
||||
error="Invalid code. Please try again."
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui — Error</h3>
|
||||
<OtpInput
|
||||
value={newErrorValue}
|
||||
onChange={setNewErrorValue}
|
||||
error="Invalid code. Please try again."
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disabled state */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy — Disabled</h3>
|
||||
<LegacyOtpInput value="123" onChange={() => {}} disabled autoFocus={false} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
shadcn/ui — Disabled
|
||||
</h3>
|
||||
<OtpInput value="123" onChange={() => {}} disabled autoFocus={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
"use client";
|
||||
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
|
||||
interface OtpInputProps {
|
||||
@ -45,13 +44,18 @@ export function OtpInput({
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length }, (_, i) => (
|
||||
<InputOTPSlot key={i} index={i} className={cn(error && "border-danger")} />
|
||||
<InputOTPSlot
|
||||
key={i}
|
||||
index={i}
|
||||
aria-invalid={!!error || undefined}
|
||||
className="h-14 w-12 text-xl font-semibold"
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-danger text-center" role="alert">
|
||||
<p className="text-sm text-destructive text-center" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -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 };
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { PaginationBar } from "./PaginationBar";
|
||||
import { PaginationBar as PaginationBarLegacy } from "./PaginationBar.legacy";
|
||||
|
||||
const meta: Meta<typeof PaginationBar> = {
|
||||
title: "Molecules/PaginationBar",
|
||||
@ -37,3 +38,27 @@ export const LastPage: Story = {
|
||||
export const SinglePage: Story = {
|
||||
args: { currentPage: 1, pageSize: 10, totalItems: 5, onPageChange: () => {} },
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: function Render() {
|
||||
const [page, setPage] = useState(3);
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-[700px]">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">New (shadcn/ui)</h3>
|
||||
<PaginationBar currentPage={page} pageSize={10} totalItems={100} onPageChange={setPage} />
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Legacy</h3>
|
||||
<PaginationBarLegacy
|
||||
currentPage={page}
|
||||
pageSize={10}
|
||||
totalItems={100}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface PaginationBarProps {
|
||||
currentPage: number;
|
||||
@ -10,6 +20,37 @@ interface PaginationBarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which page numbers to display, up to 5 visible pages with ellipsis.
|
||||
*/
|
||||
function getPageRange(
|
||||
currentPage: number,
|
||||
totalPages: number
|
||||
): (number | "ellipsis-start" | "ellipsis-end")[] {
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
// Always show first and last page
|
||||
if (currentPage <= 3) {
|
||||
return [1, 2, 3, 4, "ellipsis-end", totalPages];
|
||||
}
|
||||
|
||||
if (currentPage >= totalPages - 2) {
|
||||
return [1, "ellipsis-start", totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
}
|
||||
|
||||
return [
|
||||
1,
|
||||
"ellipsis-start",
|
||||
currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1,
|
||||
"ellipsis-end",
|
||||
totalPages,
|
||||
];
|
||||
}
|
||||
|
||||
export function PaginationBar({
|
||||
currentPage,
|
||||
pageSize,
|
||||
@ -21,54 +62,79 @@ export function PaginationBar({
|
||||
const canPrev = currentPage > 1;
|
||||
const canNext = currentPage < totalPages;
|
||||
|
||||
const pageRange = useMemo(() => getPageRange(currentPage, totalPages), [currentPage, totalPages]);
|
||||
|
||||
const startItem = (currentPage - 1) * pageSize + 1;
|
||||
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||||
|
||||
return (
|
||||
<div className={`px-1 sm:px-0 py-1 flex items-center justify-between ${className || ""}`}>
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={!canPrev}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-4 py-1 sm:flex-row sm:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={!canNext}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "}
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> of{" "}
|
||||
Showing <span className="font-medium">{startItem}</span> to{" "}
|
||||
<span className="font-medium">{endItem}</span> of{" "}
|
||||
<span className="font-medium">{totalItems}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
|
||||
<Pagination className="mx-0 w-auto">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (canPrev) onPageChange(currentPage - 1);
|
||||
}}
|
||||
aria-disabled={!canPrev}
|
||||
className={cn(!canPrev && "pointer-events-none opacity-50")}
|
||||
role="button"
|
||||
tabIndex={canPrev ? 0 : -1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{pageRange.map(page => {
|
||||
if (page === "ellipsis-start" || page === "ellipsis-end") {
|
||||
return (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
isActive={page === currentPage}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={!canPrev}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={!canNext}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (canNext) onPageChange(currentPage + 1);
|
||||
}}
|
||||
aria-disabled={!canNext}
|
||||
className={cn(!canNext && "pointer-events-none opacity-50")}
|
||||
role="button"
|
||||
tabIndex={canNext ? 0 : -1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { SectionCard as SectionCardLegacy } from "./SectionCard.legacy";
|
||||
import { CreditCardIcon, UserIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "../../atoms/button";
|
||||
|
||||
@ -70,3 +71,56 @@ export const AllTones: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-[500px]">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
|
||||
<SectionCard
|
||||
icon={<UserIcon className="h-5 w-5" />}
|
||||
title="Account Details"
|
||||
subtitle="Your personal information"
|
||||
tone="info"
|
||||
actions={
|
||||
<Button size="sm" variant="outline">
|
||||
Edit
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Name:</span> John Doe
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Email:</span> john@example.com
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
|
||||
<SectionCardLegacy
|
||||
icon={<UserIcon className="h-5 w-5" />}
|
||||
title="Account Details"
|
||||
subtitle="Your personal information"
|
||||
tone="info"
|
||||
actions={
|
||||
<Button size="sm" variant="outline">
|
||||
Edit
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Name:</span> John Doe
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Email:</span> john@example.com
|
||||
</p>
|
||||
</div>
|
||||
</SectionCardLegacy>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Card, CardHeader, CardAction, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type SectionTone = "primary" | "success" | "info" | "warning" | "danger" | "neutral";
|
||||
|
||||
@ -32,14 +34,8 @@ export function SectionCard({
|
||||
className,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/40 px-6 py-4 border-b border-border/40">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Card className={cn("gap-0 py-0 shadow-[var(--cp-shadow-1)] overflow-hidden", className)}>
|
||||
<CardHeader className="bg-muted/40 px-6 py-4 gap-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
@ -54,10 +50,14 @@ export function SectionCard({
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">{actions}</div>
|
||||
</CardAction>
|
||||
)}
|
||||
</CardHeader>
|
||||
<Separator className="bg-border/40" />
|
||||
<CardContent className="px-6 py-5">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SubCard } from "./SubCard";
|
||||
import { SubCard as SubCardLegacy } from "./SubCard.legacy";
|
||||
import { CreditCardIcon, CogIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "../../atoms/button";
|
||||
|
||||
@ -72,3 +73,54 @@ export const Interactive: Story = {
|
||||
children: <p className="text-sm text-muted-foreground">This card has hover effects.</p>,
|
||||
},
|
||||
};
|
||||
|
||||
export const ComparisonWithLegacy: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-8 w-[500px]">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Current (shadcn/ui Card)</h3>
|
||||
<SubCard
|
||||
title="Subscription"
|
||||
icon={<CreditCardIcon className="h-5 w-5" />}
|
||||
right={
|
||||
<Button size="sm" variant="outline">
|
||||
Manage
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-muted-foreground">Next billing: April 1</span>
|
||||
<Button size="sm" variant="outline">
|
||||
Renew
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">Fiber Internet 1Gbps - Active</p>
|
||||
</SubCard>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">Legacy</h3>
|
||||
<SubCardLegacy
|
||||
title="Subscription"
|
||||
icon={<CreditCardIcon className="h-5 w-5" />}
|
||||
right={
|
||||
<Button size="sm" variant="outline">
|
||||
Manage
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-muted-foreground">Next billing: April 1</span>
|
||||
<Button size="sm" variant="outline">
|
||||
Renew
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">Fiber Internet 1Gbps - Active</p>
|
||||
</SubCardLegacy>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Card, CardHeader, CardAction, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export interface SubCardProps {
|
||||
title?: string;
|
||||
@ -31,17 +33,17 @@ function renderSubCardHeader({
|
||||
headerClassName,
|
||||
}: SubCardHeaderOptions): ReactNode {
|
||||
if (header) {
|
||||
return <div className={`${headerClassName || "mb-5"}`}>{header}</div>;
|
||||
return <CardHeader className={cn("px-0 pt-0", headerClassName || "pb-5")}>{header}</CardHeader>;
|
||||
}
|
||||
if (title) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between mb-5 ${headerClassName}`}>
|
||||
<CardHeader className={cn("px-0 pt-0 pb-5 gap-0", headerClassName)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <div className="text-primary">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
{right && <CardAction>{right}</CardAction>}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@ -63,19 +65,24 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
<Card
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border border-border bg-card text-card-foreground shadow-sm rounded-2xl p-5 sm:p-6",
|
||||
"gap-0 rounded-2xl p-5 sm:p-6",
|
||||
interactive &&
|
||||
"transition-all duration-200 hover:shadow-md hover:border-border/80 cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{renderSubCardHeader({ header, title, icon, right, headerClassName })}
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
{footer ? <div className="mt-5 pt-5 border-t border-border/60">{footer}</div> : null}
|
||||
</div>
|
||||
<CardContent className={cn("px-0 py-0", bodyClassName)}>{children}</CardContent>
|
||||
{footer ? (
|
||||
<>
|
||||
<Separator className="mt-5 bg-border/60" />
|
||||
<CardFooter className="px-0 pt-5 pb-0">{footer}</CardFooter>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
SubCard.displayName = "SubCard";
|
||||
|
||||
64
apps/portal/src/components/ui/accordion.tsx
Normal file
64
apps/portal/src/components/ui/accordion.tsx
Normal 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 };
|
||||
102
apps/portal/src/components/ui/breadcrumb.tsx
Normal file
102
apps/portal/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
};
|
||||
75
apps/portal/src/components/ui/card.tsx
Normal file
75
apps/portal/src/components/ui/card.tsx
Normal 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 };
|
||||
21
apps/portal/src/components/ui/collapsible.tsx
Normal file
21
apps/portal/src/components/ui/collapsible.tsx
Normal 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 };
|
||||
144
apps/portal/src/components/ui/dialog.tsx
Normal file
144
apps/portal/src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
228
apps/portal/src/components/ui/dropdown-menu.tsx
Normal file
228
apps/portal/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
@ -6,18 +6,165 @@
|
||||
* import from "@/components/atoms" instead.
|
||||
*/
|
||||
|
||||
// Form primitives
|
||||
export { Button, buttonVariants } from "./button";
|
||||
export { Input } from "./input";
|
||||
export { Checkbox } from "./checkbox";
|
||||
export { Label } from "./label";
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
|
||||
// Accordion
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./accordion";
|
||||
|
||||
// Display primitives
|
||||
export { Badge, badgeVariants } from "./badge";
|
||||
export { Skeleton } from "./skeleton";
|
||||
// Alert
|
||||
export { Alert, AlertTitle, AlertDescription } from "./alert";
|
||||
|
||||
// Toggle primitives
|
||||
// Badge
|
||||
export { Badge, badgeVariants } from "./badge";
|
||||
|
||||
// Breadcrumb
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
} from "./breadcrumb";
|
||||
|
||||
// Button
|
||||
export { Button, buttonVariants } from "./button";
|
||||
|
||||
// Card
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "./card";
|
||||
|
||||
// Checkbox
|
||||
export { Checkbox } from "./checkbox";
|
||||
|
||||
// Collapsible
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./collapsible";
|
||||
|
||||
// Dialog
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./dialog";
|
||||
|
||||
// Dropdown Menu
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
// Input
|
||||
export { Input } from "./input";
|
||||
|
||||
// Input OTP
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
|
||||
|
||||
// Label
|
||||
export { Label } from "./label";
|
||||
|
||||
// Radio Group
|
||||
export { RadioGroup, RadioGroupItem } from "./radio-group";
|
||||
|
||||
// Pagination
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
} from "./pagination";
|
||||
|
||||
// Popover
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
} from "./popover";
|
||||
|
||||
// Select
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./select";
|
||||
|
||||
// Separator
|
||||
export { Separator } from "./separator";
|
||||
|
||||
// Sheet
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "./sheet";
|
||||
|
||||
// Skeleton
|
||||
export { Skeleton } from "./skeleton";
|
||||
|
||||
// Table
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
} from "./table";
|
||||
|
||||
// Tabs
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } from "./tabs";
|
||||
|
||||
// Toggle
|
||||
export { Toggle, toggleVariants } from "./toggle";
|
||||
|
||||
// Toggle Group
|
||||
export { ToggleGroup, ToggleGroupItem } from "./toggle-group";
|
||||
|
||||
// Tooltip
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./tooltip";
|
||||
|
||||
@ -3,13 +3,16 @@
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & { containerClassName?: string }) {
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
@ -30,20 +33,18 @@ function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { index: number }) {
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
// Index is always valid — we control it via the length prop in OtpInput
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]!;
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex h-14 w-12 items-center justify-center border-y border-r border-border text-xl font-semibold shadow-xs transition-all",
|
||||
"first:rounded-l-lg first:border-l last:rounded-r-lg",
|
||||
"bg-card text-foreground",
|
||||
isActive && "z-10 ring-2 ring-primary border-primary",
|
||||
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -51,7 +52,7 @@ function InputOTPSlot({
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-6 w-px bg-foreground duration-1000" />
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
106
apps/portal/src/components/ui/pagination.tsx
Normal file
106
apps/portal/src/components/ui/pagination.tsx
Normal 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,
|
||||
};
|
||||
74
apps/portal/src/components/ui/popover.tsx
Normal file
74
apps/portal/src/components/ui/popover.tsx
Normal 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,
|
||||
};
|
||||
45
apps/portal/src/components/ui/radio-group.tsx
Normal file
45
apps/portal/src/components/ui/radio-group.tsx
Normal 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 };
|
||||
175
apps/portal/src/components/ui/select.tsx
Normal file
175
apps/portal/src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
28
apps/portal/src/components/ui/separator.tsx
Normal file
28
apps/portal/src/components/ui/separator.tsx
Normal 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 };
|
||||
134
apps/portal/src/components/ui/sheet.tsx
Normal file
134
apps/portal/src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
92
apps/portal/src/components/ui/table.tsx
Normal file
92
apps/portal/src/components/ui/table.tsx
Normal 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 };
|
||||
81
apps/portal/src/components/ui/tabs.tsx
Normal file
81
apps/portal/src/components/ui/tabs.tsx
Normal 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 };
|
||||
53
apps/portal/src/components/ui/tooltip.tsx
Normal file
53
apps/portal/src/components/ui/tooltip.tsx
Normal 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 };
|
||||
@ -3,6 +3,7 @@
|
||||
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Input } from "@/components/atoms/input";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
|
||||
/** Data required for displaying personal info card */
|
||||
interface PersonalInfoData {
|
||||
@ -41,7 +42,7 @@ function ReadOnlyField({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">{label}</label>
|
||||
<Label className="block text-sm font-medium text-muted-foreground mb-2">{label}</Label>
|
||||
<div className="bg-card rounded-lg p-4 border border-border shadow-sm">
|
||||
<p className="text-base text-foreground font-medium">
|
||||
{value || <span className="text-muted-foreground italic">Not provided</span>}
|
||||
@ -68,7 +69,7 @@ function EditableEmailField({
|
||||
}) {
|
||||
return (
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">Email Address</label>
|
||||
<Label className="block text-sm font-medium text-muted-foreground mb-2">Email Address</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="email"
|
||||
@ -101,7 +102,7 @@ function EditablePhoneField({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-2">Phone Number</label>
|
||||
<Label className="block text-sm font-medium text-muted-foreground mb-2">Phone Number</Label>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="tel"
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { Home, Building2, CheckCircle2, MapPin, ChevronRight, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { cn } from "@/shared/utils";
|
||||
@ -185,12 +186,13 @@ function ResidenceTypeSelector({
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onChange(RESIDENCE_TYPE.HOUSE)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300 h-auto",
|
||||
"hover:scale-[1.02] active:scale-[0.98]",
|
||||
value === RESIDENCE_TYPE.HOUSE
|
||||
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
|
||||
@ -216,14 +218,15 @@ function ResidenceTypeSelector({
|
||||
>
|
||||
House
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onChange(RESIDENCE_TYPE.APARTMENT)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300",
|
||||
"group relative flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all duration-300 h-auto",
|
||||
"hover:scale-[1.02] active:scale-[0.98]",
|
||||
value === RESIDENCE_TYPE.APARTMENT
|
||||
? "border-primary bg-primary/5 shadow-[0_0_0_4px] shadow-primary/10"
|
||||
@ -249,7 +252,7 @@ function ResidenceTypeSelector({
|
||||
>
|
||||
Apartment
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger mt-2">{error}</p>}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input, PasswordInput, ErrorMessage } from "@/components/atoms";
|
||||
import { Button, Input, PasswordInput, ErrorMessage, Checkbox } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { LoginOtpStep } from "../LoginOtpStep";
|
||||
import { useLoginWithOtp } from "../../hooks/use-auth";
|
||||
@ -240,20 +240,14 @@ function CredentialsForm({
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
checked={values.rememberMe}
|
||||
onChange={e => setValue("rememberMe", e.target.checked)}
|
||||
checked={values.rememberMe ?? false}
|
||||
onCheckedChange={val => setValue("rememberMe", val === true)}
|
||||
disabled={isDisabled}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary"
|
||||
label="Remember me"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-muted-foreground">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
{showForgotPasswordLink && (
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button, ErrorMessage } from "@/components/atoms";
|
||||
import { Button, ErrorMessage, Checkbox } from "@/components/atoms";
|
||||
import { OtpInput, formatTimeRemaining } from "@/components/molecules";
|
||||
import { ArrowLeft, Mail, Clock } from "lucide-react";
|
||||
|
||||
@ -124,18 +124,14 @@ export function LoginOtpStep({
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
<Checkbox
|
||||
id="remember-device"
|
||||
name="remember-device"
|
||||
type="checkbox"
|
||||
checked={rememberDevice}
|
||||
onChange={e => setRememberDevice(e.target.checked)}
|
||||
onCheckedChange={val => setRememberDevice(val === true)}
|
||||
disabled={isSubmitting || isExpired}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary"
|
||||
label="Remember this device for 7 days"
|
||||
/>
|
||||
<label htmlFor="remember-device" className="ml-2 block text-sm text-muted-foreground">
|
||||
Remember this device for 7 days
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@ -147,15 +143,16 @@ export function LoginOtpStep({
|
||||
>
|
||||
Verify and Sign In
|
||||
</Button>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 disabled:opacity-50"
|
||||
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to login</span>
|
||||
</button>
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Didn't receive the code? Check your spam folder or go back to try again.
|
||||
</p>
|
||||
|
||||
@ -15,7 +15,7 @@ export function MarketingCheckbox({ checked, onChange, disabled }: MarketingChec
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
onCheckedChange={val => onChange(val === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
|
||||
@ -17,7 +17,7 @@ export function TermsCheckbox({ checked, onChange, disabled, error }: TermsCheck
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={checked}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
onCheckedChange={val => onChange(val === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
|
||||
|
||||
@ -104,14 +104,15 @@ function EmailSentConfirmation({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBackToForm}
|
||||
className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Try a different email</span>
|
||||
</button>
|
||||
</Button>
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="block text-center text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { Activity } from "@customer-portal/domain/dashboard";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { motionVariants } from "@/components/atoms";
|
||||
@ -99,13 +100,14 @@ function ActivityItem({ activity, isLast = false }: ActivityItemProps) {
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(navPath!)}
|
||||
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2"
|
||||
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2 h-auto"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
@ -74,13 +75,14 @@ export function DashboardActivityItem({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors cursor-pointer -mx-2 px-2"
|
||||
variant="ghost"
|
||||
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors cursor-pointer -mx-2 px-2 h-auto"
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -202,8 +202,8 @@ function MigrateTermsSection({
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={form.acceptTerms}
|
||||
onChange={e => {
|
||||
form.setAcceptTerms(e.target.checked);
|
||||
onCheckedChange={val => {
|
||||
form.setAcceptTerms(val === true);
|
||||
form.clearError("acceptTerms");
|
||||
}}
|
||||
disabled={loading}
|
||||
@ -237,7 +237,7 @@ function MigrateTermsSection({
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={form.marketingConsent}
|
||||
onChange={e => form.setMarketingConsent(e.target.checked)}
|
||||
onCheckedChange={val => form.setMarketingConsent(val === true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label } from "@/components/atoms";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
type Gender = "male" | "female" | "other" | "";
|
||||
@ -74,25 +75,22 @@ export function PersonalInfoFields({
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
onGenderChange(option);
|
||||
<RadioGroup
|
||||
value={gender || ""}
|
||||
onValueChange={(value: string) => {
|
||||
onGenderChange(value as "male" | "female" | "other");
|
||||
clearError("gender");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
className="flex gap-4"
|
||||
>
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<RadioGroupItem value={option} />
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{errors.gender && <p className="text-sm text-danger">{errors.gender}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { LayoutGroup, motion, useInView } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import TextRotate from "@/components/fancy/text/text-rotate";
|
||||
|
||||
const SERVICE_WORDS = ["Internet", "Phone Plans", "VPN", "IT Support", "Business"];
|
||||
|
||||
interface HeroSectionProps {
|
||||
heroCTARef: React.RefObject<HTMLDivElement | null>;
|
||||
@ -45,10 +48,30 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
|
||||
<LayoutGroup>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground font-heading">
|
||||
<span className="block">Seamless IT Solutions</span>
|
||||
<span className="block text-primary mt-2">Built for Your Business</span>
|
||||
<motion.span
|
||||
className="flex items-center justify-center gap-2 sm:gap-3 mt-2"
|
||||
layout
|
||||
transition={{ type: "spring", damping: 30, stiffness: 400 }}
|
||||
>
|
||||
<span className="text-foreground">for</span>
|
||||
<TextRotate
|
||||
texts={SERVICE_WORDS}
|
||||
mainClassName="text-white px-3 sm:px-4 bg-primary overflow-hidden py-1 sm:py-1.5 justify-center rounded-lg"
|
||||
staggerFrom="last"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "-120%" }}
|
||||
staggerDuration={0.025}
|
||||
splitLevelClassName="overflow-hidden pb-0.5 sm:pb-1"
|
||||
transition={{ type: "spring", damping: 30, stiffness: 400 }}
|
||||
rotationInterval={2500}
|
||||
/>
|
||||
</motion.span>
|
||||
</h1>
|
||||
</LayoutGroup>
|
||||
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto">
|
||||
From connectivity to communication, we handle the complexity so you can focus on what
|
||||
matters — with dedicated English support across Japan.
|
||||
|
||||
@ -4,6 +4,7 @@ import { memo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useCarousel, useInView } from "@/features/landing-page/hooks";
|
||||
import {
|
||||
@ -251,14 +252,15 @@ function CarouselHeader({
|
||||
</div>
|
||||
<div className="flex bg-muted rounded-full p-1 self-start relative">
|
||||
{(["personal", "business"] as const).map(tab => (
|
||||
<button
|
||||
<Button
|
||||
key={tab}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={cn(
|
||||
"relative z-10 px-5 py-2.5 text-sm font-semibold rounded-full transition-colors duration-300",
|
||||
"relative z-10 px-5 py-2.5 text-sm font-semibold rounded-full transition-colors duration-300 h-auto",
|
||||
activeTab === tab
|
||||
? "text-background"
|
||||
? "text-background hover:text-background"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
@ -272,7 +274,7 @@ function CarouselHeader({
|
||||
<span className="relative z-10">
|
||||
{tab === "personal" ? "For You" : "For Business"}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -298,25 +300,28 @@ function CarouselNav({
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 sm:px-10">
|
||||
<div className="flex items-center justify-center gap-6 mt-8">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Previous service"
|
||||
onClick={goPrev}
|
||||
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{cards.map((card, i) => {
|
||||
const styles = ACCENTS[card.accent];
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={`${card.title}-${i}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={`Go to ${card.title}`}
|
||||
onClick={() => goTo(i)}
|
||||
className={cn(
|
||||
"rounded-full transition-all duration-300 h-2.5",
|
||||
"rounded-full transition-all duration-300 h-2.5 p-0 min-w-0",
|
||||
i === activeIndex
|
||||
? cn("w-8", styles.dotBg)
|
||||
: "w-2.5 bg-border hover:bg-muted-foreground"
|
||||
@ -325,14 +330,16 @@ function CarouselNav({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Next service"
|
||||
onClick={goNext}
|
||||
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { memo, useState, useRef, useCallback, useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { BellIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { useUnreadNotificationCount } from "../hooks/useNotifications";
|
||||
import { NotificationDropdown } from "./NotificationDropdown";
|
||||
import { cn } from "@/shared/utils";
|
||||
@ -63,8 +64,10 @@ export const NotificationBell = memo(function NotificationBell({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"relative p-2.5 rounded-xl transition-all duration-200",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted/60",
|
||||
@ -83,7 +86,7 @@ export const NotificationBell = memo(function NotificationBell({
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
|
||||
@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { BellSlashIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import {
|
||||
useNotifications,
|
||||
useMarkNotificationAsRead,
|
||||
@ -54,15 +55,17 @@ export const NotificationDropdown = memo(function NotificationDropdown({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground">Notifications</h3>
|
||||
{hasUnread && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors h-auto px-2 py-1"
|
||||
onClick={() => markAllAsRead.mutate()}
|
||||
disabled={markAllAsRead.isPending}
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
@ -84,14 +85,16 @@ export const NotificationItem = memo(function NotificationItem({
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-muted transition-opacity h-auto w-auto"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!notification.read && (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
|
||||
// Local type that includes all properties we need
|
||||
// This avoids type intersection issues with exactOptionalPropertyTypes
|
||||
@ -154,11 +155,10 @@ export function AddonGroup({
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => handleGroupToggle(group)}
|
||||
className="text-green-600 focus:ring-green-500 mt-1"
|
||||
onCheckedChange={() => handleGroupToggle(group)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -9,6 +9,14 @@ import {
|
||||
type AddressFormData,
|
||||
type Address,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface AddressFormProps {
|
||||
// Initial values
|
||||
@ -119,25 +127,28 @@ function AddressField({
|
||||
|
||||
return (
|
||||
<div key={field} className={variant === "compact" ? "mb-3" : "mb-4"}>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
<Label className="block text-sm font-medium text-foreground mb-1">
|
||||
{labels[field]}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
</Label>
|
||||
|
||||
{type === "select" ? (
|
||||
<select
|
||||
<Select
|
||||
value={formValues[field] || ""}
|
||||
onChange={e => onFieldChange(field, e.target.value)}
|
||||
onBlur={() => onBlur(field)}
|
||||
className={baseInputClasses}
|
||||
onValueChange={value => onFieldChange(field, value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{SELECT_COUNTRY_OPTIONS.map(option => (
|
||||
<option key={option.code} value={option.code}>
|
||||
<SelectTrigger className={baseInputClasses} onBlur={() => onBlur(field)}>
|
||||
<SelectValue placeholder="Select Country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SELECT_COUNTRY_OPTIONS.filter(option => option.code !== "").map(option => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
{option.name}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, type ElementType, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
@ -21,10 +22,11 @@ export function CollapsibleSection({
|
||||
|
||||
return (
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-card">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors h-auto rounded-none"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
@ -33,7 +35,7 @@ export function CollapsibleSection({
|
||||
<motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</button>
|
||||
</Button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
|
||||
@ -6,6 +6,8 @@ import { Button } from "@/components/atoms/button";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
|
||||
export interface PaymentFormProps {
|
||||
existingMethods?: PaymentMethod[];
|
||||
@ -49,12 +51,10 @@ function PaymentMethodItem({
|
||||
method,
|
||||
isSelected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
method: PaymentMethod;
|
||||
isSelected: boolean;
|
||||
disabled: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const methodId = String(method.id);
|
||||
const cardLastFourDisplay = method.cardLastFour ? `•••• ${method.cardLastFour}` : "";
|
||||
@ -63,9 +63,9 @@ function PaymentMethodItem({
|
||||
: (method.description ?? method.type);
|
||||
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
className={[
|
||||
"flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-colors",
|
||||
"flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-colors font-normal",
|
||||
isSelected ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
@ -87,18 +87,12 @@ function PaymentMethodItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
<RadioGroupItem
|
||||
value={methodId}
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (!disabled) onSelect(methodId);
|
||||
}}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
@ -241,14 +235,17 @@ export function PaymentForm({
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<RadioGroup
|
||||
value={selectedMethod}
|
||||
onValueChange={value => handleSelect(value)}
|
||||
className="space-y-3"
|
||||
>
|
||||
{methods.map(method => (
|
||||
<PaymentMethodItem
|
||||
key={method.id}
|
||||
method={method}
|
||||
isSelected={selectedMethod === String(method.id)}
|
||||
disabled={disabled}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
{allowNewMethod && onAddNewMethod ? (
|
||||
@ -263,7 +260,7 @@ export function PaymentForm({
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
export interface FAQItem {
|
||||
question: string;
|
||||
@ -30,10 +31,11 @@ function FAQItemComponent({
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border/60 last:border-b-0">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full py-4 flex items-start justify-between gap-3 text-left group"
|
||||
className="w-full py-4 flex items-start justify-between gap-3 text-left group h-auto rounded-none px-0"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
@ -45,7 +47,7 @@ function FAQItemComponent({
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
export interface HighlightFeature {
|
||||
icon: React.ReactNode;
|
||||
@ -104,13 +105,15 @@ export function ServiceHighlights({ features, className = "" }: ServiceHighlight
|
||||
{/* Dot indicators */}
|
||||
<div className="flex justify-center gap-1.5 mt-1.5">
|
||||
{features.map((_, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={index}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => scrollToIndex(index)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all duration-300",
|
||||
"h-1.5 rounded-full transition-all duration-300 p-0 min-w-0",
|
||||
activeIndex === index
|
||||
? "w-5 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/25 hover:bg-muted-foreground/40"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Input, Label, ErrorMessage } from "@/components/atoms";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
type Gender = "male" | "female" | "other" | "";
|
||||
|
||||
@ -77,25 +78,22 @@ export function PersonalInfoFields({
|
||||
<Label>
|
||||
Gender <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-4">
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="gender"
|
||||
value={option}
|
||||
checked={gender === option}
|
||||
onChange={() => {
|
||||
onGenderChange(option);
|
||||
<RadioGroup
|
||||
value={gender || ""}
|
||||
onValueChange={(value: string) => {
|
||||
onGenderChange(value as "male" | "female" | "other");
|
||||
clearError("gender");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-4 w-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
className="flex gap-4"
|
||||
>
|
||||
{GENDER_OPTIONS.map(option => (
|
||||
<label key={option} className="flex items-center gap-2 cursor-pointer">
|
||||
<RadioGroupItem value={option} />
|
||||
<span className="text-sm capitalize">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<ErrorMessage>{errors.gender}</ErrorMessage>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Wrench, Sparkles, Network, ChevronDown, ChevronUp, HelpCircle } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
interface PlanGuideItemProps {
|
||||
tier: "Silver" | "Gold" | "Platinum";
|
||||
@ -85,10 +86,11 @@ export function PlanComparisonGuide() {
|
||||
return (
|
||||
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||
{/* Collapsible header */}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3.5 flex items-center justify-between gap-3 text-left hover:bg-muted/30 transition-colors"
|
||||
className="w-full px-4 py-3.5 flex items-center justify-between gap-3 text-left hover:bg-muted/30 transition-colors h-auto rounded-none"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-info-soft/50 text-info border border-info/20 flex-shrink-0">
|
||||
@ -111,7 +113,7 @@ export function PlanComparisonGuide() {
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||
|
||||
interface ActivationFeeDetails {
|
||||
@ -44,26 +46,23 @@ export function ActivationForm({
|
||||
activationFee,
|
||||
}: ActivationFormProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<RadioGroup
|
||||
value={activationType}
|
||||
onValueChange={value => onActivationTypeChange(value as "Immediate" | "Scheduled")}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
>
|
||||
{activationOptions.map(option => {
|
||||
const isSelected = activationType === option.type;
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
key={option.type}
|
||||
className={`p-6 rounded-xl border text-left transition-shadow duration-[var(--cp-duration-normal)] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background cursor-pointer flex flex-col gap-3 ${
|
||||
className={`p-6 rounded-xl border text-left transition-shadow duration-[var(--cp-duration-normal)] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background cursor-pointer flex flex-col gap-3 font-normal ${
|
||||
isSelected
|
||||
? "border-primary bg-primary-soft shadow-[var(--cp-shadow-2)]"
|
||||
: "border-border hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="activationType"
|
||||
value={option.type}
|
||||
checked={isSelected}
|
||||
onChange={e => onActivationTypeChange(e.target.value as "Immediate" | "Scheduled")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<RadioGroupItem value={option.type} className="sr-only" />
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -99,12 +98,12 @@ export function ActivationForm({
|
||||
aria-hidden={!isSelected}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<label
|
||||
<Label
|
||||
htmlFor="scheduledActivationDate"
|
||||
className="block text-sm font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Preferred activation date *
|
||||
</label>
|
||||
</Label>
|
||||
<input
|
||||
type="date"
|
||||
id="scheduledActivationDate"
|
||||
@ -125,9 +124,9 @@ export function ActivationForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Search, Smartphone, Check, X } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
// Device categories with their devices
|
||||
const DEVICE_CATEGORIES = [
|
||||
@ -291,14 +292,15 @@ export function DeviceCompatibility() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:text-primary/80 transition-colors h-auto"
|
||||
>
|
||||
<Smartphone className="h-4 w-4" />
|
||||
{isExpanded ? "Hide full device list" : "View all compatible devices"}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 rounded-xl border border-border bg-card overflow-hidden">
|
||||
@ -325,10 +327,11 @@ function DeviceCategorySection({ category }: { category: (typeof DEVICE_CATEGORI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
|
||||
className="w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors h-auto rounded-none"
|
||||
>
|
||||
<span className="font-medium text-foreground">{category.name}</span>
|
||||
<span
|
||||
@ -339,7 +342,7 @@ function DeviceCategorySection({ category }: { category: (typeof DEVICE_CATEGORI
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import React from "react";
|
||||
import type { MnpData } from "@customer-portal/domain/sim";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
||||
@ -103,20 +112,19 @@ function MnpGenderSelect({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="portingGender" className={LABEL_CLASS}>
|
||||
<Label htmlFor="portingGender" className={LABEL_CLASS}>
|
||||
Gender *
|
||||
</label>
|
||||
<select
|
||||
id="portingGender"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
<option value="Corporate/Other">Corporate/Other</option>
|
||||
</select>
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id="portingGender" className={INPUT_CLASS}>
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Male">Male</SelectItem>
|
||||
<SelectItem value="Female">Female</SelectItem>
|
||||
<SelectItem value="Corporate/Other">Corporate/Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
@ -179,12 +187,11 @@ export function MnpForm({
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={wantsMnp}
|
||||
onChange={e => onWantsMnpChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
onCheckedChange={checked => onWantsMnpChange(checked === true)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">
|
||||
@ -195,7 +202,7 @@ export function MnpForm({
|
||||
Additional fees may apply.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{wantsMnp && (
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { PhoneIcon, ChatBubbleLeftIcon, GlobeAltIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/solid";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
const domesticRates = {
|
||||
calling: { rate: 10, unit: "30 sec" },
|
||||
@ -74,9 +75,10 @@ function InternationalRatesSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 flex items-center justify-between text-left hover:bg-muted/50 transition-colors"
|
||||
className="w-full p-4 flex items-center justify-between text-left hover:bg-muted/50 transition-colors h-auto rounded-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeAltIcon className="w-5 h-5 text-primary" />
|
||||
@ -85,7 +87,7 @@ function InternationalRatesSection({
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-6 pb-6">
|
||||
|
||||
@ -288,12 +288,13 @@ function SimTabSwitcher({
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex rounded-lg bg-muted/60 p-0.5 border border-border/60">
|
||||
{SIM_TABS.map(tab => (
|
||||
<button
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium transition-all",
|
||||
"flex items-center gap-1.5 px-4 py-2 rounded-md text-sm font-medium transition-all h-auto",
|
||||
activeTab === tab.key
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
@ -305,7 +306,7 @@ function SimTabSwitcher({
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/8 text-primary font-semibold">
|
||||
{plansByType[tab.planTypeKey].length}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
CheckIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
const esimFeatures = [
|
||||
{ text: "No physical card needed", included: true },
|
||||
@ -53,12 +54,14 @@ function ESimCard({ onToggleEidInfo }: { onToggleEidInfo: () => void }) {
|
||||
<span className="text-sm text-foreground">
|
||||
{feature.text}
|
||||
{feature.note && (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleEidInfo}
|
||||
className="ml-1 text-primary hover:text-primary-hover"
|
||||
className="ml-1 text-primary hover:text-primary-hover inline-flex h-auto w-auto p-0"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
InformationCircleIcon,
|
||||
CheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
const SIM_TYPE_PHYSICAL = "Physical SIM" as const;
|
||||
|
||||
@ -24,10 +25,11 @@ const compatibleEidPrefixes = ["89049032", "89033023", "89033024", "89043051", "
|
||||
|
||||
function ESimOption({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 ${
|
||||
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 h-auto whitespace-normal ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50"
|
||||
@ -73,7 +75,7 @@ function ESimOption({ isSelected, onSelect }: { isSelected: boolean; onSelect: (
|
||||
<span>EID number required</span>
|
||||
</li>
|
||||
</ul>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -85,10 +87,11 @@ function PhysicalSimOption({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 ${
|
||||
className={`relative text-left p-5 rounded-xl border-2 transition-all duration-200 h-auto whitespace-normal ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border bg-card hover:border-primary/40 hover:bg-muted/50"
|
||||
@ -134,7 +137,7 @@ function PhysicalSimOption({
|
||||
<span>3-in-1 size (Nano/Micro/Standard)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -197,13 +200,14 @@ function EidInput({
|
||||
{hasError && <p className="text-destructive text-sm mt-2">{errors["eid"]}</p>}
|
||||
{!hasError && warning && <p className="text-warning text-sm mt-2">{warning}</p>}
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowEidInfo(!showEidInfo)}
|
||||
className="text-sm text-primary hover:underline mt-2"
|
||||
className="text-sm text-primary hover:underline mt-2 h-auto p-0"
|
||||
>
|
||||
{showEidInfo ? "Hide" : "Where to find your EID?"}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{showEidInfo && (
|
||||
<div className="mt-3 p-4 bg-card border border-border rounded-lg text-sm">
|
||||
|
||||
@ -4,6 +4,15 @@ import { useState, type ReactNode } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// ============================================================================
|
||||
@ -157,22 +166,24 @@ function Step1Content({
|
||||
</div>
|
||||
{serviceInfo}
|
||||
<div>
|
||||
<label htmlFor="cancelMonth" className="block text-sm font-medium text-foreground mb-2">
|
||||
<Label htmlFor="cancelMonth" className="block text-sm font-medium text-foreground mb-2">
|
||||
Cancellation Month
|
||||
</label>
|
||||
<select
|
||||
</Label>
|
||||
<Select value={selectedMonth || ""} onValueChange={onMonthChange}>
|
||||
<SelectTrigger
|
||||
id="cancelMonth"
|
||||
value={selectedMonth}
|
||||
onChange={e => onMonthChange(e.target.value)}
|
||||
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="">Select a month...</option>
|
||||
<SelectValue placeholder="Select a month..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableMonths.map(month => (
|
||||
<option key={month.value} value={month.value}>
|
||||
<SelectItem key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Service will end at the end of the selected month.
|
||||
</p>
|
||||
@ -221,22 +232,20 @@ function Step2Content({
|
||||
</div>
|
||||
{termsContent}
|
||||
<div className="space-y-4 p-4 bg-muted/50 rounded-lg">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={acceptTerms}
|
||||
onChange={e => onAcceptTermsChange(e.target.checked)}
|
||||
onCheckedChange={val => onAcceptTermsChange(val === true)}
|
||||
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-foreground">
|
||||
I have read and understood the cancellation terms above.
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
</Label>
|
||||
<Label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={confirmMonthEnd}
|
||||
onChange={e => onConfirmMonthEndChange(e.target.checked)}
|
||||
onCheckedChange={val => onConfirmMonthEndChange(val === true)}
|
||||
disabled={!selectedMonth}
|
||||
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
@ -244,7 +253,7 @@ function Step2Content({
|
||||
I confirm cancellation at the end of{" "}
|
||||
<strong>{selectedMonthLabel || "the selected month"}</strong>.
|
||||
</span>
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
@ -296,9 +305,9 @@ function Step3Content({
|
||||
<div className="text-sm font-medium text-foreground">{customerEmail || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="comments" className="block text-sm font-medium text-foreground mb-2">
|
||||
<Label htmlFor="comments" className="block text-sm font-medium text-foreground mb-2">
|
||||
Additional Comments <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
</Label>
|
||||
<textarea
|
||||
id="comments"
|
||||
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary resize-none"
|
||||
|
||||
@ -5,6 +5,15 @@ import { motion } from "framer-motion";
|
||||
import { apiClient } from "@/core/api";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { mapToSimplifiedFormat } from "../../utils/plan";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface ChangePlanModalProps {
|
||||
subscriptionId: number;
|
||||
@ -28,19 +37,19 @@ function PlanSelector({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Select New Plan</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value as PlanCode)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="">Choose a plan</option>
|
||||
<Label className="block text-sm font-medium text-gray-700">Select New Plan</Label>
|
||||
<Select value={value || ""} onValueChange={v => onChange(v as PlanCode)}>
|
||||
<SelectTrigger className="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
|
||||
<SelectValue placeholder="Choose a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowedPlans.map(code => (
|
||||
<option key={code} value={code}>
|
||||
<SelectItem key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Only plans different from your current plan are listed. The change will be scheduled for the
|
||||
1st of the next month.
|
||||
@ -105,9 +114,14 @@ export function ChangePlanModal({
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<PlanSelector
|
||||
@ -120,22 +134,23 @@ export function ChangePlanModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing..." : "Change Plan"}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,8 @@ import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService } from "@/features/subscriptions/api/sim-actions.api";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Label } from "@/components/atoms/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
type SimKind = "physical" | "esim";
|
||||
|
||||
@ -36,17 +38,14 @@ function SimTypeSelector({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground">Select SIM type</label>
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="sim-type"
|
||||
value="physical"
|
||||
checked={selectedSimType === "physical"}
|
||||
onChange={() => onSelect("physical")}
|
||||
className="mt-1 text-primary focus:ring-ring"
|
||||
/>
|
||||
<Label className="block text-sm font-medium text-muted-foreground">Select SIM type</Label>
|
||||
<RadioGroup
|
||||
value={selectedSimType}
|
||||
onValueChange={v => onSelect(v as SimKind)}
|
||||
className="mt-3 space-y-2"
|
||||
>
|
||||
<Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
|
||||
<RadioGroupItem value="physical" className="mt-1 text-primary focus:ring-ring" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Physical SIM</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -54,16 +53,9 @@ function SimTypeSelector({
|
||||
contact support to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="sim-type"
|
||||
value="esim"
|
||||
checked={selectedSimType === "esim"}
|
||||
onChange={() => onSelect("esim")}
|
||||
className="mt-1 text-primary focus:ring-ring"
|
||||
/>
|
||||
</Label>
|
||||
<Label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3 cursor-pointer">
|
||||
<RadioGroupItem value="esim" className="mt-1 text-primary focus:ring-ring" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">eSIM</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -71,8 +63,8 @@ function SimTypeSelector({
|
||||
processing completes.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -149,14 +141,16 @@ export function ReissueSimModal({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Close reissue SIM modal"
|
||||
type="button"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<AlertBanner variant="warning" title="Important information" elevated>
|
||||
|
||||
@ -69,7 +69,7 @@ function ActionButton({
|
||||
}`;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={buttonClasses}>
|
||||
<Button variant="ghost" onClick={onClick} disabled={disabled} className={buttonClasses}>
|
||||
<div className="flex items-center">
|
||||
{icon}
|
||||
<div className="text-left">
|
||||
@ -82,7 +82,7 @@ function ActionButton({
|
||||
{disabledReason}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ function ReissueButton({
|
||||
}`;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={buttonClasses}>
|
||||
<Button variant="ghost" onClick={onClick} disabled={disabled} className={buttonClasses}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||
@ -127,7 +127,7 @@ function ReissueButton({
|
||||
{disabledReason}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -217,8 +217,9 @@ function FeatureToggleRow({
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={onChange}
|
||||
@ -229,7 +230,7 @@ function FeatureToggleRow({
|
||||
animate={{ x: checked ? 20 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -258,9 +259,10 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
|
||||
aria-label="Network Type"
|
||||
>
|
||||
{options.map(value => (
|
||||
<button
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="radio"
|
||||
aria-checked={nt === value}
|
||||
onClick={() => setNt(value)}
|
||||
@ -278,7 +280,7 @@ function NetworkTypeSelector({ nt, setNt }: NetworkTypeSelectorProps): React.Rea
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">5G connectivity for enhanced speeds</p>
|
||||
|
||||
@ -14,6 +14,7 @@ import { apiClient } from "@/core/api";
|
||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { Checkbox } from "@/components/atoms/checkbox";
|
||||
import { formatIsoDate } from "@/shared/utils";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
@ -462,9 +463,10 @@ type ActionTileProps = {
|
||||
function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProps) {
|
||||
const isDanger = intent === "danger";
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={`flex flex-col gap-2 items-center justify-center rounded-xl border transition-all duration-200 p-5 text-center ${
|
||||
className={`flex flex-col gap-2 items-center justify-center rounded-xl border transition-all duration-200 p-5 text-center h-auto ${
|
||||
isDanger
|
||||
? "border-danger/25 bg-danger-soft/40 hover:bg-danger-soft hover:shadow-[var(--cp-shadow-2)]"
|
||||
: "border-border bg-muted/40 hover:bg-background hover:shadow-[var(--cp-shadow-2)]"
|
||||
@ -476,7 +478,7 @@ function ActionTile({ label, icon, onClick, intent = "default" }: ActionTileProp
|
||||
<span className={`text-sm font-medium ${isDanger ? "text-danger" : "text-foreground"}`}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -514,20 +516,21 @@ function StatusToggle({
|
||||
<p className="font-medium text-foreground">{label}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<label
|
||||
className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={handleClick}
|
||||
onCheckedChange={val => {
|
||||
if (!isDisabled && onChange) {
|
||||
onChange(val === true);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}
|
||||
role="presentation"
|
||||
onClick={handleClick}
|
||||
className={`relative w-11 h-6 bg-muted rounded-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all ${checked ? "bg-primary after:translate-x-full after:border-background" : ""} ${loading ? "animate-pulse" : ""} ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ import React, { useState } from "react";
|
||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { apiClient } from "@/core/api";
|
||||
import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPricing";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
interface TopUpModalProps {
|
||||
subscriptionId: number;
|
||||
@ -104,12 +105,14 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
<p className="text-sm text-gray-500">Add data quota to your SIM service</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={e => void handleSubmit(e)}>
|
||||
<div className="mb-6">
|
||||
@ -150,21 +153,22 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 space-y-3 space-y-reverse sm:space-y-0">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !isValidAmount()}
|
||||
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
@ -285,16 +286,17 @@ interface TabButtonProps {
|
||||
function TabButton({ active, onClick, icon, label, count }: TabButtonProps): React.ReactElement {
|
||||
const tabClasses = active ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${tabClasses}`}
|
||||
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors rounded-none ${tabClasses}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<span className="ml-1 text-xs text-muted-foreground/70">({count})</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user