diff --git a/apps/portal/src/components/atoms/animated-container.stories.tsx b/apps/portal/src/components/atoms/animated-container.stories.tsx new file mode 100644 index 00000000..cd202838 --- /dev/null +++ b/apps/portal/src/components/atoms/animated-container.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatedContainer } from "./animated-container"; + +const meta: Meta = { + title: "Atoms/AnimatedContainer", + component: AnimatedContainer, + argTypes: { + animation: { control: "select", options: ["fade-up", "fade-scale", "slide-left", "none"] }, + stagger: { control: "boolean" }, + delay: { control: { type: "number", min: 0, max: 2000, step: 100 } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const FadeUp: Story = { + args: { + animation: "fade-up", + children: ( +
Fade Up Animation
+ ), + }, +}; + +export const FadeScale: Story = { + args: { + animation: "fade-scale", + children: ( +
+ Fade Scale Animation +
+ ), + }, +}; + +export const SlideLeft: Story = { + args: { + animation: "slide-left", + children: ( +
+ Slide Left Animation +
+ ), + }, +}; diff --git a/apps/portal/src/components/atoms/badge.stories.tsx b/apps/portal/src/components/atoms/badge.stories.tsx new file mode 100644 index 00000000..dbbdf884 --- /dev/null +++ b/apps/portal/src/components/atoms/badge.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Badge } from "./badge"; + +const meta: Meta = { + title: "Atoms/Badge", + component: Badge, + argTypes: { + variant: { + control: "select", + options: ["default", "secondary", "success", "warning", "error", "info", "outline", "ghost"], + }, + size: { control: "select", options: ["sm", "default", "lg"] }, + dot: { control: "boolean" }, + removable: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { children: "Badge" }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ Default + Secondary + Success + Warning + Error + Info + Outline + Ghost +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ Small + Default + Large +
+ ), +}; + +export const WithDot: Story = { + render: () => ( +
+ + Active + + + Pending + + + Failed + + + Processing + +
+ ), +}; + +export const Removable: Story = { + args: { children: "Removable", removable: true, onRemove: () => alert("Removed!") }, +}; diff --git a/apps/portal/src/components/atoms/button.stories.tsx b/apps/portal/src/components/atoms/button.stories.tsx new file mode 100644 index 00000000..37cc30b6 --- /dev/null +++ b/apps/portal/src/components/atoms/button.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Button } from "./button"; +import { ArrowRightIcon, PlusIcon } from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Atoms/Button", + component: Button, + argTypes: { + variant: { + control: "select", + options: [ + "default", + "destructive", + "outline", + "secondary", + "ghost", + "subtle", + "link", + "pill", + "pillOutline", + ], + }, + size: { control: "select", options: ["default", "sm", "lg"] }, + loading: { control: "boolean" }, + disabled: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { children: "Button" }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const WithIcons: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Loading: Story = { + args: { children: "Submitting...", loading: true }, +}; + +export const Disabled: Story = { + args: { children: "Disabled", disabled: true }, +}; diff --git a/apps/portal/src/components/atoms/checkbox.stories.tsx b/apps/portal/src/components/atoms/checkbox.stories.tsx new file mode 100644 index 00000000..9cb0cf7b --- /dev/null +++ b/apps/portal/src/components/atoms/checkbox.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Checkbox } from "./checkbox"; + +const meta: Meta = { + title: "Atoms/Checkbox", + component: Checkbox, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { label: "Accept terms and conditions" }, +}; + +export const WithHelperText: Story = { + args: { label: "Send me emails", helperText: "We'll only send important updates" }, +}; + +export const WithError: Story = { + args: { label: "I agree to the terms", error: "You must accept the terms" }, +}; + +export const Checked: Story = { + args: { label: "Already checked", defaultChecked: true }, +}; + +export const AllStates: Story = { + render: () => ( +
+ + + + + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/empty-state.stories.tsx b/apps/portal/src/components/atoms/empty-state.stories.tsx new file mode 100644 index 00000000..31e6f780 --- /dev/null +++ b/apps/portal/src/components/atoms/empty-state.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { EmptyState, SearchEmptyState } from "./empty-state"; +import { InboxIcon } from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Atoms/EmptyState", + component: EmptyState, + argTypes: { + variant: { control: "select", options: ["default", "compact"] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "No invoices yet", + description: "When you receive invoices, they will appear here.", + }, +}; + +export const WithAction: Story = { + args: { + icon: , + title: "No services", + description: "Get started by adding your first service.", + action: { label: "Add Service", onClick: () => {} }, + }, +}; + +export const Compact: Story = { + args: { + title: "No results", + description: "Try adjusting your filters.", + variant: "compact", + }, +}; + +export const SearchEmpty: Story = { + render: () => {}} />, +}; diff --git a/apps/portal/src/components/atoms/error-message.stories.tsx b/apps/portal/src/components/atoms/error-message.stories.tsx new file mode 100644 index 00000000..ad3855a6 --- /dev/null +++ b/apps/portal/src/components/atoms/error-message.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ErrorMessage } from "./error-message"; + +const meta: Meta = { + title: "Atoms/ErrorMessage", + component: ErrorMessage, + argTypes: { + variant: { control: "select", options: ["default", "inline", "subtle"] }, + showIcon: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { children: "This field is required" }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ Default error message + Inline error message + Subtle error message +
+ ), +}; + +export const WithoutIcon: Story = { + args: { children: "Error without icon", showIcon: false }, +}; diff --git a/apps/portal/src/components/atoms/error-state.stories.tsx b/apps/portal/src/components/atoms/error-state.stories.tsx new file mode 100644 index 00000000..c0ff6e6a --- /dev/null +++ b/apps/portal/src/components/atoms/error-state.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ErrorState } from "./error-state"; + +const meta: Meta = { + title: "Atoms/ErrorState", + component: ErrorState, + argTypes: { + variant: { control: "select", options: ["page", "card", "inline"] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Card: Story = { + args: { variant: "card" }, +}; + +export const Page: Story = { + args: { variant: "page" }, +}; + +export const Inline: Story = { + args: { variant: "inline" }, +}; + +export const WithRetry: Story = { + args: { + variant: "card", + title: "Failed to load data", + message: "Could not connect to the server. Please check your connection.", + onRetry: () => alert("Retrying..."), + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ {}} + /> + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/inline-toast.stories.tsx b/apps/portal/src/components/atoms/inline-toast.stories.tsx new file mode 100644 index 00000000..40a8578a --- /dev/null +++ b/apps/portal/src/components/atoms/inline-toast.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InlineToast } from "./inline-toast"; + +const meta: Meta = { + title: "Atoms/InlineToast", + component: InlineToast, + argTypes: { + tone: { control: "select", options: ["info", "success", "warning", "error"] }, + visible: { control: "boolean" }, + }, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + args: { visible: true, text: "Changes saved successfully!", tone: "success" }, +}; + +export const Error: Story = { + args: { visible: true, text: "Failed to save changes", tone: "error" }, +}; + +export const AllTones: Story = { + render: () => ( +
+
+ + + + +
+
+ ), +}; diff --git a/apps/portal/src/components/atoms/input.stories.tsx b/apps/portal/src/components/atoms/input.stories.tsx new file mode 100644 index 00000000..b43665fb --- /dev/null +++ b/apps/portal/src/components/atoms/input.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Input } from "./input"; + +const meta: Meta = { + title: "Atoms/Input", + component: Input, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Enter text..." }, +}; + +export const WithValue: Story = { + args: { defaultValue: "Hello world" }, +}; + +export const WithError: Story = { + args: { placeholder: "Email", error: "Invalid email address" }, +}; + +export const Disabled: Story = { + args: { placeholder: "Disabled input", disabled: true }, +}; + +export const AllStates: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/label.stories.tsx b/apps/portal/src/components/atoms/label.stories.tsx new file mode 100644 index 00000000..9cf475b6 --- /dev/null +++ b/apps/portal/src/components/atoms/label.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Label } from "./label"; + +const meta: Meta = { + title: "Atoms/Label", + component: Label, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { children: "Email address" }, +}; + +export const Required: Story = { + render: () => ( + + ), +}; diff --git a/apps/portal/src/components/atoms/loading-overlay.stories.tsx b/apps/portal/src/components/atoms/loading-overlay.stories.tsx new file mode 100644 index 00000000..fdb27294 --- /dev/null +++ b/apps/portal/src/components/atoms/loading-overlay.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { LoadingOverlay } from "./loading-overlay"; + +const meta: Meta = { + title: "Atoms/LoadingOverlay", + component: LoadingOverlay, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isVisible: true, + title: "Processing your order...", + subtitle: "This may take a moment", + }, +}; + +export const SimpleMessage: Story = { + args: { + isVisible: true, + title: "Loading...", + }, +}; diff --git a/apps/portal/src/components/atoms/logo.stories.tsx b/apps/portal/src/components/atoms/logo.stories.tsx new file mode 100644 index 00000000..6be41052 --- /dev/null +++ b/apps/portal/src/components/atoms/logo.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Logo } from "./logo"; + +const meta: Meta = { + title: "Atoms/Logo", + component: Logo, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { size: 32 }, +}; + +export const Sizes: Story = { + render: () => ( +
+ + + + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/password-input.stories.tsx b/apps/portal/src/components/atoms/password-input.stories.tsx new file mode 100644 index 00000000..1824e0f0 --- /dev/null +++ b/apps/portal/src/components/atoms/password-input.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PasswordInput } from "./password-input"; + +const meta: Meta = { + title: "Atoms/PasswordInput", + component: PasswordInput, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Enter password" }, +}; + +export const WithValue: Story = { + args: { defaultValue: "mysecretpassword" }, +}; + +export const Disabled: Story = { + args: { placeholder: "Disabled", disabled: true }, +}; diff --git a/apps/portal/src/components/atoms/skeleton.stories.tsx b/apps/portal/src/components/atoms/skeleton.stories.tsx new file mode 100644 index 00000000..ba8681f0 --- /dev/null +++ b/apps/portal/src/components/atoms/skeleton.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Skeleton } from "./skeleton"; + +const meta: Meta = { + title: "Atoms/Skeleton", + component: Skeleton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { className: "h-4 w-48" }, +}; + +export const CommonPatterns: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const NoAnimation: Story = { + args: { className: "h-4 w-48", animate: false }, +}; diff --git a/apps/portal/src/components/atoms/spinner.stories.tsx b/apps/portal/src/components/atoms/spinner.stories.tsx new file mode 100644 index 00000000..66620315 --- /dev/null +++ b/apps/portal/src/components/atoms/spinner.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Spinner } from "./spinner"; + +const meta: Meta = { + title: "Atoms/Spinner", + component: Spinner, + argTypes: { + size: { control: "select", options: ["xs", "sm", "md", "lg", "xl"] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { size: "md" }, +}; + +export const AllSizes: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const Colored: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/status-indicator.stories.tsx b/apps/portal/src/components/atoms/status-indicator.stories.tsx new file mode 100644 index 00000000..5ae7ff2b --- /dev/null +++ b/apps/portal/src/components/atoms/status-indicator.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatusIndicator } from "./status-indicator"; + +const meta: Meta = { + title: "Atoms/StatusIndicator", + component: StatusIndicator, + argTypes: { + status: { control: "select", options: ["active", "warning", "error", "inactive", "pending"] }, + size: { control: "select", options: ["sm", "md", "lg"] }, + pulse: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { status: "active", label: "Online" }, +}; + +export const AllStatuses: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/status-pill.stories.tsx b/apps/portal/src/components/atoms/status-pill.stories.tsx new file mode 100644 index 00000000..977242cb --- /dev/null +++ b/apps/portal/src/components/atoms/status-pill.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatusPill } from "./status-pill"; +import { CheckCircleIcon, ClockIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Atoms/StatusPill", + component: StatusPill, + argTypes: { + variant: { control: "select", options: ["success", "warning", "info", "neutral", "error"] }, + size: { control: "select", options: ["sm", "md", "lg"] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { label: "Active", variant: "success" }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const WithIcons: Story = { + render: () => ( +
+ } + /> + } /> + } + /> +
+ ), +}; diff --git a/apps/portal/src/components/atoms/step-header.stories.tsx b/apps/portal/src/components/atoms/step-header.stories.tsx new file mode 100644 index 00000000..e6fa091b --- /dev/null +++ b/apps/portal/src/components/atoms/step-header.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StepHeader } from "./step-header"; + +const meta: Meta = { + title: "Atoms/StepHeader", + component: StepHeader, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + stepNumber: 1, + title: "Choose Your Plan", + description: "Select the plan that best fits your needs", + }, +}; + +export const MultipleSteps: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/portal/src/components/atoms/view-toggle.stories.tsx b/apps/portal/src/components/atoms/view-toggle.stories.tsx new file mode 100644 index 00000000..782db064 --- /dev/null +++ b/apps/portal/src/components/atoms/view-toggle.stories.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { ViewToggle, type ViewMode } from "./view-toggle"; + +const meta: Meta = { + title: "Atoms/ViewToggle", + component: ViewToggle, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function Render() { + const [mode, setMode] = useState("grid"); + return ; + }, +}; + +export const ListView: Story = { + render: function Render() { + const [mode, setMode] = useState("list"); + return ; + }, +}; diff --git a/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx new file mode 100644 index 00000000..58702d67 --- /dev/null +++ b/apps/portal/src/components/molecules/AlertBanner/AlertBanner.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AlertBanner } from "./AlertBanner"; + +const meta: Meta = { + title: "Molecules/AlertBanner", + component: AlertBanner, + argTypes: { + variant: { control: "select", options: ["success", "info", "warning", "error"] }, + size: { control: "select", options: ["sm", "md"] }, + elevated: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Info: Story = { + args: { + variant: "info", + title: "New feature available", + children: "Check out the new dashboard layout.", + }, +}; + +export const Success: Story = { + args: { + variant: "success", + title: "Payment received", + children: "Your payment has been processed successfully.", + }, +}; + +export const Warning: Story = { + args: { + variant: "warning", + title: "Account expiring", + children: "Your subscription expires in 3 days.", + }, +}; + +export const Error: Story = { + args: { + variant: "error", + title: "Payment failed", + children: "Please update your payment method.", + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ + Informational message + + + Operation completed + + + Attention needed + + + Something went wrong + +
+ ), +}; + +export const Closable: Story = { + args: { + variant: "info", + title: "Dismissible", + children: "Click the X to close.", + onClose: () => alert("Closed!"), + }, +}; + +export const Small: Story = { + args: { variant: "warning", title: "Heads up", size: "sm" }, +}; diff --git a/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx new file mode 100644 index 00000000..72e722a4 --- /dev/null +++ b/apps/portal/src/components/molecules/AnimatedCard/AnimatedCard.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatedCard } from "./AnimatedCard"; + +const meta: Meta = { + title: "Molecules/AnimatedCard", + component: AnimatedCard, + argTypes: { + variant: { control: "select", options: ["default", "highlighted", "success", "static"] }, + disabled: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
Default animated card content
, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ +
Default
+
+ +
Highlighted
+
+ +
Success
+
+ +
Static
+
+
+ ), +}; + +export const Interactive: Story = { + args: { + onClick: () => alert("Clicked!"), + children:
Click me! (interactive card)
, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + onClick: () => {}, + children:
Disabled card
, + }, +}; diff --git a/apps/portal/src/components/molecules/BackLink/BackLink.stories.tsx b/apps/portal/src/components/molecules/BackLink/BackLink.stories.tsx new file mode 100644 index 00000000..5876f992 --- /dev/null +++ b/apps/portal/src/components/molecules/BackLink/BackLink.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BackLink } from "./BackLink"; + +const meta: Meta = { + title: "Molecules/BackLink", + component: BackLink, + argTypes: { + align: { control: "select", options: ["left", "center", "right"] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { href: "/", label: "Back" }, +}; + +export const CustomLabel: Story = { + args: { href: "/account", label: "Back to Account" }, +}; + +export const Alignments: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/portal/src/components/molecules/ClearFiltersButton/ClearFiltersButton.stories.tsx b/apps/portal/src/components/molecules/ClearFiltersButton/ClearFiltersButton.stories.tsx new file mode 100644 index 00000000..a8c402ff --- /dev/null +++ b/apps/portal/src/components/molecules/ClearFiltersButton/ClearFiltersButton.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ClearFiltersButton } from "./ClearFiltersButton"; + +const meta: Meta = { + title: "Molecules/ClearFiltersButton", + component: ClearFiltersButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { onClick: () => alert("Cleared!"), show: true }, +}; + +export const CustomLabel: Story = { + args: { onClick: () => {}, show: true, label: "Reset Filters" }, +}; + +export const Hidden: Story = { + args: { onClick: () => {}, show: false }, +}; diff --git a/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx new file mode 100644 index 00000000..b41befb6 --- /dev/null +++ b/apps/portal/src/components/molecules/DataTable/DataTable.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DataTable } from "./DataTable"; +import { InboxIcon } from "@heroicons/react/24/outline"; + +interface SampleRow { + id: number; + name: string; + email: string; + status: string; + amount: string; +} + +const sampleData: SampleRow[] = [ + { id: 1, name: "John Doe", email: "john@example.com", status: "Active", amount: "¥12,000" }, + { id: 2, name: "Jane Smith", email: "jane@example.com", status: "Pending", amount: "¥8,500" }, + { id: 3, name: "Bob Wilson", email: "bob@example.com", status: "Active", amount: "¥15,200" }, + { id: 4, name: "Alice Brown", email: "alice@example.com", status: "Inactive", amount: "¥3,100" }, + { + id: 5, + name: "Charlie Davis", + email: "charlie@example.com", + status: "Active", + amount: "¥9,800", + }, +]; + +const columns = [ + { key: "name", header: "Name", render: (r: SampleRow) => r.name, primary: true }, + { key: "email", header: "Email", render: (r: SampleRow) => r.email }, + { + key: "status", + header: "Status", + render: (r: SampleRow) => ( + + {r.status} + + ), + }, + { key: "amount", header: "Amount", render: (r: SampleRow) => r.amount }, +]; + +const meta: Meta> = { + title: "Molecules/DataTable", + component: DataTable, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj>; + +export const Default: Story = { + args: { data: sampleData, columns, forceTableView: true }, +}; + +export const Clickable: Story = { + args: { + data: sampleData, + columns, + onRowClick: item => alert(`Clicked: ${item.name}`), + forceTableView: true, + }, +}; + +export const Empty: Story = { + args: { + data: [], + columns, + emptyState: { + icon: , + title: "No records found", + description: "Try adjusting your search or filters.", + }, + }, +}; diff --git a/apps/portal/src/components/molecules/DetailHeader/DetailHeader.stories.tsx b/apps/portal/src/components/molecules/DetailHeader/DetailHeader.stories.tsx new file mode 100644 index 00000000..f8673dc6 --- /dev/null +++ b/apps/portal/src/components/molecules/DetailHeader/DetailHeader.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DetailHeader } from "./DetailHeader"; +import { DocumentTextIcon, WifiIcon } from "@heroicons/react/24/outline"; +import { Button } from "../../atoms/button"; + +const meta: Meta = { + title: "Molecules/DetailHeader", + component: DetailHeader, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Order #12345", + subtitle: "Placed on March 1, 2026", + status: { label: "Active", variant: "success" }, + }, +}; + +export const WithIcon: Story = { + args: { + title: "Internet Plan - Fiber 1Gbps", + subtitle: "Subscription #SUB-789", + leftIcon: , + status: { label: "Active", variant: "success" }, + }, +}; + +export const WithActions: Story = { + args: { + title: "Invoice #INV-2026-001", + subtitle: "Due: March 15, 2026", + leftIcon: , + status: { label: "Pending", variant: "warning" }, + actions: , + }, +}; + +export const WithMeta: Story = { + args: { + title: "Support Ticket #4567", + status: { label: "Open", variant: "info" }, + meta: ( +
+ Priority: High + Category: Billing + Created: 2 hours ago +
+ ), + }, +}; diff --git a/apps/portal/src/components/molecules/DetailStatsGrid/DetailStatsGrid.stories.tsx b/apps/portal/src/components/molecules/DetailStatsGrid/DetailStatsGrid.stories.tsx new file mode 100644 index 00000000..ac616e8c --- /dev/null +++ b/apps/portal/src/components/molecules/DetailStatsGrid/DetailStatsGrid.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DetailStatsGrid } from "./DetailStatsGrid"; +import { + CalendarIcon, + CurrencyYenIcon, + DocumentTextIcon, + ClockIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Molecules/DetailStatsGrid", + component: DetailStatsGrid, + argTypes: { + columns: { control: "select", options: [2, 3, 4] }, + }, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const FourColumns: Story = { + args: { + columns: 4, + items: [ + { icon: , label: "Start Date", value: "Jan 15, 2026" }, + { icon: , label: "Monthly Cost", value: "¥4,800" }, + { icon: , label: "Contract", value: "24 months" }, + { icon: , label: "Next Billing", value: "Apr 1, 2026" }, + ], + }, +}; + +export const ThreeColumns: Story = { + args: { + columns: 3, + items: [ + { icon: , label: "Created", value: "Mar 1, 2026" }, + { icon: , label: "Total", value: "¥32,400" }, + { icon: , label: "Status", value: "Processing" }, + ], + }, +}; + +export const TwoColumns: Story = { + args: { + columns: 2, + items: [ + { label: "Plan", value: "Fiber 1Gbps" }, + { label: "Speed", value: "Up to 1Gbps" }, + ], + }, +}; diff --git a/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx new file mode 100644 index 00000000..4baca8ae --- /dev/null +++ b/apps/portal/src/components/molecules/FilterDropdown/FilterDropdown.stories.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { FilterDropdown } from "./FilterDropdown"; + +const meta: Meta = { + title: "Molecules/FilterDropdown", + component: FilterDropdown, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function Render() { + const [value, setValue] = useState("all"); + return ( + + ); + }, +}; + +export const CustomWidth: Story = { + render: function Render() { + const [value, setValue] = useState("all"); + return ( + + ); + }, +}; diff --git a/apps/portal/src/components/molecules/FormField/FormField.stories.tsx b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx new file mode 100644 index 00000000..2ff82b8e --- /dev/null +++ b/apps/portal/src/components/molecules/FormField/FormField.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FormField } from "./FormField"; + +const meta: Meta = { + title: "Molecules/FormField", + component: FormField, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { label: "Email", placeholder: "you@example.com" }, +}; + +export const Required: Story = { + args: { label: "Full Name", placeholder: "John Doe", required: true }, +}; + +export const WithError: Story = { + args: { + label: "Email", + placeholder: "you@example.com", + error: "Invalid email address", + required: true, + }, +}; + +export const WithHelperText: Story = { + args: { label: "Phone", placeholder: "+81 90-1234-5678", helperText: "Include country code" }, +}; + +export const FormExample: Story = { + render: () => ( +
+ + + + + +
+ ), +}; diff --git a/apps/portal/src/components/molecules/LoadingSkeletons/LoadingSkeletons.stories.tsx b/apps/portal/src/components/molecules/LoadingSkeletons/LoadingSkeletons.stories.tsx new file mode 100644 index 00000000..0032c0a1 --- /dev/null +++ b/apps/portal/src/components/molecules/LoadingSkeletons/LoadingSkeletons.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { LoadingCard } from "./loading-card"; +import { LoadingTable } from "./loading-table"; +import { LoadingStats } from "./loading-stats"; + +const meta: Meta = { + title: "Molecules/LoadingSkeletons", + parameters: { layout: "padded" }, +}; + +export default meta; + +export const Card: StoryObj = { + render: () => ( +
+ +
+ ), +}; + +export const MultipleCards: StoryObj = { + render: () => ( +
+ + +
+ ), +}; + +export const Table: StoryObj = { + render: () => ( +
+ +
+ ), +}; + +export const TableSmall: StoryObj = { + render: () => ( +
+ +
+ ), +}; + +export const Stats: StoryObj = { + render: () => , +}; + +export const StatsThree: StoryObj = { + render: () => , +}; diff --git a/apps/portal/src/components/molecules/MetricCard/MetricCard.stories.tsx b/apps/portal/src/components/molecules/MetricCard/MetricCard.stories.tsx new file mode 100644 index 00000000..cea2105b --- /dev/null +++ b/apps/portal/src/components/molecules/MetricCard/MetricCard.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MetricCard, MetricCardSkeleton } from "./MetricCard"; +import { + CurrencyYenIcon, + UsersIcon, + DocumentTextIcon, + WifiIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Molecules/MetricCard", + component: MetricCard, + argTypes: { + tone: { + control: "select", + options: ["primary", "success", "warning", "danger", "info", "neutral"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + label: "Total Revenue", + value: "¥1,234,567", + tone: "primary", + }, +}; + +export const WithTrend: Story = { + args: { + icon: , + label: "Active Users", + value: "2,847", + tone: "success", + trend: { value: "+12.5%", positive: true }, + }, +}; + +export const AllTones: Story = { + render: () => ( +
+ } + label="Revenue" + value="¥1.2M" + tone="primary" + /> + } + label="Users" + value="2,847" + tone="success" + trend={{ value: "+5%", positive: true }} + /> + } + label="Pending" + value="23" + tone="warning" + /> + } + label="Downtime" + value="2hrs" + tone="danger" + trend={{ value: "+0.5%", positive: false }} + /> +
+ ), +}; + +export const LoadingSkeleton: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.stories.tsx b/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.stories.tsx new file mode 100644 index 00000000..5b7cd53a --- /dev/null +++ b/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OtpExpiryDisplay } from "./OtpExpiryDisplay"; + +const meta: Meta = { + title: "Molecules/OtpExpiryDisplay", + component: OtpExpiryDisplay, +}; + +export default meta; +type Story = StoryObj; + +export const CountingDown: Story = { + args: { timeRemaining: 245, isExpired: false }, +}; + +export const AlmostExpired: Story = { + args: { timeRemaining: 15, isExpired: false }, +}; + +export const Expired: Story = { + args: { timeRemaining: 0, isExpired: true }, +}; + +export const NoTimer: Story = { + args: { timeRemaining: null, isExpired: false }, +}; diff --git a/apps/portal/src/components/molecules/OtpInput/OtpInput.stories.tsx b/apps/portal/src/components/molecules/OtpInput/OtpInput.stories.tsx new file mode 100644 index 00000000..4681ed6a --- /dev/null +++ b/apps/portal/src/components/molecules/OtpInput/OtpInput.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { OtpInput } from "./OtpInput"; + +const meta: Meta = { + title: "Molecules/OtpInput", + component: OtpInput, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function Render() { + const [value, setValue] = useState(""); + return ; + }, +}; + +export const WithError: Story = { + render: function Render() { + const [value, setValue] = useState("123456"); + return ( + + ); + }, +}; + +export const Disabled: Story = { + render: function Render() { + const [value, setValue] = useState("123"); + return ; + }, +}; + +export const FourDigit: Story = { + render: function Render() { + const [value, setValue] = useState(""); + return ; + }, +}; diff --git a/apps/portal/src/components/molecules/PaginationBar/PaginationBar.stories.tsx b/apps/portal/src/components/molecules/PaginationBar/PaginationBar.stories.tsx new file mode 100644 index 00000000..11e54dea --- /dev/null +++ b/apps/portal/src/components/molecules/PaginationBar/PaginationBar.stories.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { PaginationBar } from "./PaginationBar"; + +const meta: Meta = { + title: "Molecules/PaginationBar", + component: PaginationBar, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function Render() { + const [page, setPage] = useState(1); + return ( +
+ +
+ ); + }, +}; + +export const FirstPage: Story = { + args: { currentPage: 1, pageSize: 10, totalItems: 100, onPageChange: () => {} }, +}; + +export const MiddlePage: Story = { + args: { currentPage: 5, pageSize: 10, totalItems: 100, onPageChange: () => {} }, +}; + +export const LastPage: Story = { + args: { currentPage: 10, pageSize: 10, totalItems: 100, onPageChange: () => {} }, +}; + +export const SinglePage: Story = { + args: { currentPage: 1, pageSize: 10, totalItems: 5, onPageChange: () => {} }, +}; diff --git a/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.stories.tsx b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.stories.tsx new file mode 100644 index 00000000..053406a5 --- /dev/null +++ b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProgressSteps } from "./ProgressSteps"; + +const meta: Meta = { + title: "Molecules/ProgressSteps", + component: ProgressSteps, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +const steps = [ + { number: 1, title: "Plan", completed: false }, + { number: 2, title: "Details", completed: false }, + { number: 3, title: "Review", completed: false }, + { number: 4, title: "Payment", completed: false }, +]; + +export const AtStart: Story = { + args: { + steps, + currentStep: 1, + }, +}; + +export const InProgress: Story = { + args: { + steps: [ + { number: 1, title: "Plan", completed: true }, + { number: 2, title: "Details", completed: true }, + { number: 3, title: "Review", completed: false }, + { number: 4, title: "Payment", completed: false }, + ], + currentStep: 3, + }, +}; + +export const AllComplete: Story = { + args: { + steps: steps.map(s => ({ ...s, completed: true })), + }, +}; diff --git a/apps/portal/src/components/molecules/RouteLoading.stories.tsx b/apps/portal/src/components/molecules/RouteLoading.stories.tsx new file mode 100644 index 00000000..44438885 --- /dev/null +++ b/apps/portal/src/components/molecules/RouteLoading.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +// RouteLoading depends on PageLayout which is a template - show it as a simple skeleton +const meta: Meta = { + title: "Molecules/RouteLoading", + parameters: { layout: "fullscreen" }, +}; + +export default meta; + +export const Default: StoryObj = { + render: () => ( +
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+
+ ), +}; diff --git a/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.stories.tsx b/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.stories.tsx new file mode 100644 index 00000000..367a113b --- /dev/null +++ b/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { SearchFilterBar } from "./SearchFilterBar"; + +const meta: Meta = { + title: "Molecules/SearchFilterBar", + component: SearchFilterBar, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const SearchOnly: Story = { + render: function Render() { + const [search, setSearch] = useState(""); + return ( +
+ +
+ ); + }, +}; + +export const WithFilter: Story = { + render: function Render() { + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + return ( +
+ +
+ ); + }, +}; + +export const WithActiveFilters: Story = { + render: function Render() { + const [search, setSearch] = useState("fiber"); + return ( +
+ {} }, + { label: "Type: Internet", onRemove: () => {} }, + ]} + /> +
+ ); + }, +}; diff --git a/apps/portal/src/components/molecules/SectionCard/SectionCard.stories.tsx b/apps/portal/src/components/molecules/SectionCard/SectionCard.stories.tsx new file mode 100644 index 00000000..cfe54d58 --- /dev/null +++ b/apps/portal/src/components/molecules/SectionCard/SectionCard.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SectionCard } from "./SectionCard"; +import { CreditCardIcon, UserIcon } from "@heroicons/react/24/outline"; +import { Button } from "../../atoms/button"; + +const meta: Meta = { + title: "Molecules/SectionCard", + component: SectionCard, + argTypes: { + tone: { + control: "select", + options: ["primary", "success", "info", "warning", "danger", "neutral"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "Payment Methods", + subtitle: "Manage your payment options", + children: ( +

Your saved payment methods will appear here.

+ ), + }, +}; + +export const WithActions: Story = { + args: { + icon: , + title: "Account Details", + subtitle: "Your personal information", + tone: "info", + actions: ( + + ), + children: ( +
+

+ Name: John Doe +

+

+ Email: john@example.com +

+
+ ), + }, +}; + +export const AllTones: Story = { + render: () => ( +
+ } title="Primary" tone="primary"> + Content + + } title="Success" tone="success"> + Content + + } title="Warning" tone="warning"> + Content + + } title="Danger" tone="danger"> + Content + +
+ ), +}; diff --git a/apps/portal/src/components/molecules/SectionHeader/SectionHeader.stories.tsx b/apps/portal/src/components/molecules/SectionHeader/SectionHeader.stories.tsx new file mode 100644 index 00000000..8a4c7cb0 --- /dev/null +++ b/apps/portal/src/components/molecules/SectionHeader/SectionHeader.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SectionHeader } from "./SectionHeader"; +import { Button } from "../../atoms/button"; + +const meta: Meta = { + title: "Molecules/SectionHeader", + component: SectionHeader, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { title: "Recent Orders" }, +}; + +export const WithAction: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/apps/portal/src/components/molecules/ServiceCard/ServiceCard.stories.tsx b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.stories.tsx new file mode 100644 index 00000000..9d0df430 --- /dev/null +++ b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceCard } from "./ServiceCard"; +import { + WifiIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, + ServerIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Molecules/ServiceCard", + component: ServiceCard, + argTypes: { + variant: { + control: "select", + options: ["default", "featured", "minimal", "bento-sm", "bento-md", "bento-lg"], + }, + accentColor: { + control: "select", + options: ["blue", "green", "purple", "orange", "cyan", "pink", "amber", "rose"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "Internet", + description: "High-speed fiber internet for your home or office", + price: "¥3,200/mo", + accentColor: "blue", + }, +}; + +export const Featured: Story = { + args: { + variant: "featured", + icon: , + title: "SIM & eSIM", + description: "Mobile data plans with flexible options", + highlight: "1st month free", + }, +}; + +export const Minimal: Story = { + args: { + variant: "minimal", + icon: , + title: "VPN", + }, +}; + +export const WithBadge: Story = { + args: { + icon: , + title: "Fiber Internet", + description: "Ultra-fast connection up to 10Gbps", + price: "¥4,800/mo", + badge: "Popular", + accentColor: "blue", + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ } + title="Default" + description="Standard card" + price="¥3,200/mo" + accentColor="blue" + /> + } + title="Featured" + description="Premium styling" + highlight="New" + /> + } + title="Minimal" + /> +
+ ), +}; + +export const BentoGrid: Story = { + render: () => ( +
+
+ } + title="Internet" + description="High-speed fiber for home and office" + accentColor="blue" + /> +
+ } + title="Mobile" + description="SIM & eSIM plans" + accentColor="green" + /> + } + title="VPN" + description="Secure browsing" + accentColor="purple" + /> + } + title="Hosting" + accentColor="orange" + /> + } + title="WiFi Router" + accentColor="cyan" + /> +
+ ), +}; + +export const AccentColors: Story = { + render: () => ( +
+ {(["blue", "green", "purple", "orange", "cyan", "pink", "amber", "rose"] as const).map( + color => ( + } + title={color.charAt(0).toUpperCase() + color.slice(1)} + description={`${color} accent`} + accentColor={color} + /> + ) + )} +
+ ), +}; diff --git a/apps/portal/src/components/molecules/StatusBadge/StatusBadge.stories.tsx b/apps/portal/src/components/molecules/StatusBadge/StatusBadge.stories.tsx new file mode 100644 index 00000000..9ffd3a4a --- /dev/null +++ b/apps/portal/src/components/molecules/StatusBadge/StatusBadge.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatusBadge } from "./status-badge"; +import { + CheckCircleIcon, + ClockIcon, + XCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; +import type { StatusConfigMap } from "./types"; + +const orderStatusConfig: StatusConfigMap = { + active: { + variant: "success", + icon: , + label: "Active", + }, + pending: { variant: "warning", icon: , label: "Pending" }, + cancelled: { + variant: "error", + icon: , + label: "Cancelled", + }, + suspended: { + variant: "warning", + icon: , + label: "Suspended", + }, +}; + +const meta: Meta = { + title: "Molecules/StatusBadge", + component: StatusBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { status: "Active", configMap: orderStatusConfig }, +}; + +export const AllStatuses: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const WithoutIcons: Story = { + render: () => ( +
+ + +
+ ), +}; + +export const UnknownStatus: Story = { + args: { status: "Unknown", configMap: orderStatusConfig }, +}; diff --git a/apps/portal/src/components/molecules/SubCard/SubCard.stories.tsx b/apps/portal/src/components/molecules/SubCard/SubCard.stories.tsx new file mode 100644 index 00000000..be669b51 --- /dev/null +++ b/apps/portal/src/components/molecules/SubCard/SubCard.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SubCard } from "./SubCard"; +import { CreditCardIcon, CogIcon } from "@heroicons/react/24/outline"; +import { Button } from "../../atoms/button"; + +const meta: Meta = { + title: "Molecules/SubCard", + component: SubCard, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Payment Information", + children: ( +

Your payment details will appear here.

+ ), + }, +}; + +export const WithIcon: Story = { + args: { + title: "Billing Details", + icon: , + children: ( +
+

+ Card: **** **** **** 4242 +

+

+ Expires: 12/2027 +

+
+ ), + }, +}; + +export const WithHeaderRight: Story = { + args: { + title: "Settings", + icon: , + right: ( + + ), + children:

Manage your preferences.

, + }, +}; + +export const WithFooter: Story = { + args: { + title: "Subscription", + children:

Fiber Internet 1Gbps - Active

, + footer: ( +
+ Next billing: April 1 + +
+ ), + }, +}; + +export const Interactive: Story = { + args: { + interactive: true, + title: "Click me", + children:

This card has hover effects.

, + }, +}; diff --git a/apps/portal/src/components/molecules/SummaryStats/SummaryStats.stories.tsx b/apps/portal/src/components/molecules/SummaryStats/SummaryStats.stories.tsx new file mode 100644 index 00000000..c209ccb6 --- /dev/null +++ b/apps/portal/src/components/molecules/SummaryStats/SummaryStats.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SummaryStats } from "./SummaryStats"; +import { + DocumentTextIcon, + CurrencyYenIcon, + ClockIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Molecules/SummaryStats", + component: SummaryStats, + argTypes: { + variant: { control: "select", options: ["inline", "cards"] }, + }, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +const items = [ + { + icon: , + label: "Total Orders", + value: 24, + tone: "primary" as const, + }, + { + icon: , + label: "Revenue", + value: "¥1.2M", + tone: "success" as const, + }, + { icon: , label: "Pending", value: 3, tone: "warning" as const }, +]; + +export const Inline: Story = { + args: { items, variant: "inline" }, +}; + +export const Cards: Story = { + args: { items, variant: "cards" }, +}; + +export const WithTones: Story = { + args: { + variant: "cards", + items: [ + { + icon: , + label: "Active", + value: 12, + tone: "success", + }, + { icon: , label: "Pending", value: 3, tone: "warning" }, + { + icon: , + label: "Total Spent", + value: "¥89,400", + tone: "info", + }, + ], + }, +}; diff --git a/apps/portal/src/components/molecules/error-fallbacks.stories.tsx b/apps/portal/src/components/molecules/error-fallbacks.stories.tsx new file mode 100644 index 00000000..8a7f924e --- /dev/null +++ b/apps/portal/src/components/molecules/error-fallbacks.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { GlobalErrorFallback, PageErrorFallback } from "./error-fallbacks"; + +const meta: Meta = { + title: "Molecules/ErrorFallbacks", + parameters: { layout: "fullscreen" }, +}; + +export default meta; + +export const GlobalError: StoryObj = { + render: () => , +}; + +export const PageError: StoryObj = { + render: () => ( +
+ +
+ ), +}; diff --git a/apps/portal/src/components/organisms/SiteFooter/SiteFooter.stories.tsx b/apps/portal/src/components/organisms/SiteFooter/SiteFooter.stories.tsx new file mode 100644 index 00000000..5a29a569 --- /dev/null +++ b/apps/portal/src/components/organisms/SiteFooter/SiteFooter.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SiteFooter } from "./SiteFooter"; + +const meta: Meta = { + title: "Organisms/SiteFooter", + component: SiteFooter, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/components/templates/AuthLayout/AuthLayout.stories.tsx b/apps/portal/src/components/templates/AuthLayout/AuthLayout.stories.tsx new file mode 100644 index 00000000..3da1d276 --- /dev/null +++ b/apps/portal/src/components/templates/AuthLayout/AuthLayout.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AuthLayout } from "./AuthLayout"; + +const meta: Meta = { + title: "Templates/AuthLayout", + component: AuthLayout, + parameters: { layout: "fullscreen" }, + argTypes: { + wide: { control: "boolean" }, + showBackButton: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Login: Story = { + args: { + title: "Welcome back", + subtitle: "Sign in to your account to continue", + children: ( +
+
+ + +
+
+ + +
+ +
+ ), + }, +}; + +export const SignUp: Story = { + args: { + title: "Create your account", + subtitle: "Get started with Assist Solutions services", + wide: true, + showBackButton: true, + children: ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ ), + }, +}; diff --git a/apps/portal/src/components/templates/PageLayout/PageLayout.stories.tsx b/apps/portal/src/components/templates/PageLayout/PageLayout.stories.tsx new file mode 100644 index 00000000..ab3e1d05 --- /dev/null +++ b/apps/portal/src/components/templates/PageLayout/PageLayout.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PageLayout } from "./PageLayout"; +import { Button } from "../../atoms/button"; +import { StatusPill } from "../../atoms/status-pill"; + +const meta: Meta = { + title: "Templates/PageLayout", + component: PageLayout, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Dashboard", + description: "Overview of your account", + children: ( +
+
+ {["Services", "Invoices", "Support"].map(title => ( +
+

{title}

+

+ Manage your {title.toLowerCase()} +

+
+ ))} +
+
+ ), + }, +}; + +export const WithActions: Story = { + args: { + title: "Subscriptions", + description: "Manage your active services", + actions: , + children:
Content area
, + }, +}; + +export const WithBackLink: Story = { + args: { + title: "Order #12345", + backLink: { label: "Orders", href: "/orders" }, + statusPill: , + children: ( +
Order details content
+ ), + }, +}; + +export const Loading: Story = { + args: { + title: "Loading Page", + loading: true, + children:
This won't be shown
, + }, +}; + +export const Error: Story = { + args: { + title: "Error Page", + error: "Failed to load data from the server", + onRetry: () => alert("Retrying..."), + children:
This won't be shown
, + }, +}; diff --git a/apps/portal/src/components/ui/input-otp.stories.tsx b/apps/portal/src/components/ui/input-otp.stories.tsx new file mode 100644 index 00000000..46bedc15 --- /dev/null +++ b/apps/portal/src/components/ui/input-otp.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; + +const meta: Meta = { + title: "UI/InputOTP", + parameters: { layout: "centered" }, +}; + +export default meta; + +export const SixDigit: StoryObj = { + render: function Render() { + const [value, setValue] = useState(""); + return ( + + + {[0, 1, 2, 3, 4, 5].map(i => ( + + ))} + + + ); + }, +}; + +export const WithSeparator: StoryObj = { + render: function Render() { + const [value, setValue] = useState(""); + return ( + + + + + + + + + + + + + + ); + }, +}; + +export const FourDigit: StoryObj = { + render: function Render() { + const [value, setValue] = useState(""); + return ( + + + {[0, 1, 2, 3].map(i => ( + + ))} + + + ); + }, +}; diff --git a/apps/portal/src/features/account/components/AddressCard.stories.tsx b/apps/portal/src/features/account/components/AddressCard.stories.tsx new file mode 100644 index 00000000..8f6a6209 --- /dev/null +++ b/apps/portal/src/features/account/components/AddressCard.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddressCard } from "./AddressCard"; + +/** + * AddressCard uses AddressForm internally (for editing mode). + * The non-editing stories render without AddressForm so they work standalone. + * Editing stories may need the AddressForm module available. + */ + +const mockAddress = { + address1: "Sunshine Mansion 201", + address2: "Nishi-Shinjuku 1-5-3", + city: "Shinjuku-ku", + state: "Tokyo", + postcode: "160-0023", + country: "JP", + countryCode: "JP", + phoneNumber: "+81 90-1234-5678", + phoneCountryCode: "81", +}; + +const emptyAddress = { + address1: "", + address2: "", + city: "", + state: "", + postcode: "", + country: "", + countryCode: "", + phoneNumber: "", + phoneCountryCode: "", +}; + +const meta: Meta = { + title: "Features/Account/AddressCard", + component: AddressCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + address: mockAddress, + isEditing: false, + isSaving: false, + error: null, + onEdit: () => {}, + onCancel: () => {}, + onSave: () => {}, + onAddressChange: () => {}, + }, +}; + +export const EmptyAddress: Story = { + args: { + ...Default.args, + address: emptyAddress, + }, +}; + +export const WithError: Story = { + args: { + ...Default.args, + isEditing: true, + error: "Failed to save address. Please try again.", + }, +}; diff --git a/apps/portal/src/features/account/components/PasswordChangeCard.stories.tsx b/apps/portal/src/features/account/components/PasswordChangeCard.stories.tsx new file mode 100644 index 00000000..b6232e57 --- /dev/null +++ b/apps/portal/src/features/account/components/PasswordChangeCard.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PasswordChangeCard } from "./PasswordChangeCard"; + +const meta: Meta = { + title: "Features/Account/PasswordChangeCard", + component: PasswordChangeCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isChanging: false, + error: null, + success: null, + form: { currentPassword: "", newPassword: "", confirmPassword: "" }, + setForm: () => {}, + onSubmit: () => {}, + }, +}; + +export const Filled: Story = { + args: { + ...Default.args, + form: { + currentPassword: "oldPassword123", + newPassword: "NewSecure!Pass1", + confirmPassword: "NewSecure!Pass1", + }, + }, +}; + +export const PasswordMismatch: Story = { + args: { + ...Default.args, + form: { + currentPassword: "oldPassword123", + newPassword: "NewSecure!Pass1", + confirmPassword: "Different!Pass2", + }, + }, +}; + +export const Changing: Story = { + args: { + ...Default.args, + isChanging: true, + form: { + currentPassword: "oldPassword123", + newPassword: "NewSecure!Pass1", + confirmPassword: "NewSecure!Pass1", + }, + }, +}; + +export const WithError: Story = { + args: { + ...Default.args, + error: "Current password is incorrect.", + }, +}; + +export const WithSuccess: Story = { + args: { + ...Default.args, + success: "Password changed successfully!", + }, +}; diff --git a/apps/portal/src/features/account/components/PersonalInfoCard.stories.tsx b/apps/portal/src/features/account/components/PersonalInfoCard.stories.tsx new file mode 100644 index 00000000..fe97cb17 --- /dev/null +++ b/apps/portal/src/features/account/components/PersonalInfoCard.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PersonalInfoCard } from "./PersonalInfoCard"; + +const meta: Meta = { + title: "Features/Account/PersonalInfoCard", + component: PersonalInfoCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const mockData = { + firstname: "Taro", + lastname: "Yamada", + email: "taro.yamada@example.com", + phonenumber: "+81 90-1234-5678", + sfNumber: "SF-001234", + dateOfBirth: "1990-05-15", + gender: "Male", +}; + +export const Default: Story = { + args: { + data: mockData, + editEmail: mockData.email, + editPhoneNumber: mockData.phonenumber ?? "", + isEditing: false, + isSaving: false, + onEdit: () => {}, + onCancel: () => {}, + onChange: () => {}, + onSave: () => {}, + }, +}; + +export const Editing: Story = { + args: { + ...Default.args, + isEditing: true, + }, +}; + +export const Saving: Story = { + args: { + ...Default.args, + isEditing: true, + isSaving: true, + }, +}; + +export const MissingFields: Story = { + args: { + ...Default.args, + data: { + firstname: null, + lastname: null, + email: "user@example.com", + phonenumber: null, + sfNumber: null, + dateOfBirth: null, + gender: null, + }, + editEmail: "user@example.com", + editPhoneNumber: "", + }, +}; diff --git a/apps/portal/src/features/account/components/VerificationCard.stories.tsx b/apps/portal/src/features/account/components/VerificationCard.stories.tsx new file mode 100644 index 00000000..81d5568c --- /dev/null +++ b/apps/portal/src/features/account/components/VerificationCard.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createRef } from "react"; +import { VerificationCard } from "./VerificationCard"; + +/** + * VerificationCard requires complex query/upload objects. + * We mock them inline with static data to avoid importing hooks. + */ + +const noopFileUpload = { + canUpload: false, + file: null, + isSubmitting: false, + isError: false, + error: null, + inputRef: createRef(), + handleFileChange: () => {}, + clearFile: () => {}, + submit: () => {}, +}; + +const uploadableFileUpload = { + ...noopFileUpload, + canUpload: true, +}; + +function makeQuery(overrides: Record = {}) { + return { + data: undefined, + error: null, + isLoading: false, + isError: false, + isSuccess: false, + isPending: false, + isFetching: false, + isRefetching: false, + status: "success" as const, + fetchStatus: "idle" as const, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isInitialLoading: false, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isStale: false, + refetch: async () => ({}) as never, + promise: Promise.resolve({} as never), + ...overrides, + } as never; +} + +const meta: Meta = { + title: "Features/Account/VerificationCard", + component: VerificationCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + args: { + verificationQuery: makeQuery({ isLoading: true, data: undefined }), + fileUpload: noopFileUpload as never, + }, +}; + +export const Verified: Story = { + args: { + verificationQuery: makeQuery({ + data: { + status: "verified", + submittedAt: "2025-01-10T12:00:00Z", + reviewedAt: "2025-01-12T09:30:00Z", + reviewerNotes: null, + }, + }), + fileUpload: noopFileUpload as never, + }, +}; + +export const Pending: Story = { + args: { + verificationQuery: makeQuery({ + data: { + status: "pending", + submittedAt: "2025-02-01T08:00:00Z", + reviewedAt: null, + reviewerNotes: null, + }, + }), + fileUpload: noopFileUpload as never, + }, +}; + +export const Rejected: Story = { + args: { + verificationQuery: makeQuery({ + data: { + status: "rejected", + submittedAt: "2025-01-15T10:00:00Z", + reviewedAt: "2025-01-17T14:00:00Z", + reviewerNotes: "The photo is too blurry. Please upload a clearer image.", + }, + }), + fileUpload: uploadableFileUpload as never, + }, +}; + +export const NotSubmitted: Story = { + args: { + verificationQuery: makeQuery({ data: undefined }), + fileUpload: uploadableFileUpload as never, + }, +}; diff --git a/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.stories.tsx b/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.stories.tsx new file mode 100644 index 00000000..7af41b8c --- /dev/null +++ b/apps/portal/src/features/account/components/skeletons/ProfileLoadingSkeleton.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProfileLoadingSkeleton } from "./ProfileLoadingSkeleton"; + +const meta: Meta = { + title: "Features/Account/ProfileLoadingSkeleton", + component: ProfileLoadingSkeleton, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/address/components/AddressStepJapan.stories.tsx b/apps/portal/src/features/address/components/AddressStepJapan.stories.tsx new file mode 100644 index 00000000..9a6bdb18 --- /dev/null +++ b/apps/portal/src/features/address/components/AddressStepJapan.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddressStepJapan } from "./AddressStepJapan"; + +/** + * AddressStepJapan is an integration adapter that wraps JapanAddressForm. + * It expects a form interface with values, errors, touched, setValue, and setTouchedField. + * We mock this form interface with static data. + */ + +const emptyForm = { + values: { + address: { + address1: "", + address2: "", + city: "", + state: "", + postcode: "", + country: "JP", + countryCode: "JP", + }, + }, + errors: {} as Record, + touched: {} as Record, + setValue: () => {}, + setTouchedField: () => {}, +}; + +const filledForm = { + values: { + address: { + address1: "Sunshine Mansion 201", + address2: "Nishi-Shinjuku", + city: "Shinjuku-ku", + state: "Tokyo", + postcode: "160-0023", + country: "JP", + countryCode: "JP", + }, + }, + errors: {} as Record, + touched: {} as Record, + setValue: () => {}, + setTouchedField: () => {}, +}; + +const formWithErrors = { + values: { + address: { + address1: "", + address2: "", + city: "", + state: "", + postcode: "", + country: "JP", + countryCode: "JP", + }, + }, + errors: { + "address.postcode": "Postal code is required", + "address.state": "Prefecture is required", + } as Record, + touched: { + address: true, + "address.postcode": true, + "address.state": true, + } as Record, + setValue: () => {}, + setTouchedField: () => {}, +}; + +const meta: Meta = { + title: "Features/Address/AddressStepJapan", + component: AddressStepJapan, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + form: emptyForm, + }, +}; + +export const WithExistingAddress: Story = { + args: { + form: filledForm, + }, +}; + +export const WithValidationErrors: Story = { + args: { + form: formWithErrors, + }, +}; diff --git a/apps/portal/src/features/address/components/AnimatedSection.stories.tsx b/apps/portal/src/features/address/components/AnimatedSection.stories.tsx new file mode 100644 index 00000000..66a1f8bb --- /dev/null +++ b/apps/portal/src/features/address/components/AnimatedSection.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatedSection } from "./AnimatedSection"; + +const meta: Meta = { + title: "Features/Address/AnimatedSection", + component: AnimatedSection, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Visible: Story = { + args: { + show: true, + delay: 0, + children: ( +
+

This content is visible with animation.

+
+ ), + }, +}; + +export const Hidden: Story = { + args: { + show: false, + delay: 0, + children: ( +
+

This content is hidden.

+
+ ), + }, +}; + +export const WithDelay: Story = { + args: { + show: true, + delay: 300, + children: ( +
+

This content appears with a 300ms delay.

+
+ ), + }, +}; diff --git a/apps/portal/src/features/address/components/BilingualValue.stories.tsx b/apps/portal/src/features/address/components/BilingualValue.stories.tsx new file mode 100644 index 00000000..03f51fad --- /dev/null +++ b/apps/portal/src/features/address/components/BilingualValue.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BilingualValue } from "./BilingualValue"; + +const meta: Meta = { + title: "Features/Address/BilingualValue", + component: BilingualValue, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Verified: Story = { + args: { + romaji: "Shinjuku-ku", + japanese: "新宿区", + placeholder: "—", + verified: true, + }, +}; + +export const VerifiedWithoutJapanese: Story = { + args: { + romaji: "Shinjuku-ku", + placeholder: "—", + verified: true, + }, +}; + +export const NotVerified: Story = { + args: { + romaji: "", + japanese: "", + placeholder: "Awaiting verification", + verified: false, + }, +}; diff --git a/apps/portal/src/features/address/components/JapanAddressForm.stories.tsx b/apps/portal/src/features/address/components/JapanAddressForm.stories.tsx new file mode 100644 index 00000000..e2c039b7 --- /dev/null +++ b/apps/portal/src/features/address/components/JapanAddressForm.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { JapanAddressForm } from "./JapanAddressForm"; + +/** + * JapanAddressForm uses useJapanAddressForm internally. + * Stories provide initial values; interactive behavior depends on the hook + * being functional in the Storybook environment. + */ + +const meta: Meta = { + title: "Features/Address/JapanAddressForm", + component: JapanAddressForm, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + onChange: () => {}, + disabled: false, + }, +}; + +export const WithInitialValues: Story = { + args: { + initialValues: { + postcode: "160-0023", + prefecture: "Tokyo", + prefectureJa: "東京都", + city: "Shinjuku-ku", + cityJa: "新宿区", + town: "Nishi-Shinjuku", + townJa: "西新宿", + streetAddress: "1-5-3", + buildingName: "Sunshine Mansion", + roomNumber: "201", + residenceType: "apartment" as const, + }, + onChange: () => {}, + disabled: false, + }, +}; + +export const WithErrors: Story = { + args: { + initialValues: { + postcode: "", + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + streetAddress: "", + buildingName: "", + roomNumber: "", + residenceType: undefined, + }, + errors: { + postcode: "Postal code is required", + }, + touched: { + postcode: true, + }, + onChange: () => {}, + disabled: false, + }, +}; + +export const Disabled: Story = { + args: { + ...WithInitialValues.args, + disabled: true, + }, +}; + +export const WithCustomCompletion: Story = { + args: { + ...WithInitialValues.args, + completionContent: ( +
+ Custom completion message: Your address has been saved. +
+ ), + }, +}; diff --git a/apps/portal/src/features/address/components/ProgressIndicator.stories.tsx b/apps/portal/src/features/address/components/ProgressIndicator.stories.tsx new file mode 100644 index 00000000..75c3672f --- /dev/null +++ b/apps/portal/src/features/address/components/ProgressIndicator.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProgressIndicator } from "./ProgressIndicator"; + +const meta: Meta = { + title: "Features/Address/ProgressIndicator", + component: ProgressIndicator, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const FirstStep: Story = { + args: { + currentStep: 0, + totalSteps: 4, + }, +}; + +export const MiddleStep: Story = { + args: { + currentStep: 2, + totalSteps: 4, + }, +}; + +export const LastStep: Story = { + args: { + currentStep: 3, + totalSteps: 4, + }, +}; + +export const AllComplete: Story = { + args: { + currentStep: 4, + totalSteps: 4, + }, +}; + +export const ManySteps: Story = { + args: { + currentStep: 3, + totalSteps: 7, + }, +}; diff --git a/apps/portal/src/features/address/components/ZipCodeInput.stories.tsx b/apps/portal/src/features/address/components/ZipCodeInput.stories.tsx new file mode 100644 index 00000000..ba1a4347 --- /dev/null +++ b/apps/portal/src/features/address/components/ZipCodeInput.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ZipCodeInput } from "./ZipCodeInput"; + +/** + * ZipCodeInput uses an internal useZipCodeLookup hook. + * Stories provide static props; the lookup will not fire in isolation + * unless the hook's API endpoint is available. Visual appearance is + * still rendered correctly from the props. + */ + +const meta: Meta = { + title: "Features/Address/ZipCodeInput", + component: ZipCodeInput, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + value: "", + onChange: () => {}, + required: true, + label: "Postal Code", + helperText: "Format: XXX-XXXX", + autoLookup: false, + }, +}; + +export const WithValue: Story = { + args: { + ...Empty.args, + value: "160-0023", + }, +}; + +export const WithError: Story = { + args: { + ...Empty.args, + value: "123", + error: "Please enter a valid 7-digit ZIP code.", + }, +}; + +export const Disabled: Story = { + args: { + ...Empty.args, + value: "160-0023", + disabled: true, + }, +}; + +export const CustomLabel: Story = { + args: { + ...Empty.args, + label: "ZIP Code (Japan)", + helperText: "Enter your Japanese postal code", + }, +}; diff --git a/apps/portal/src/features/auth/components/MarketingCheckbox.stories.tsx b/apps/portal/src/features/auth/components/MarketingCheckbox.stories.tsx new file mode 100644 index 00000000..eb108cd8 --- /dev/null +++ b/apps/portal/src/features/auth/components/MarketingCheckbox.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MarketingCheckbox } from "./MarketingCheckbox"; + +const meta: Meta = { + title: "Features/Auth/MarketingCheckbox", + component: MarketingCheckbox, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Unchecked: Story = { + args: { + checked: false, + onChange: () => {}, + }, +}; + +export const Checked: Story = { + args: { + checked: true, + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + checked: false, + onChange: () => {}, + disabled: true, + }, +}; diff --git a/apps/portal/src/features/auth/components/PasswordMatchIndicator.stories.tsx b/apps/portal/src/features/auth/components/PasswordMatchIndicator.stories.tsx new file mode 100644 index 00000000..d054c43f --- /dev/null +++ b/apps/portal/src/features/auth/components/PasswordMatchIndicator.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PasswordMatchIndicator } from "./PasswordMatchIndicator"; + +const meta: Meta = { + title: "Features/Auth/PasswordMatchIndicator", + component: PasswordMatchIndicator, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Matching: Story = { + args: { + passwordsMatch: true, + }, +}; + +export const NotMatching: Story = { + args: { + passwordsMatch: false, + }, +}; diff --git a/apps/portal/src/features/auth/components/PasswordRequirements.stories.tsx b/apps/portal/src/features/auth/components/PasswordRequirements.stories.tsx new file mode 100644 index 00000000..9ad1ac0f --- /dev/null +++ b/apps/portal/src/features/auth/components/PasswordRequirements.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PasswordRequirements } from "./PasswordRequirements"; + +const meta: Meta = { + title: "Features/Auth/PasswordRequirements", + component: PasswordRequirements, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const AllMet: Story = { + args: { + checks: { + minLength: true, + hasUppercase: true, + hasLowercase: true, + hasNumber: true, + }, + }, +}; + +export const NoneMet: Story = { + args: { + checks: { + minLength: false, + hasUppercase: false, + hasLowercase: false, + hasNumber: false, + }, + }, +}; + +export const PartiallyMet: Story = { + args: { + checks: { + minLength: true, + hasUppercase: true, + hasLowercase: false, + hasNumber: false, + }, + }, +}; + +export const HintMode: Story = { + args: { + checks: { + minLength: false, + hasUppercase: false, + hasLowercase: false, + hasNumber: false, + }, + showHint: true, + }, +}; diff --git a/apps/portal/src/features/auth/components/PasswordSection.stories.tsx b/apps/portal/src/features/auth/components/PasswordSection.stories.tsx new file mode 100644 index 00000000..909f9296 --- /dev/null +++ b/apps/portal/src/features/auth/components/PasswordSection.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { PasswordSection } from "./PasswordSection"; + +const meta: Meta = { + title: "Features/Auth/PasswordSection", + component: PasswordSection, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + password: "", + confirmPassword: "", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const WithPassword: Story = { + args: { + password: "MyPass123", + confirmPassword: "", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const PasswordsMatch: Story = { + args: { + password: "MyPass123", + confirmPassword: "MyPass123", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const PasswordsMismatch: Story = { + args: { + password: "MyPass123", + confirmPassword: "MyPass456", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const WithErrors: Story = { + args: { + password: "short", + confirmPassword: "", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: { + password: "Password does not meet requirements", + confirmPassword: "Please confirm your password", + }, + clearError: () => {}, + loading: false, + }, +}; + +export const Loading: Story = { + args: { + password: "MyPass123", + confirmPassword: "MyPass123", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: true, + }, +}; diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.stories.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.stories.tsx new file mode 100644 index 00000000..35fdf6ab --- /dev/null +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { Button } from "@/components/atoms/button"; + +/** + * SessionTimeoutWarning is a container component that relies on auth session + * stores and hooks internally. It renders nothing unless the session is about + * to expire. Instead, we render the visual TimeoutDialog inline to showcase + * the UI. + */ + +function TimeoutDialogPreview({ + timeLeft = 5, + onExtend = () => {}, + onLogout = () => {}, +}: { + timeLeft?: number; + onExtend?: () => void; + onLogout?: () => void; +}) { + return ( +
+
+
+ ⚠️ +

+ Session Expiring Soon +

+
+

+ Your session will expire in{" "} + + {timeLeft} minute{timeLeft === 1 ? "" : "s"} + + . Would you like to extend your session? +

+
+ + +
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Auth/SessionTimeoutWarning", + component: TimeoutDialogPreview, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const FiveMinutesLeft: Story = { + args: { + timeLeft: 5, + }, +}; + +export const OneMinuteLeft: Story = { + args: { + timeLeft: 1, + }, +}; diff --git a/apps/portal/src/features/auth/components/TermsCheckbox.stories.tsx b/apps/portal/src/features/auth/components/TermsCheckbox.stories.tsx new file mode 100644 index 00000000..3456bcb2 --- /dev/null +++ b/apps/portal/src/features/auth/components/TermsCheckbox.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TermsCheckbox } from "./TermsCheckbox"; + +const meta: Meta = { + title: "Features/Auth/TermsCheckbox", + component: TermsCheckbox, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Unchecked: Story = { + args: { + checked: false, + onChange: () => {}, + }, +}; + +export const Checked: Story = { + args: { + checked: true, + onChange: () => {}, + }, +}; + +export const WithError: Story = { + args: { + checked: false, + onChange: () => {}, + error: "You must accept the terms to continue", + }, +}; + +export const Disabled: Story = { + args: { + checked: false, + onChange: () => {}, + disabled: true, + }, +}; diff --git a/apps/portal/src/features/billing/components/BillingStatusBadge/BillingStatusBadge.stories.tsx b/apps/portal/src/features/billing/components/BillingStatusBadge/BillingStatusBadge.stories.tsx new file mode 100644 index 00000000..761793ce --- /dev/null +++ b/apps/portal/src/features/billing/components/BillingStatusBadge/BillingStatusBadge.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BillingStatusBadge } from "./BillingStatusBadge"; + +const meta: Meta = { + title: "Features/Billing/BillingStatusBadge", + component: BillingStatusBadge, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Paid: Story = { + args: { + status: "Paid", + }, +}; + +export const Unpaid: Story = { + args: { + status: "Unpaid", + }, +}; + +export const Overdue: Story = { + args: { + status: "Overdue", + }, +}; + +export const Cancelled: Story = { + args: { + status: "Cancelled", + }, +}; + +export const Draft: Story = { + args: { + status: "Draft", + }, +}; + +export const Refunded: Story = { + args: { + status: "Refunded", + }, +}; + +export const Collections: Story = { + args: { + status: "Collections", + }, +}; + +export const PaymentPending: Story = { + args: { + status: "Payment Pending", + }, +}; + +export const WithoutIcon: Story = { + args: { + status: "Paid", + showIcon: false, + }, +}; + +export const CustomLabel: Story = { + args: { + status: "Paid", + children: "Payment Complete", + }, +}; diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.stories.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.stories.tsx new file mode 100644 index 00000000..94842896 --- /dev/null +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BillingSummary } from "./BillingSummary"; +import type { BillingSummary as BillingSummaryType } from "@customer-portal/domain/billing"; + +const mockSummary: BillingSummaryType = { + totalOutstanding: 249.98, + totalOverdue: 79.99, + totalPaid: 1520.0, + currency: "EUR", + invoiceCount: { + total: 24, + unpaid: 3, + overdue: 1, + paid: 20, + }, +}; + +const meta: Meta = { + title: "Features/Billing/BillingSummary", + component: BillingSummary, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + summary: mockSummary, + }, +}; + +export const Compact: Story = { + args: { + summary: mockSummary, + compact: true, + }, +}; + +export const Loading: Story = { + args: { + summary: mockSummary, + loading: true, + }, +}; + +export const AllPaid: Story = { + args: { + summary: { + totalOutstanding: 0, + totalOverdue: 0, + totalPaid: 3200.0, + currency: "EUR", + invoiceCount: { + total: 15, + unpaid: 0, + overdue: 0, + paid: 15, + }, + }, + }, +}; + +export const HighOutstanding: Story = { + args: { + summary: { + totalOutstanding: 1500.0, + totalOverdue: 800.0, + totalPaid: 200.0, + currency: "USD", + invoiceCount: { + total: 10, + unpaid: 5, + overdue: 3, + paid: 2, + }, + }, + }, +}; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.stories.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.stories.tsx new file mode 100644 index 00000000..a260f568 --- /dev/null +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceItems } from "./InvoiceItems"; +import type { InvoiceItem } from "@customer-portal/domain/billing"; + +const mockItems: InvoiceItem[] = [ + { + id: 1, + description: "Premium Mobile Plan - Monthly", + amount: 49.99, + quantity: 1, + type: "subscription", + serviceId: 101, + }, + { + id: 2, + description: "SIM Card Activation Fee", + amount: 9.99, + quantity: 1, + type: "one-time", + }, + { + id: 3, + description: "Data Add-on 10GB", + amount: 14.99, + quantity: 2, + type: "subscription", + serviceId: 102, + }, +]; + +const meta: Meta = { + title: "Features/Billing/InvoiceItems", + component: InvoiceItems, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + items: mockItems, + currency: "EUR", + }, +}; + +export const LinkedItemsOnly: Story = { + args: { + items: mockItems.filter(item => item.serviceId), + currency: "EUR", + }, +}; + +export const OneTimeItemsOnly: Story = { + args: { + items: mockItems.filter(item => !item.serviceId), + currency: "EUR", + }, +}; + +export const Empty: Story = { + args: { + items: [], + currency: "EUR", + }, +}; + +export const SingleItem: Story = { + args: { + items: [mockItems[0]], + currency: "USD", + }, +}; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.stories.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.stories.tsx new file mode 100644 index 00000000..7a11b85e --- /dev/null +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceSummaryBar } from "./InvoiceSummaryBar"; +import type { Invoice } from "@customer-portal/domain/billing"; + +const baseInvoice: Invoice = { + id: 1001, + number: "INV-2026-001", + status: "Paid", + currency: "EUR", + total: 129.99, + subtotal: 109.99, + tax: 20.0, + issuedAt: "2026-02-15T00:00:00Z", + dueDate: "2026-03-15T00:00:00Z", + paidDate: "2026-02-28T00:00:00Z", + description: "Monthly subscription", +}; + +const meta: Meta = { + title: "Features/Billing/InvoiceSummaryBar", + component: InvoiceSummaryBar, + parameters: { layout: "padded" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Paid: Story = { + args: { + invoice: baseInvoice, + onDownload: () => {}, + }, +}; + +export const Unpaid: Story = { + args: { + invoice: { + ...baseInvoice, + status: "Unpaid", + paidDate: undefined, + dueDate: "2026-03-20T00:00:00Z", + }, + onDownload: () => {}, + onPay: () => {}, + }, +}; + +export const Overdue: Story = { + args: { + invoice: { + ...baseInvoice, + status: "Overdue", + paidDate: undefined, + daysOverdue: 12, + dueDate: "2026-02-23T00:00:00Z", + }, + onDownload: () => {}, + onPay: () => {}, + }, +}; + +export const Cancelled: Story = { + args: { + invoice: { + ...baseInvoice, + status: "Cancelled", + paidDate: undefined, + }, + onDownload: () => {}, + }, +}; + +export const DownloadLoading: Story = { + args: { + invoice: baseInvoice, + onDownload: () => {}, + loadingDownload: true, + }, +}; + +export const PaymentLoading: Story = { + args: { + invoice: { + ...baseInvoice, + status: "Unpaid", + paidDate: undefined, + }, + onDownload: () => {}, + onPay: () => {}, + loadingPayment: true, + }, +}; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.stories.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.stories.tsx new file mode 100644 index 00000000..e0d3cddd --- /dev/null +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceTotals } from "./InvoiceTotals"; + +const meta: Meta = { + title: "Features/Billing/InvoiceTotals", + component: InvoiceTotals, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + subtotal: 99.99, + tax: 10.0, + total: 109.99, + }, +}; + +export const NoTax: Story = { + args: { + subtotal: 49.99, + tax: 0, + total: 49.99, + }, +}; + +export const LargeAmounts: Story = { + args: { + subtotal: 12500.0, + tax: 2500.0, + total: 15000.0, + }, +}; diff --git a/apps/portal/src/features/billing/components/InvoiceItemRow.stories.tsx b/apps/portal/src/features/billing/components/InvoiceItemRow.stories.tsx new file mode 100644 index 00000000..ee0db46f --- /dev/null +++ b/apps/portal/src/features/billing/components/InvoiceItemRow.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceItemRow } from "./InvoiceItemRow"; + +const meta: Meta = { + title: "Features/Billing/InvoiceItemRow", + component: InvoiceItemRow, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const OneTimeItem: Story = { + args: { + id: 1, + description: "SIM Card Activation Fee", + amount: 9.99, + }, +}; + +export const LinkedToService: Story = { + args: { + id: 2, + description: "Premium Mobile Plan - Monthly", + amount: 49.99, + serviceId: 101, + }, +}; + +export const WithQuantity: Story = { + args: { + id: 3, + description: "Data Add-on 5GB", + amount: 29.98, + quantity: 2, + serviceId: 55, + }, +}; + +export const LargeAmount: Story = { + args: { + id: 4, + description: "Enterprise Plan - Annual License", + amount: 2499.99, + }, +}; diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.stories.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.stories.tsx new file mode 100644 index 00000000..d9dbcb2b --- /dev/null +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceTable } from "./InvoiceTable"; +import type { Invoice } from "@customer-portal/domain/billing"; + +const mockInvoices: Invoice[] = [ + { + id: 1, + number: "INV-2026-001", + status: "Paid", + currency: "EUR", + total: 129.99, + subtotal: 109.99, + tax: 20.0, + issuedAt: "2026-01-15T00:00:00Z", + dueDate: "2026-02-15T00:00:00Z", + paidDate: "2026-01-30T00:00:00Z", + description: "Premium Mobile Plan - January", + }, + { + id: 2, + number: "INV-2026-002", + status: "Unpaid", + currency: "EUR", + total: 49.99, + subtotal: 41.99, + tax: 8.0, + issuedAt: "2026-02-15T00:00:00Z", + dueDate: "2026-03-15T00:00:00Z", + description: "Standard Plan - February", + }, + { + id: 3, + number: "INV-2026-003", + status: "Overdue", + currency: "EUR", + total: 79.99, + subtotal: 67.22, + tax: 12.77, + issuedAt: "2026-01-01T00:00:00Z", + dueDate: "2026-02-01T00:00:00Z", + daysOverdue: 34, + description: "Data Add-on Package", + }, + { + id: 4, + number: "INV-2026-004", + status: "Cancelled", + currency: "EUR", + total: 19.99, + subtotal: 16.8, + tax: 3.19, + issuedAt: "2026-01-10T00:00:00Z", + description: "SIM Activation - Cancelled", + }, +]; + +const meta: Meta = { + title: "Features/Billing/InvoiceTable", + component: InvoiceTable, + parameters: { layout: "padded" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + invoices: mockInvoices, + onInvoiceClick: () => {}, + }, +}; + +export const WithoutActions: Story = { + args: { + invoices: mockInvoices, + showActions: false, + onInvoiceClick: () => {}, + }, +}; + +export const Compact: Story = { + args: { + invoices: mockInvoices, + compact: true, + onInvoiceClick: () => {}, + }, +}; + +export const Loading: Story = { + args: { + invoices: [], + loading: true, + }, +}; + +export const Empty: Story = { + args: { + invoices: [], + onInvoiceClick: () => {}, + }, +}; + +export const SingleInvoice: Story = { + args: { + invoices: [mockInvoices[0]], + onInvoiceClick: () => {}, + }, +}; diff --git a/apps/portal/src/features/billing/components/skeletons/invoice-list-skeleton.stories.tsx b/apps/portal/src/features/billing/components/skeletons/invoice-list-skeleton.stories.tsx new file mode 100644 index 00000000..07b7cdcd --- /dev/null +++ b/apps/portal/src/features/billing/components/skeletons/invoice-list-skeleton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InvoiceListSkeleton } from "./invoice-list-skeleton"; + +const meta: Meta = { + title: "Features/Billing/InvoiceListSkeleton", + component: InvoiceListSkeleton, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const ThreeRows: Story = { + args: { + rows: 3, + }, +}; + +export const TenRows: Story = { + args: { + rows: 10, + }, +}; diff --git a/apps/portal/src/features/checkout/components/CheckoutErrorFallback.stories.tsx b/apps/portal/src/features/checkout/components/CheckoutErrorFallback.stories.tsx new file mode 100644 index 00000000..2ed552db --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutErrorFallback.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CheckoutErrorFallback } from "./CheckoutErrorFallback"; + +const meta: Meta = { + title: "Features/Checkout/CheckoutErrorFallback", + component: CheckoutErrorFallback, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + shopHref: "/account/services", + }, +}; diff --git a/apps/portal/src/features/checkout/components/CheckoutShell.stories.tsx b/apps/portal/src/features/checkout/components/CheckoutShell.stories.tsx new file mode 100644 index 00000000..3afbbf1c --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutShell.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CheckoutShell } from "./CheckoutShell"; + +const meta: Meta = { + title: "Features/Checkout/CheckoutShell", + component: CheckoutShell, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+

Checkout Content Area

+

This area would contain the checkout form.

+
+ ), + }, +}; diff --git a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.stories.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.stories.tsx new file mode 100644 index 00000000..f2efef6d --- /dev/null +++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.stories.tsx @@ -0,0 +1,135 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CheckoutStatusBanners } from "./CheckoutStatusBanners"; +import { fn } from "@storybook/test"; + +const meta: Meta = { + title: "Features/Checkout/CheckoutStatusBanners", + component: CheckoutStatusBanners, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + activeInternetWarning: null, + eligibility: { + isLoading: false, + isError: false, + isPending: false, + isNotRequested: false, + isIneligible: false, + notes: null, + requestedAt: null, + refetch: fn(), + }, + eligibilityRequest: { + isPending: false, + mutate: fn(), + }, + hasServiceAddress: true, + addressLabel: "123 Tokyo Street, Shibuya-ku, Tokyo", + userAddress: { + address1: "123 Tokyo Street", + city: "Tokyo", + state: "Shibuya-ku", + postcode: "150-0001", + country: "JP", + }, + planSku: "FIBER-100", + }, +}; +export default meta; +type Story = StoryObj; + +export const NoWarnings: Story = {}; + +export const ActiveInternetWarning: Story = { + args: { + activeInternetWarning: + "You already have an active internet subscription. Adding a new one may result in duplicate billing.", + }, +}; + +export const EligibilityLoading: Story = { + args: { + eligibility: { + isLoading: true, + isError: false, + isPending: false, + isNotRequested: false, + isIneligible: false, + refetch: fn(), + }, + }, +}; + +export const EligibilityError: Story = { + args: { + eligibility: { + isLoading: false, + isError: true, + isPending: false, + isNotRequested: false, + isIneligible: false, + refetch: fn(), + }, + }, +}; + +export const EligibilityPending: Story = { + args: { + eligibility: { + isLoading: false, + isError: false, + isPending: true, + isNotRequested: false, + isIneligible: false, + refetch: fn(), + }, + }, +}; + +export const EligibilityNotRequested: Story = { + args: { + eligibility: { + isLoading: false, + isError: false, + isPending: false, + isNotRequested: true, + isIneligible: false, + refetch: fn(), + }, + }, +}; + +export const EligibilityNotRequestedNoAddress: Story = { + args: { + hasServiceAddress: false, + eligibility: { + isLoading: false, + isError: false, + isPending: false, + isNotRequested: true, + isIneligible: false, + refetch: fn(), + }, + }, +}; + +export const Ineligible: Story = { + args: { + eligibility: { + isLoading: false, + isError: false, + isPending: false, + isNotRequested: false, + isIneligible: true, + notes: "Service is not available in your area due to infrastructure limitations.", + requestedAt: "2026-03-01T10:00:00Z", + refetch: fn(), + }, + }, +}; diff --git a/apps/portal/src/features/checkout/components/OrderConfirmation.stories.tsx b/apps/portal/src/features/checkout/components/OrderConfirmation.stories.tsx new file mode 100644 index 00000000..fe35d4a7 --- /dev/null +++ b/apps/portal/src/features/checkout/components/OrderConfirmation.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderConfirmation } from "./OrderConfirmation"; + +const meta: Meta = { + title: "Features/Checkout/OrderConfirmation", + component: OrderConfirmation, + parameters: { + layout: "centered", + nextjs: { navigation: { searchParams: { orderId: "ORD-20260307-001" } } }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithoutOrderId: Story = { + parameters: { + nextjs: { navigation: { searchParams: {} } }, + }, +}; diff --git a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.stories.tsx b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.stories.tsx new file mode 100644 index 00000000..cbfc0da5 --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { IdentityVerificationSection } from "./IdentityVerificationSection"; +import { fn } from "@storybook/test"; + +const formatDateTime = (iso?: string | null) => (iso ? new Date(iso).toLocaleString() : null); + +const meta: Meta = { + title: "Features/Checkout/IdentityVerificationSection", + component: IdentityVerificationSection, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + isLoading: false, + isError: false, + onRefetch: fn(), + onSubmitFile: fn(), + isSubmitting: false, + submitError: null, + formatDateTime, + }, +}; +export default meta; +type Story = StoryObj; + +export const Verified: Story = { + args: { + status: "verified", + data: { + submittedAt: "2026-02-20T14:30:00Z", + reviewedAt: "2026-02-21T09:15:00Z", + reviewerNotes: null, + }, + }, +}; + +export const Pending: Story = { + args: { + status: "pending", + data: { + submittedAt: "2026-03-06T10:00:00Z", + reviewedAt: null, + reviewerNotes: null, + }, + }, +}; + +export const NotSubmitted: Story = { + args: { + status: "not_submitted", + data: undefined, + }, +}; + +export const Rejected: Story = { + args: { + status: "rejected", + data: { + reviewerNotes: "The document is expired. Please upload a valid residence card.", + }, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + status: undefined, + }, +}; + +export const ErrorState: Story = { + args: { + isError: true, + status: undefined, + }, +}; + +export const Submitting: Story = { + args: { + status: "not_submitted", + isSubmitting: true, + }, +}; diff --git a/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.stories.tsx b/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.stories.tsx new file mode 100644 index 00000000..0a6cf16d --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/OrderSubmitSection.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderSubmitSection } from "./OrderSubmitSection"; +import { fn } from "@storybook/test"; + +const meta: Meta = { + title: "Features/Checkout/OrderSubmitSection", + component: OrderSubmitSection, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + onSubmit: fn(), + onBack: fn(), + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + pricing: { monthlyTotal: 5980, oneTimeTotal: 0 }, + submitError: null, + isSubmitting: false, + canSubmit: true, + }, +}; + +export const WithOneTimeFee: Story = { + args: { + pricing: { monthlyTotal: 5980, oneTimeTotal: 3300 }, + submitError: null, + isSubmitting: false, + canSubmit: true, + }, +}; + +export const Submitting: Story = { + args: { + pricing: { monthlyTotal: 5980, oneTimeTotal: 0 }, + submitError: null, + isSubmitting: true, + canSubmit: true, + }, +}; + +export const CannotSubmit: Story = { + args: { + pricing: { monthlyTotal: 5980, oneTimeTotal: 0 }, + submitError: null, + isSubmitting: false, + canSubmit: false, + }, +}; + +export const WithError: Story = { + args: { + pricing: { monthlyTotal: 5980, oneTimeTotal: 0 }, + submitError: "An unexpected error occurred. Please try again or contact support.", + isSubmitting: false, + canSubmit: true, + }, +}; diff --git a/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.stories.tsx b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.stories.tsx new file mode 100644 index 00000000..5ee380ff --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PaymentMethodSection } from "./PaymentMethodSection"; +import { fn } from "@storybook/test"; + +const meta: Meta = { + title: "Features/Checkout/PaymentMethodSection", + component: PaymentMethodSection, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + onManagePayment: fn(), + onRefresh: fn(), + isOpeningPortal: false, + }, +}; +export default meta; +type Story = StoryObj; + +export const WithPaymentMethod: Story = { + args: { + isLoading: false, + isError: false, + hasPaymentMethod: true, + paymentMethodDisplay: { + title: "Visa ending in 4242", + subtitle: "Expires 12/2028", + }, + }, +}; + +export const NoPaymentMethod: Story = { + args: { + isLoading: false, + isError: false, + hasPaymentMethod: false, + paymentMethodDisplay: null, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + isError: false, + hasPaymentMethod: false, + paymentMethodDisplay: null, + }, +}; + +export const Error: Story = { + args: { + isLoading: false, + isError: true, + hasPaymentMethod: false, + paymentMethodDisplay: null, + }, +}; + +export const OpeningPortal: Story = { + args: { + isLoading: false, + isError: false, + hasPaymentMethod: true, + paymentMethodDisplay: { + title: "Visa ending in 4242", + subtitle: "Expires 12/2028", + }, + isOpeningPortal: true, + }, +}; diff --git a/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.stories.tsx b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.stories.tsx new file mode 100644 index 00000000..00762400 --- /dev/null +++ b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ResidenceCardUploadInput } from "./ResidenceCardUploadInput"; +import { fn } from "@storybook/test"; + +const meta: Meta = { + title: "Features/Checkout/ResidenceCardUploadInput", + component: ResidenceCardUploadInput, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + onSubmit: fn(), + isPending: false, + isError: false, + error: null, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithDescription: Story = { + args: { + description: "Upload a JPG, PNG, or PDF (max 5MB).", + submitLabel: "Submit for review", + }, +}; + +export const Uploading: Story = { + args: { + isPending: true, + submitLabel: "Submit for review", + }, +}; + +export const WithError: Story = { + args: { + isError: true, + error: new Error("File size exceeds the 5MB limit. Please choose a smaller file."), + }, +}; + +export const CustomSubmitLabel: Story = { + args: { + submitLabel: "Submit replacement", + description: "Replacing the file restarts the verification process.", + }, +}; diff --git a/apps/portal/src/features/dashboard/components/AccountStatusCard.stories.tsx b/apps/portal/src/features/dashboard/components/AccountStatusCard.stories.tsx new file mode 100644 index 00000000..6b1732f7 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/AccountStatusCard.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AccountStatusCard } from "./AccountStatusCard"; + +const meta: Meta = { + title: "Features/Dashboard/AccountStatusCard", + component: AccountStatusCard, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/dashboard/components/ActivityTimeline.stories.tsx b/apps/portal/src/features/dashboard/components/ActivityTimeline.stories.tsx new file mode 100644 index 00000000..2c8364fe --- /dev/null +++ b/apps/portal/src/features/dashboard/components/ActivityTimeline.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ActivityTimeline } from "./ActivityTimeline"; +import { fn } from "@storybook/test"; + +const today = new Date().toISOString(); +const yesterday = new Date(Date.now() - 86400000).toISOString(); +const twoDaysAgo = new Date(Date.now() - 2 * 86400000).toISOString(); + +const meta: Meta = { + title: "Features/Dashboard/ActivityTimeline", + component: ActivityTimeline, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activities: [ + { + id: "act-1", + type: "invoice_created", + title: "Invoice #1042 created", + description: "Monthly internet service", + date: today, + }, + { + id: "act-2", + type: "invoice_paid", + title: "Invoice #1041 paid", + description: "Payment of 5,980 JPY received", + date: today, + }, + { + id: "act-3", + type: "service_activated", + title: "Fiber 100Mbps activated", + description: "Internet service is now active", + date: yesterday, + }, + { + id: "act-4", + type: "case_created", + title: "Support case opened", + description: "Connection issues reported", + date: twoDaysAgo, + }, + { + id: "act-5", + type: "case_closed", + title: "Support case resolved", + description: "Connection issue has been fixed", + date: twoDaysAgo, + }, + ], + onItemClick: fn(), + }, +}; + +export const Empty: Story = { + args: { + activities: [], + }, +}; + +export const SingleActivity: Story = { + args: { + activities: [ + { + id: "act-1", + type: "invoice_paid", + title: "Invoice #1041 paid", + description: "Payment of 5,980 JPY received", + date: today, + }, + ], + }, +}; + +export const LimitedItems: Story = { + args: { + activities: Array.from({ length: 20 }, (_, i) => ({ + id: `act-${i}`, + type: "invoice_created" as const, + title: `Invoice #${1000 + i} created`, + description: "Monthly service charge", + date: new Date(Date.now() - i * 86400000).toISOString(), + })), + maxItems: 5, + }, +}; diff --git a/apps/portal/src/features/dashboard/components/DashboardActivityItem.stories.tsx b/apps/portal/src/features/dashboard/components/DashboardActivityItem.stories.tsx new file mode 100644 index 00000000..ba5f5abe --- /dev/null +++ b/apps/portal/src/features/dashboard/components/DashboardActivityItem.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DashboardActivityItem } from "./DashboardActivityItem"; +import { fn } from "@storybook/test"; + +const meta: Meta = { + title: "Features/Dashboard/DashboardActivityItem", + component: DashboardActivityItem, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const InvoiceCreated: Story = { + args: { + activity: { + id: "act-1", + type: "invoice_created", + title: "Invoice #1042 created", + description: "Monthly internet service - March 2026", + date: new Date().toISOString(), + }, + showConnector: true, + }, +}; + +export const InvoicePaid: Story = { + args: { + activity: { + id: "act-2", + type: "invoice_paid", + title: "Invoice #1041 paid", + description: "Payment of 5,980 JPY received", + date: new Date().toISOString(), + }, + showConnector: true, + }, +}; + +export const ServiceActivated: Story = { + args: { + activity: { + id: "act-3", + type: "service_activated", + title: "Fiber 100Mbps activated", + description: "Internet service is now active", + date: new Date().toISOString(), + }, + showConnector: false, + }, +}; + +export const CaseCreated: Story = { + args: { + activity: { + id: "act-4", + type: "case_created", + title: "Support case opened", + description: "Connection issues reported", + date: new Date().toISOString(), + }, + showConnector: true, + }, +}; + +export const CaseClosed: Story = { + args: { + activity: { + id: "act-5", + type: "case_closed", + title: "Support case resolved", + description: "Connection issue has been fixed", + date: new Date().toISOString(), + }, + showConnector: false, + }, +}; + +export const Clickable: Story = { + args: { + activity: { + id: "act-6", + type: "invoice_created", + title: "Invoice #1042 created", + description: "Click to view invoice details", + date: new Date().toISOString(), + }, + onClick: fn(), + showConnector: false, + }, +}; + +export const WithoutConnector: Story = { + args: { + activity: { + id: "act-7", + type: "invoice_paid", + title: "Invoice #1040 paid", + description: "Payment received", + date: new Date().toISOString(), + }, + showConnector: false, + }, +}; diff --git a/apps/portal/src/features/dashboard/components/QuickAction.stories.tsx b/apps/portal/src/features/dashboard/components/QuickAction.stories.tsx new file mode 100644 index 00000000..d95f4331 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/QuickAction.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { QuickAction } from "./QuickAction"; +import { + CreditCardIcon, + Squares2X2Icon, + ChatBubbleLeftRightIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Features/Dashboard/QuickAction", + component: QuickAction, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + href: "/account/billing", + title: "Pay Invoice", + description: "View and pay your outstanding invoices", + icon: CreditCardIcon, + iconColor: "text-primary", + bgColor: "bg-primary/10", + }, +}; + +export const Services: Story = { + args: { + href: "/account/services", + title: "Browse Services", + description: "Explore available internet and SIM plans", + icon: Squares2X2Icon, + iconColor: "text-info", + bgColor: "bg-info/10", + }, +}; + +export const Support: Story = { + args: { + href: "/account/support", + title: "Get Support", + description: "Open a support case or check existing tickets", + icon: ChatBubbleLeftRightIcon, + iconColor: "text-warning", + bgColor: "bg-warning/10", + }, +}; diff --git a/apps/portal/src/features/dashboard/components/StatCard.stories.tsx b/apps/portal/src/features/dashboard/components/StatCard.stories.tsx new file mode 100644 index 00000000..d47086a0 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/StatCard.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StatCard } from "./StatCard"; +import { + Squares2X2Icon, + DocumentTextIcon, + ChatBubbleLeftRightIcon, + CurrencyYenIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Features/Dashboard/StatCard", + component: StatCard, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Active Services", + value: 3, + icon: Squares2X2Icon, + href: "/account/services", + tone: "primary", + }, +}; + +export const UnpaidInvoices: Story = { + args: { + title: "Unpaid Invoices", + value: 2, + icon: DocumentTextIcon, + href: "/account/billing", + tone: "warning", + }, +}; + +export const OpenCases: Story = { + args: { + title: "Open Cases", + value: 0, + icon: ChatBubbleLeftRightIcon, + href: "/account/support", + tone: "info", + }, +}; + +export const TotalSpent: Story = { + args: { + title: "Total Spent", + value: "17,940 JPY", + icon: CurrencyYenIcon, + href: "/account/billing", + tone: "success", + }, +}; + +export const Neutral: Story = { + args: { + title: "Recent Orders", + value: 1, + icon: DocumentTextIcon, + href: "/account/orders", + tone: "neutral", + }, +}; diff --git a/apps/portal/src/features/dashboard/components/TaskCard.stories.tsx b/apps/portal/src/features/dashboard/components/TaskCard.stories.tsx new file mode 100644 index 00000000..40e7cd46 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/TaskCard.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TaskCard } from "./TaskCard"; +import { fn } from "@storybook/test"; +import { + ExclamationCircleIcon, + CreditCardIcon, + ClockIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; + +const meta: Meta = { + title: "Features/Dashboard/TaskCard", + component: TaskCard, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Critical: Story = { + args: { + id: "task-1", + icon: ExclamationCircleIcon, + title: "Invoice #1042 overdue", + description: + "Payment of 5,980 JPY was due on March 1, 2026. Pay now to avoid service interruption.", + actionLabel: "Pay now", + detailHref: "/account/billing", + onAction: fn(), + tone: "critical", + }, +}; + +export const Warning: Story = { + args: { + id: "task-2", + icon: CreditCardIcon, + title: "Add a payment method", + description: "A payment method is required before you can place orders.", + actionLabel: "Add payment", + onAction: fn(), + tone: "warning", + }, +}; + +export const Info: Story = { + args: { + id: "task-3", + icon: ClockIcon, + title: "Order being processed", + description: "Your internet service order is being reviewed by our team.", + actionLabel: "View order", + detailHref: "/account/orders/ORD-001", + tone: "info", + }, +}; + +export const Neutral: Story = { + args: { + id: "task-4", + icon: SparklesIcon, + title: "Complete your profile", + description: "Add your address and verify your identity to unlock all features.", + actionLabel: "Get started", + detailHref: "/account/settings", + tone: "neutral", + }, +}; + +export const Loading: Story = { + args: { + id: "task-5", + icon: CreditCardIcon, + title: "Add a payment method", + description: "A payment method is required before you can place orders.", + actionLabel: "Add payment", + onAction: fn(), + tone: "warning", + isLoading: true, + loadingText: "Opening...", + }, +}; + +export const WithoutDetailLink: Story = { + args: { + id: "task-6", + icon: CreditCardIcon, + title: "Update payment method", + description: "Your card on file has expired. Please update it.", + actionLabel: "Update", + onAction: fn(), + tone: "warning", + }, +}; diff --git a/apps/portal/src/features/dashboard/components/TaskList.stories.tsx b/apps/portal/src/features/dashboard/components/TaskList.stories.tsx new file mode 100644 index 00000000..cd332831 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/TaskList.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + ExclamationCircleIcon, + CreditCardIcon, + ClockIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import { TaskCard, TaskCardSkeleton } from "./TaskCard"; +import type { TaskTone } from "./TaskCard"; + +/** + * TaskList uses internal hooks (useCreateInvoiceSsoLink, useCreatePaymentMethodsSsoLink) + * that require provider context. Instead of mocking those hooks, we compose stories + * from the underlying TaskCard and TaskCardSkeleton components directly, which + * faithfully represents what TaskList renders. + */ + +interface MockTask { + id: string; + icon: React.ComponentType>; + title: string; + description: string; + actionLabel: string; + detailHref?: string; + tone: TaskTone; +} + +function TaskListPreview({ tasks, isLoading }: { tasks: MockTask[]; isLoading?: boolean }) { + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (tasks.length === 0) { + return ( +
+ All caught up! No tasks to show. +
+ ); + } + + return ( +
+ {tasks.map(task => ( + + ))} +
+ ); +} + +const meta: Meta = { + title: "Features/Dashboard/TaskList", + component: TaskListPreview, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const WithTasks: Story = { + args: { + isLoading: false, + tasks: [ + { + id: "task-1", + title: "Invoice #1042 overdue", + description: "Payment of 5,980 JPY was due on March 1, 2026.", + actionLabel: "Pay now", + detailHref: "/account/billing", + tone: "critical", + icon: ExclamationCircleIcon, + }, + { + id: "task-2", + title: "Add a payment method", + description: "A payment method is required before you can place orders.", + actionLabel: "Add payment", + tone: "warning", + icon: CreditCardIcon, + }, + { + id: "task-3", + title: "Order being processed", + description: "Your internet service order is being reviewed.", + actionLabel: "View order", + detailHref: "/account/orders/ORD-001", + tone: "info", + icon: ClockIcon, + }, + ], + }, +}; + +export const Empty: Story = { + args: { + isLoading: false, + tasks: [], + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + tasks: [], + }, +}; + +export const SingleTask: Story = { + args: { + isLoading: false, + tasks: [ + { + id: "task-1", + title: "Complete your profile", + description: "Add your address and verify your identity to unlock all features.", + actionLabel: "Get started", + detailHref: "/account/settings", + tone: "neutral", + icon: SparklesIcon, + }, + ], + }, +}; diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/NewCustomerFields.stories.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/NewCustomerFields.stories.tsx new file mode 100644 index 00000000..ed0a22ce --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/NewCustomerFields.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Input, Label } from "@/components/atoms"; + +/** + * Preview wrapper that recreates the NewCustomerFields visual output + * without importing the real component (which depends on JapanAddressForm). + */ + +interface AccountFormErrors { + firstName?: string; + lastName?: string; + address?: string; + [key: string]: string | undefined; +} + +interface NewCustomerFieldsPreviewProps { + firstName: string; + lastName: string; + onFirstNameChange: (value: string) => void; + onLastNameChange: (value: string) => void; + onAddressChange: (data: unknown, isComplete: boolean) => void; + errors: AccountFormErrors; + clearError: (field: string) => void; + loading: boolean; +} + +function NewCustomerFieldsPreview({ + firstName, + lastName, + onFirstNameChange, + onLastNameChange, + errors, + clearError, + loading, +}: NewCustomerFieldsPreviewProps) { + return ( + <> +
+
+ + { + onFirstNameChange(e.target.value); + clearError("firstName"); + }} + placeholder="Taro" + disabled={loading} + error={errors.firstName} + /> + {errors.firstName &&

{errors.firstName}

} +
+ +
+ + { + onLastNameChange(e.target.value); + clearError("lastName"); + }} + placeholder="Yamada" + disabled={loading} + error={errors.lastName} + /> + {errors.lastName &&

{errors.lastName}

} +
+
+ +
+ +
+ [Japan Address Form Placeholder] +
+ {errors.address &&

{errors.address}

} +
+ + ); +} + +const meta: Meta = { + title: "Features/GetStarted/CompleteAccount/NewCustomerFields", + component: NewCustomerFieldsPreview, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + firstName: "", + lastName: "", + onFirstNameChange: fn(), + onLastNameChange: fn(), + onAddressChange: fn(), + errors: {}, + clearError: fn(), + loading: false, + }, +}; + +export const Filled: Story = { + args: { + firstName: "Taro", + lastName: "Yamada", + onFirstNameChange: fn(), + onLastNameChange: fn(), + onAddressChange: fn(), + errors: {}, + clearError: fn(), + loading: false, + }, +}; + +export const WithErrors: Story = { + args: { + firstName: "", + lastName: "", + onFirstNameChange: fn(), + onLastNameChange: fn(), + onAddressChange: fn(), + errors: { + firstName: "First name is required", + lastName: "Last name is required", + address: "Please enter a valid address", + }, + clearError: fn(), + loading: false, + }, +}; + +export const Loading: Story = { + args: { + firstName: "Taro", + lastName: "Yamada", + onFirstNameChange: fn(), + onLastNameChange: fn(), + onAddressChange: fn(), + errors: {}, + clearError: fn(), + loading: true, + }, +}; diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PasswordSection.stories.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PasswordSection.stories.tsx new file mode 100644 index 00000000..94ef3616 --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PasswordSection.stories.tsx @@ -0,0 +1,234 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Check, X } from "lucide-react"; +import { PasswordInput, Label, ErrorMessage } from "@/components/atoms"; + +/** + * Preview wrapper that recreates the PasswordSection visual output + * without importing the real component (which depends on usePasswordValidation). + */ + +interface PasswordChecks { + minLength: boolean; + hasUppercase: boolean; + hasLowercase: boolean; + hasNumber: boolean; +} + +function RequirementCheck({ met, label }: { met: boolean; label: string }) { + return ( +
+ {met ? ( + + ) : ( + + )} + {label} +
+ ); +} + +function PasswordRequirementsPreview({ + checks, + showHint = false, +}: { + checks: PasswordChecks; + showHint?: boolean; +}) { + if (showHint) { + return ( +

+ At least 8 characters with uppercase, lowercase, and numbers +

+ ); + } + + return ( +
+ + + + +
+ ); +} + +function PasswordMatchIndicatorPreview({ passwordsMatch }: { passwordsMatch: boolean }) { + if (passwordsMatch) { + return ( +
+ + Passwords match +
+ ); + } + + return ( +
+ + Passwords do not match +
+ ); +} + +interface PasswordSectionPreviewProps { + password: string; + confirmPassword: string; + onPasswordChange: (value: string) => void; + onConfirmPasswordChange: (value: string) => void; + errors: { + password?: string | undefined; + confirmPassword?: string | undefined; + }; + clearError: (field: "password" | "confirmPassword") => void; + loading: boolean; + /** Static checks to display (since we cannot call the real hook) */ + checks: PasswordChecks; +} + +function PasswordSectionPreview({ + password, + confirmPassword, + onPasswordChange, + onConfirmPasswordChange, + errors, + clearError, + loading, + checks, +}: PasswordSectionPreviewProps) { + const showPasswordMatch = confirmPassword.length > 0 && !errors.confirmPassword; + const passwordsMatch = password === confirmPassword; + + return ( + <> +
+ + { + onPasswordChange(e.target.value); + clearError("password"); + }} + placeholder="Create a strong password" + disabled={loading} + error={errors.password} + autoComplete="new-password" + /> + {errors.password} + +
+ +
+ + { + onConfirmPasswordChange(e.target.value); + clearError("confirmPassword"); + }} + placeholder="Confirm your password" + disabled={loading} + error={errors.confirmPassword} + autoComplete="new-password" + /> + {errors.confirmPassword} + {showPasswordMatch && } +
+ + ); +} + +const allChecksPassing: PasswordChecks = { + minLength: true, + hasUppercase: true, + hasLowercase: true, + hasNumber: true, +}; + +const allChecksFailing: PasswordChecks = { + minLength: false, + hasUppercase: false, + hasLowercase: false, + hasNumber: false, +}; + +const meta: Meta = { + title: "Features/GetStarted/CompleteAccount/PasswordSection", + component: PasswordSectionPreview, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + password: "", + confirmPassword: "", + onPasswordChange: fn(), + onConfirmPasswordChange: fn(), + errors: {}, + clearError: fn(), + loading: false, + checks: allChecksFailing, + }, +}; + +export const FilledMatching: Story = { + args: { + password: "StrongP@ss1", + confirmPassword: "StrongP@ss1", + onPasswordChange: fn(), + onConfirmPasswordChange: fn(), + errors: {}, + clearError: fn(), + loading: false, + checks: allChecksPassing, + }, +}; + +export const WithErrors: Story = { + args: { + password: "weak", + confirmPassword: "different", + onPasswordChange: fn(), + onConfirmPasswordChange: fn(), + errors: { + password: "Password does not meet requirements", + confirmPassword: "Passwords do not match", + }, + clearError: fn(), + loading: false, + checks: { + minLength: false, + hasUppercase: false, + hasLowercase: true, + hasNumber: false, + }, + }, +}; + +export const Loading: Story = { + args: { + password: "StrongP@ss1", + confirmPassword: "StrongP@ss1", + onPasswordChange: fn(), + onConfirmPasswordChange: fn(), + errors: {}, + clearError: fn(), + loading: true, + checks: allChecksPassing, + }, +}; diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PersonalInfoFields.stories.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PersonalInfoFields.stories.tsx new file mode 100644 index 00000000..b0c1c9e6 --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PersonalInfoFields.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PersonalInfoFields } from "./PersonalInfoFields"; + +const meta: Meta = { + title: "Features/GetStarted/CompleteAccount/PersonalInfoFields", + component: PersonalInfoFields, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + phone: "", + dateOfBirth: "", + gender: "", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const Filled: Story = { + args: { + phone: "090-1234-5678", + dateOfBirth: "1990-05-15", + gender: "male", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const WithErrors: Story = { + args: { + phone: "123", + dateOfBirth: "", + gender: "", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: { + phone: "Please enter a valid phone number", + dateOfBirth: "Date of birth is required", + gender: "Please select a gender", + }, + clearError: () => {}, + loading: false, + }, +}; + +export const Loading: Story = { + args: { + phone: "090-1234-5678", + dateOfBirth: "1990-05-15", + gender: "female", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: true, + }, +}; diff --git a/apps/portal/src/features/landing-page/components/CTABanner.stories.tsx b/apps/portal/src/features/landing-page/components/CTABanner.stories.tsx new file mode 100644 index 00000000..5e50e2e7 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/CTABanner.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CTABanner } from "./CTABanner"; + +const meta: Meta = { + title: "Features/LandingPage/CTABanner", + component: CTABanner, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/Chapter.stories.tsx b/apps/portal/src/features/landing-page/components/Chapter.stories.tsx new file mode 100644 index 00000000..adffcd0e --- /dev/null +++ b/apps/portal/src/features/landing-page/components/Chapter.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Chapter } from "./Chapter"; + +const meta: Meta = { + title: "Features/LandingPage/Chapter", + component: Chapter, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+

Chapter Content

+

+ This is an example section wrapped in a Chapter component. +

+
+ ), + }, +}; + +export const WithCustomClass: Story = { + args: { + children: ( +
+

Styled Chapter

+

This chapter has a custom background class.

+
+ ), + className: "bg-muted rounded-xl", + }, +}; diff --git a/apps/portal/src/features/landing-page/components/ContactSection.stories.tsx b/apps/portal/src/features/landing-page/components/ContactSection.stories.tsx new file mode 100644 index 00000000..562ec530 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/ContactSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ContactSection } from "./ContactSection"; + +const meta: Meta = { + title: "Features/LandingPage/ContactSection", + component: ContactSection, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/HeroSection.stories.tsx b/apps/portal/src/features/landing-page/components/HeroSection.stories.tsx new file mode 100644 index 00000000..b3e04249 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/HeroSection.stories.tsx @@ -0,0 +1,18 @@ +import { useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { HeroSection } from "./HeroSection"; + +function HeroSectionWrapper() { + const ctaRef = useRef(null); + return ; +} + +const meta: Meta = { + title: "Features/LandingPage/HeroSection", + component: HeroSectionWrapper, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/ServicesCarousel.stories.tsx b/apps/portal/src/features/landing-page/components/ServicesCarousel.stories.tsx new file mode 100644 index 00000000..8e7ab96a --- /dev/null +++ b/apps/portal/src/features/landing-page/components/ServicesCarousel.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServicesCarousel } from "./ServicesCarousel"; + +const meta: Meta = { + title: "Features/LandingPage/ServicesCarousel", + component: ServicesCarousel, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.stories.tsx b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.stories.tsx new file mode 100644 index 00000000..88780606 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SupportDownloadsSection } from "./SupportDownloadsSection"; + +const meta: Meta = { + title: "Features/LandingPage/SupportDownloadsSection", + component: SupportDownloadsSection, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/TrustStrip.stories.tsx b/apps/portal/src/features/landing-page/components/TrustStrip.stories.tsx new file mode 100644 index 00000000..95b948e0 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/TrustStrip.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TrustStrip } from "./TrustStrip"; + +const meta: Meta = { + title: "Features/LandingPage/TrustStrip", + component: TrustStrip, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/landing-page/components/WhyUsSection.stories.tsx b/apps/portal/src/features/landing-page/components/WhyUsSection.stories.tsx new file mode 100644 index 00000000..15deeb55 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/WhyUsSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { WhyUsSection } from "./WhyUsSection"; + +const meta: Meta = { + title: "Features/LandingPage/WhyUsSection", + component: WhyUsSection, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/notifications/components/NotificationBell.stories.tsx b/apps/portal/src/features/notifications/components/NotificationBell.stories.tsx new file mode 100644 index 00000000..406d2426 --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationBell.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +/** + * NotificationBell is tightly coupled to useUnreadNotificationCount hook. + * This story recreates the visual bell icon with badge using static data. + */ + +function NotificationBellPreview({ unreadCount = 2 }: { unreadCount?: number }) { + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: "Features/Notifications/NotificationBell", + component: NotificationBellPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const WithUnread: Story = { + args: { unreadCount: 2 }, +}; + +export const NoUnread: Story = { + args: { unreadCount: 0 }, +}; + +export const ManyUnread: Story = { + args: { unreadCount: 15 }, +}; diff --git a/apps/portal/src/features/notifications/components/NotificationDropdown.stories.tsx b/apps/portal/src/features/notifications/components/NotificationDropdown.stories.tsx new file mode 100644 index 00000000..496828c9 --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationDropdown.stories.tsx @@ -0,0 +1,133 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NotificationItem } from "./NotificationItem"; + +/** + * NotificationDropdown is tightly coupled to hooks (useNotifications, useMarkNotificationAsRead, etc.). + * This story recreates the visual layout with static data to avoid needing jest.mock in Vite/Storybook. + */ + +const mockNotifications = [ + { + id: "n-001", + userId: "u-001", + type: "ORDER_ACTIVATED" as const, + title: "Service activated", + message: "Your internet service is now active and ready to use.", + actionUrl: "/account/services", + actionLabel: "View Service", + source: "PORTAL" as const, + sourceId: null, + read: false, + readAt: null, + dismissed: false, + createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + expiresAt: "2026-12-31T23:59:59Z", + }, + { + id: "n-002", + userId: "u-001", + type: "ORDER_APPROVED" as const, + title: "Order approved", + message: "Your order has been approved and is being processed.", + actionUrl: "/account/orders", + actionLabel: "View Order", + source: "SALESFORCE" as const, + sourceId: null, + read: true, + readAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), + dismissed: false, + createdAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), + expiresAt: "2026-12-31T23:59:59Z", + }, + { + id: "n-003", + userId: "u-001", + type: "VERIFICATION_REJECTED" as const, + title: "ID verification requires attention", + message: "We couldn't verify your ID. Please review the feedback and resubmit.", + actionUrl: "/account/settings/verification", + actionLabel: "Resubmit", + source: "PORTAL" as const, + sourceId: null, + read: false, + readAt: null, + dismissed: false, + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), + expiresAt: "2026-12-31T23:59:59Z", + }, +]; + +function NotificationDropdownPreview({ + position: _position = "below", +}: { + position?: "below" | "right"; +}) { + return ( +
+ {/* Header */} +
+

Notifications

+ +
+ + {/* Notification list */} +
+ {mockNotifications.map(notification => ( + {}} + onDismiss={() => {}} + /> + ))} +
+ + {/* Footer */} +
+ + View all notifications + +
+
+ ); +} + +const meta: Meta = { + title: "Features/Notifications/NotificationDropdown", + component: NotificationDropdownPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + render: () => ( +
+
+

Notifications

+
+
+

No notifications yet

+

+ We'll notify you when something important happens +

+
+
+ ), +}; diff --git a/apps/portal/src/features/notifications/components/NotificationItem.stories.tsx b/apps/portal/src/features/notifications/components/NotificationItem.stories.tsx new file mode 100644 index 00000000..c84af41e --- /dev/null +++ b/apps/portal/src/features/notifications/components/NotificationItem.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NotificationItem } from "./NotificationItem"; + +const meta: Meta = { + title: "Features/Notifications/NotificationItem", + component: NotificationItem, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const baseNotification = { + id: "n-001", + userId: "u-001", + source: "PORTAL" as const, + sourceId: null, + actionLabel: null, + readAt: null, + dismissed: false, + expiresAt: "2026-12-31T23:59:59Z", +}; + +export const UnreadSuccess: Story = { + args: { + notification: { + ...baseNotification, + type: "ORDER_ACTIVATED" as const, + title: "Service activated", + message: "Your internet service is now active and ready to use.", + actionUrl: "/account/services", + read: false, + createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + }, + onMarkAsRead: (id: string) => alert(`Mark as read: ${id}`), + onDismiss: (id: string) => alert(`Dismiss: ${id}`), + }, +}; + +export const ReadInfo: Story = { + args: { + notification: { + ...baseNotification, + id: "n-002", + type: "ORDER_APPROVED" as const, + title: "Order approved", + message: "Your order has been approved and is being processed.", + actionUrl: "/account/orders", + read: true, + readAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), + createdAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), + }, + onMarkAsRead: (id: string) => alert(`Mark as read: ${id}`), + onDismiss: (id: string) => alert(`Dismiss: ${id}`), + }, +}; + +export const UnreadWarning: Story = { + args: { + notification: { + ...baseNotification, + id: "n-003", + type: "VERIFICATION_REJECTED" as const, + title: "ID verification requires attention", + message: "We couldn't verify your ID. Please review the feedback and resubmit.", + actionUrl: "/account/settings/verification", + read: false, + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), + }, + onMarkAsRead: (id: string) => alert(`Mark as read: ${id}`), + onDismiss: (id: string) => alert(`Dismiss: ${id}`), + }, +}; + +export const NoActionUrl: Story = { + args: { + notification: { + ...baseNotification, + id: "n-004", + type: "SYSTEM_ANNOUNCEMENT" as const, + title: "Scheduled maintenance", + message: "We will be performing maintenance on March 15th from 2:00-4:00 AM JST.", + actionUrl: null, + read: false, + createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), + }, + }, +}; + +export const NoMessage: Story = { + args: { + notification: { + ...baseNotification, + id: "n-005", + type: "ELIGIBILITY_ELIGIBLE" as const, + title: "Good news! Internet service is available", + message: null, + actionUrl: "/account/services/internet", + read: false, + createdAt: new Date(Date.now() - 1000 * 60 * 10).toISOString(), + }, + }, +}; diff --git a/apps/portal/src/features/orders/components/OrderCard.stories.tsx b/apps/portal/src/features/orders/components/OrderCard.stories.tsx new file mode 100644 index 00000000..5f1a791d --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderCard.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderCard } from "./OrderCard"; +import type { OrderSummaryLike } from "./OrderCard"; + +const baseOrder: OrderSummaryLike = { + id: "ord-001", + orderNumber: "ORD-2025-0042", + status: "Active", + orderType: "Internet", + effectiveDate: "2025-01-15T00:00:00Z", + totalAmount: 5500, + createdDate: "2025-01-10T09:30:00Z", + lastModifiedDate: "2025-01-15T14:00:00Z", + activationStatus: "Activated", + itemsSummary: [ + { + productName: "Fiber 1Gbps Plan", + name: "Fiber 1Gbps Plan", + billingCycle: "Monthly", + quantity: 1, + unitPrice: 4500, + totalPrice: 4500, + }, + { + productName: "Wi-Fi Router Rental", + name: "Wi-Fi Router Rental", + billingCycle: "Monthly", + quantity: 1, + unitPrice: 1000, + totalPrice: 1000, + }, + ], +}; + +const meta: Meta = { + title: "Features/Orders/OrderCard", + component: OrderCard, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + order: baseOrder, + }, +}; + +export const Interactive: Story = { + args: { + order: baseOrder, + onClick: () => alert("Order clicked"), + }, +}; + +export const SimOrder: Story = { + args: { + order: { + ...baseOrder, + id: "ord-002", + orderNumber: "ORD-2025-0099", + orderType: "SIM", + status: "Draft", + activationStatus: "Pending Activation", + totalAmount: 3000, + itemsSummary: [ + { + productName: "Data SIM 20GB", + name: "Data SIM 20GB", + billingCycle: "Monthly", + quantity: 1, + unitPrice: 3000, + totalPrice: 3000, + }, + ], + }, + }, +}; + +export const VpnOrder: Story = { + args: { + order: { + ...baseOrder, + id: "ord-003", + orderNumber: "ORD-2025-0155", + orderType: "VPN", + status: "Active", + activationStatus: "Activated", + totalAmount: 1500, + itemsSummary: [ + { + productName: "VPN Japan Endpoint", + name: "VPN Japan Endpoint", + billingCycle: "Monthly", + quantity: 1, + unitPrice: 1500, + totalPrice: 1500, + }, + ], + }, + }, +}; + +export const ListVariant: Story = { + args: { + order: baseOrder, + variant: "list", + onClick: () => alert("Order clicked"), + }, +}; + +export const WithFooter: Story = { + args: { + order: baseOrder, + footer:

Last updated: Jan 15, 2025

, + }, +}; diff --git a/apps/portal/src/features/orders/components/OrderCardSkeleton.stories.tsx b/apps/portal/src/features/orders/components/OrderCardSkeleton.stories.tsx new file mode 100644 index 00000000..5e0565eb --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderCardSkeleton.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderCardSkeleton } from "./OrderCardSkeleton"; + +const meta: Meta = { + title: "Features/Orders/OrderCardSkeleton", + component: OrderCardSkeleton, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/orders/components/OrderDetailSkeleton.stories.tsx b/apps/portal/src/features/orders/components/OrderDetailSkeleton.stories.tsx new file mode 100644 index 00000000..48c218cb --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderDetailSkeleton.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderDetailSkeleton } from "./OrderDetailSkeleton"; + +const meta: Meta = { + title: "Features/Orders/OrderDetailSkeleton", + component: OrderDetailSkeleton, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithCustomClass: Story = { + args: { + className: "max-w-2xl", + }, +}; diff --git a/apps/portal/src/features/orders/components/OrderProgressTimeline.stories.tsx b/apps/portal/src/features/orders/components/OrderProgressTimeline.stories.tsx new file mode 100644 index 00000000..a47d9a3d --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderProgressTimeline.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderProgressTimeline, OrderProgressTimelineSkeleton } from "./OrderProgressTimeline"; + +const meta: Meta = { + title: "Features/Orders/OrderProgressTimeline", + component: OrderProgressTimeline, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const InternetProcessing: Story = { + args: { + serviceCategory: "internet", + currentState: "processing", + }, +}; + +export const InternetReview: Story = { + args: { + serviceCategory: "internet", + currentState: "review", + }, +}; + +export const InternetScheduled: Story = { + args: { + serviceCategory: "internet", + currentState: "scheduled", + }, +}; + +export const InternetActive: Story = { + args: { + serviceCategory: "internet", + currentState: "active", + }, +}; + +export const SimProcessing: Story = { + args: { + serviceCategory: "sim", + currentState: "processing", + }, +}; + +export const SimActivating: Story = { + args: { + serviceCategory: "sim", + currentState: "activating", + }, +}; + +export const SimActive: Story = { + args: { + serviceCategory: "sim", + currentState: "active", + }, +}; + +export const VpnProcessing: Story = { + args: { + serviceCategory: "vpn", + currentState: "processing", + }, +}; + +export const VpnActive: Story = { + args: { + serviceCategory: "vpn", + currentState: "active", + }, +}; + +export const Skeleton: StoryObj = { + render: () => , +}; diff --git a/apps/portal/src/features/orders/components/OrderServiceIcon.stories.tsx b/apps/portal/src/features/orders/components/OrderServiceIcon.stories.tsx new file mode 100644 index 00000000..d0402ea6 --- /dev/null +++ b/apps/portal/src/features/orders/components/OrderServiceIcon.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OrderServiceIcon } from "./OrderServiceIcon"; + +const meta: Meta = { + title: "Features/Orders/OrderServiceIcon", + component: OrderServiceIcon, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Internet: Story = { + args: { + orderType: "Internet", + }, +}; + +export const Sim: Story = { + args: { + orderType: "SIM", + }, +}; + +export const Vpn: Story = { + args: { + orderType: "VPN", + }, +}; + +export const DefaultType: Story = { + args: { + orderType: "Other", + }, +}; + +export const ByCategory: Story = { + args: { + category: "internet", + }, +}; + +export const LargeIcon: Story = { + args: { + orderType: "Internet", + className: "h-10 w-10", + }, +}; diff --git a/apps/portal/src/features/services/components/base/AddonGroup.stories.tsx b/apps/portal/src/features/services/components/base/AddonGroup.stories.tsx new file mode 100644 index 00000000..4b8eeaf0 --- /dev/null +++ b/apps/portal/src/features/services/components/base/AddonGroup.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddonGroup } from "./AddonGroup"; + +const meta: Meta = { + title: "Features/Services/Base/AddonGroup", + component: AddonGroup, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const sampleAddons = [ + { + id: "addon-1", + sku: "ADDON-WIFI", + name: "Wi-Fi Router Rental", + description: "High-speed Wi-Fi router included", + displayOrder: 1, + billingCycle: "Monthly", + monthlyPrice: 500, + oneTimePrice: 0, + }, + { + id: "addon-2", + sku: "ADDON-STATIC-IP", + name: "Static IP Address", + description: "Dedicated static IP for your connection", + displayOrder: 2, + billingCycle: "Monthly", + monthlyPrice: 1000, + oneTimePrice: 0, + }, + { + id: "addon-3", + sku: "ADDON-SECURITY", + name: "Security Suite", + description: "Antivirus and firewall protection", + displayOrder: 3, + billingCycle: "Monthly", + monthlyPrice: 300, + oneTimePrice: 0, + }, +]; + +const bundledAddons = [ + { + id: "addon-4", + sku: "ADDON-TV-MONTHLY", + name: "TV Service Monthly", + description: "Streaming TV package", + displayOrder: 1, + billingCycle: "Monthly", + monthlyPrice: 800, + oneTimePrice: 0, + isBundledAddon: true, + bundledAddonId: "addon-5", + }, + { + id: "addon-5", + sku: "ADDON-TV-INSTALL", + name: "TV Service Installation Fee", + description: "One-time setup fee", + displayOrder: 2, + billingCycle: "Onetime", + monthlyPrice: 0, + oneTimePrice: 3000, + isBundledAddon: true, + bundledAddonId: "addon-4", + }, +]; + +export const Default: Story = { + args: { + addons: sampleAddons, + selectedAddonSkus: [], + onAddonToggle: () => {}, + }, +}; + +export const WithSelection: Story = { + args: { + addons: sampleAddons, + selectedAddonSkus: ["ADDON-WIFI", "ADDON-SECURITY"], + onAddonToggle: () => {}, + }, +}; + +export const WithBundledAddons: Story = { + args: { + addons: [...sampleAddons, ...bundledAddons], + selectedAddonSkus: [], + onAddonToggle: () => {}, + }, +}; + +export const WithSkus: Story = { + args: { + addons: sampleAddons, + selectedAddonSkus: ["ADDON-WIFI"], + onAddonToggle: () => {}, + showSkus: true, + }, +}; + +export const EmptyAddons: Story = { + args: { + addons: [], + selectedAddonSkus: [], + onAddonToggle: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/base/AddressConfirmation.stories.tsx b/apps/portal/src/features/services/components/base/AddressConfirmation.stories.tsx new file mode 100644 index 00000000..957eea15 --- /dev/null +++ b/apps/portal/src/features/services/components/base/AddressConfirmation.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +/** + * AddressConfirmation has heavy dependencies on API services (accountService), + * React Query (useQueryClient), and the JapanAddressForm component. + * Stories for this component require full provider mocking and are best tested + * in an integration test environment. + * + * This file provides a documentation-only entry in Storybook. + */ + +// Minimal placeholder component for Storybook documentation +function AddressConfirmationPlaceholder({ + orderType, + embedded, + titleOverride, +}: { + orderType?: string; + embedded?: boolean; + titleOverride?: string; +}) { + return ( +
+
+
+

+ {titleOverride ?? "Service Address"} +

+ + Verified + +
+
+

2-20-9 Wakabayashi

+

Gramercy 201

+

Setagaya-ku, Tokyo 154-0023

+

Japan

+
+

+ Order type: {orderType ?? "N/A"} | Embedded: {String(embedded ?? false)} +

+
+ ); +} + +const meta: Meta = { + title: "Features/Services/Base/AddressConfirmation", + component: AddressConfirmationPlaceholder, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + orderType: "INTERNET", + embedded: false, + }, +}; + +export const Embedded: Story = { + args: { + orderType: "INTERNET", + embedded: true, + }, +}; + +export const CustomTitle: Story = { + args: { + orderType: "SIM", + titleOverride: "Delivery Address", + }, +}; diff --git a/apps/portal/src/features/services/components/base/AddressForm.stories.tsx b/apps/portal/src/features/services/components/base/AddressForm.stories.tsx new file mode 100644 index 00000000..81c07bbc --- /dev/null +++ b/apps/portal/src/features/services/components/base/AddressForm.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddressForm } from "./AddressForm"; + +const meta: Meta = { + title: "Features/Services/Base/AddressForm", + component: AddressForm, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onChange: () => {}, + }, +}; + +export const WithInitialAddress: Story = { + args: { + initialAddress: { + address1: "Gramercy 201", + address2: "2-20-9 Wakabayashi", + city: "Setagaya-ku", + state: "Tokyo", + postcode: "154-0023", + country: "JP", + }, + onChange: () => {}, + }, +}; + +export const CompactVariant: Story = { + args: { + variant: "compact", + onChange: () => {}, + }, +}; + +export const InlineVariant: Story = { + args: { + variant: "inline", + showTitle: false, + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + initialAddress: { + address1: "Gramercy 201", + address2: "2-20-9 Wakabayashi", + city: "Setagaya-ku", + state: "Tokyo", + postcode: "154-0023", + country: "JP", + }, + disabled: true, + onChange: () => {}, + }, +}; + +export const CustomTitle: Story = { + args: { + title: "Installation Address", + description: "Enter the address where internet service will be installed.", + onChange: () => {}, + }, +}; + +export const NoTitle: Story = { + args: { + showTitle: false, + onChange: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/base/CardBadge.stories.tsx b/apps/portal/src/features/services/components/base/CardBadge.stories.tsx new file mode 100644 index 00000000..95bf2577 --- /dev/null +++ b/apps/portal/src/features/services/components/base/CardBadge.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CardBadge } from "./CardBadge"; + +const meta: Meta = { + title: "Features/Services/Base/CardBadge", + component: CardBadge, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: "Standard", + }, +}; + +export const Gold: Story = { + args: { + text: "Gold Plan", + variant: "gold", + }, +}; + +export const Platinum: Story = { + args: { + text: "Platinum", + variant: "platinum", + }, +}; + +export const Silver: Story = { + args: { + text: "Silver", + variant: "silver", + }, +}; + +export const Recommended: Story = { + args: { + text: "Recommended", + variant: "recommended", + }, +}; + +export const Family: Story = { + args: { + text: "Family Plan", + variant: "family", + }, +}; + +export const New: Story = { + args: { + text: "New", + variant: "new", + }, +}; + +export const ExtraSmall: Story = { + args: { + text: "XS Badge", + variant: "recommended", + size: "xs", + }, +}; + +export const Small: Story = { + args: { + text: "Small Badge", + variant: "gold", + size: "sm", + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; diff --git a/apps/portal/src/features/services/components/base/CardPricing.stories.tsx b/apps/portal/src/features/services/components/base/CardPricing.stories.tsx new file mode 100644 index 00000000..5ce0d793 --- /dev/null +++ b/apps/portal/src/features/services/components/base/CardPricing.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CardPricing } from "./CardPricing"; + +const meta: Meta = { + title: "Features/Services/Base/CardPricing", + component: CardPricing, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const MonthlyOnly: Story = { + args: { + monthlyPrice: 4980, + }, +}; + +export const OneTimeOnly: Story = { + args: { + oneTimePrice: 3300, + }, +}; + +export const BothPrices: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + }, +}; + +export const SmallSize: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + size: "sm", + }, +}; + +export const LargeSize: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + size: "lg", + }, +}; + +export const LeftAligned: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + alignment: "left", + }, +}; + +export const NoPrices: Story = { + args: { + monthlyPrice: null, + oneTimePrice: null, + }, +}; diff --git a/apps/portal/src/features/services/components/base/CollapsibleSection.stories.tsx b/apps/portal/src/features/services/components/base/CollapsibleSection.stories.tsx new file mode 100644 index 00000000..e8133705 --- /dev/null +++ b/apps/portal/src/features/services/components/base/CollapsibleSection.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CollapsibleSection } from "./CollapsibleSection"; +import { Settings, Info, HelpCircle } from "lucide-react"; + +const meta: Meta = { + title: "Features/Services/Base/CollapsibleSection", + component: CollapsibleSection, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Advanced Settings", + icon: Settings, + children: ( +
+

Configure advanced settings for your plan.

+

These settings are optional and can be changed later.

+
+ ), + }, +}; + +export const DefaultOpen: Story = { + args: { + title: "Important Information", + icon: Info, + defaultOpen: true, + children: ( +
+

This section is open by default.

+

It contains important details about your service.

+
+ ), + }, +}; + +export const HelpSection: Story = { + args: { + title: "Need Help?", + icon: HelpCircle, + children: ( +
+

If you need assistance, contact our support team.

+
    +
  • Phone: 0120-XXX-XXX
  • +
  • Email: support@example.com
  • +
  • Hours: 9:00 - 18:00 JST
  • +
+
+ ), + }, +}; diff --git a/apps/portal/src/features/services/components/base/ConfigurationStep.stories.tsx b/apps/portal/src/features/services/components/base/ConfigurationStep.stories.tsx new file mode 100644 index 00000000..994c9c52 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ConfigurationStep.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ConfigurationStep } from "./ConfigurationStep"; + +const meta: Meta = { + title: "Features/Services/Base/ConfigurationStep", + component: ConfigurationStep, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + stepNumber: 1, + title: "Select Your Plan", + description: "Choose the internet plan that best fits your needs.", + isActive: true, + children:
Step content goes here
, + onNext: () => {}, + onPrevious: () => {}, + }, +}; + +export const Completed: Story = { + args: { + stepNumber: 1, + title: "Plan Selected", + description: "Fiber Internet 1G plan selected.", + isCompleted: true, + children: ( +
+ Plan configured successfully +
+ ), + validation: { isValid: true }, + onNext: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + stepNumber: 3, + title: "Payment Method", + description: "Complete previous steps first.", + isDisabled: true, + children:
This content is hidden when disabled
, + }, +}; + +export const WithValidationErrors: Story = { + args: { + stepNumber: 2, + title: "Configure Add-ons", + description: "Select optional add-on services.", + isActive: true, + children:
Form with errors
, + validation: { + isValid: false, + errors: ["Please select at least one add-on", "Invalid configuration"], + }, + onNext: () => {}, + onPrevious: () => {}, + }, +}; + +export const WithWarnings: Story = { + args: { + stepNumber: 2, + title: "Address Verification", + description: "Confirm your installation address.", + isActive: true, + children:
Address form
, + validation: { + isValid: true, + warnings: ["Address could not be verified automatically"], + }, + onNext: () => {}, + }, +}; + +export const WithHelpAndInfo: Story = { + args: { + stepNumber: 1, + title: "Choose Speed Tier", + description: "Select your preferred connection speed.", + isActive: true, + children:
Speed selection form
, + helpText: "Higher speeds are recommended for households with multiple devices.", + infoText: "All plans include unlimited data and free router rental.", + onNext: () => {}, + }, +}; + +export const Loading: Story = { + args: { + stepNumber: 2, + title: "Processing Order", + description: "Please wait while we process your configuration.", + isActive: true, + children:
Processing...
, + loading: true, + onNext: () => {}, + }, +}; + +export const WithSkipAction: Story = { + args: { + stepNumber: 2, + title: "Optional Add-ons", + description: "Add optional services or skip this step.", + isActive: true, + children:
Add-on selection
, + onNext: () => {}, + onPrevious: () => {}, + onSkip: () => {}, + skipLabel: "Skip Add-ons", + }, +}; + +export const Highlighted: Story = { + args: { + stepNumber: 1, + title: "Featured Step", + description: "This step uses the highlighted card variant.", + isActive: true, + variant: "highlighted", + children: ( +
Highlighted content
+ ), + onNext: () => {}, + }, +}; + +export const NoStepIndicator: Story = { + args: { + stepNumber: 1, + title: "Simple Step", + description: "Without the step number indicator.", + isActive: true, + showStepIndicator: false, + children:
Content
, + onNext: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/base/HowItWorks.stories.tsx b/apps/portal/src/features/services/components/base/HowItWorks.stories.tsx new file mode 100644 index 00000000..bbcb71bf --- /dev/null +++ b/apps/portal/src/features/services/components/base/HowItWorks.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HowItWorks } from "./HowItWorks"; + +const meta: Meta = { + title: "Features/Services/Base/HowItWorks", + component: HowItWorks, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const iconPlaceholder = (label: string) => ( + + {label} + + +); + +const sampleSteps = [ + { + icon: iconPlaceholder("Choose"), + title: "Choose a Plan", + description: "Browse our plans and select the one that fits your needs.", + }, + { + icon: iconPlaceholder("Configure"), + title: "Configure", + description: "Customize your plan with add-ons and preferences.", + }, + { + icon: iconPlaceholder("Schedule"), + title: "Schedule Installation", + description: "Pick a convenient date for professional installation.", + }, + { + icon: iconPlaceholder("Enjoy"), + title: "Get Connected", + description: "Enjoy high-speed internet at your home or office.", + }, +]; + +export const Default: Story = { + args: { + steps: sampleSteps, + }, +}; + +export const CustomTitle: Story = { + args: { + title: "Getting Started is Easy", + eyebrow: "4 Simple Steps", + steps: sampleSteps, + }, +}; + +export const TwoSteps: Story = { + args: { + steps: sampleSteps.slice(0, 2), + }, +}; diff --git a/apps/portal/src/features/services/components/base/OrderSummary.stories.tsx b/apps/portal/src/features/services/components/base/OrderSummary.stories.tsx new file mode 100644 index 00000000..4cc934d1 --- /dev/null +++ b/apps/portal/src/features/services/components/base/OrderSummary.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { CatalogProductBase } from "@customer-portal/domain/services"; +import { OrderSummary } from "./OrderSummary"; + +const meta: Meta = { + title: "Features/Services/Base/OrderSummary", + component: OrderSummary, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const SimpleVariant: Story = { + args: { + plan: { + name: "Fiber Internet 500", + monthlyPrice: 4980, + }, + monthlyTotal: 4980, + variant: "simple", + }, +}; + +export const WithAddonsAndFees: Story = { + args: { + plan: { + name: "Fiber Internet 1G", + monthlyPrice: 6980, + internetPlanTier: "1 Gbps", + }, + selectedAddons: [ + { + id: "addon-1", + sku: "WIFI-ROUTER", + name: "Wi-Fi Router Rental", + billingCycle: "Monthly", + monthlyPrice: 500, + oneTimePrice: 0, + }, + ] as CatalogProductBase[], + activationFees: [ + { + id: "fee-1", + sku: "INSTALL-FEE", + name: "Installation Fee", + billingCycle: "Onetime", + monthlyPrice: 0, + oneTimePrice: 3300, + }, + ] as CatalogProductBase[], + monthlyTotal: 7480, + oneTimeTotal: 3300, + variant: "simple", + showActions: true, + onContinue: () => {}, + backUrl: "/services/internet", + }, +}; + +export const EnhancedVariant: Story = { + args: { + plan: { + name: "Fiber Internet 1G", + monthlyPrice: 6980, + internetPlanTier: "1 Gbps", + }, + selectedAddons: [ + { + id: "addon-1", + sku: "WIFI-ROUTER", + name: "Wi-Fi Router Rental", + billingCycle: "Monthly", + monthlyPrice: 500, + oneTimePrice: 0, + }, + ] as CatalogProductBase[], + activationFees: [ + { + id: "fee-1", + sku: "INSTALL-FEE", + name: "Installation Fee", + billingCycle: "Onetime", + monthlyPrice: 0, + oneTimePrice: 3300, + }, + ] as CatalogProductBase[], + configDetails: [ + { label: "Speed", value: "1 Gbps" }, + { label: "Contract", value: "24 months" }, + ], + monthlyTotal: 7480, + oneTimeTotal: 3300, + variant: "enhanced", + onContinue: () => {}, + }, +}; + +export const WithInfoLines: Story = { + args: { + plan: { + name: "Fiber Internet 500", + monthlyPrice: 4980, + }, + infoLines: ["Prices shown exclude tax", "First month is prorated", "24-month minimum contract"], + monthlyTotal: 4980, + variant: "simple", + }, +}; + +export const NoActions: Story = { + args: { + plan: { + name: "Fiber Internet 500", + monthlyPrice: 4980, + }, + monthlyTotal: 4980, + showActions: false, + variant: "simple", + }, +}; + +export const Disabled: Story = { + args: { + plan: { + name: "Fiber Internet 500", + monthlyPrice: 4980, + }, + monthlyTotal: 4980, + variant: "simple", + onContinue: () => {}, + disabled: true, + }, +}; diff --git a/apps/portal/src/features/services/components/base/PaymentForm.stories.tsx b/apps/portal/src/features/services/components/base/PaymentForm.stories.tsx new file mode 100644 index 00000000..ee96712a --- /dev/null +++ b/apps/portal/src/features/services/components/base/PaymentForm.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PaymentForm } from "./PaymentForm"; + +const meta: Meta = { + title: "Features/Services/Base/PaymentForm", + component: PaymentForm, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const sampleMethods = [ + { + id: "pm-1", + type: "credit_card" as const, + cardType: "visa", + cardLastFour: "4242", + expiryDate: "12/2027", + isDefault: true, + description: "Visa ending in 4242", + }, + { + id: "pm-2", + type: "credit_card" as const, + cardType: "mastercard", + cardLastFour: "8888", + expiryDate: "06/2026", + isDefault: false, + description: "Mastercard ending in 8888", + }, +]; + +export const Default: Story = { + args: { + existingMethods: sampleMethods, + onMethodSelect: () => {}, + }, +}; + +export const WithSelectedMethod: Story = { + args: { + existingMethods: sampleMethods, + selectedMethodId: "pm-1", + onMethodSelect: () => {}, + }, +}; + +export const NoMethods: Story = { + args: { + existingMethods: [], + onAddNewMethod: () => {}, + }, +}; + +export const Loading: Story = { + args: { + loading: true, + }, +}; + +export const Disabled: Story = { + args: { + existingMethods: sampleMethods, + selectedMethodId: "pm-1", + disabled: true, + }, +}; + +export const WithDescription: Story = { + args: { + existingMethods: sampleMethods, + title: "Select Payment", + description: "Choose a payment method for your monthly subscription.", + onMethodSelect: () => {}, + }, +}; + +export const NoTitle: Story = { + args: { + existingMethods: sampleMethods, + showTitle: false, + onMethodSelect: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/base/PricingDisplay.stories.tsx b/apps/portal/src/features/services/components/base/PricingDisplay.stories.tsx new file mode 100644 index 00000000..ddeeec4d --- /dev/null +++ b/apps/portal/src/features/services/components/base/PricingDisplay.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PricingDisplay } from "./PricingDisplay"; + +const meta: Meta = { + title: "Features/Services/Base/PricingDisplay", + component: PricingDisplay, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const SimpleMonthly: Story = { + args: { + monthlyPrice: 4980, + variant: "simple", + }, +}; + +export const SimpleWithOneTime: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + variant: "simple", + }, +}; + +export const DetailedWithDiscount: Story = { + args: { + monthlyPrice: 3980, + originalMonthlyPrice: 4980, + oneTimePrice: 2200, + originalOneTimePrice: 3300, + variant: "detailed", + features: ["Unlimited data", "24/7 support", "Free router rental"], + }, +}; + +export const WithTiers: Story = { + args: { + tiers: [ + { + name: "Basic", + price: 2980, + billingCycle: "Month", + description: "For light users", + features: ["100 Mbps", "Basic support"], + }, + { + name: "Standard", + price: 4980, + billingCycle: "Month", + description: "Most popular choice", + isRecommended: true, + features: ["500 Mbps", "Priority support", "Free router"], + }, + { + name: "Premium", + price: 7980, + billingCycle: "Month", + description: "For power users", + features: ["1 Gbps", "Dedicated support", "Free router", "Static IP"], + }, + ], + }, +}; + +export const SmallSize: Story = { + args: { + monthlyPrice: 4980, + size: "sm", + }, +}; + +export const LargeSize: Story = { + args: { + monthlyPrice: 4980, + size: "lg", + }, +}; + +export const WithDisclaimer: Story = { + args: { + monthlyPrice: 4980, + disclaimer: "Prices shown exclude tax. Contract period: 24 months.", + variant: "simple", + }, +}; + +export const WithInfoText: Story = { + args: { + monthlyPrice: 4980, + infoText: "First month free for new customers!", + variant: "simple", + }, +}; + +export const CenterAligned: Story = { + args: { + monthlyPrice: 4980, + oneTimePrice: 3300, + alignment: "center", + }, +}; diff --git a/apps/portal/src/features/services/components/base/ProductCard.stories.tsx b/apps/portal/src/features/services/components/base/ProductCard.stories.tsx new file mode 100644 index 00000000..6fbaca94 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ProductCard.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProductCard } from "./ProductCard"; + +const meta: Meta = { + title: "Features/Services/Base/ProductCard", + component: ProductCard, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + id: "plan-1", + name: "Fiber Internet 500", + sku: "FIBER-500", + description: "High-speed fiber internet with 500 Mbps download speeds.", + monthlyPrice: 4980, + features: ["500 Mbps download", "200 Mbps upload", "Unlimited data", "Free Wi-Fi router"], + actionLabel: "Configure", + onClick: () => {}, + }, +}; + +export const WithBadge: Story = { + args: { + id: "plan-2", + name: "Fiber Internet 1G", + sku: "FIBER-1G", + description: "Ultra-fast gigabit fiber for power users.", + monthlyPrice: 6980, + badge: { text: "Most Popular", variant: "recommended" }, + features: ["1 Gbps download", "500 Mbps upload", "Unlimited data", "Free Wi-Fi 6 router"], + actionLabel: "Configure", + onClick: () => {}, + }, +}; + +export const WithOneTimePrice: Story = { + args: { + id: "plan-3", + name: "Fiber Internet Basic", + sku: "FIBER-BASIC", + description: "Affordable fiber internet for everyday use.", + monthlyPrice: 2980, + oneTimePrice: 3300, + features: ["100 Mbps download", "50 Mbps upload"], + actionLabel: "Get Started", + onClick: () => {}, + }, +}; + +export const Highlighted: Story = { + args: { + id: "plan-4", + name: "Premium Plan", + sku: "FIBER-PREM", + description: "Our best plan with all the extras.", + monthlyPrice: 9800, + variant: "highlighted", + badge: { text: "Best Value", variant: "success" }, + features: ["2 Gbps download", "1 Gbps upload", "Unlimited data", "Premium support"], + actionLabel: "Select Plan", + onClick: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + id: "plan-5", + name: "Unavailable Plan", + sku: "FIBER-NA", + description: "This plan is not available in your area.", + monthlyPrice: 4980, + disabled: true, + actionLabel: "Not Available", + }, +}; + +export const WithHref: Story = { + args: { + id: "plan-6", + name: "Fiber Internet 500", + sku: "FIBER-500", + description: "Click to navigate to configuration page.", + monthlyPrice: 4980, + href: "/services/internet/configure", + actionLabel: "Configure", + }, +}; + +export const CompactSize: Story = { + args: { + id: "plan-7", + name: "Compact Plan", + sku: "COMPACT", + description: "A compact card display.", + monthlyPrice: 1980, + size: "compact", + actionLabel: "Select", + onClick: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/base/ProductComparison.stories.tsx b/apps/portal/src/features/services/components/base/ProductComparison.stories.tsx new file mode 100644 index 00000000..d7cb5950 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ProductComparison.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ProductComparison } from "./ProductComparison"; + +const meta: Meta = { + title: "Features/Services/Base/ProductComparison", + component: ProductComparison, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const sampleProducts = [ + { + id: "basic", + name: "Basic", + sku: "FIBER-100", + description: "For light internet users", + monthlyPrice: 2980, + href: "/services/internet/basic", + }, + { + id: "standard", + name: "Standard", + sku: "FIBER-500", + description: "Most popular choice", + monthlyPrice: 4980, + isRecommended: true, + href: "/services/internet/standard", + }, + { + id: "premium", + name: "Premium", + sku: "FIBER-1G", + description: "For power users", + monthlyPrice: 7980, + oneTimePrice: 0, + badge: "Best Speed", + href: "/services/internet/premium", + }, +]; + +const sampleFeatures = [ + { + id: "speed", + name: "Download Speed", + values: ["100 Mbps", "500 Mbps", "1 Gbps"], + }, + { + id: "upload", + name: "Upload Speed", + values: ["50 Mbps", "200 Mbps", "500 Mbps"], + }, + { + id: "data", + name: "Unlimited Data", + values: [true, true, true], + }, + { + id: "router", + name: "Free Router", + values: [false, true, true], + }, + { + id: "support", + name: "Priority Support", + values: [false, false, true], + }, + { + id: "static-ip", + name: "Static IP", + values: [false, false, true], + }, +]; + +export const TableView: Story = { + args: { + products: sampleProducts, + features: sampleFeatures, + variant: "table", + }, +}; + +export const CardView: Story = { + args: { + products: sampleProducts, + features: sampleFeatures, + variant: "cards", + }, +}; + +export const WithCustomTitle: Story = { + args: { + products: sampleProducts, + features: sampleFeatures, + title: "Choose Your Internet Plan", + description: "Compare our plans side by side to find the perfect fit.", + variant: "table", + }, +}; + +export const NoPricing: Story = { + args: { + products: sampleProducts, + features: sampleFeatures, + showPricing: false, + variant: "cards", + }, +}; + +export const NoActions: Story = { + args: { + products: sampleProducts, + features: sampleFeatures, + showActions: false, + variant: "table", + }, +}; + +export const TwoProducts: Story = { + args: { + products: sampleProducts.slice(0, 2), + features: sampleFeatures, + maxColumns: 2, + variant: "cards", + }, +}; diff --git a/apps/portal/src/features/services/components/base/ServiceCTA.stories.tsx b/apps/portal/src/features/services/components/base/ServiceCTA.stories.tsx new file mode 100644 index 00000000..f4b28c9c --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceCTA.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceCTA } from "./ServiceCTA"; + +const meta: Meta = { + title: "Features/Services/Base/ServiceCTA", + component: ServiceCTA, + parameters: { layout: "padded" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + headline: "Ready to get connected?", + description: "Sign up today and enjoy high-speed internet in as little as 2 weeks.", + primaryAction: { + label: "Get Started", + href: "/services/internet", + }, + }, +}; + +export const WithSecondaryAction: Story = { + args: { + headline: "Upgrade your internet today", + description: "Experience blazing fast speeds with our fiber plans.", + primaryAction: { + label: "View Plans", + href: "/services/internet", + }, + secondaryAction: { + label: "Learn More", + href: "/services/internet/about", + }, + }, +}; + +export const WithOnClick: Story = { + args: { + headline: "Start your order", + description: "Configure your plan and get connected.", + primaryAction: { + label: "Configure Now", + onClick: () => {}, + }, + }, +}; + +export const CustomEyebrow: Story = { + args: { + eyebrow: "Limited time offer", + headline: "50% off for the first 3 months", + description: "Don't miss this special promotion on all fiber plans.", + primaryAction: { + label: "Claim Offer", + href: "/services/promo", + }, + }, +}; diff --git a/apps/portal/src/features/services/components/base/ServiceFAQ.stories.tsx b/apps/portal/src/features/services/components/base/ServiceFAQ.stories.tsx new file mode 100644 index 00000000..d7ab3409 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceFAQ.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceFAQ } from "./ServiceFAQ"; + +const meta: Meta = { + title: "Features/Services/Base/ServiceFAQ", + component: ServiceFAQ, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const sampleItems = [ + { + question: "What speeds can I expect?", + answer: + "Our fiber plans offer speeds from 100 Mbps up to 2 Gbps depending on your selected plan.", + }, + { + question: "Is there a contract period?", + answer: + "Our standard plans have a 24-month minimum contract. Month-to-month options are available at a slightly higher rate.", + }, + { + question: "What equipment is included?", + answer: "All plans include a free Wi-Fi router rental. Premium plans include a Wi-Fi 6 router.", + }, + { + question: "How long does installation take?", + answer: + "Installation typically takes 2-3 weeks from order confirmation. A technician will visit your location for setup.", + }, +]; + +export const Default: Story = { + args: { + items: sampleItems, + }, +}; + +export const WithCustomTitle: Story = { + args: { + title: "Internet Service FAQ", + eyebrow: "Need Help?", + items: sampleItems, + }, +}; + +export const WithDefaultOpen: Story = { + args: { + items: sampleItems, + defaultOpenIndex: 0, + }, +}; + +export const SingleItem: Story = { + args: { + items: [sampleItems[0]], + }, +}; diff --git a/apps/portal/src/features/services/components/base/ServiceHighlights.stories.tsx b/apps/portal/src/features/services/components/base/ServiceHighlights.stories.tsx new file mode 100644 index 00000000..8d23471f --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServiceHighlights.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceHighlights } from "./ServiceHighlights"; + +const meta: Meta = { + title: "Features/Services/Base/ServiceHighlights", + component: ServiceHighlights, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const iconPlaceholder = ( + + + +); + +const sampleFeatures = [ + { + icon: iconPlaceholder, + title: "Lightning Fast", + description: "Up to 2 Gbps download speeds with our fiber network.", + highlight: "NEW", + }, + { + icon: iconPlaceholder, + title: "Unlimited Data", + description: "No data caps or throttling on any plan.", + }, + { + icon: iconPlaceholder, + title: "24/7 Support", + description: "English and Japanese support available around the clock.", + }, + { + icon: iconPlaceholder, + title: "Free Installation", + description: "Professional installation included with all annual plans.", + highlight: "PROMO", + }, + { + icon: iconPlaceholder, + title: "No Lock-in", + description: "Flexible month-to-month plans available.", + }, + { + icon: iconPlaceholder, + title: "Coverage", + description: "Available in major metropolitan areas across Japan.", + }, +]; + +export const Default: Story = { + args: { + features: sampleFeatures, + }, +}; + +export const ThreeFeatures: Story = { + args: { + features: sampleFeatures.slice(0, 3), + }, +}; diff --git a/apps/portal/src/features/services/components/base/ServicesBackLink.stories.tsx b/apps/portal/src/features/services/components/base/ServicesBackLink.stories.tsx new file mode 100644 index 00000000..e3f4869c --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServicesBackLink.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServicesBackLink } from "./ServicesBackLink"; + +const meta: Meta = { + title: "Features/Services/Base/ServicesBackLink", + component: ServicesBackLink, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + href: "/services", + }, +}; + +export const WithCustomLabel: Story = { + args: { + href: "/services", + label: "Back to Services", + }, +}; + +export const CenterAligned: Story = { + args: { + href: "/services", + label: "Back to Plans", + align: "center", + }, +}; diff --git a/apps/portal/src/features/services/components/base/ServicesHero.stories.tsx b/apps/portal/src/features/services/components/base/ServicesHero.stories.tsx new file mode 100644 index 00000000..93869259 --- /dev/null +++ b/apps/portal/src/features/services/components/base/ServicesHero.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServicesHero } from "./ServicesHero"; + +const meta: Meta = { + title: "Features/Services/Base/ServicesHero", + component: ServicesHero, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Internet Services", + description: + "Choose from our range of high-speed fiber internet plans designed for your needs.", + }, +}; + +export const WithEyebrow: Story = { + args: { + eyebrow: "Fiber Internet", + title: "Blazing Fast Internet", + description: "Experience the best internet speeds in Japan with our fiber network.", + }, +}; + +export const LeftAligned: Story = { + args: { + title: "Configure Your Plan", + description: "Select your preferred options below.", + align: "left", + }, +}; + +export const NotAnimated: Story = { + args: { + title: "Static Hero Section", + description: "This version does not use entrance animations.", + animated: false, + }, +}; + +export const WithChildren: Story = { + args: { + title: "Get Started Today", + description: "Pick a plan and configure it to your needs.", + children: ( +
+ Custom content slot +
+ ), + }, +}; + +export const NoDisplayFont: Story = { + args: { + title: "Standard Font Hero", + description: "Uses the default body font instead of the display font.", + displayFont: false, + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.stories.tsx new file mode 100644 index 00000000..42564b3a --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/HelpPanel.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HelpPanel } from "./HelpPanel"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/HelpPanel", + component: HelpPanel, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: "Select the speed tier that best matches your household needs. Larger households with multiple devices benefit from higher speeds.", + }, +}; + +export const Short: Story = { + args: { + text: "Need help? Contact support at 0120-XXX-XXX.", + }, +}; + +export const Long: Story = { + args: { + text: "When selecting your internet plan, consider the number of devices in your household, your typical usage patterns (streaming, gaming, remote work), and the number of simultaneous users. Our Standard plan (500 Mbps) is suitable for most households with 3-5 devices. For heavy usage or smart home setups, we recommend the Premium plan (1 Gbps).", + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.stories.tsx new file mode 100644 index 00000000..212a1857 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/InfoPanel.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InfoPanel } from "./InfoPanel"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/InfoPanel", + component: InfoPanel, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: "All plans include unlimited data and a free router rental for the duration of your contract.", + }, +}; + +export const Short: Story = { + args: { + text: "Prices shown exclude tax.", + }, +}; + +export const Long: Story = { + args: { + text: "Your installation will be scheduled after order confirmation. A technician will visit your location to set up the fiber connection. The process typically takes 2-3 hours. Please ensure someone is available at the installation address during the scheduled time window.", + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepActions.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepActions.stories.tsx new file mode 100644 index 00000000..f01c45c8 --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepActions.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StepActions } from "./StepActions"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/StepActions", + component: StepActions, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const NextOnly: Story = { + args: { + onNext: () => {}, + }, +}; + +export const NextAndPrevious: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + }, +}; + +export const AllActions: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + onSkip: () => {}, + }, +}; + +export const CustomLabels: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + onSkip: () => {}, + nextLabel: "Proceed to Payment", + previousLabel: "Go Back", + skipLabel: "Skip This Step", + }, +}; + +export const Loading: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + loading: true, + }, +}; + +export const Disabled: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + disabled: true, + }, +}; + +export const WithErrors: Story = { + args: { + onNext: () => {}, + onPrevious: () => {}, + hasErrors: true, + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepContent.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepContent.stories.tsx new file mode 100644 index 00000000..1964b0aa --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepContent.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StepContent } from "./StepContent"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/StepContent", + component: StepContent, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isDisabled: false, + children: ( +
+

This is the main step content area where forms and controls are placed.

+
+ ), + }, +}; + +export const WithHelpText: Story = { + args: { + isDisabled: false, + helpText: "Select the plan that best matches your usage needs. You can always upgrade later.", + children: ( +
+

Step content with help text displayed below.

+
+ ), + }, +}; + +export const WithInfoText: Story = { + args: { + isDisabled: false, + infoText: "All plans include a 14-day free trial period.", + children: ( +
+

Step content with info text displayed below.

+
+ ), + }, +}; + +export const WithBothPanels: Story = { + args: { + isDisabled: false, + helpText: "Need help choosing? Our support team is available 24/7.", + infoText: "Prices shown are tax-inclusive.", + children: ( +
+

Step content with both help and info panels.

+
+ ), + }, +}; + +export const DisabledState: Story = { + args: { + isDisabled: true, + children: ( +
+

This content is hidden when the step is disabled.

+
+ ), + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepHeader.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepHeader.stories.tsx new file mode 100644 index 00000000..c0addc5a --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepHeader.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StepHeader } from "./StepHeader"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/StepHeader", + component: StepHeader, + parameters: { layout: "centered" }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + stepNumber: 1, + title: "Select Your Plan", + description: "Choose the internet plan that best fits your needs.", + status: "active", + }, +}; + +export const Completed: Story = { + args: { + stepNumber: 1, + title: "Plan Selected", + description: "Fiber Internet 1G plan selected.", + status: "completed", + validation: { isValid: true }, + }, +}; + +export const Disabled: Story = { + args: { + stepNumber: 3, + title: "Payment Method", + description: "Complete previous steps first.", + status: "disabled", + }, +}; + +export const Pending: Story = { + args: { + stepNumber: 2, + title: "Configure Add-ons", + description: "Select optional add-on services.", + status: "pending", + }, +}; + +export const WithErrors: Story = { + args: { + stepNumber: 2, + title: "Address Verification", + description: "Confirm your installation address.", + status: "active", + validation: { + isValid: false, + errors: ["Address is required", "Postcode format is invalid"], + }, + }, +}; + +export const WithWarnings: Story = { + args: { + stepNumber: 2, + title: "Address Verification", + description: "Confirm your installation address.", + status: "active", + validation: { + isValid: true, + warnings: ["Address could not be verified automatically"], + }, + }, +}; + +export const NoStepIndicator: Story = { + args: { + stepNumber: 1, + title: "Simple Header", + description: "Without the step number indicator.", + status: "active", + showStepIndicator: false, + }, +}; + +export const WithHeaderContent: Story = { + args: { + stepNumber: 1, + title: "Plan Selection", + description: "Choose your plan.", + status: "active", + headerContent: ( +
Custom header content slot
+ ), + }, +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.stories.tsx new file mode 100644 index 00000000..5aaa8e9a --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/StepIndicator.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { StepIndicator } from "./StepIndicator"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/StepIndicator", + component: StepIndicator, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { + stepNumber: 1, + status: "active", + }, +}; + +export const Completed: Story = { + args: { + stepNumber: 1, + status: "completed", + }, +}; + +export const Pending: Story = { + args: { + stepNumber: 2, + status: "pending", + }, +}; + +export const Disabled: Story = { + args: { + stepNumber: 3, + status: "disabled", + }, +}; + +export const AllStatuses: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.stories.tsx b/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.stories.tsx new file mode 100644 index 00000000..ad9c5e0e --- /dev/null +++ b/apps/portal/src/features/services/components/base/configuration-step/ValidationStatus.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ValidationStatus } from "./ValidationStatus"; + +const meta: Meta = { + title: "Features/Services/ConfigurationStep/ValidationStatus", + component: ValidationStatus, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const WithErrors: Story = { + args: { + errors: ["This field is required", "Invalid format"], + }, +}; + +export const SingleError: Story = { + args: { + errors: ["Please select a payment method"], + }, +}; + +export const WithWarnings: Story = { + args: { + warnings: ["Address could not be verified", "Consider upgrading your plan"], + }, +}; + +export const Success: Story = { + args: { + showSuccess: true, + }, +}; + +export const NoStatus: Story = { + args: {}, +}; diff --git a/apps/portal/src/features/services/components/common/FeatureCard.stories.tsx b/apps/portal/src/features/services/components/common/FeatureCard.stories.tsx new file mode 100644 index 00000000..e65969ed --- /dev/null +++ b/apps/portal/src/features/services/components/common/FeatureCard.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { FeatureCard } from "./FeatureCard"; + +const meta: Meta = { + title: "Features/Services/Common/FeatureCard", + component: FeatureCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: 🌐, + title: "Global Coverage", + description: + "Access fast, reliable internet coverage across Japan with our nationwide NTT Docomo network.", + }, +}; + +export const WithEmojiIcon: Story = { + args: { + icon: , + title: "Easy Setup", + description: + "Get started in minutes with our simple activation process. No technical knowledge required.", + }, +}; diff --git a/apps/portal/src/features/services/components/common/ServiceHeroCard.stories.tsx b/apps/portal/src/features/services/components/common/ServiceHeroCard.stories.tsx new file mode 100644 index 00000000..b1a0fb80 --- /dev/null +++ b/apps/portal/src/features/services/components/common/ServiceHeroCard.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { ServiceHeroCard } from "./ServiceHeroCard"; + +const meta: Meta = { + title: "Features/Services/Common/ServiceHeroCard", + component: ServiceHeroCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Blue: Story = { + args: { + title: "Internet", + description: "High-speed fiber optic internet for your home or apartment in Japan.", + icon: 📶, + features: [ + "Up to 10Gbps speeds", + "NTT Fiber Network", + "Professional installation", + "English support", + ], + href: "/services/internet", + color: "blue", + }, +}; + +export const Green: Story = { + args: { + title: "SIM Cards", + description: "Mobile data, voice, and SMS plans on the NTT Docomo network.", + icon: 📱, + features: [ + "eSIM & Physical SIM", + "Data + Voice plans", + "First month free", + "No Japanese bank needed", + ], + href: "/services/sim", + color: "green", + }, +}; + +export const Purple: Story = { + args: { + title: "VPN Router", + description: "Access US and UK streaming content with a pre-configured VPN router.", + icon: 🛡️, + features: [ + "US & UK servers", + "Pre-configured router", + "Plug and play", + "Stream your favorites", + ], + href: "/services/vpn", + color: "purple", + }, +}; diff --git a/apps/portal/src/features/services/components/common/ServicesOverviewContent.stories.tsx b/apps/portal/src/features/services/components/common/ServicesOverviewContent.stories.tsx new file mode 100644 index 00000000..fde9246c --- /dev/null +++ b/apps/portal/src/features/services/components/common/ServicesOverviewContent.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServicesOverviewContent } from "./ServicesOverviewContent"; + +const meta: Meta = { + title: "Features/Services/Common/ServicesOverviewContent", + component: ServicesOverviewContent, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Public: Story = { + args: { + basePath: "/services", + }, +}; + +export const Account: Story = { + args: { + basePath: "/account/services", + }, +}; + +export const NoHero: Story = { + args: { + basePath: "/services", + showHero: false, + }, +}; + +export const NoCta: Story = { + args: { + basePath: "/services", + showCta: false, + }, +}; + +export const Minimal: Story = { + args: { + basePath: "/services", + showHero: false, + showCta: false, + }, +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.stories.tsx new file mode 100644 index 00000000..693dfe5e --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CompleteAccountStep } from "./CompleteAccountStep"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/CompleteAccountStep", + component: CompleteAccountStep, + parameters: { layout: "centered" }, + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: { + postcode: "100-0001", + prefectureJa: "\u6771\u4EAC\u90FD", + cityJa: "\u5343\u4EE3\u7530\u533A", + townJa: "\u5343\u4EE3\u7530", + streetAddress: "1-1-1", + buildingName: "", + roomNumber: "", + }, + }, + accountData: { + password: "", + confirmPassword: "", + phone: "", + dateOfBirth: "", + gender: "" as "male" | "female" | "other" | "", + acceptTerms: false, + marketingConsent: false, + }, + loading: false, + error: null, + }); + return ; + }, + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.stories.tsx new file mode 100644 index 00000000..d3dbd19c --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FormStep } from "./FormStep"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/FormStep", + component: FormStep, + parameters: { layout: "centered" }, + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "", + lastName: "", + email: "", + address: null, + }, + isAddressComplete: false, + loading: false, + submitType: null, + error: null, + }); + return ; + }, + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Prefilled: Story = { + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: null, + }, + isAddressComplete: false, + loading: false, + submitType: null, + error: null, + }); + return ; + }, + ], +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.stories.tsx new file mode 100644 index 00000000..0266eadd --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { OtpStep } from "./OtpStep"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/OtpStep", + component: OtpStep, + parameters: { layout: "centered" }, + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: null, + }, + loading: false, + otpError: null, + attemptsRemaining: 5, + resendDisabled: false, + resendCountdown: 0, + }); + return ; + }, + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithError: Story = { + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: null, + }, + loading: false, + otpError: "Invalid verification code. Please try again.", + attemptsRemaining: 2, + resendDisabled: false, + resendCountdown: 0, + }); + return ; + }, + ], +}; + +export const ResendCooldown: Story = { + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: null, + }, + loading: false, + otpError: null, + attemptsRemaining: 5, + resendDisabled: true, + resendCountdown: 45, + }); + return ; + }, + ], +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.stories.tsx new file mode 100644 index 00000000..209b8add --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SuccessStep } from "./SuccessStep"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/SuccessStep", + component: SuccessStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const WithAccount: Story = { + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + hasAccount: true, + formData: { + firstName: "Taro", + lastName: "Yamada", + email: "taro@example.com", + address: null, + }, + }); + return ; + }, + ], +}; + +export const WithoutAccount: Story = { + decorators: [ + Story => { + useEligibilityCheckStore.setState({ + hasAccount: false, + formData: { + firstName: "Jane", + lastName: "Smith", + email: "jane@example.com", + address: null, + }, + }); + return ; + }, + ], +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/AccountInfoDisplay.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/AccountInfoDisplay.stories.tsx new file mode 100644 index 00000000..5726cb3b --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/AccountInfoDisplay.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AccountInfoDisplay } from "./AccountInfoDisplay"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/AccountInfoDisplay", + component: AccountInfoDisplay, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + firstName: "Taro", + lastName: "Yamada", + email: "taro.yamada@example.com", + }, +}; + +export const WithAddress: Story = { + args: { + firstName: "Taro", + lastName: "Yamada", + email: "taro.yamada@example.com", + address: { + postcode: "100-0001", + prefectureJa: "\u6771\u4EAC\u90FD", + cityJa: "\u5343\u4EE3\u7530\u533A", + townJa: "\u5343\u4EE3\u7530", + streetAddress: "1-1-1", + buildingName: "\u30D1\u30EC\u30B9\u30D3\u30EB", + roomNumber: "101", + }, + }, +}; + +export const WithoutAddress: Story = { + args: { + firstName: "Jane", + lastName: "Smith", + email: "jane.smith@example.com", + address: null, + }, +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PasswordSection.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PasswordSection.stories.tsx new file mode 100644 index 00000000..2fb63b55 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PasswordSection.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PasswordSection } from "./PasswordSection"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/PasswordSection", + component: PasswordSection, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + password: "", + confirmPassword: "", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const Filled: Story = { + args: { + password: "StrongP@ss123", + confirmPassword: "StrongP@ss123", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const WithErrors: Story = { + args: { + password: "weak", + confirmPassword: "different", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: { + password: "Password must be at least 8 characters", + confirmPassword: "Passwords do not match", + }, + clearError: () => {}, + loading: false, + }, +}; + +export const Loading: Story = { + args: { + password: "StrongP@ss123", + confirmPassword: "StrongP@ss123", + onPasswordChange: () => {}, + onConfirmPasswordChange: () => {}, + errors: {}, + clearError: () => {}, + loading: true, + }, +}; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PersonalInfoFields.stories.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PersonalInfoFields.stories.tsx new file mode 100644 index 00000000..cd275a12 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/complete-account/PersonalInfoFields.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PersonalInfoFields } from "./PersonalInfoFields"; + +const meta: Meta = { + title: "Features/Services/EligibilityCheck/PersonalInfoFields", + component: PersonalInfoFields, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + phone: "", + dateOfBirth: "", + gender: "", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const Filled: Story = { + args: { + phone: "090-1234-5678", + dateOfBirth: "1990-01-15", + gender: "male", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: false, + }, +}; + +export const WithErrors: Story = { + args: { + phone: "", + dateOfBirth: "", + gender: "", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: { + phone: "Phone number is required", + dateOfBirth: "Date of birth is required", + gender: "Please select a gender", + }, + clearError: () => {}, + loading: false, + }, +}; + +export const Loading: Story = { + args: { + phone: "090-1234-5678", + dateOfBirth: "1990-01-15", + gender: "female", + onPhoneChange: () => {}, + onDateOfBirthChange: () => {}, + onGenderChange: () => {}, + errors: {}, + clearError: () => {}, + loading: true, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.stories.tsx b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.stories.tsx new file mode 100644 index 00000000..45f2f58f --- /dev/null +++ b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { EligibilityStatusBadge } from "./EligibilityStatusBadge"; + +const meta: Meta = { + title: "Features/Services/Internet/EligibilityStatusBadge", + component: EligibilityStatusBadge, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Eligible: Story = { + args: { + status: "eligible", + speed: "1Gbps", + }, +}; + +export const EligibleNoSpeed: Story = { + args: { + status: "eligible", + }, +}; + +export const Pending: Story = { + args: { + status: "pending", + }, +}; + +export const NotRequested: Story = { + args: { + status: "not_requested", + }, +}; + +export const Ineligible: Story = { + args: { + status: "ineligible", + }, +}; diff --git a/apps/portal/src/features/services/components/internet/HowItWorksSection.stories.tsx b/apps/portal/src/features/services/components/internet/HowItWorksSection.stories.tsx new file mode 100644 index 00000000..9c6a0532 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/HowItWorksSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { HowItWorksSection } from "./HowItWorksSection"; + +const meta: Meta = { + title: "Features/Services/Internet/HowItWorksSection", + component: HowItWorksSection, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/internet/InstallationOptions.stories.tsx b/apps/portal/src/features/services/components/internet/InstallationOptions.stories.tsx new file mode 100644 index 00000000..20d46b34 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InstallationOptions.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InstallationOptions } from "./InstallationOptions"; + +const mockInstallations = [ + { + id: "inst-001", + sku: "INT-INST-ONETIME", + name: "One-time Payment", + description: "Pay the full installation fee in one payment.", + displayOrder: 1, + billingCycle: "One-time", + monthlyPrice: 0, + oneTimePrice: 22800, + catalogMetadata: { installationTerm: "One-time" as const }, + }, + { + id: "inst-002", + sku: "INT-INST-12M", + name: "12-Month Installment", + description: "Spread the installation fee across 12 monthly payments.", + displayOrder: 2, + billingCycle: "Monthly", + monthlyPrice: 1900, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "12-Month" as const }, + }, + { + id: "inst-003", + sku: "INT-INST-24M", + name: "24-Month Installment", + description: "Spread the installation fee across 24 monthly payments.", + displayOrder: 3, + billingCycle: "Monthly", + monthlyPrice: 950, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "24-Month" as const }, + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/InstallationOptions", + component: InstallationOptions, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + installations: mockInstallations, + selectedInstallationSku: null, + onInstallationSelect: () => {}, + }, +}; + +export const WithSelection: Story = { + args: { + installations: mockInstallations, + selectedInstallationSku: "INT-INST-12M", + onInstallationSelect: () => {}, + }, +}; + +export const WithSkus: Story = { + args: { + installations: mockInstallations, + selectedInstallationSku: null, + onInstallationSelect: () => {}, + showSkus: true, + }, +}; + +export const Empty: Story = { + args: { + installations: [], + selectedInstallationSku: null, + onInstallationSelect: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetImportantNotes.stories.tsx b/apps/portal/src/features/services/components/internet/InternetImportantNotes.stories.tsx new file mode 100644 index 00000000..6b1f6718 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetImportantNotes.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetImportantNotes } from "./InternetImportantNotes"; + +const meta: Meta = { + title: "Features/Services/Internet/InternetImportantNotes", + component: InternetImportantNotes, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/internet/InternetIneligibleState.stories.tsx b/apps/portal/src/features/services/components/internet/InternetIneligibleState.stories.tsx new file mode 100644 index 00000000..34c2841b --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetIneligibleState.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetIneligibleState } from "./InternetIneligibleState"; + +const meta: Meta = { + title: "Features/Services/Internet/InternetIneligibleState", + component: InternetIneligibleState, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithRejectionNotes: Story = { + args: { + rejectionNotes: + "NTT has confirmed that fiber infrastructure is not available in your building. An alternative connection method may be available - please contact support for options.", + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetModalShell.stories.tsx b/apps/portal/src/features/services/components/internet/InternetModalShell.stories.tsx new file mode 100644 index 00000000..1554e981 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetModalShell.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetModalShell } from "./InternetModalShell"; + +const meta: Meta = { + title: "Features/Services/Internet/InternetModalShell", + component: InternetModalShell, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + onClose: () => {}, + title: "Modal Title", + description: "This is a description of the modal content.", + children: ( +
+

+ This is example content inside the modal shell. +

+
+ Content area +
+
+ ), + }, +}; + +export const MediumSize: Story = { + args: { + isOpen: true, + onClose: () => {}, + title: "Medium Modal", + children:

Medium-sized modal content.

, + size: "md", + }, +}; + +export const LargeSize: Story = { + args: { + isOpen: true, + onClose: () => {}, + title: "Large Modal", + description: "A larger modal for more complex content.", + children:

Large modal content area.

, + size: "lg", + }, +}; + +export const Closed: Story = { + args: { + isOpen: false, + onClose: () => {}, + title: "Hidden Modal", + children:

This should not be visible.

, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetOfferingCard.stories.tsx b/apps/portal/src/features/services/components/internet/InternetOfferingCard.stories.tsx new file mode 100644 index 00000000..0a1c93fb --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetOfferingCard.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetOfferingCard } from "./InternetOfferingCard"; + +const mockTiers = [ + { + tier: "Silver" as const, + planSku: "INT-SILVER-1G-HOME", + monthlyPrice: 4800, + description: "Bring your own router", + features: ["NTT modem included", "Self-configure your router", "PPPoE or IPoE"], + }, + { + tier: "Gold" as const, + planSku: "INT-GOLD-1G-HOME", + monthlyPrice: 6800, + description: "Everything included", + features: ["NTT modem included", "WiFi router included", "ISP pre-configured"], + recommended: true, + }, + { + tier: "Platinum" as const, + planSku: "INT-PLAT-1G-HOME", + monthlyPrice: 9800, + description: "Custom mesh network", + features: ["Custom mesh network", "Netgear INSIGHT routers", "Professional setup"], + pricingNote: "+ device fees", + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/InternetOfferingCard", + component: InternetOfferingCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Home1Gbps: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/services/internet/configure", + }, +}; + +export const Apartment1Gbps: Story = { + args: { + offeringType: "Apartment 1G", + title: "Apartment 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for apartment buildings", + iconType: "apartment", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/services/internet/configure", + }, +}; + +export const Premium10Gbps: Story = { + args: { + offeringType: "Home 10G", + title: "Home 10Gbps", + speedBadge: "10Gbps", + description: "Ultra-fast fiber for power users", + iconType: "home", + startingPrice: 7800, + setupFee: 22800, + tiers: mockTiers, + isPremium: true, + ctaPath: "/services/internet/configure", + }, +}; + +export const Disabled: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/services/internet/configure", + disabled: true, + disabledReason: "Not available at your address", + }, +}; + +export const PreviewMode: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/services/internet/configure", + previewMode: true, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetPendingState.stories.tsx b/apps/portal/src/features/services/components/internet/InternetPendingState.stories.tsx new file mode 100644 index 00000000..0297dc3b --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetPendingState.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetPendingState } from "./InternetPendingState"; + +const meta: Meta = { + title: "Features/Services/Internet/InternetPendingState", + component: InternetPendingState, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + servicesBasePath: "/services", + }, +}; + +export const WithRequestDate: Story = { + args: { + requestedAt: "2026-03-05T10:30:00Z", + servicesBasePath: "/services", + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetPlanCard.stories.tsx b/apps/portal/src/features/services/components/internet/InternetPlanCard.stories.tsx new file mode 100644 index 00000000..040a98c3 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetPlanCard.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetPlanCard } from "./InternetPlanCard"; + +const mockPlan = { + id: "plan-001", + sku: "INT-GOLD-1G-HOME", + name: "Internet Gold 1Gbps Home", + description: "High-speed fiber internet for homes", + monthlyPrice: 6800, + oneTimePrice: 0, + internetPlanTier: "Gold", + internetOfferingType: "Home 1G", + catalogMetadata: { + tierDescription: "Hassle-free setup with router included", + features: [ + "NTT Optical Fiber (Flet's Hikari Next)", + "Home 1Gbps connection", + "WiFi router included", + "ISP pre-configured (IPoE)", + ], + isRecommended: true, + }, +}; + +const mockInstallations = [ + { + id: "inst-001", + sku: "INT-INST-ONETIME", + name: "One-time Payment", + displayOrder: 1, + billingCycle: "One-time", + monthlyPrice: 0, + oneTimePrice: 22800, + catalogMetadata: { installationTerm: "One-time" as const }, + }, + { + id: "inst-002", + sku: "INT-INST-12M", + name: "12-Month Installment", + displayOrder: 2, + billingCycle: "Monthly", + monthlyPrice: 1900, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "12-Month" as const }, + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/InternetPlanCard", + component: InternetPlanCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const GoldPlan: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + }, +}; + +export const SilverPlan: Story = { + args: { + plan: { + ...mockPlan, + id: "plan-002", + sku: "INT-SILVER-1G-HOME", + name: "Internet Silver 1Gbps Home", + internetPlanTier: "Silver", + monthlyPrice: 4800, + catalogMetadata: { + tierDescription: "Bring your own router and configure it yourself", + features: [ + "NTT Optical Fiber (Flet's Hikari Next)", + "Home 1Gbps connection", + "NTT modem provided", + "BYOD: bring your own router", + ], + }, + }, + installations: mockInstallations, + }, +}; + +export const PlatinumPlan: Story = { + args: { + plan: { + ...mockPlan, + id: "plan-003", + sku: "INT-PLAT-1G-HOME", + name: "Internet Platinum 1Gbps Home", + internetPlanTier: "Platinum", + monthlyPrice: 9800, + catalogMetadata: { + tierDescription: "Custom mesh network with professional setup", + features: [ + "NTT Optical Fiber (Flet's Hikari Next)", + "Home 1Gbps connection", + "Custom mesh network design", + "Netgear INSIGHT cloud management", + ], + }, + }, + installations: mockInstallations, + }, +}; + +export const Disabled: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + disabled: true, + disabledReason: "Not available at your address", + }, +}; + +export const WithAction: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + action: { label: "View Details", href: "/internet/details" }, + }, +}; + +export const WithPricingPrefix: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + pricingPrefix: "Starting from", + }, +}; + +export const HiddenFeatures: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + showFeatures: false, + }, +}; + +export const HiddenTierBadge: Story = { + args: { + plan: mockPlan, + installations: mockInstallations, + showTierBadge: false, + }, +}; + +export const NoInstallations: Story = { + args: { + plan: mockPlan, + installations: [], + }, +}; diff --git a/apps/portal/src/features/services/components/internet/InternetTierPricingModal.stories.tsx b/apps/portal/src/features/services/components/internet/InternetTierPricingModal.stories.tsx new file mode 100644 index 00000000..ed788f96 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetTierPricingModal.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InternetTierPricingModal } from "./InternetTierPricingModal"; + +const mockTiers = [ + { + tier: "Silver" as const, + planSku: "INT-SILVER-1G", + monthlyPrice: 4800, + description: "Bring your own router. NTT modem and ISP connection provided.", + features: ["NTT modem included", "Self-configure your router", "PPPoE or IPoE"], + recommended: false, + }, + { + tier: "Gold" as const, + planSku: "INT-GOLD-1G", + monthlyPrice: 6800, + description: "Everything included: modem, router, and pre-configured ISP.", + features: ["NTT modem included", "WiFi router included", "ISP pre-configured"], + recommended: true, + }, + { + tier: "Platinum" as const, + planSku: "INT-PLAT-1G", + monthlyPrice: 9800, + description: "Custom mesh network with professional setup.", + features: ["Custom mesh network", "Netgear INSIGHT routers", "Cloud management"], + pricingNote: "+ device fees", + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/InternetTierPricingModal", + component: InternetTierPricingModal, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + onClose: () => {}, + offeringTitle: "Home 1Gbps", + tiers: mockTiers, + setupFee: 22800, + ctaHref: "/services/internet", + }, +}; + +export const WithSubtitle: Story = { + args: { + isOpen: true, + onClose: () => {}, + offeringTitle: "Home 1Gbps", + offeringSubtitle: "NTT Flet's Hikari Next - Residential fiber", + tiers: mockTiers, + setupFee: 22800, + ctaHref: "/services/internet", + }, +}; + +export const Closed: Story = { + args: { + isOpen: false, + onClose: () => {}, + offeringTitle: "Home 1Gbps", + tiers: mockTiers, + setupFee: 22800, + ctaHref: "/services/internet", + }, +}; diff --git a/apps/portal/src/features/services/components/internet/PlanComparisonGuide.stories.tsx b/apps/portal/src/features/services/components/internet/PlanComparisonGuide.stories.tsx new file mode 100644 index 00000000..09872962 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PlanComparisonGuide.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlanComparisonGuide } from "./PlanComparisonGuide"; + +const meta: Meta = { + title: "Features/Services/Internet/PlanComparisonGuide", + component: PlanComparisonGuide, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/internet/PlanHeader.stories.tsx b/apps/portal/src/features/services/components/internet/PlanHeader.stories.tsx new file mode 100644 index 00000000..efacd5b4 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PlanHeader.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlanHeader } from "./PlanHeader"; + +const mockPlan = { + id: "plan-001", + sku: "INT-GOLD-1G-HOME", + name: "Internet Gold 1Gbps Home", + description: "High-speed fiber internet for homes", + monthlyPrice: 6800, + oneTimePrice: 0, + internetPlanTier: "Gold", + internetOfferingType: "Home 1G", + catalogMetadata: { + tierDescription: "Hassle-free setup with router included", + features: ["NTT Fiber", "WiFi Router included", "ISP pre-configured"], + isRecommended: true, + }, +}; + +const meta: Meta = { + title: "Features/Services/Internet/PlanHeader", + component: PlanHeader, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + plan: mockPlan, + }, +}; + +export const WithBackLink: Story = { + args: { + plan: mockPlan, + backHref: "/services/internet", + backLabel: "Back to Internet Plans", + title: "Configure your plan", + }, +}; + +export const SilverTier: Story = { + args: { + plan: { + ...mockPlan, + name: "Internet Silver 1Gbps Home", + internetPlanTier: "Silver", + monthlyPrice: 4800, + }, + }, +}; + +export const PlatinumTier: Story = { + args: { + plan: { + ...mockPlan, + name: "Internet Platinum 1Gbps Home", + internetPlanTier: "Platinum", + monthlyPrice: 9800, + }, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/PublicOfferingCard.stories.tsx b/apps/portal/src/features/services/components/internet/PublicOfferingCard.stories.tsx new file mode 100644 index 00000000..3ed8aa20 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/PublicOfferingCard.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PublicOfferingCard } from "./PublicOfferingCard"; + +const mockTiers = [ + { + tier: "Silver" as const, + monthlyPrice: 4800, + description: "Bring your own router", + features: ["NTT modem included", "Self-configure your router", "PPPoE or IPoE"], + }, + { + tier: "Gold" as const, + monthlyPrice: 6800, + description: "Everything included", + features: ["NTT modem included", "WiFi router included", "ISP pre-configured"], + }, + { + tier: "Platinum" as const, + monthlyPrice: 9800, + description: "Custom mesh network", + features: ["Custom mesh network", "Netgear INSIGHT routers", "Professional setup"], + pricingNote: "+ device fees", + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/PublicOfferingCard", + component: PublicOfferingCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const HomeCollapsed: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/internet", + }, +}; + +export const HomeExpanded: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/internet", + defaultExpanded: true, + }, +}; + +export const ApartmentWithConnectionInfo: Story = { + args: { + offeringType: "Apartment 1G", + title: "Apartment 1Gbps", + speedBadge: "Up to 1Gbps", + description: "NTT Flet's Hikari Next for apartment buildings", + iconType: "apartment", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/internet", + defaultExpanded: true, + showConnectionInfo: true, + }, +}; + +export const PremiumOffering: Story = { + args: { + offeringType: "Home 10G", + title: "Home 10Gbps", + speedBadge: "10Gbps", + description: "Ultra-fast fiber for power users", + iconType: "home", + startingPrice: 7800, + setupFee: 22800, + tiers: mockTiers, + isPremium: true, + ctaPath: "/internet", + defaultExpanded: true, + }, +}; + +export const WithPriceRange: Story = { + args: { + offeringType: "Apartment 1G", + title: "Apartment 1Gbps", + speedBadge: "Up to 1Gbps", + description: "NTT Flet's Hikari Next for apartment buildings", + iconType: "apartment", + startingPrice: 4800, + maxPrice: 6800, + setupFee: 22800, + tiers: mockTiers.map(t => ({ + ...t, + maxMonthlyPrice: t.monthlyPrice + 500, + })), + ctaPath: "/internet", + defaultExpanded: true, + }, +}; + +export const CustomCtaLabel: Story = { + args: { + offeringType: "Home 1G", + title: "Home 1Gbps", + speedBadge: "1Gbps", + description: "NTT Flet's Hikari Next for residential homes", + iconType: "home", + startingPrice: 4800, + setupFee: 22800, + tiers: mockTiers, + ctaPath: "/internet", + defaultExpanded: true, + customCtaLabel: "Get started", + onCtaClick: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/configure/components/ConfigureLoadingSkeleton.stories.tsx b/apps/portal/src/features/services/components/internet/configure/components/ConfigureLoadingSkeleton.stories.tsx new file mode 100644 index 00000000..8ebd7f69 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/configure/components/ConfigureLoadingSkeleton.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ConfigureLoadingSkeleton } from "./ConfigureLoadingSkeleton"; + +const meta: Meta = { + title: "Features/Services/Internet/Configure/ConfigureLoadingSkeleton", + component: ConfigureLoadingSkeleton, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.stories.tsx b/apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.stories.tsx new file mode 100644 index 00000000..b3f9ea83 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/configure/steps/AddonsStep.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddonsStep } from "./AddonsStep"; + +const mockAddons = [ + { + id: "addon-001", + sku: "INT-ADDON-PHONE", + name: "Hikari Denwa (IP Phone)", + description: "Home phone service over fiber connection", + monthlyPrice: 450, + oneTimePrice: 1000, + displayOrder: 1, + catalogMetadata: { addonType: "phone" }, + }, + { + id: "addon-002", + sku: "INT-ADDON-EXTENDER", + name: "WiFi Range Extender", + description: "Extend your WiFi coverage to larger areas", + monthlyPrice: 300, + oneTimePrice: 0, + displayOrder: 2, + catalogMetadata: { addonType: "equipment" }, + }, + { + id: "addon-003", + sku: "INT-ADDON-STATIC-IP", + name: "Static IP Address", + description: "Fixed IP address for hosting or remote access", + monthlyPrice: 800, + oneTimePrice: 0, + displayOrder: 3, + catalogMetadata: { addonType: "network" }, + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/Configure/AddonsStep", + component: AddonsStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + addons: mockAddons, + selectedAddonSkus: [], + onAddonToggle: () => {}, + isTransitioning: false, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const WithSelections: Story = { + args: { + addons: mockAddons, + selectedAddonSkus: ["INT-ADDON-PHONE", "INT-ADDON-STATIC-IP"], + onAddonToggle: () => {}, + isTransitioning: false, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const NoAddonsAvailable: Story = { + args: { + addons: [], + selectedAddonSkus: [], + onAddonToggle: () => {}, + isTransitioning: false, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const Transitioning: Story = { + args: { + addons: mockAddons, + selectedAddonSkus: [], + onAddonToggle: () => {}, + isTransitioning: true, + onBack: () => {}, + onNext: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.stories.tsx b/apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.stories.tsx new file mode 100644 index 00000000..4d8079cd --- /dev/null +++ b/apps/portal/src/features/services/components/internet/configure/steps/InstallationStep.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InstallationStep } from "./InstallationStep"; + +const mockInstallations = [ + { + id: "inst-001", + sku: "INT-INST-ONETIME", + name: "One-time Payment", + description: "Pay the full installation fee in one payment.", + displayOrder: 1, + billingCycle: "One-time", + monthlyPrice: 0, + oneTimePrice: 22800, + catalogMetadata: { installationTerm: "One-time" as const }, + }, + { + id: "inst-002", + sku: "INT-INST-12M", + name: "12-Month Installment", + description: "Spread the installation fee across 12 monthly payments.", + displayOrder: 2, + billingCycle: "Monthly", + monthlyPrice: 1900, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "12-Month" as const }, + }, + { + id: "inst-003", + sku: "INT-INST-24M", + name: "24-Month Installment", + description: "Spread the installation fee across 24 monthly payments.", + displayOrder: 3, + billingCycle: "Monthly", + monthlyPrice: 950, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "24-Month" as const }, + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/Configure/InstallationStep", + component: InstallationStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + installations: mockInstallations, + selectedInstallation: null, + setSelectedInstallationSku: () => {}, + isTransitioning: false, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const WithSelection: Story = { + args: { + installations: mockInstallations, + selectedInstallation: mockInstallations[1], + setSelectedInstallationSku: () => {}, + isTransitioning: false, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const Transitioning: Story = { + args: { + installations: mockInstallations, + selectedInstallation: null, + setSelectedInstallationSku: () => {}, + isTransitioning: true, + onBack: () => {}, + onNext: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.stories.tsx b/apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.stories.tsx new file mode 100644 index 00000000..b22885d0 --- /dev/null +++ b/apps/portal/src/features/services/components/internet/configure/steps/ReviewOrderStep.stories.tsx @@ -0,0 +1,136 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ReviewOrderStep } from "./ReviewOrderStep"; + +const mockPlan = { + id: "plan-001", + sku: "INT-GOLD-1G-HOME", + name: "Internet Gold 1Gbps Home", + description: "High-speed fiber internet for homes", + monthlyPrice: 6800, + oneTimePrice: 0, + internetPlanTier: "Gold", + internetOfferingType: "Home 1G", + catalogMetadata: { + tierDescription: "Hassle-free setup with router included", + features: ["NTT Fiber", "WiFi Router included"], + isRecommended: true, + }, +}; + +const mockInstallation = { + id: "inst-002", + sku: "INT-INST-12M", + name: "12-Month Installment", + description: "Spread the installation fee across 12 monthly payments.", + displayOrder: 2, + billingCycle: "Monthly", + monthlyPrice: 1900, + oneTimePrice: 0, + catalogMetadata: { installationTerm: "12-Month" as const }, +}; + +const mockAddons = [ + { + id: "addon-001", + sku: "INT-ADDON-PHONE", + name: "Hikari Denwa (IP Phone)", + description: "Home phone service over fiber connection", + monthlyPrice: 450, + oneTimePrice: 1000, + displayOrder: 1, + catalogMetadata: { addonType: "phone" }, + }, + { + id: "addon-002", + sku: "INT-ADDON-EXTENDER", + name: "WiFi Range Extender", + description: "Extend your WiFi coverage", + monthlyPrice: 300, + oneTimePrice: 0, + displayOrder: 2, + catalogMetadata: { addonType: "equipment" }, + }, +]; + +const meta: Meta = { + title: "Features/Services/Internet/Configure/ReviewOrderStep", + component: ReviewOrderStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + plan: mockPlan, + selectedInstallation: mockInstallation, + selectedAddonSkus: ["INT-ADDON-PHONE"], + addons: mockAddons, + mode: "IPoE-HGW", + isTransitioning: false, + onBack: () => {}, + onConfirm: () => {}, + }, +}; + +export const NoAddons: Story = { + args: { + plan: mockPlan, + selectedInstallation: mockInstallation, + selectedAddonSkus: [], + addons: mockAddons, + mode: "IPoE-HGW", + isTransitioning: false, + onBack: () => {}, + onConfirm: () => {}, + }, +}; + +export const AllAddonsSelected: Story = { + args: { + plan: mockPlan, + selectedInstallation: mockInstallation, + selectedAddonSkus: ["INT-ADDON-PHONE", "INT-ADDON-EXTENDER"], + addons: mockAddons, + mode: "PPPoE", + isTransitioning: false, + onBack: () => {}, + onConfirm: () => {}, + }, +}; + +export const OneTimeInstallation: Story = { + args: { + plan: mockPlan, + selectedInstallation: { + id: "inst-001", + sku: "INT-INST-ONETIME", + name: "One-time Payment", + description: "Pay the full installation fee in one payment.", + displayOrder: 1, + billingCycle: "One-time", + monthlyPrice: 0, + oneTimePrice: 22800, + catalogMetadata: { installationTerm: "One-time" as const }, + }, + selectedAddonSkus: [], + addons: [], + mode: null, + isTransitioning: false, + onBack: () => {}, + onConfirm: () => {}, + }, +}; + +export const Transitioning: Story = { + args: { + plan: mockPlan, + selectedInstallation: mockInstallation, + selectedAddonSkus: [], + addons: [], + mode: null, + isTransitioning: true, + onBack: () => {}, + onConfirm: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.stories.tsx b/apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.stories.tsx new file mode 100644 index 00000000..fc5b98cf --- /dev/null +++ b/apps/portal/src/features/services/components/internet/configure/steps/ServiceConfigurationStep.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceConfigurationStep } from "./ServiceConfigurationStep"; + +const mockGoldPlan = { + id: "plan-001", + sku: "INT-GOLD-1G-HOME", + name: "Internet Gold 1Gbps Home", + description: "High-speed fiber internet for homes", + monthlyPrice: 6800, + oneTimePrice: 0, + internetPlanTier: "Gold", + internetOfferingType: "Home 1G", + catalogMetadata: { + tierDescription: "Hassle-free setup with router included", + features: ["NTT Fiber", "WiFi Router included", "ISP pre-configured"], + isRecommended: true, + }, +}; + +const mockSilverPlan = { + ...mockGoldPlan, + id: "plan-002", + sku: "INT-SILVER-1G-HOME", + name: "Internet Silver 1Gbps Home", + internetPlanTier: "Silver", + monthlyPrice: 4800, + catalogMetadata: { + tierDescription: "Bring your own router", + features: ["NTT modem included"], + }, +}; + +const mockPlatinumPlan = { + ...mockGoldPlan, + id: "plan-003", + sku: "INT-PLAT-1G-HOME", + name: "Internet Platinum 1Gbps Home", + internetPlanTier: "Platinum", + monthlyPrice: 9800, + catalogMetadata: { + tierDescription: "Custom mesh network with professional setup", + features: ["Mesh network", "Netgear INSIGHT", "Cloud management"], + }, +}; + +const meta: Meta = { + title: "Features/Services/Internet/Configure/ServiceConfigurationStep", + component: ServiceConfigurationStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const GoldPlan: Story = { + args: { + plan: mockGoldPlan, + mode: null, + setMode: () => {}, + isTransitioning: false, + onNext: () => {}, + }, +}; + +export const SilverPlanNoMode: Story = { + args: { + plan: mockSilverPlan, + mode: null, + setMode: () => {}, + isTransitioning: false, + onNext: () => {}, + }, +}; + +export const SilverPlanPPPoE: Story = { + args: { + plan: mockSilverPlan, + mode: "PPPoE", + setMode: () => {}, + isTransitioning: false, + onNext: () => {}, + }, +}; + +export const SilverPlanIPoE: Story = { + args: { + plan: mockSilverPlan, + mode: "IPoE-BYOR", + setMode: () => {}, + isTransitioning: false, + onNext: () => {}, + }, +}; + +export const PlatinumPlan: Story = { + args: { + plan: mockPlatinumPlan, + mode: null, + setMode: () => {}, + isTransitioning: false, + onNext: () => {}, + }, +}; + +export const Transitioning: Story = { + args: { + plan: mockGoldPlan, + mode: null, + setMode: () => {}, + isTransitioning: true, + onNext: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/ActivationForm.stories.tsx b/apps/portal/src/features/services/components/sim/ActivationForm.stories.tsx new file mode 100644 index 00000000..acadbb6c --- /dev/null +++ b/apps/portal/src/features/services/components/sim/ActivationForm.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ActivationForm } from "./ActivationForm"; + +const meta: Meta = { + title: "Features/Services/SIM/ActivationForm", + component: ActivationForm, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Immediate: Story = { + args: { + activationType: "Immediate", + onActivationTypeChange: () => {}, + scheduledActivationDate: "", + onScheduledActivationDateChange: () => {}, + errors: {}, + }, +}; + +export const Scheduled: Story = { + args: { + activationType: "Scheduled", + onActivationTypeChange: () => {}, + scheduledActivationDate: "2026-04-01", + onScheduledActivationDateChange: () => {}, + errors: {}, + }, +}; + +export const WithActivationFee: Story = { + args: { + activationType: "Immediate", + onActivationTypeChange: () => {}, + scheduledActivationDate: "", + onScheduledActivationDateChange: () => {}, + errors: {}, + activationFee: { name: "Activation Fee", amount: 1500 }, + }, +}; + +export const WithDateError: Story = { + args: { + activationType: "Scheduled", + onActivationTypeChange: () => {}, + scheduledActivationDate: "", + onScheduledActivationDateChange: () => {}, + errors: { scheduledActivationDate: "Please select a valid date" }, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/DeviceCompatibility.stories.tsx b/apps/portal/src/features/services/components/sim/DeviceCompatibility.stories.tsx new file mode 100644 index 00000000..398cfb99 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/DeviceCompatibility.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DeviceCompatibility } from "./DeviceCompatibility"; + +const meta: Meta = { + title: "Features/Services/SIM/DeviceCompatibility", + component: DeviceCompatibility, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/MnpForm.stories.tsx b/apps/portal/src/features/services/components/sim/MnpForm.stories.tsx new file mode 100644 index 00000000..7134b81f --- /dev/null +++ b/apps/portal/src/features/services/components/sim/MnpForm.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MnpForm } from "./MnpForm"; + +const meta: Meta = { + title: "Features/Services/SIM/MnpForm", + component: MnpForm, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const emptyMnpData = { + reservationNumber: "", + expiryDate: "", + phoneNumber: "", + mvnoAccountNumber: "", + portingLastName: "", + portingFirstName: "", + portingLastNameKatakana: "", + portingFirstNameKatakana: "", + portingGender: "", + portingDateOfBirth: "", +}; + +export const Default: Story = { + args: { + wantsMnp: false, + onWantsMnpChange: () => {}, + mnpData: emptyMnpData, + onMnpDataChange: () => {}, + errors: {}, + }, +}; + +export const WithMnpEnabled: Story = { + args: { + wantsMnp: true, + onWantsMnpChange: () => {}, + mnpData: { + reservationNumber: "1234567890", + expiryDate: "2026-04-01", + phoneNumber: "090-1234-5678", + mvnoAccountNumber: "ACC-001", + portingLastName: "Tanaka", + portingFirstName: "Taro", + portingLastNameKatakana: "\u30BF\u30CA\u30AB", + portingFirstNameKatakana: "\u30BF\u30ED\u30A6", + portingGender: "Male", + portingDateOfBirth: "1990-01-15", + }, + onMnpDataChange: () => {}, + errors: {}, + }, +}; + +export const WithErrors: Story = { + args: { + wantsMnp: true, + onWantsMnpChange: () => {}, + mnpData: emptyMnpData, + onMnpDataChange: () => {}, + errors: { + reservationNumber: "Reservation number is required", + phoneNumber: "Phone number is required", + portingLastName: "Last name is required", + portingFirstName: "First name is required", + }, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/SimCallingRates.stories.tsx b/apps/portal/src/features/services/components/sim/SimCallingRates.stories.tsx new file mode 100644 index 00000000..cd95be93 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimCallingRates.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimCallingRates } from "./SimCallingRates"; + +const meta: Meta = { + title: "Features/Services/SIM/SimCallingRates", + component: SimCallingRates, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/SimConfigureView.stories.tsx b/apps/portal/src/features/services/components/sim/SimConfigureView.stories.tsx new file mode 100644 index 00000000..72cfc415 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimConfigureView.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimConfigureView } from "./SimConfigureView"; + +const meta: Meta = { + title: "Features/Services/SIM/SimConfigureView", + component: SimConfigureView, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlan = { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB Plan", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, +}; + +const emptyMnpData = { + reservationNumber: "", + expiryDate: "", + phoneNumber: "", + mvnoAccountNumber: "", + portingLastName: "", + portingFirstName: "", + portingLastNameKatakana: "", + portingFirstNameKatakana: "", + portingGender: "", + portingDateOfBirth: "", +}; + +const baseArgs = { + plan: mockPlan, + loading: false, + simType: "" as const, + setSimType: () => {}, + eid: "", + setEid: () => {}, + selectedAddons: [] as string[], + setSelectedAddons: () => {}, + activationType: "" as const, + setActivationType: () => {}, + scheduledActivationDate: "", + setScheduledActivationDate: () => {}, + wantsMnp: false, + setWantsMnp: () => {}, + mnpData: emptyMnpData, + setMnpData: () => {}, + validate: () => true, + currentStep: 1, + setCurrentStep: () => {}, + activationFees: [], + addons: [], + onConfirm: () => {}, +}; + +export const Step1: Story = { + args: { ...baseArgs, currentStep: 1 }, +}; + +export const Step2: Story = { + args: { ...baseArgs, currentStep: 2, simType: "eSIM" }, +}; + +export const Loading: Story = { + args: { ...baseArgs, loading: true }, +}; + +export const PlanNotFound: Story = { + args: { ...baseArgs, plan: undefined }, +}; diff --git a/apps/portal/src/features/services/components/sim/SimFees.stories.tsx b/apps/portal/src/features/services/components/sim/SimFees.stories.tsx new file mode 100644 index 00000000..e64b5c7a --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimFees.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimFees } from "./SimFees"; + +const meta: Meta = { + title: "Features/Services/SIM/SimFees", + component: SimFees, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/SimHowItWorksSection.stories.tsx b/apps/portal/src/features/services/components/sim/SimHowItWorksSection.stories.tsx new file mode 100644 index 00000000..63cf6593 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimHowItWorksSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimHowItWorksSection } from "./SimHowItWorksSection"; + +const meta: Meta = { + title: "Features/Services/SIM/SimHowItWorksSection", + component: SimHowItWorksSection, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/SimOrderProcess.stories.tsx b/apps/portal/src/features/services/components/sim/SimOrderProcess.stories.tsx new file mode 100644 index 00000000..e490a7b2 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimOrderProcess.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimOrderProcess } from "./SimOrderProcess"; + +const meta: Meta = { + title: "Features/Services/SIM/SimOrderProcess", + component: SimOrderProcess, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/SimPlanCard.stories.tsx b/apps/portal/src/features/services/components/sim/SimPlanCard.stories.tsx new file mode 100644 index 00000000..2c9f8412 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimPlanCard.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimPlanCard } from "./SimPlanCard"; + +const meta: Meta = { + title: "Features/Services/SIM/SimPlanCard", + component: SimPlanCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlan = { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB Plan", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, +}; + +export const Default: Story = { + args: { + plan: mockPlan, + }, +}; + +export const FamilyPlan: Story = { + args: { + plan: { ...mockPlan, simHasFamilyDiscount: true, monthlyPrice: 800 }, + isFamily: true, + }, +}; + +export const Disabled: Story = { + args: { + plan: mockPlan, + disabled: true, + disabledReason: "Account required", + }, +}; + +export const WithCustomAction: Story = { + args: { + plan: mockPlan, + action: { label: "Order Now", href: "/order" }, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/SimPlanTypeSection.stories.tsx b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.stories.tsx new file mode 100644 index 00000000..270fad71 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { SimPlanTypeSection } from "./SimPlanTypeSection"; + +const meta: Meta = { + title: "Features/Services/SIM/SimPlanTypeSection", + component: SimPlanTypeSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlans = [ + { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + { + id: "2", + sku: "SIM-DV-10GB", + name: "Data + Voice 10GB", + monthlyPrice: 2200, + unitPrice: 2200, + oneTimePrice: 0, + simDataSize: "10GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, +]; + +const mockFamilyPlans = [ + { + id: "3", + sku: "SIM-DV-3GB-F", + name: "Data + Voice 3GB (Family)", + monthlyPrice: 800, + unitPrice: 800, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: true, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, +]; + +export const Default: Story = { + args: { + title: "Data + Voice Plans", + description: "Plans with data, SMS, and voice calling", + icon: 📱, + plans: mockPlans, + showFamilyDiscount: false, + }, +}; + +export const WithFamilyDiscount: Story = { + args: { + title: "Data + Voice Plans", + description: "Plans with data, SMS, and voice calling", + icon: 📱, + plans: [...mockPlans, ...mockFamilyPlans], + showFamilyDiscount: true, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.stories.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.stories.tsx new file mode 100644 index 00000000..afb93da7 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimPlansContent } from "./SimPlansContent"; + +const meta: Meta = { + title: "Features/Services/SIM/SimPlansContent", + component: SimPlansContent, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlans = [ + { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB Plan", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + { + id: "2", + sku: "SIM-DV-10GB", + name: "Data + Voice 10GB Plan", + monthlyPrice: 2200, + unitPrice: 2200, + oneTimePrice: 0, + simDataSize: "10GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + { + id: "3", + sku: "SIM-DO-3GB", + name: "Data Only 3GB Plan", + monthlyPrice: 900, + unitPrice: 900, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataOnly" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, +]; + +export const Default: Story = { + args: { + variant: "public", + plans: mockPlans, + isLoading: false, + error: null, + activeTab: "data-voice", + onTabChange: () => {}, + onSelectPlan: () => {}, + }, +}; + +export const Loading: Story = { + args: { + variant: "public", + plans: [], + isLoading: true, + error: null, + activeTab: "data-voice", + onTabChange: () => {}, + onSelectPlan: () => {}, + }, +}; + +export const Error: Story = { + args: { + variant: "public", + plans: [], + isLoading: false, + error: new Error("Failed to fetch SIM plans"), + activeTab: "data-voice", + onTabChange: () => {}, + onSelectPlan: () => {}, + }, +}; + +export const AccountVariant: Story = { + args: { + variant: "account", + plans: [ + ...mockPlans, + { + id: "4", + sku: "SIM-DV-3GB-F", + name: "Data + Voice 3GB (Family)", + monthlyPrice: 800, + unitPrice: 800, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: true, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + ], + isLoading: false, + error: null, + activeTab: "data-voice", + onTabChange: () => {}, + onSelectPlan: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/SimTypeComparison.stories.tsx b/apps/portal/src/features/services/components/sim/SimTypeComparison.stories.tsx new file mode 100644 index 00000000..b92765b7 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimTypeComparison.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimTypeComparison } from "./SimTypeComparison"; + +const meta: Meta = { + title: "Features/Services/SIM/SimTypeComparison", + component: SimTypeComparison, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/SimTypeSelector.stories.tsx b/apps/portal/src/features/services/components/sim/SimTypeSelector.stories.tsx new file mode 100644 index 00000000..d394bcfe --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimTypeSelector.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimTypeSelector } from "./SimTypeSelector"; + +const meta: Meta = { + title: "Features/Services/SIM/SimTypeSelector", + component: SimTypeSelector, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + simType: "", + onSimTypeChange: () => {}, + eid: "", + onEidChange: () => {}, + errors: {}, + }, +}; + +export const ESimSelected: Story = { + args: { + simType: "eSIM", + onSimTypeChange: () => {}, + eid: "", + onEidChange: () => {}, + errors: {}, + }, +}; + +export const PhysicalSimSelected: Story = { + args: { + simType: "Physical SIM", + onSimTypeChange: () => {}, + eid: "", + onEidChange: () => {}, + errors: {}, + }, +}; + +export const ESimWithEid: Story = { + args: { + simType: "eSIM", + onSimTypeChange: () => {}, + eid: "89049032000000000000000000000001", + onEidChange: () => {}, + errors: {}, + }, +}; + +export const ESimWithEidError: Story = { + args: { + simType: "eSIM", + onSimTypeChange: () => {}, + eid: "12345", + onEidChange: () => {}, + errors: { eid: "EID must start with a compatible prefix" }, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.stories.tsx b/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.stories.tsx new file mode 100644 index 00000000..d1185992 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/LoadingSkeleton.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { LoadingSkeleton } from "./LoadingSkeleton"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/LoadingSkeleton", + component: LoadingSkeleton, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/configure/PlanCard.stories.tsx b/apps/portal/src/features/services/components/sim/configure/PlanCard.stories.tsx new file mode 100644 index 00000000..36071365 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlanCard.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlanCard } from "./PlanCard"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/PlanCard", + component: PlanCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlan = { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB Plan", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, +}; + +export const Default: Story = { + args: { + plan: mockPlan, + }, +}; + +export const FamilyDiscount: Story = { + args: { + plan: { ...mockPlan, simHasFamilyDiscount: true, monthlyPrice: 800 }, + }, +}; + +export const DataOnly: Story = { + args: { + plan: { + ...mockPlan, + simPlanType: "DataOnly" as const, + name: "Data Only 10GB", + simDataSize: "10GB", + monthlyPrice: 2200, + }, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/PlanNotFound.stories.tsx b/apps/portal/src/features/services/components/sim/configure/PlanNotFound.stories.tsx new file mode 100644 index 00000000..4de97856 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlanNotFound.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlanNotFound } from "./PlanNotFound"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/PlanNotFound", + component: PlanNotFound, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.stories.tsx b/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.stories.tsx new file mode 100644 index 00000000..5441b71a --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/PlatinumNotice.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PlatinumNotice } from "./PlatinumNotice"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/PlatinumNotice", + component: PlatinumNotice, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Visible: Story = { + args: { + planName: "PLATINUM 50GB Plan", + }, +}; + +export const Hidden: Story = { + args: { + planName: "Data + Voice 3GB Plan", + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.stories.tsx b/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.stories.tsx new file mode 100644 index 00000000..90e07bba --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/ActivationStep.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ActivationStep } from "./ActivationStep"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/ActivationStep", + component: ActivationStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activationType: "", + setActivationType: () => {}, + scheduledActivationDate: "", + setScheduledActivationDate: () => {}, + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const ImmediateSelected: Story = { + args: { + activationType: "Immediate", + setActivationType: () => {}, + scheduledActivationDate: "", + setScheduledActivationDate: () => {}, + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const ScheduledSelected: Story = { + args: { + activationType: "Scheduled", + setActivationType: () => {}, + scheduledActivationDate: "2026-04-01", + setScheduledActivationDate: () => {}, + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const WithActivationFee: Story = { + args: { + activationType: "Immediate", + setActivationType: () => {}, + scheduledActivationDate: "", + setScheduledActivationDate: () => {}, + activationFee: { name: "Activation Fee", amount: 1500 }, + onNext: () => {}, + onBack: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.stories.tsx b/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.stories.tsx new file mode 100644 index 00000000..e770f313 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/AddonsStep.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AddonsStep } from "./AddonsStep"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/AddonsStep", + component: AddonsStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockAddons = [ + { + id: "addon-1", + sku: "ADDON-UNLIMITED-CALL", + name: "Unlimited Domestic Calling", + monthlyPrice: 3000, + unitPrice: 3000, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + simDataSize: "", + simPlanType: "" as const, + simHasFamilyDiscount: false, + catalogMetadata: {}, + }, + { + id: "addon-2", + sku: "ADDON-VOICEMAIL", + name: "Voicemail", + monthlyPrice: 300, + unitPrice: 300, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + simDataSize: "", + simPlanType: "" as const, + simHasFamilyDiscount: false, + catalogMetadata: {}, + }, +]; + +export const Default: Story = { + args: { + addons: mockAddons, + selectedAddons: [], + setSelectedAddons: () => {}, + planType: "DataSmsVoice", + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const WithSelectedAddons: Story = { + args: { + addons: mockAddons, + selectedAddons: ["ADDON-UNLIMITED-CALL"], + setSelectedAddons: () => {}, + planType: "DataSmsVoice", + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const NoAddonsAvailable: Story = { + args: { + addons: [], + selectedAddons: [], + setSelectedAddons: () => {}, + planType: "DataOnly", + onNext: () => {}, + onBack: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.stories.tsx b/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.stories.tsx new file mode 100644 index 00000000..dd7861ee --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/NumberPortingStep.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NumberPortingStep } from "./NumberPortingStep"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/NumberPortingStep", + component: NumberPortingStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const emptyMnpData = { + reservationNumber: "", + expiryDate: "", + phoneNumber: "", + mvnoAccountNumber: "", + portingLastName: "", + portingFirstName: "", + portingLastNameKatakana: "", + portingFirstNameKatakana: "", + portingGender: "", + portingDateOfBirth: "", +}; + +export const Default: Story = { + args: { + wantsMnp: false, + setWantsMnp: () => {}, + mnpData: emptyMnpData, + setMnpData: () => {}, + activationType: "Immediate", + validate: () => true, + onNext: () => {}, + onBack: () => {}, + }, +}; + +export const WithMnpEnabled: Story = { + args: { + wantsMnp: true, + setWantsMnp: () => {}, + mnpData: { + reservationNumber: "1234567890", + expiryDate: "2026-04-01", + phoneNumber: "090-1234-5678", + mvnoAccountNumber: "", + portingLastName: "Tanaka", + portingFirstName: "Taro", + portingLastNameKatakana: "\u30BF\u30CA\u30AB", + portingFirstNameKatakana: "\u30BF\u30ED\u30A6", + portingGender: "Male", + portingDateOfBirth: "1990-01-15", + }, + setMnpData: () => {}, + activationType: "Immediate", + validate: () => true, + onNext: () => {}, + onBack: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.stories.tsx b/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.stories.tsx new file mode 100644 index 00000000..15e8f230 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/ReviewOrderStep.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ReviewOrderStep } from "./ReviewOrderStep"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/ReviewOrderStep", + component: ReviewOrderStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlan = { + id: "1", + sku: "SIM-DV-3GB", + name: "Data + Voice 3GB Plan", + monthlyPrice: 1100, + unitPrice: 1100, + oneTimePrice: 0, + simDataSize: "3GB", + simPlanType: "DataSmsVoice" as const, + simHasFamilyDiscount: false, + billingCycle: "Monthly" as const, + catalogMetadata: {}, +}; + +const mockAddons = [ + { + id: "addon-1", + sku: "ADDON-UNLIMITED-CALL", + name: "Unlimited Domestic Calling", + monthlyPrice: 3000, + unitPrice: 3000, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + simDataSize: "", + simPlanType: "" as const, + simHasFamilyDiscount: false, + catalogMetadata: {}, + }, +]; + +export const Default: Story = { + args: { + plan: mockPlan, + simType: "eSIM", + eid: "89049032000000000000000000000001", + activationType: "Immediate", + scheduledActivationDate: "", + wantsMnp: false, + selectedAddons: [], + addons: mockAddons, + activationFee: { name: "Activation Fee", amount: 1500 }, + monthlyTotal: 1100, + oneTimeTotal: 1500, + onBack: () => {}, + onConfirm: () => {}, + isDefault: true, + }, +}; + +export const WithAddons: Story = { + args: { + plan: mockPlan, + simType: "Physical SIM", + eid: "", + activationType: "Scheduled", + scheduledActivationDate: "2026-04-01", + wantsMnp: true, + selectedAddons: ["ADDON-UNLIMITED-CALL"], + addons: mockAddons, + activationFee: { name: "Activation Fee", amount: 1500 }, + monthlyTotal: 4100, + oneTimeTotal: 1500, + onBack: () => {}, + onConfirm: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.stories.tsx b/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.stories.tsx new file mode 100644 index 00000000..1fb66ee0 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/configure/steps/SimTypeStep.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimTypeStep } from "./SimTypeStep"; + +const meta: Meta = { + title: "Features/Services/SIM/Configure/SimTypeStep", + component: SimTypeStep, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + simType: "", + setSimType: () => {}, + eid: "", + setEid: () => {}, + validate: () => true, + onNext: () => {}, + }, +}; + +export const ESimSelected: Story = { + args: { + simType: "eSIM", + setSimType: () => {}, + eid: "89049032000000000000000000000001", + setEid: () => {}, + validate: () => true, + onNext: () => {}, + }, +}; + +export const PhysicalSimSelected: Story = { + args: { + simType: "Physical SIM", + setSimType: () => {}, + eid: "", + setEid: () => {}, + validate: () => true, + onNext: () => {}, + }, +}; diff --git a/apps/portal/src/features/services/components/vpn/VpnPlanCard.stories.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.stories.tsx new file mode 100644 index 00000000..2a1e2ae8 --- /dev/null +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { VpnPlanCard } from "./VpnPlanCard"; + +const meta: Meta = { + title: "Features/Services/VPN/VpnPlanCard", + component: VpnPlanCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const UsPlan: Story = { + args: { + plan: { + id: "vpn-1", + sku: "VPN-US", + name: "US (San Francisco)", + monthlyPrice: 2500, + unitPrice: 2500, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + }, +}; + +export const UkPlan: Story = { + args: { + plan: { + id: "vpn-2", + sku: "VPN-UK", + name: "UK (London)", + monthlyPrice: 2500, + unitPrice: 2500, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + }, +}; diff --git a/apps/portal/src/features/services/components/vpn/VpnPlansContent.stories.tsx b/apps/portal/src/features/services/components/vpn/VpnPlansContent.stories.tsx new file mode 100644 index 00000000..ba2be2f2 --- /dev/null +++ b/apps/portal/src/features/services/components/vpn/VpnPlansContent.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { VpnPlansContent } from "./VpnPlansContent"; + +const meta: Meta = { + title: "Features/Services/VPN/VpnPlansContent", + component: VpnPlansContent, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +const mockPlans = [ + { + id: "vpn-1", + sku: "VPN-US", + name: "US (San Francisco)", + monthlyPrice: 2500, + unitPrice: 2500, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, + { + id: "vpn-2", + sku: "VPN-UK", + name: "UK (London)", + monthlyPrice: 2500, + unitPrice: 2500, + oneTimePrice: 0, + billingCycle: "Monthly" as const, + catalogMetadata: {}, + }, +]; + +const mockActivationFees = [ + { + id: "vpn-fee-1", + sku: "VPN-ACTIVATION", + name: "VPN Activation Fee", + monthlyPrice: 0, + unitPrice: 3000, + oneTimePrice: 3000, + billingCycle: "OneTime" as const, + catalogMetadata: {}, + }, +]; + +export const Default: Story = { + args: { + variant: "public", + plans: mockPlans, + activationFees: mockActivationFees, + isLoading: false, + error: null, + }, +}; + +export const AccountVariant: Story = { + args: { + variant: "account", + plans: mockPlans, + activationFees: mockActivationFees, + isLoading: false, + error: null, + }, +}; + +export const Loading: Story = { + args: { + variant: "public", + plans: [], + activationFees: [], + isLoading: true, + error: null, + }, +}; + +export const Error: Story = { + args: { + variant: "public", + plans: [], + activationFees: [], + isLoading: false, + error: new Error("Failed to load VPN plans"), + }, +}; + +export const NoPlans: Story = { + args: { + variant: "public", + plans: [], + activationFees: [], + isLoading: false, + error: null, + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.stories.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.stories.tsx new file mode 100644 index 00000000..56b9829a --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.stories.tsx @@ -0,0 +1,267 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { Button } from "@/components/atoms/button"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import type { Subscription } from "@customer-portal/domain/subscriptions"; +import { cn, formatIsoDate } from "@/shared/utils"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of SubscriptionCard + * without importing the real component, which relies on useFormatCurrency + * (backed by useQuery / QueryClientProvider). + * -------------------------------------------------------------------------*/ + +function getStatusVariant(status: string) { + switch (status) { + case "Active": + return "success" as const; + case "Pending": + return "warning" as const; + case "Cancelled": + case "Terminated": + return "error" as const; + default: + return "neutral" as const; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "Active": + return ( + + ✓ + + ); + case "Pending": + return ( + + … + + ); + default: + return ( + + — + + ); + } +} + +interface SubscriptionCardPreviewProps { + subscription: Subscription; + variant?: "list" | "grid"; + showActions?: boolean; + onViewClick?: (subscription: Subscription) => void; + className?: string; +} + +function SubscriptionCardPreview({ + subscription, + variant = "list", + showActions = true, + onViewClick, + className, +}: SubscriptionCardPreviewProps) { + const fmtCurrency = (amount: number) => + Formatting.formatCurrency(amount, subscription.currency ?? "JPY"); + const cycleLabel = subscription.cycle; + + if (variant === "grid") { + return ( + +
+
+
+ {getStatusIcon(subscription.status)} +
+

+ {subscription.productName} +

+

+ Service ID: {subscription.serviceId} +

+
+
+ +
+
+
+

+ Price +

+

+ {fmtCurrency(subscription.amount)} +

+

{cycleLabel}

+
+
+

+ Next Due +

+
+ +

{formatIsoDate(subscription.nextDue)}

+
+
+
+ {showActions && ( +
+

+ Created {formatIsoDate(subscription.registrationDate)} +

+
+ +
+
+ )} +
+
+ ); + } + + return ( + +
+
+ {getStatusIcon(subscription.status)} +
+
+

+ {subscription.productName} +

+ +
+

+ Service ID: {subscription.serviceId} +

+
+
+
+
+

+ {fmtCurrency(subscription.amount)} +

+

{cycleLabel}

+
+
+
+ +

{formatIsoDate(subscription.nextDue)}

+
+

Next due

+
+ {showActions && ( +
+ +
+ )} +
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/SubscriptionCard", + component: SubscriptionCardPreview, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const mockSubscription: Subscription = { + id: 1, + serviceId: 1001, + productName: "SIM 10GB Data Plan", + cycle: "Monthly", + status: "Active" as const, + nextDue: "2026-04-01", + amount: 3500, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2025-06-15", +}; + +export const ListVariant: Story = { + args: { + subscription: mockSubscription, + variant: "list", + showActions: true, + onViewClick: fn(), + }, +}; + +export const GridVariant: Story = { + args: { + subscription: mockSubscription, + variant: "grid", + showActions: true, + onViewClick: fn(), + }, +}; + +export const WithoutActions: Story = { + args: { + subscription: mockSubscription, + variant: "grid", + showActions: false, + }, +}; + +export const PendingStatus: Story = { + args: { + subscription: { + ...mockSubscription, + status: "Pending" as const, + }, + variant: "grid", + showActions: true, + }, +}; + +export const TerminatedStatus: Story = { + args: { + subscription: { + ...mockSubscription, + status: "Terminated" as const, + }, + variant: "list", + showActions: true, + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.stories.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.stories.tsx new file mode 100644 index 00000000..792cac41 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.stories.tsx @@ -0,0 +1,356 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + ServerIcon, + CalendarIcon, + CurrencyYenIcon, + IdentificationIcon, + TagIcon, +} from "@heroicons/react/24/outline"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import type { Subscription } from "@customer-portal/domain/subscriptions"; +import { cn, formatIsoDate } from "@/shared/utils"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of SubscriptionDetails + * without importing the real component, which relies on useFormatCurrency + * (backed by useQuery / QueryClientProvider). + * -------------------------------------------------------------------------*/ + +function getStatusVariant(status: string) { + switch (status) { + case "Active": + return "success" as const; + case "Pending": + return "warning" as const; + case "Cancelled": + case "Terminated": + return "error" as const; + default: + return "neutral" as const; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "Active": + return ; + case "Pending": + return ; + default: + return ; + } +} + +const isSimService = (productName: string) => productName.toLowerCase().includes("sim"); +const isInternetService = (productName: string) => { + const name = productName.toLowerCase(); + return name.includes("internet") || name.includes("broadband") || name.includes("fiber"); +}; +const isVpnService = (productName: string) => productName.toLowerCase().includes("vpn"); + +interface ServiceInfoCardProps { + title: string; + heading: string; + description: string; + features: string[]; + colorScheme: "info" | "success"; +} + +function ServiceInfoCard({ + title, + heading, + description, + features, + colorScheme, +}: ServiceInfoCardProps) { + const bgClass = + colorScheme === "info" + ? "bg-info-bg border-info-border" + : "bg-success-bg border-success-border"; + const iconBgClass = colorScheme === "info" ? "bg-info/20" : "bg-success/20"; + const iconTextClass = colorScheme === "info" ? "text-info" : "text-success"; + const headingClass = colorScheme === "info" ? "text-info" : "text-success"; + const textClass = colorScheme === "info" ? "text-info/80" : "text-success/80"; + const listClass = colorScheme === "info" ? "text-info/70" : "text-success/70"; + + return ( + }> +
+
+
+ +
+
+

{heading}

+

{description}

+
    + {features.map(feature => ( +
  • • {feature}
  • + ))} +
+
+
+
+
+ ); +} + +interface SubscriptionDetailsPreviewProps { + subscription: Subscription; + showServiceSpecificSections?: boolean; + className?: string; +} + +function SubscriptionDetailsPreview({ + subscription, + showServiceSpecificSections = true, + className, +}: SubscriptionDetailsPreviewProps) { + const formatCurrency = (amount: number) => + Formatting.formatCurrency(amount, subscription.currency ?? "JPY"); + + return ( +
+ +
+ {getStatusIcon(subscription.status)} +
+

Subscription Details

+

Service subscription information

+
+
+ +
+ } + > +
+
+
+ +

+ Billing Amount +

+
+

+ {formatCurrency(subscription.amount)} +

+

{subscription.cycle}

+
+
+
+ +

+ Next Due Date +

+
+

+ {formatIsoDate(subscription.nextDue)} +

+

Due date

+
+
+
+ +

+ Registration Date +

+
+

+ {formatIsoDate(subscription.registrationDate)} +

+

Service created

+
+
+ +
+
+
+
+ +

Service ID

+
+

{subscription.serviceId}

+
+ {subscription.orderNumber && ( +
+
+ +

Order Number

+
+

{subscription.orderNumber}

+
+ )} + {subscription.groupName && ( +
+

Product Group

+

{subscription.groupName}

+
+ )} + {subscription.paymentMethod && ( +
+

Payment Method

+

+ {subscription.paymentMethod} +

+
+ )} +
+
+ + {subscription.customFields && Object.keys(subscription.customFields).length > 0 && ( +
+

+ Additional Information +

+
+ {Object.entries(subscription.customFields).map(([key, value]) => ( +
+

+ {key.replace(/([A-Z])/g, " $1").trim()} +

+

{value}

+
+ ))} +
+
+ )} + + {subscription.notes && ( +
+

Notes

+

{subscription.notes}

+
+ )} + + + {showServiceSpecificSections && ( + <> + {isSimService(subscription.productName) && ( + + )} + {isInternetService(subscription.productName) && ( + + )} + {isVpnService(subscription.productName) && ( + + )} + + )} +
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/SubscriptionDetails", + component: SubscriptionDetailsPreview, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const mockSubscription: Subscription = { + id: 1, + serviceId: 1001, + productName: "SIM 10GB Data Plan", + cycle: "Monthly", + status: "Active" as const, + nextDue: "2026-04-01", + amount: 3500, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2025-06-15", + orderNumber: "ORD-20250615-001", + groupName: "Mobile Services", + paymentMethod: "Credit Card", + notes: "Customer requested 5G upgrade in March.", + customFields: { + contractType: "12-month", + promotionCode: "SPRING2025", + }, +}; + +export const Default: Story = { + args: { + subscription: mockSubscription, + showServiceSpecificSections: true, + }, +}; + +export const InternetService: Story = { + args: { + subscription: { + ...mockSubscription, + productName: "Fiber Internet 1Gbps", + groupName: "Internet Services", + }, + showServiceSpecificSections: true, + }, +}; + +export const VpnService: Story = { + args: { + subscription: { + ...mockSubscription, + productName: "VPN Premium", + groupName: "Security Services", + }, + showServiceSpecificSections: true, + }, +}; + +export const MinimalData: Story = { + args: { + subscription: { + id: 2, + serviceId: 1002, + productName: "Basic Plan", + cycle: "Monthly", + status: "Active" as const, + amount: 1000, + currency: "JPY", + registrationDate: "2025-01-01", + }, + showServiceSpecificSections: false, + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.stories.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.stories.tsx new file mode 100644 index 00000000..6e332c30 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/SubscriptionGridCard.stories.tsx @@ -0,0 +1,174 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CalendarDaysIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; +import { StatusIndicator, type StatusIndicatorStatus } from "@/components/atoms"; +import { cn } from "@/shared/utils"; +import type { Subscription, SubscriptionStatus } from "@customer-portal/domain/subscriptions"; +import { SUBSCRIPTION_STATUS } from "@customer-portal/domain/subscriptions"; +import { Formatting } from "@customer-portal/domain/toolkit"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of SubscriptionGridCard + * without importing the real component, which relies on useFormatCurrency + * (backed by useQuery / QueryClientProvider). + * -------------------------------------------------------------------------*/ + +function mapSubscriptionStatus(status: SubscriptionStatus): StatusIndicatorStatus { + switch (status) { + case SUBSCRIPTION_STATUS.ACTIVE: + return "active"; + case SUBSCRIPTION_STATUS.PENDING: + return "pending"; + case SUBSCRIPTION_STATUS.SUSPENDED: + case SUBSCRIPTION_STATUS.CANCELLED: + return "warning"; + case SUBSCRIPTION_STATUS.TERMINATED: + return "error"; + default: + return "inactive"; + } +} + +interface SubscriptionGridCardPreviewProps { + subscription: Subscription; + className?: string; +} + +function SubscriptionGridCardPreview({ + subscription, + className, +}: SubscriptionGridCardPreviewProps) { + const statusIndicator = mapSubscriptionStatus(subscription.status); + const cycleLabel = subscription.cycle.toLowerCase(); + const isInactive = ( + [ + SUBSCRIPTION_STATUS.COMPLETED, + SUBSCRIPTION_STATUS.CANCELLED, + SUBSCRIPTION_STATUS.TERMINATED, + ] as string[] + ).includes(subscription.status); + + return ( + +
+
+

+ {subscription.productName} +

+

+ #{subscription.serviceId} +

+
+ +
+
+ + {Formatting.formatCurrency(subscription.amount, subscription.currency)} + + {cycleLabel && /{cycleLabel}} +
+
+ {subscription.nextDue && ( +
+ + + {new Date(subscription.nextDue).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + +
+ )} + + Manage + + +
+
+ ); +} + +function SubscriptionGridCardSkeletonPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/SubscriptionGridCard", + component: SubscriptionGridCardPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockSubscription: Subscription = { + id: 1, + serviceId: 1001, + productName: "SIM 10GB Data Plan", + cycle: "Monthly", + status: "Active" as const, + nextDue: "2026-04-01", + amount: 3500, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2025-06-15", +}; + +export const Active: Story = { + args: { + subscription: mockSubscription, + }, +}; + +export const Pending: Story = { + args: { + subscription: { + ...mockSubscription, + status: "Pending" as const, + }, + }, +}; + +export const Cancelled: Story = { + args: { + subscription: { + ...mockSubscription, + status: "Cancelled" as const, + }, + }, +}; + +export const Terminated: Story = { + args: { + subscription: { + ...mockSubscription, + status: "Terminated" as const, + }, + }, +}; + +export const Skeleton: Story = { + render: () => , +}; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.stories.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.stories.tsx new file mode 100644 index 00000000..927a5be6 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SubscriptionStatusBadge } from "./SubscriptionStatusBadge"; + +const meta: Meta = { + title: "Features/Subscriptions/SubscriptionStatusBadge", + component: SubscriptionStatusBadge, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { status: "Active" }, +}; + +export const Pending: Story = { + args: { status: "Pending" }, +}; + +export const Suspended: Story = { + args: { status: "Suspended" }, +}; + +export const Cancelled: Story = { + args: { status: "Cancelled" }, +}; + +export const Terminated: Story = { + args: { status: "Terminated" }, +}; + +export const Completed: Story = { + args: { status: "Completed" }, +}; + +export const Inactive: Story = { + args: { status: "Inactive" }, +}; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.stories.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.stories.tsx new file mode 100644 index 00000000..7bafb0e8 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { SubscriptionTable } from "./SubscriptionTable"; + +const meta: Meta = { + title: "Features/Subscriptions/SubscriptionTable", + component: SubscriptionTable, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const mockSubscriptions = [ + { + id: 1, + serviceId: 1001, + productName: "SIM 10GB Data Plan", + cycle: "Monthly" as const, + status: "Active" as const, + nextDue: "2026-04-01", + amount: 3500, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2025-06-15", + }, + { + id: 2, + serviceId: 1002, + productName: "Fiber Internet 1Gbps", + cycle: "Monthly" as const, + status: "Active" as const, + nextDue: "2026-04-01", + amount: 5800, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2025-03-10", + }, + { + id: 3, + serviceId: 1003, + productName: "VPN Premium", + cycle: "Annually" as const, + status: "Pending" as const, + nextDue: "2027-01-01", + amount: 12000, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2026-01-01", + }, + { + id: 4, + serviceId: 1004, + productName: "SIM 5GB Data Plan", + cycle: "Monthly" as const, + status: "Cancelled" as const, + nextDue: "2026-03-15", + amount: 2000, + currency: "JPY", + currencySymbol: "\u00a5", + registrationDate: "2024-12-01", + }, +]; + +export const Default: Story = { + args: { + subscriptions: mockSubscriptions, + loading: false, + onSubscriptionClick: fn(), + }, +}; + +export const Loading: Story = { + args: { + subscriptions: [], + loading: true, + }, +}; + +export const Empty: Story = { + args: { + subscriptions: [], + loading: false, + }, +}; + +export const SingleItem: Story = { + args: { + subscriptions: mockSubscriptions.slice(0, 1), + loading: false, + onSubscriptionClick: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/ChangePlanModal.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/ChangePlanModal.stories.tsx new file mode 100644 index 00000000..4e5c1294 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/ChangePlanModal.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import { motion } from "framer-motion"; +import { XMarkIcon } from "@heroicons/react/24/outline"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of ChangePlanModal without + * importing the real component, which relies on apiClient and + * mapToSimplifiedFormat. + * -------------------------------------------------------------------------*/ + +const PLAN_CODES = ["5GB", "10GB", "25GB", "50GB"] as const; +type PlanCode = (typeof PLAN_CODES)[number]; + +interface ChangePlanModalPreviewProps { + subscriptionId: number; + currentPlanCode?: string | undefined; + onClose: () => void; + onSuccess: () => void; + onError: (message: string) => void; +} + +function ChangePlanModalPreview({ currentPlanCode, onClose }: ChangePlanModalPreviewProps) { + const normalizedCurrentPlan = currentPlanCode ?? ""; + const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter( + code => code !== (normalizedCurrentPlan as PlanCode) + ); + const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); + + return ( +
+
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/Sim/ChangePlanModal", + component: ChangePlanModalPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + subscriptionId: 123, + currentPlanCode: "10GB", + onClose: fn(), + onSuccess: fn(), + onError: fn(), + }, +}; + +export const NoPlanSelected: Story = { + args: { + subscriptionId: 123, + currentPlanCode: undefined, + onClose: fn(), + onSuccess: fn(), + onError: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/DataUsageChart.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/DataUsageChart.stories.tsx new file mode 100644 index 00000000..056d4192 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/DataUsageChart.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { DataUsageChart } from "./DataUsageChart"; + +const meta: Meta = { + title: "Features/Subscriptions/Sim/DataUsageChart", + component: DataUsageChart, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const mockUsage = { + account: "ACC-001", + todayUsageKb: 512000, + todayUsageMb: 500, + recentDaysUsage: [ + { date: "2026-03-06", usageKb: 256000, usageMb: 250 }, + { date: "2026-03-05", usageKb: 1024000, usageMb: 1000 }, + { date: "2026-03-04", usageKb: 128000, usageMb: 125 }, + { date: "2026-03-03", usageKb: 768000, usageMb: 750 }, + { date: "2026-03-02", usageKb: 384000, usageMb: 375 }, + ], + isBlacklisted: false, +}; + +export const Default: Story = { + args: { + usage: mockUsage, + remainingQuotaMb: 8000, + isLoading: false, + error: null, + embedded: false, + }, +}; + +export const HighUsage: Story = { + args: { + usage: { + ...mockUsage, + todayUsageMb: 2000, + todayUsageKb: 2000000, + recentDaysUsage: mockUsage.recentDaysUsage.map(d => ({ + ...d, + usageMb: d.usageMb * 5, + usageKb: d.usageKb * 5, + })), + }, + remainingQuotaMb: 500, + isLoading: false, + error: null, + }, +}; + +export const CriticalUsage: Story = { + args: { + usage: { + ...mockUsage, + todayUsageMb: 3000, + todayUsageKb: 3000000, + recentDaysUsage: mockUsage.recentDaysUsage.map(d => ({ + ...d, + usageMb: d.usageMb * 8, + usageKb: d.usageKb * 8, + })), + }, + remainingQuotaMb: 100, + isLoading: false, + error: null, + }, +}; + +export const Loading: Story = { + args: { + usage: mockUsage, + remainingQuotaMb: 8000, + isLoading: true, + error: null, + }, +}; + +export const WithError: Story = { + args: { + usage: mockUsage, + remainingQuotaMb: 8000, + isLoading: false, + error: "Failed to load usage data. Please try again later.", + }, +}; + +export const Embedded: Story = { + args: { + usage: mockUsage, + remainingQuotaMb: 8000, + isLoading: false, + error: null, + embedded: true, + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.stories.tsx new file mode 100644 index 00000000..6f34faa2 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/ReissueSimModal.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of ReissueSimModal without + * importing the real component, which relies on simActionsService for API + * calls. + * -------------------------------------------------------------------------*/ + +type SimKind = "physical" | "esim"; + +const IMPORTANT_POINTS: string[] = [ + "The reissue request cannot be reversed.", + "Service to the existing SIM will be terminated with immediate effect.", + "A fee of 1,500 yen + tax will be incurred.", + "For physical SIM: allow approximately 3-5 business days for shipping.", + "For eSIM: activation typically completes within 30-60 minutes after processing.", +]; + +const EID_HELP = + "Enter the 32-digit EID (numbers only). Leave blank to reuse Freebit's generated EID."; + +function SimTypeSelector({ + selectedSimType, + onSelect, +}: { + selectedSimType: SimKind; + onSelect: (type: SimKind) => void; +}) { + return ( +
+ +
+ + +
+
+ ); +} + +interface ReissueSimModalPreviewProps { + subscriptionId: number; + currentSimType: SimKind; + onClose: () => void; + onSuccess: () => void; + onError: (message: string) => void; +} + +function ReissueSimModalPreview({ currentSimType, onClose }: ReissueSimModalPreviewProps) { + const [selectedSimType, setSelectedSimType] = useState(currentSimType); + const [newEid, setNewEid] = useState(""); + + const isEsimSelected = selectedSimType === "esim"; + const isPhysicalSelected = selectedSimType === "physical"; + + return ( +
+ + ); +} + +const meta: Meta = { + title: "Features/Subscriptions/Sim/ReissueSimModal", + component: ReissueSimModalPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const PhysicalSim: Story = { + args: { + subscriptionId: 123, + currentSimType: "physical", + onClose: fn(), + onSuccess: fn(), + onError: fn(), + }, +}; + +export const ESim: Story = { + args: { + subscriptionId: 123, + currentSimType: "esim", + onClose: fn(), + onSuccess: fn(), + onError: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/SimActions.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/SimActions.stories.tsx new file mode 100644 index 00000000..916831cd --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/SimActions.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { PlusIcon, ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of SimActions without + * importing the real component, which relies on useRouter, apiClient, and + * several modal sub-components that also make API calls. + * -------------------------------------------------------------------------*/ + +const ACTION_BUTTON_BASE_CLASSES = + "w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)]"; +const ACTIVE_BUTTON_FOCUS_CLASSES = + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"; +const DISABLED_BUTTON_CLASSES = "text-muted-foreground bg-muted cursor-not-allowed"; +const SHADOW_CLASSES = "shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]"; + +interface SimActionsPreviewProps { + subscriptionId: number; + simType: "physical" | "esim"; + status: string; + embedded?: boolean; + currentPlanCode?: string; + onTopUpClick?: () => void; + onChangePlanClick?: () => void; + onReissueClick?: () => void; + onCancelClick?: () => void; +} + +function SimActionsPreview({ + status, + embedded = false, + onTopUpClick, + onChangePlanClick, + onReissueClick, + onCancelClick, +}: SimActionsPreviewProps) { + const isActive = status === "active"; + const containerClasses = embedded + ? "" + : "bg-card shadow-[var(--cp-shadow-1)] rounded-xl border border-border"; + const contentClasses = embedded ? "" : "px-6 lg:px-8 py-6"; + + return ( +
+ {!embedded && ( +
+

+ SIM Management Actions +

+

Manage your SIM service

+
+ )} +
+ {!isActive && ( +
+ + SIM management actions are only available for active services. + +
+ )} +
+ + + + +
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/Sim/SimActions", + component: SimActionsPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const ActiveSim: Story = { + args: { + subscriptionId: 123, + simType: "esim", + status: "active", + embedded: false, + currentPlanCode: "10GB", + onTopUpClick: fn(), + onChangePlanClick: fn(), + onReissueClick: fn(), + onCancelClick: fn(), + }, +}; + +export const SuspendedSim: Story = { + args: { + subscriptionId: 123, + simType: "physical", + status: "suspended", + embedded: false, + currentPlanCode: "5GB", + onTopUpClick: fn(), + onChangePlanClick: fn(), + onReissueClick: fn(), + onCancelClick: fn(), + }, +}; + +export const Embedded: Story = { + args: { + subscriptionId: 123, + simType: "esim", + status: "active", + embedded: true, + currentPlanCode: "25GB", + onTopUpClick: fn(), + onChangePlanClick: fn(), + onReissueClick: fn(), + onCancelClick: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.stories.tsx new file mode 100644 index 00000000..b361862c --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/SimDetailsCard.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SimDetailsCard } from "./SimDetailsCard"; + +const meta: Meta = { + title: "Features/Subscriptions/Sim/SimDetailsCard", + component: SimDetailsCard, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +const baseSimDetails = { + account: "ACC-001", + status: "active" as const, + planCode: "PASI 10GB", + planName: "10GB Data Plan", + simType: "standard" as const, + iccid: "8981100000000000001", + eid: "", + msisdn: "090-1234-5678", + imsi: "440101234567890", + remainingQuotaMb: 8500, + remainingQuotaKb: 8500000, + voiceMailEnabled: true, + callWaitingEnabled: false, + internationalRoamingEnabled: true, + networkType: "5G", + hasVoice: true, + hasSms: true, + activatedAt: "2025-01-15", +}; + +export const PhysicalSim: Story = { + args: { + simDetails: baseSimDetails, + isLoading: false, + error: null, + embedded: false, + showFeaturesSummary: true, + }, +}; + +export const ESim: Story = { + args: { + simDetails: { + ...baseSimDetails, + simType: "esim", + eid: "89012345678901234567890123456789", + }, + isLoading: false, + error: null, + embedded: false, + }, +}; + +export const Loading: Story = { + args: { + simDetails: baseSimDetails, + isLoading: true, + error: null, + embedded: false, + }, +}; + +export const WithError: Story = { + args: { + simDetails: baseSimDetails, + isLoading: false, + error: "Failed to load SIM details. Please try again.", + embedded: false, + }, +}; + +export const Embedded: Story = { + args: { + simDetails: baseSimDetails, + isLoading: false, + error: null, + embedded: true, + showFeaturesSummary: true, + }, +}; + +export const DataOnlyPlan: Story = { + args: { + simDetails: { + ...baseSimDetails, + hasVoice: false, + hasSms: false, + voiceMailEnabled: false, + callWaitingEnabled: false, + }, + isLoading: false, + error: null, + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/SimFeatureToggles.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/SimFeatureToggles.stories.tsx new file mode 100644 index 00000000..3cb76f84 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/SimFeatureToggles.stories.tsx @@ -0,0 +1,253 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import { motion } from "framer-motion"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of SimFeatureToggles + * without importing the real component, which relies on apiClient for + * submitting feature changes. + * -------------------------------------------------------------------------*/ + +const TOGGLE_BASE = + "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"; +const TOGGLE_ACTIVE = "bg-primary"; +const TOGGLE_INACTIVE = "bg-muted"; +const TOGGLE_KNOB_BASE = + "pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow ring-0"; + +function FeatureToggleRow({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: () => void; +}) { + return ( +
+
+
{label}
+
{description}
+
+ +
+ ); +} + +function NetworkTypeSelector({ + nt, + setNt, +}: { + nt: "4G" | "5G"; + setNt: (value: "4G" | "5G") => void; +}) { + const options: Array<"4G" | "5G"> = ["4G", "5G"]; + + return ( +
+
+
Network Type
+
Choose your preferred connectivity
+
+ Voice, network, and plan changes must be requested at least 30 minutes apart. If you just + changed another option, you may need to wait before submitting. +
+
+
+ {options.map(value => ( + + ))} +
+

5G connectivity for enhanced speeds

+
+ ); +} + +interface SimFeatureTogglesPreviewProps { + subscriptionId: number; + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; + embedded?: boolean; + onApply?: () => void; + onReset?: () => void; +} + +function SimFeatureTogglesPreview({ + voiceMailEnabled = false, + callWaitingEnabled = false, + internationalRoamingEnabled = false, + networkType = "4G", + embedded = false, + onApply, + onReset, +}: SimFeatureTogglesPreviewProps) { + const [vm, setVm] = useState(!!voiceMailEnabled); + const [cw, setCw] = useState(!!callWaitingEnabled); + const [ir, setIr] = useState(!!internationalRoamingEnabled); + const [nt, setNt] = useState<"4G" | "5G">(networkType === "5G" ? "5G" : "4G"); + + const cardClasses = embedded + ? "" + : "bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)]"; + const contentClasses = embedded ? "" : "p-6"; + + const containerClasses = embedded + ? "" + : "bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"; + + return ( +
+
+
+ setVm(!vm)} + /> + setCw(!cw)} + /> + setIr(!ir)} + /> + +
+
+
+ +
    +
  • Changes take effect approximately 30 minutes after submission.
  • +
  • You may need to restart your device after changes are applied.
  • +
  • + + Voice, network, and plan changes must be requested at least 30 minutes apart. + +
  • +
  • + Voice Mail / Call Waiting changes must be requested before the 25th of the month. +
  • +
+
+
+ + +
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/Sim/SimFeatureToggles", + component: SimFeatureTogglesPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + subscriptionId: 123, + voiceMailEnabled: true, + callWaitingEnabled: false, + internationalRoamingEnabled: true, + networkType: "4G", + embedded: false, + onApply: fn(), + onReset: fn(), + }, +}; + +export const AllEnabled: Story = { + args: { + subscriptionId: 123, + voiceMailEnabled: true, + callWaitingEnabled: true, + internationalRoamingEnabled: true, + networkType: "5G", + embedded: false, + onApply: fn(), + onReset: fn(), + }, +}; + +export const AllDisabled: Story = { + args: { + subscriptionId: 123, + voiceMailEnabled: false, + callWaitingEnabled: false, + internationalRoamingEnabled: false, + networkType: "4G", + embedded: false, + onApply: fn(), + onReset: fn(), + }, +}; + +export const Embedded: Story = { + args: { + subscriptionId: 123, + voiceMailEnabled: true, + callWaitingEnabled: true, + internationalRoamingEnabled: false, + networkType: "5G", + embedded: true, + onApply: fn(), + onReset: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/sim/TopUpModal.stories.tsx b/apps/portal/src/features/subscriptions/components/sim/TopUpModal.stories.tsx new file mode 100644 index 00000000..1260a148 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/sim/TopUpModal.stories.tsx @@ -0,0 +1,164 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { useState } from "react"; +import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; + +/* --------------------------------------------------------------------------- + * Preview wrapper — reproduces the visual output of TopUpModal without + * importing the real component, which relies on useSimTopUpPricing hook + * and apiClient for API calls. + * -------------------------------------------------------------------------*/ + +const STATIC_PRICING = { + pricePerGbJpy: 500, + minQuotaMb: 1000, + maxQuotaMb: 50000, +}; + +interface TopUpModalPreviewProps { + subscriptionId: number; + onClose: () => void; + onSuccess: () => void; + onError: (message: string) => void; +} + +function TopUpModalPreview({ onClose }: TopUpModalPreviewProps) { + const [gbAmount, setGbAmount] = useState("1"); + const pricePerGb = STATIC_PRICING.pricePerGbJpy; + const minGb = Math.ceil(STATIC_PRICING.minQuotaMb / 1000); + const maxGb = Math.floor(STATIC_PRICING.maxQuotaMb / 1000); + + const getCurrentAmountMb = () => { + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * 1000; + }; + + const isValidAmount = () => { + const gb = Number(gbAmount); + return Number.isInteger(gb) && gb >= minGb && gb <= maxGb; + }; + + const calculateCost = () => { + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * pricePerGb; + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + return ( +
+
+
+
+
+
+
+
+ +
+
+

Top Up Data

+

Add data quota to your SIM service

+
+
+ +
+
e.preventDefault()}> +
+ +
+ setGbAmount(e.target.value)} + placeholder="Enter amount in GB" + min={minGb} + max={maxGb} + step="1" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12" + /> +
+ GB +
+
+

+ Enter the amount of data you want to add ({minGb} - {maxGb} GB, whole numbers) +

+
+
+
+
+
+ {gbAmount && !Number.isNaN(Number.parseInt(gbAmount, 10)) + ? `${gbAmount} GB` + : "0 GB"} +
+
= {getCurrentAmountMb()} MB
+
+
+
+ \u00a5{calculateCost().toLocaleString()} +
+
+ (1GB = \u00a5{pricePerGb.toLocaleString()}) +
+
+
+
+ {!isValidAmount() && gbAmount && ( +
+
+ +

+ Amount must be a whole number between {minGb} GB and {maxGb} GB +

+
+
+ )} +
+ + +
+
+
+
+
+
+ ); +} + +const meta: Meta = { + title: "Features/Subscriptions/Sim/TopUpModal", + component: TopUpModalPreview, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + subscriptionId: 123, + onClose: fn(), + onSuccess: fn(), + onError: fn(), + }, +}; diff --git a/apps/portal/src/features/subscriptions/components/skeletons/subscription-detail-stats-skeleton.stories.tsx b/apps/portal/src/features/subscriptions/components/skeletons/subscription-detail-stats-skeleton.stories.tsx new file mode 100644 index 00000000..17febdb1 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/skeletons/subscription-detail-stats-skeleton.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SubscriptionDetailStatsSkeleton } from "./subscription-detail-stats-skeleton"; + +const meta: Meta = { + title: "Features/Subscriptions/Skeletons/SubscriptionDetailStatsSkeleton", + component: SubscriptionDetailStatsSkeleton, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/subscriptions/components/skeletons/subscription-stats-cards-skeleton.stories.tsx b/apps/portal/src/features/subscriptions/components/skeletons/subscription-stats-cards-skeleton.stories.tsx new file mode 100644 index 00000000..b0eb4296 --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/skeletons/subscription-stats-cards-skeleton.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SubscriptionStatsCardsSkeleton } from "./subscription-stats-cards-skeleton"; + +const meta: Meta = { + title: "Features/Subscriptions/Skeletons/SubscriptionStatsCardsSkeleton", + component: SubscriptionStatsCardsSkeleton, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/portal/src/features/subscriptions/components/skeletons/subscription-table-skeleton.stories.tsx b/apps/portal/src/features/subscriptions/components/skeletons/subscription-table-skeleton.stories.tsx new file mode 100644 index 00000000..5fc25bcd --- /dev/null +++ b/apps/portal/src/features/subscriptions/components/skeletons/subscription-table-skeleton.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { SubscriptionTableSkeleton } from "./subscription-table-skeleton"; + +const meta: Meta = { + title: "Features/Subscriptions/Skeletons/SubscriptionTableSkeleton", + component: SubscriptionTableSkeleton, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + rows: 6, + }, +}; + +export const FewRows: Story = { + args: { + rows: 3, + }, +};