feat: for improved UI consistency

This commit is contained in:
barsa 2026-01-15 11:30:29 +09:00
parent 0f6bae840f
commit 2db3d9ec5d
19 changed files with 693 additions and 0 deletions

View File

@ -0,0 +1,42 @@
"use client";
import { cn } from "@/shared/utils";
interface AnimatedContainerProps {
children: React.ReactNode;
className?: string;
/** Animation type */
animation?: "fade-up" | "fade-scale" | "slide-left" | "none";
/** Whether to stagger children animations */
stagger?: boolean;
/** Delay before animation starts in ms */
delay?: number;
}
/**
* Reusable animation wrapper component
* Provides consistent entrance animations for page content
*/
export function AnimatedContainer({
children,
className,
animation = "fade-up",
stagger = false,
delay = 0,
}: AnimatedContainerProps) {
const animationClass = {
"fade-up": "cp-animate-in",
"fade-scale": "cp-animate-scale-in",
"slide-left": "cp-animate-slide-left",
none: "",
}[animation];
return (
<div
className={cn(animationClass, stagger && "cp-stagger-children", className)}
style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined}
>
{children}
</div>
);
}

View File

@ -0,0 +1,41 @@
import { Spinner } from "./spinner";
interface LoadingOverlayProps {
/** Whether the overlay is visible */
isVisible: boolean;
/** Main loading message */
title: string;
/** Optional subtitle/description */
subtitle?: string;
/** Spinner size */
spinnerSize?: "xs" | "sm" | "md" | "lg" | "xl";
/** Custom spinner color */
spinnerClassName?: string;
/** Custom overlay background */
overlayClassName?: string;
}
export function LoadingOverlay({
isVisible,
title,
subtitle,
spinnerSize = "xl",
spinnerClassName = "text-primary",
overlayClassName = "bg-background/80 backdrop-blur-sm",
}: LoadingOverlayProps) {
if (!isVisible) {
return null;
}
return (
<div className={`fixed inset-0 z-50 flex items-center justify-center ${overlayClassName}`}>
<div className="text-center max-w-sm mx-auto px-4">
<div className="flex justify-center mb-6">
<Spinner size={spinnerSize} className={spinnerClassName} />
</div>
<p className="text-lg font-medium text-foreground">{title}</p>
{subtitle && <p className="text-sm text-muted-foreground mt-2">{subtitle}</p>}
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { cn } from "@/shared/utils";
interface SkeletonProps {
className?: string;
animate?: boolean;
}
/**
* Base skeleton atom for loading states.
* A simple shimmer box primitive that can be composed into loading patterns.
*
* For composed loading skeletons, use:
* - LoadingCard, LoadingTable, LoadingStats from molecules/LoadingSkeletons
* - Feature-specific skeletons from features/[feature]/components/skeletons
*/
export function Skeleton({ className, animate = true }: SkeletonProps) {
return (
<div className={cn("rounded-md", animate ? "cp-skeleton-shimmer" : "bg-muted", className)} />
);
}

View File

@ -0,0 +1,31 @@
import { cn } from "@/shared/utils";
interface SpinnerProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
}
const sizeClasses = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
xl: "h-10 w-10",
};
export function Spinner({ size = "sm", className }: SpinnerProps) {
return (
<svg
className={cn("animate-spin text-current", sizeClasses[size], className)}
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -0,0 +1,8 @@
/**
* Loading Skeleton Molecules
* Generic, reusable loading skeleton components for common UI patterns.
*/
export { LoadingCard } from "./loading-card";
export { LoadingTable } from "./loading-table";
export { LoadingStats } from "./loading-stats";

View File

@ -0,0 +1,36 @@
import { cn } from "@/shared/utils";
import { Skeleton } from "@/components/atoms";
interface LoadingCardProps {
className?: string;
}
/**
* Generic loading skeleton for card-like content.
* Shows a header with avatar and two lines, plus body text lines.
*/
export function LoadingCard({ className }: LoadingCardProps) {
return (
<div
className={cn(
"bg-card text-card-foreground border border-border rounded-2xl p-6 shadow-md",
className
)}
>
<div className="space-y-4">
<div className="flex items-center space-x-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
<Skeleton className="h-3 w-3/5" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Skeleton } from "@/components/atoms";
interface LoadingStatsProps {
/** Number of stat cards to display */
count?: number;
}
/**
* Generic loading skeleton for stats/metrics cards.
* Shows a grid of stat cards with icon and two value lines.
*/
export function LoadingStats({ count = 4 }: LoadingStatsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-card text-card-foreground border border-border rounded-2xl p-6 shadow-md"
>
<div className="flex items-center">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="ml-4 space-y-2 flex-1">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-12" />
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,40 @@
import { Skeleton } from "@/components/atoms";
interface LoadingTableProps {
/** Number of rows to display */
rows?: number;
/** Number of columns to display */
columns?: number;
}
/**
* Generic loading skeleton for table-like content.
* Shows a header row and configurable number of body rows.
*/
export function LoadingTable({ rows = 5, columns = 4 }: LoadingTableProps) {
return (
<div className="bg-card text-card-foreground border border-border rounded-2xl overflow-hidden">
{/* Header */}
<div className="border-b border-border p-4">
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className="h-4 w-20" />
))}
</div>
</div>
{/* Rows */}
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="p-4">
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton key={colIndex} className="h-4 w-full" />
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
/**
* StatusBadge Molecule
*
* A configurable status display component that wraps StatusPill
* with domain-specific configuration maps.
*/
export { StatusBadge } from "./status-badge";
export type { StatusBadgeProps, StatusConfig, StatusConfigMap } from "./types";

View File

@ -0,0 +1,62 @@
"use client";
import { forwardRef } from "react";
import { StatusPill } from "@/components/atoms/status-pill";
import type { StatusBadgeProps, StatusConfig } from "./types";
const DEFAULT_CONFIG: StatusConfig = {
variant: "neutral",
};
/**
* StatusBadge - A configurable status display component.
*
* Wraps the StatusPill atom with a configuration-driven approach,
* allowing domain-specific status mappings to be passed in.
*
* @example
* ```tsx
* // Simple usage with inline config
* <StatusBadge
* status="active"
* configMap={{
* active: { variant: "success", label: "Active" },
* inactive: { variant: "neutral", label: "Inactive" },
* }}
* />
*
* // Or create a domain-specific wrapper
* const ORDER_STATUS_CONFIG = {
* pending: { variant: "warning", icon: <ClockIcon />, label: "Pending" },
* completed: { variant: "success", icon: <CheckIcon />, label: "Completed" },
* };
*
* function OrderStatusBadge({ status }) {
* return <StatusBadge status={status} configMap={ORDER_STATUS_CONFIG} />;
* }
* ```
*/
export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ status, configMap, showIcon = true, defaultConfig = DEFAULT_CONFIG, ...props }, ref) => {
// Normalize status to lowercase for config lookup
const normalizedStatus = status.toLowerCase();
// Look up config, fall back to default
const config = configMap?.[normalizedStatus as keyof typeof configMap] ?? defaultConfig;
// Determine the label (config label, or original status)
const label = config.label ?? status;
return (
<StatusPill
ref={ref}
variant={config.variant ?? "neutral"}
icon={showIcon ? config.icon : undefined}
label={label}
{...props}
/>
);
}
);
StatusBadge.displayName = "StatusBadge";

View File

@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import type { StatusPillProps } from "@/components/atoms/status-pill";
/**
* Status configuration for a single status value.
* Maps a status string to its visual representation.
*/
export interface StatusConfig {
/** The visual variant/color for this status */
variant: StatusPillProps["variant"];
/** Optional icon to display */
icon?: ReactNode;
/** Display label (defaults to the status key if not provided) */
label?: string;
}
/**
* A map of status values to their configurations.
* Keys should be lowercase for case-insensitive matching.
*/
export type StatusConfigMap<T extends string = string> = Partial<
Record<Lowercase<T>, StatusConfig>
>;
/**
* Props for the StatusBadge molecule.
*/
export interface StatusBadgeProps<T extends string = string> extends Omit<
StatusPillProps,
"variant" | "icon" | "label"
> {
/** The status value to display */
status: T;
/** Configuration map for status values */
configMap?: StatusConfigMap<T>;
/** Whether to show the icon (default: true) */
showIcon?: boolean;
/** Default config for unknown statuses */
defaultConfig?: StatusConfig;
}

View File

@ -0,0 +1,6 @@
/**
* Billing Loading Skeletons
* Feature-specific skeletons for consistent loading states.
*/
export { InvoiceListSkeleton } from "./invoice-list-skeleton";

View File

@ -0,0 +1,31 @@
import { Skeleton } from "@/components/atoms";
interface InvoiceListSkeletonProps {
/** Number of rows to show */
rows?: number;
}
/**
* List skeleton for invoices/billing.
* Shows a heading and list items with left content and right price.
*/
export function InvoiceListSkeleton({ rows = 5 }: InvoiceListSkeletonProps) {
return (
<div className="space-y-4">
<Skeleton className="h-7 w-32" />
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="p-4 flex justify-between items-center">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid";
/**
* AccountServicesOverview - Authenticated user's services landing page.
*
* Shows available services for the logged-in user with a header
* and services grid linking to account-specific service pages.
*/
export function AccountServicesOverview() {
return (
<div className="py-8">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Our Services
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
From high-speed internet to onsite support, we provide comprehensive solutions for your
home and business.
</p>
</div>
<ServicesGrid basePath="/account/services" />
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
import Link from "next/link";
import {
Wifi,
Smartphone,
ShieldCheck,
ArrowRight,
Phone,
CheckCircle2,
Globe,
Headphones,
Building2,
Wrench,
Tv,
} from "lucide-react";
import { ServiceCard } from "@/components/molecules/ServiceCard";
/**
* PublicServicesOverview - Public-facing services landing page.
*
* Shows all available services with hero section, value propositions,
* service cards grid, and contact CTA.
*/
export function PublicServicesOverview() {
return (
<div className="space-y-12 pb-16">
{/* Hero */}
<section className="text-center pt-8">
<div
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "0ms" }}
>
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium mb-6">
<CheckCircle2 className="h-4 w-4" />
Full English Support
</span>
</div>
<h1
className="text-display-lg font-display font-bold text-foreground mb-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "100ms" }}
>
Our Services
</h1>
<p
className="text-lg text-muted-foreground max-w-xl mx-auto animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
Connectivity and support solutions for Japan&apos;s international community.
</p>
</section>
{/* Value Props - Compact */}
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" />
<span>English support</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span>
</div>
</section>
{/* All Services - Clean Grid with staggered animations */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
<ServiceCard
href="/services/internet"
icon={<Wifi className="h-6 w-6" />}
title="Internet"
description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation."
price="¥3,200/mo"
accentColor="blue"
/>
<ServiceCard
href="/services/sim"
icon={<Smartphone className="h-6 w-6" />}
title="SIM & eSIM"
description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation."
price="¥1,100/mo"
badge="1st month free"
accentColor="green"
/>
<ServiceCard
href="/services/vpn"
icon={<ShieldCheck className="h-6 w-6" />}
title="VPN Router"
description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play."
price="¥2,500/mo"
accentColor="purple"
/>
<ServiceCard
href="/services/business"
icon={<Building2 className="h-6 w-6" />}
title="Business"
description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs."
accentColor="orange"
/>
<ServiceCard
href="/services/onsite"
icon={<Wrench className="h-6 w-6" />}
title="Onsite Support"
description="Professional technicians visit your location for setup, troubleshooting, and maintenance."
accentColor="cyan"
/>
<ServiceCard
href="/services/tv"
icon={<Tv className="h-6 w-6" />}
title="TV"
description="Streaming TV packages with international channels. Watch content from home countries."
accentColor="pink"
/>
</section>
{/* CTA */}
<section
className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-xl font-bold text-foreground font-display mb-3">Need help choosing?</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Our bilingual team can help you find the right solution.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/contact"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
>
Contact Us
<ArrowRight className="h-4 w-4" />
</Link>
<a
href="tel:0120660470"
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Call us toll free at 0120-660-470"
>
<Phone className="h-4 w-4" aria-hidden="true" />
0120-660-470 (Toll Free)
</a>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,8 @@
/**
* Subscription Loading Skeletons
* Feature-specific skeletons for consistent loading states.
*/
export { SubscriptionStatsCardsSkeleton } from "./subscription-stats-cards-skeleton";
export { SubscriptionTableSkeleton } from "./subscription-table-skeleton";
export { SubscriptionDetailStatsSkeleton } from "./subscription-detail-stats-skeleton";

View File

@ -0,0 +1,30 @@
import { Skeleton } from "@/components/atoms";
/**
* Stats skeleton for the subscription detail page.
* Shows 4 columns: Status, Amount, Next Due, Registration.
*/
export function SubscriptionDetailStatsSkeleton() {
return (
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
<div className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<Skeleton className="h-3 w-24 mb-2" />
<Skeleton className="h-7 w-20 rounded-full" />
</div>
<div>
<Skeleton className="h-3 w-24 mb-2" />
<Skeleton className="h-8 w-28" />
</div>
<div>
<Skeleton className="h-3 w-24 mb-2" />
<Skeleton className="h-6 w-32" />
</div>
<div>
<Skeleton className="h-3 w-28 mb-2" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { Skeleton } from "@/components/atoms";
/**
* Stats cards skeleton for the subscriptions list page.
* Shows 3 stat cards with icon and value placeholders.
*/
export function SubscriptionStatsCardsSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border border-border p-5 shadow-sm">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-6 w-12" />
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,50 @@
import { Skeleton } from "@/components/atoms";
interface SubscriptionTableSkeletonProps {
/** Number of rows to show */
rows?: number;
}
/**
* Table skeleton for the subscriptions list.
* Shows 3 columns: Service, Amount, Next Due.
*/
export function SubscriptionTableSkeleton({ rows = 6 }: SubscriptionTableSkeletonProps) {
return (
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
</div>
<div className="w-full">
{/* Header skeleton */}
<div className="bg-muted/50 px-6 py-4 border-b border-border">
<div className="grid grid-cols-3 gap-6">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16 ml-auto" />
<Skeleton className="h-3 w-20" />
</div>
</div>
{/* Row skeletons */}
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="px-6 py-5">
<div className="grid grid-cols-3 gap-6 items-center">
<div className="flex items-center space-x-3">
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
<Skeleton className="h-4 w-48" />
</div>
<div className="text-right">
<Skeleton className="h-4 w-32 ml-auto" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-28" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}