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