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