feat: for improved UI consistency
This commit is contained in:
parent
0f6bae840f
commit
2db3d9ec5d
42
apps/portal/src/components/atoms/animated-container.tsx
Normal file
42
apps/portal/src/components/atoms/animated-container.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/portal/src/components/atoms/loading-overlay.tsx
Normal file
41
apps/portal/src/components/atoms/loading-overlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/portal/src/components/atoms/skeleton.tsx
Normal file
20
apps/portal/src/components/atoms/skeleton.tsx
Normal 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)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/portal/src/components/atoms/spinner.tsx
Normal file
31
apps/portal/src/components/atoms/spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
@ -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";
|
||||||
40
apps/portal/src/components/molecules/StatusBadge/types.ts
Normal file
40
apps/portal/src/components/molecules/StatusBadge/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Billing Loading Skeletons
|
||||||
|
* Feature-specific skeletons for consistent loading states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { InvoiceListSkeleton } from "./invoice-list-skeleton";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user