Refactor UI components and enhance styling consistency across the portal

- Updated various components to use consistent color tokens, improving visual coherence.
- Refactored layout components to utilize the new PublicShell for better structure.
- Enhanced error and status messaging styles for improved user feedback.
- Standardized button usage across forms and modals for a unified interaction experience.
- Introduced new UI design tokens and guidelines in documentation to support future development.
This commit is contained in:
barsa 2025-12-16 13:54:31 +09:00
parent 540c0ba10c
commit b99799c2fe
80 changed files with 3041 additions and 2762 deletions

View File

@ -1,7 +1,8 @@
import { Global, Module } from "@nestjs/common";
import { LoggerModule } from "nestjs-pino";
const prettyLogsEnabled = process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production";
const prettyLogsEnabled =
process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production";
@Global()
@Module({
@ -10,6 +11,20 @@ const prettyLogsEnabled = process.env.PRETTY_LOGS === "true" || process.env.NODE
pinoHttp: {
level: process.env.LOG_LEVEL || "info",
name: process.env.APP_NAME || "customer-portal-bff",
/**
* Reduce noise from pino-http auto logging:
* - successful requests => debug (hidden when LOG_LEVEL=info)
* - 4xx => warn
* - 5xx / errors => error
*
* This keeps production logs focused on actionable events while still
* allowing full request logging by setting LOG_LEVEL=debug.
*/
customLogLevel: (_req, res, err) => {
if (err || (res?.statusCode && res.statusCode >= 500)) return "error";
if (res?.statusCode && res.statusCode >= 400) return "warn";
return "debug";
},
autoLogging: {
ignore: req => {
const url = req.url || "";

View File

@ -1,15 +1,11 @@
/**
* Public Layout
*
* Layout for public-facing pages (landing, auth, etc.)
* Route groups in Next.js 15 require a layout file for standalone builds
*
* Shared shell for public-facing pages (landing, auth, etc.)
*/
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
import { PublicShell } from "@/components/templates";
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return <PublicShell>{children}</PublicShell>;
}

View File

@ -20,8 +20,8 @@ export function LoadingOverlay({
title,
subtitle,
spinnerSize = "xl",
spinnerClassName = "text-blue-600",
overlayClassName = "bg-white/80 backdrop-blur-sm",
spinnerClassName = "text-primary",
overlayClassName = "bg-background/80 backdrop-blur-sm",
}: LoadingOverlayProps) {
if (!isVisible) {
return null;
@ -33,8 +33,8 @@ export function LoadingOverlay({
<div className="flex justify-center mb-6">
<Spinner size={spinnerSize} className={spinnerClassName} />
</div>
<p className="text-lg font-medium text-gray-900">{title}</p>
{subtitle && <p className="text-sm text-gray-600 mt-2">{subtitle}</p>}
<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

@ -7,14 +7,14 @@ const badgeVariants = cva(
{
variants: {
variant: {
default: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
success: "bg-green-100 text-green-800 hover:bg-green-200",
warning: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
error: "bg-red-100 text-red-800 hover:bg-red-200",
info: "bg-blue-100 text-blue-800 hover:bg-blue-200",
outline: "border border-gray-300 bg-white text-gray-900 hover:bg-gray-50",
ghost: "text-gray-900 hover:bg-gray-100",
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
success: "bg-success-soft text-success hover:bg-success-soft/80",
warning: "bg-warning-soft text-foreground hover:bg-warning-soft/80",
error: "bg-destructive-soft text-destructive hover:bg-destructive-soft/80",
info: "bg-info-soft text-info hover:bg-info-soft/80",
outline: "border border-border bg-background text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
},
size: {
sm: "px-2 py-0.5 text-xs rounded",
@ -30,8 +30,7 @@ const badgeVariants = cva(
);
interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
icon?: React.ReactNode;
dot?: boolean;
removable?: boolean;
@ -46,14 +45,14 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
<span
className={cn(
"mr-1.5 h-1.5 w-1.5 rounded-full",
variant === "success" && "bg-green-600",
variant === "warning" && "bg-yellow-600",
variant === "error" && "bg-red-600",
variant === "info" && "bg-blue-600",
variant === "default" && "bg-white",
variant === "secondary" && "bg-gray-600",
variant === "outline" && "bg-gray-400",
variant === "ghost" && "bg-gray-600"
variant === "success" && "bg-success",
variant === "warning" && "bg-warning",
variant === "error" && "bg-destructive",
variant === "info" && "bg-info",
variant === "default" && "bg-primary-foreground",
variant === "secondary" && "bg-secondary-foreground",
variant === "outline" && "bg-muted-foreground",
variant === "ghost" && "bg-muted-foreground"
)}
/>
)}
@ -64,9 +63,9 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
type="button"
onClick={onRemove}
className={cn(
"ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full hover:bg-black/10 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/20",
variant === "default" && "text-white hover:bg-white/20",
variant === "secondary" && "text-gray-600 hover:bg-gray-300",
"ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full hover:bg-black/10 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring/20",
variant === "default" && "text-primary-foreground hover:bg-primary-foreground/10",
variant === "secondary" && "text-secondary-foreground hover:bg-black/10",
(variant === "success" ||
variant === "warning" ||
variant === "error" ||

View File

@ -5,17 +5,20 @@ import { cn } from "@/lib/utils";
import { Spinner } from "./Spinner";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background active:scale-[0.98]",
"group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98]",
{
variants: {
variant: {
default: "bg-blue-600 text-white hover:bg-blue-700 shadow-sm hover:shadow-md",
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm hover:shadow-md",
default:
"bg-primary text-primary-foreground hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
outline:
"border border-gray-300 bg-white hover:bg-gray-50 hover:border-gray-400 shadow-sm hover:shadow-md",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 shadow-sm hover:shadow-md",
ghost: "hover:bg-gray-100 hover:shadow-sm",
link: "underline-offset-4 hover:underline text-blue-600",
"border border-border bg-background text-foreground hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
ghost: "text-foreground hover:bg-muted",
link: "underline-offset-4 hover:underline text-primary",
},
size: {
default: "h-11 py-2.5 px-4",

View File

@ -24,8 +24,8 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
id={checkboxId}
ref={ref}
className={cn(
"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 focus:ring-2",
error && "border-red-500",
"h-4 w-4 rounded border-input text-primary focus:ring-ring focus:ring-2",
error && "border-destructive",
className
)}
{...props}
@ -34,16 +34,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
<label
htmlFor={checkboxId}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
error && "text-red-600"
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
error && "text-destructive"
)}
>
{label}
</label>
)}
</div>
{helperText && !error && <p className="text-xs text-gray-500">{helperText}</p>}
{error && <p className="text-xs text-red-600">{error}</p>}
{helperText && !error && <p className="text-xs text-muted-foreground">{helperText}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View File

@ -33,16 +33,20 @@ export function EmptyState({
className
)}
>
{icon && <div className={cn("text-gray-400 mb-4", isCompact ? "mb-3" : "mb-4")}>{icon}</div>}
{icon && (
<div className={cn("text-muted-foreground/70 mb-4", isCompact ? "mb-3" : "mb-4")}>
{icon}
</div>
)}
<h3 className={cn("font-semibold text-gray-900 mb-2", isCompact ? "text-base" : "text-lg")}>
<h3 className={cn("font-semibold text-foreground mb-2", isCompact ? "text-base" : "text-lg")}>
{title}
</h3>
{description && (
<p
className={cn(
"text-gray-500 mb-6 max-w-md",
"text-muted-foreground mb-6 max-w-md",
isCompact ? "text-sm mb-4" : "text-base mb-6"
)}
>

View File

@ -23,8 +23,8 @@ export function ErrorState({
const variantClasses = {
page: "min-h-[400px] py-12",
card: "bg-white border border-red-200 rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]",
inline: "bg-red-50 border border-red-200 rounded-md p-4",
card: "bg-card text-card-foreground border border-destructive/25 rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]",
inline: "bg-destructive-soft border border-destructive/25 rounded-md p-4",
};
const iconSizes = {
@ -47,21 +47,23 @@ export function ErrorState({
return (
<div className={cn(baseClasses, variantClasses[variant], className)}>
<div className={cn("text-red-500 mb-4", variant === "inline" && "flex-shrink-0")}>
<div className={cn("text-destructive mb-4", variant === "inline" && "flex-shrink-0")}>
<ExclamationTriangleIcon className={iconSizes[variant]} />
</div>
<div className={variant === "inline" ? "ml-3 flex-1" : ""}>
<h3 className={cn("font-semibold text-red-800 mb-2", titleSizes[variant])}>{title}</h3>
<h3 className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}>{title}</h3>
<p className={cn("text-red-700 mb-4 max-w-md", messageSizes[variant])}>{message}</p>
<p className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}>
{message}
</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size={variant === "inline" ? "sm" : "default"}
className="text-red-700 border-red-300 hover:bg-red-50"
className="text-destructive border-destructive/30 hover:bg-destructive-soft"
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
{retryLabel}

View File

@ -14,8 +14,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isInvalid && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isInvalid &&
"border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2",
className
)}
aria-invalid={isInvalid || undefined}

View File

@ -21,7 +21,7 @@ export function LoadingCard({ className }: { className?: string }) {
return (
<div
className={cn(
"bg-white border border-gray-200 rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]",
"bg-card text-card-foreground border border-border rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]",
className
)}
>
@ -45,9 +45,9 @@ export function LoadingCard({ className }: { className?: string }) {
export function LoadingTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="bg-white border border-gray-200 rounded-[var(--cp-card-radius)] overflow-hidden">
<div className="bg-card text-card-foreground border border-border rounded-[var(--cp-card-radius)] overflow-hidden">
{/* Header */}
<div className="border-b border-gray-200 p-4">
<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" />
@ -56,7 +56,7 @@ export function LoadingTable({ rows = 5, columns = 4 }: { rows?: number; columns
</div>
{/* Rows */}
<div className="divide-y divide-gray-200">
<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)` }}>
@ -77,7 +77,7 @@ export function LoadingStats({ count = 4 }: { count?: number }) {
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-white border border-gray-200 rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]"
className="bg-card text-card-foreground border border-border rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] shadow-[var(--cp-card-shadow)]"
>
<div className="flex items-center">
<Skeleton className="h-8 w-8 rounded-full" />

View File

@ -12,14 +12,14 @@ export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
const tone =
variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20"
? "bg-success-soft text-success ring-success/25"
: variant === "warning"
? "bg-amber-50 text-amber-700 ring-amber-600/20"
? "bg-warning-soft text-foreground ring-warning/25"
: variant === "info"
? "bg-blue-50 text-blue-700 ring-blue-600/20"
? "bg-info-soft text-info ring-info/25"
: variant === "error"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30";
? "bg-destructive-soft text-destructive ring-destructive/25"
: "bg-muted text-muted-foreground ring-border-muted/40";
const sizing =
size === "sm"

View File

@ -9,14 +9,14 @@ export function StepHeader({ stepNumber, title, description, className = "" }: S
return (
<div className={`flex items-center gap-4 ${className}`}>
<div className="relative flex items-center justify-center">
<span className="absolute inset-0 rounded-full bg-blue-100/40 blur-sm" aria-hidden />
<span className="relative inline-flex h-11 w-11 items-center justify-center rounded-full border border-blue-200 bg-white text-base font-semibold text-blue-600 shadow-sm">
<span className="absolute inset-0 rounded-full bg-primary/10 blur-sm" aria-hidden />
<span className="relative inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-base font-semibold text-primary shadow-[var(--cp-shadow-1)]">
{stepNumber}
</span>
</div>
<div>
<h3 className="text-2xl font-bold text-gray-900">{title}</h3>
<p className="text-gray-600 text-sm mt-0.5">{description}</p>
<h3 className="text-2xl font-bold text-foreground">{title}</h3>
<p className="text-muted-foreground text-sm mt-0.5">{description}</p>
</div>
</div>
);

View File

@ -13,30 +13,34 @@ type IconType = React.ComponentType<React.SVGProps<SVGSVGElement>>;
const variantClasses: Record<
Variant,
{ bg: string; border: string; text: string; Icon: IconType }
{ bg: string; border: string; text: string; icon: string; Icon: IconType }
> = {
success: {
bg: "bg-green-50",
border: "border-green-200",
text: "text-green-800",
bg: "bg-success-soft",
border: "border-success/30",
text: "text-success",
icon: "text-success",
Icon: CheckCircleIcon,
},
info: {
bg: "bg-blue-50",
border: "border-blue-200",
text: "text-blue-800",
bg: "bg-info-soft",
border: "border-info/30",
text: "text-info",
icon: "text-info",
Icon: InformationCircleIcon,
},
warning: {
bg: "bg-amber-50",
border: "border-amber-200",
text: "text-amber-800",
bg: "bg-warning-soft",
border: "border-warning/35",
text: "text-foreground",
icon: "text-warning",
Icon: ExclamationTriangleIcon,
},
error: {
bg: "bg-red-50",
border: "border-red-200",
text: "text-red-800",
bg: "bg-destructive-soft",
border: "border-destructive/30",
text: "text-destructive",
icon: "text-destructive",
Icon: XCircleIcon,
},
};
@ -79,17 +83,19 @@ export function AlertBanner({
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
{icon ? icon : <Icon className={["h-5 w-5", styles.text].join(" ")} />}
{icon ? icon : <Icon className={["h-5 w-5", styles.icon].join(" ")} />}
</div>
<div className="flex-1">
{title && <p className={["font-medium", styles.text].join(" ")}>{title}</p>}
{children && <div className={["text-sm mt-1", styles.text].join(" ")}>{children}</div>}
{children && (
<div className={["text-sm mt-1 text-foreground/80"].join(" ")}>{children}</div>
)}
</div>
{onClose && (
<button
onClick={onClose}
aria-label="Close alert"
className="text-gray-400 hover:text-gray-600"
className="text-muted-foreground hover:text-foreground transition-colors"
>
×
</button>

View File

@ -16,18 +16,16 @@ export function AnimatedCard({
disabled = false,
}: AnimatedCardProps) {
const baseClasses =
"bg-white rounded-xl border-2 shadow-sm transition-all duration-300 ease-in-out transform";
"bg-card text-card-foreground rounded-xl border shadow-[var(--cp-shadow-1)] transition-shadow duration-[var(--cp-duration-normal)]";
const variantClasses: Record<"default" | "highlighted" | "success" | "static", string> = {
default: "border-gray-200 hover:shadow-xl hover:-translate-y-1",
highlighted:
"border-blue-300 ring-2 ring-blue-100 shadow-md hover:shadow-xl hover:-translate-y-1",
success:
"border-green-300 ring-2 ring-green-100 shadow-md hover:shadow-xl hover:-translate-y-1",
static: "border-gray-200 shadow-sm", // No hover animations for static containers
default: "border-border hover:shadow-[var(--cp-shadow-2)]",
highlighted: "border-primary/35 ring-1 ring-primary/15 hover:shadow-[var(--cp-shadow-2)]",
success: "border-success/25 ring-1 ring-success/15 hover:shadow-[var(--cp-shadow-2)]",
static: "border-border shadow-[var(--cp-shadow-1)]", // No hover animations for static containers
};
const interactiveClasses = onClick && !disabled ? "cursor-pointer hover:scale-[1.02]" : "";
const interactiveClasses = onClick && !disabled ? "cursor-pointer" : "";
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";

View File

@ -25,7 +25,7 @@ export function AsyncBlock({
return (
<div className="py-8" aria-busy="true" aria-live="polite">
<div className="max-w-5xl mx-auto px-4 sm:px-6 md:px-8 space-y-6">
{loadingText ? <p className="text-sm text-gray-500">{loadingText}</p> : null}
{loadingText ? <p className="text-sm text-muted-foreground">{loadingText}</p> : null}
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2">
@ -35,7 +35,10 @@ export function AsyncBlock({
</div>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-lg p-4">
<div
key={i}
className="bg-card border border-border rounded-lg p-4 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-3 w-3/4" />
</div>
@ -47,7 +50,7 @@ export function AsyncBlock({
}
return (
<div className="space-y-3" aria-busy="true" aria-live="polite">
{loadingText ? <p className="text-sm text-gray-500">{loadingText}</p> : null}
{loadingText ? <p className="text-sm text-muted-foreground">{loadingText}</p> : null}
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-3/5" />
<Skeleton className="h-3 w-2/5" />

View File

@ -55,7 +55,7 @@ export function DataTable<T extends { id: number | string }>({
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-border">
<tbody className="bg-card divide-y divide-border">
{data.map(item => (
<tr
key={item.id}

View File

@ -25,13 +25,13 @@ export function DetailHeader({
meta,
}: DetailHeaderProps) {
return (
<div className={`pb-4 border-b border-gray-200 ${className || ""}`}>
<div className={`pb-4 border-b border-border ${className || ""}`}>
<div className="flex items-center justify-between">
<div className="flex items-center">
{leftIcon}
<div className={leftIcon ? "ml-3" : undefined}>
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{status && <StatusPill label={status.label} variant={status.variant} />}

View File

@ -4,11 +4,10 @@ import { Label, type LabelProps } from "@/components/atoms/label";
import { Input, type InputProps } from "@/components/atoms/input";
import { ErrorMessage } from "@/components/atoms/error-message";
interface FormFieldProps
extends Omit<
InputProps,
"id" | "aria-describedby" | "aria-invalid" | "children" | "dangerouslySetInnerHTML"
> {
interface FormFieldProps extends Omit<
InputProps,
"id" | "aria-describedby" | "aria-invalid" | "children" | "dangerouslySetInnerHTML"
> {
label?: string;
error?: string;
helperText?: string;
@ -49,8 +48,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
<Label
htmlFor={id}
className={cn(
"block text-sm font-medium text-gray-700",
error && "text-red-600",
"block text-sm font-medium text-muted-foreground",
error && "text-destructive",
labelProps?.className
)}
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
@ -80,7 +79,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
aria-invalid={error ? "true" : undefined}
aria-describedby={cn(errorId, helperTextId) || undefined}
className={cn(
error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
error &&
"border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2",
inputClassName,
inputPropsClassName
)}
@ -89,7 +89,7 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
)}
{error && <ErrorMessage id={errorId}>{error}</ErrorMessage>}
{helperText && !error && (
<p id={helperTextId} className="text-sm text-gray-500">
<p id={helperTextId} className="text-sm text-muted-foreground">
{helperText}
</p>
)}

View File

@ -27,21 +27,21 @@ export function PaginationBar({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="ml-3 relative inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-background hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "}
<span className="font-medium">{Math.min(currentPage * pageSize, totalItems)}</span> of{" "}
<span className="font-medium">{totalItems}</span> results
@ -55,14 +55,14 @@ export function PaginationBar({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={!canPrev}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={!canNext}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-border bg-background text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>

View File

@ -15,7 +15,7 @@ interface ProgressStepsProps {
export function ProgressSteps({ steps, currentStep, className = "" }: ProgressStepsProps) {
return (
<div className={`mb-8 ${className}`}>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<div className="bg-card text-card-foreground p-6 rounded-xl shadow-[var(--cp-shadow-1)] border border-border">
<div className="overflow-x-auto">
<div className="flex items-center justify-center space-x-3 md:space-x-4 min-w-max px-2">
{steps.map((step, index) => (
@ -24,10 +24,10 @@ export function ProgressSteps({ steps, currentStep, className = "" }: ProgressSt
<div
className={`relative flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full border-2 transition-all duration-200 ease-out ${
step.completed
? "bg-green-500 border-green-500 text-white"
? "bg-success border-success text-success-foreground"
: currentStep === step.number
? "border-blue-500 text-blue-600 bg-blue-50"
: "border-gray-300 text-gray-400 bg-white"
? "border-primary text-primary bg-accent"
: "border-border text-muted-foreground bg-card"
}`}
>
{step.completed ? (
@ -41,10 +41,10 @@ export function ProgressSteps({ steps, currentStep, className = "" }: ProgressSt
<span
className={`text-xs md:text-sm font-medium text-center transition-all duration-150 max-w-[80px] md:max-w-none ${
step.completed
? "text-green-600"
? "text-success"
: currentStep === step.number
? "text-blue-600"
: "text-gray-500"
? "text-primary"
: "text-muted-foreground"
}`}
>
{step.title}
@ -52,10 +52,10 @@ export function ProgressSteps({ steps, currentStep, className = "" }: ProgressSt
</div>
{index < steps.length - 1 && (
<div className="relative w-10 md:w-16 h-[2px] mx-2 md:mx-4 flex-shrink-0">
<div className="absolute inset-0 bg-gray-200 rounded-full" />
<div className="absolute inset-0 bg-border rounded-full" />
<div
className={`absolute inset-0 rounded-full transition-all duration-300 ease-out ${
step.completed ? "bg-green-500" : "bg-gray-200"
step.completed ? "bg-success" : "bg-border"
}`}
style={{
transform: step.completed ? "scaleX(1)" : "scaleX(0)",

View File

@ -28,17 +28,17 @@ export function SearchFilterBar({
children,
}: SearchFilterBarProps) {
return (
<div className="bg-white shadow rounded-lg mb-6">
<div className="p-6 border-b border-gray-200">
<div className="bg-card text-card-foreground border border-border shadow-[var(--cp-shadow-1)] rounded-lg mb-6">
<div className="p-6 border-b border-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Search */}
<div className="relative max-w-xs">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground/70" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
className="block w-full pl-10 pr-3 py-2 border border-input rounded-md leading-5 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder={searchPlaceholder}
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
@ -53,7 +53,7 @@ export function SearchFilterBar({
<select
value={filterValue}
onChange={e => onFilterChange(e.target.value)}
className="block w-40 pl-3 pr-8 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md appearance-none bg-white"
className="block w-40 pl-3 pr-8 py-2 text-base border border-input focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring sm:text-sm rounded-md appearance-none bg-background text-foreground transition-colors"
aria-label={filterLabel}
>
{filterOptions.map(option => (
@ -63,7 +63,7 @@ export function SearchFilterBar({
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-gray-400" />
<FunnelIcon className="h-4 w-4 text-muted-foreground/70" />
</div>
</div>
)}

View File

@ -11,7 +11,7 @@ interface SectionHeaderProps {
export function SectionHeader({ title, children, className }: SectionHeaderProps) {
return (
<div className={["flex items-center justify-between", className].filter(Boolean).join(" ")}>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{children}
</div>
);

View File

@ -29,7 +29,7 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
) => (
<div
ref={ref}
className={`border-[var(--cp-card-border)] bg-white shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] transition-shadow duration-[var(--cp-transition-normal)] hover:shadow-[var(--cp-card-shadow-lg)] ${className}`}
className={`border-[var(--cp-card-border)] bg-card text-card-foreground shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] transition-shadow duration-[var(--cp-duration-normal)] hover:shadow-[var(--cp-card-shadow-lg)] ${className}`}
>
{header ? (
<div className={`${headerClassName || "mb-[var(--cp-space-lg)]"}`}>{header}</div>

View File

@ -2,6 +2,7 @@
import { Component, ReactNode, ErrorInfo } from "react";
import { log } from "@/lib/logger";
import { Button } from "@/components/atoms/button";
interface ErrorBoundaryState {
hasError: boolean;
@ -51,16 +52,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-4">
<h2 className="text-lg font-semibold text-red-600">Something went wrong</h2>
<p className="text-gray-600">
{this.state.error?.message || "An unexpected error occurred"}
<h2 className="text-lg font-semibold text-destructive">Something went wrong</h2>
<p className="text-muted-foreground">
{process.env.NODE_ENV === "development"
? this.state.error?.message || "An unexpected error occurred"
: "An unexpected error occurred. Please try again."}
</p>
<button
onClick={() => this.setState({ hasError: false, error: undefined })}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
<Button onClick={() => this.setState({ hasError: false, error: undefined })}>
Try again
</button>
</Button>
</div>
</div>
);

View File

@ -59,7 +59,9 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget
useEffect(() => {
// Skip if not configured
if (!scriptUrl || !orgId || !deploymentId || !baseSiteUrl || !scrt2Url) {
setError("Agentforce widget is not configured. Please set the required environment variables.");
setError(
"Agentforce widget is not configured. Please set the required environment variables."
);
return;
}
@ -77,12 +79,9 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget
try {
if (window.embeddedservice_bootstrap) {
window.embeddedservice_bootstrap.settings.language = "en";
window.embeddedservice_bootstrap.init(
orgId,
deploymentId,
baseSiteUrl,
{ scrt2URL: scrt2Url }
);
window.embeddedservice_bootstrap.init(orgId, deploymentId, baseSiteUrl, {
scrt2URL: scrt2Url,
});
setIsLoaded(true);
}
} catch (err) {
@ -121,7 +120,7 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-primary text-primary-foreground shadow-[var(--cp-shadow-3)] hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background transition-colors"
aria-label={isOpen ? "Close chat" : "Open chat"}
>
{isOpen ? (
@ -132,13 +131,13 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget
</button>
{isOpen && (
<div className="absolute bottom-16 right-0 w-80 bg-white rounded-lg shadow-xl border border-gray-200">
<div className="p-4 border-b border-gray-200 bg-blue-600 rounded-t-lg">
<h3 className="text-lg font-medium text-white">AI Assistant</h3>
<p className="text-sm text-blue-100">Loading...</p>
<div className="absolute bottom-16 right-0 w-80 bg-card text-card-foreground rounded-lg shadow-[var(--cp-shadow-3)] border border-border">
<div className="p-4 border-b border-border bg-primary rounded-t-lg">
<h3 className="text-lg font-medium text-primary-foreground">AI Assistant</h3>
<p className="text-sm text-primary-foreground/80">Loading...</p>
</div>
<div className="p-4 flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</div>
)}
@ -149,4 +148,3 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget
// Once loaded, Salesforce handles the UI
return null;
}

View File

@ -22,7 +22,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<div className="flex items-center h-16 gap-3 px-4 sm:px-6">
<button
type="button"
className="md:hidden p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-primary/20"
onClick={onMenuClick}
aria-label="Open navigation"
>
@ -36,7 +36,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
href="/support"
prefetch
aria-label="Help"
className="hidden sm:inline-flex p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
className="hidden sm:inline-flex p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Support Center"
>
<QuestionMarkCircleIcon className="h-5 w-5" />
@ -45,7 +45,7 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<Link
href="/account"
prefetch
className="hidden sm:inline-flex items-center px-2.5 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
className="hidden sm:inline-flex items-center px-2.5 py-1.5 text-sm font-medium text-foreground/80 hover:text-foreground hover:bg-muted rounded-lg transition-colors duration-[var(--cp-duration-normal)]"
>
{displayName}
</Link>

View File

@ -25,7 +25,7 @@ export const Sidebar = memo(function Sidebar({
<div className="flex flex-col h-0 flex-1 bg-[var(--cp-sidebar-bg)]">
<div className="flex items-center flex-shrink-0 h-16 px-6 border-b border-[var(--cp-sidebar-border)]">
<div className="flex items-center space-x-3">
<div className="p-2 bg-white rounded-xl border border-[var(--cp-sidebar-border)] shadow-sm">
<div className="p-2 bg-card rounded-xl border border-[var(--cp-sidebar-border)] shadow-[var(--cp-shadow-1)]">
<Logo size={20} />
</div>
<div>
@ -103,7 +103,7 @@ const NavigationItem = memo(function NavigationItem({
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive
? "bg-primary/10 text-primary"
: "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-gray-100"
: "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-[var(--cp-sidebar-hover-bg)]"
}`}
>
<item.icon className="h-5 w-5" />
@ -178,9 +178,9 @@ const NavigationItem = memo(function NavigationItem({
return (
<button
onClick={handleLogout}
className="group w-full flex items-center px-3 py-2.5 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-200"
className="group w-full flex items-center px-3 py-2.5 text-sm font-medium text-destructive hover:bg-destructive-soft rounded-lg transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-destructive/20"
>
<div className="p-1.5 rounded-md mr-3 text-red-500 group-hover:text-red-600 group-hover:bg-red-100 transition-colors duration-200">
<div className="p-1.5 rounded-md mr-3 text-destructive group-hover:bg-destructive-soft transition-colors duration-[var(--cp-duration-normal)]">
<item.icon className="h-5 w-5" />
</div>
<span>{item.name}</span>
@ -212,7 +212,7 @@ const NavigationItem = memo(function NavigationItem({
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive
? "bg-primary/10 text-primary"
: "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-gray-100"
: "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-[var(--cp-sidebar-hover-bg)]"
}`}
>
<item.icon className="h-5 w-5" />

View File

@ -22,13 +22,13 @@ export function AuthLayout({
backLabel = "Back to Home",
}: AuthLayoutProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="w-full flex flex-col items-center py-[var(--cp-space-3xl)]">
<div className="w-full max-w-md">
{showBackButton && (
<div className="mb-6">
<Link
href={backHref}
className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors duration-200"
className="inline-flex items-center text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
{backLabel}
@ -38,15 +38,19 @@ export function AuthLayout({
<div className="text-center">
<div className="flex justify-center mb-8">
<Logo size={72} className="text-blue-600" />
<Logo size={72} className="text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-gray-900 mb-3">{title}</h1>
{subtitle && <p className="text-base text-gray-600 leading-relaxed">{subtitle}</p>}
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground mb-3">
{title}
</h1>
{subtitle && (
<p className="text-base text-muted-foreground leading-relaxed">{subtitle}</p>
)}
</div>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-10 px-6 shadow-2xl rounded-3xl border border-gray-100 sm:px-12">
<div className="mt-8 w-full max-w-md">
<div className="bg-card text-card-foreground py-10 px-6 rounded-3xl border border-border shadow-[var(--cp-card-shadow-lg)] sm:px-12">
{children}
</div>
</div>

View File

@ -114,7 +114,10 @@ function PageLoadingState() {
</div>
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white border border-gray-200 rounded-lg p-4">
<div
key={i}
className="bg-card border border-border rounded-lg p-4 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-3 w-3/4" />
</div>

View File

@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Logo } from "@/components/atoms/logo";
export interface PublicShellProps {
children: ReactNode;
}
export function PublicShell({ children }: PublicShellProps) {
return (
<div className="min-h-screen flex flex-col bg-background text-foreground">
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur-sm">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-3 flex items-center justify-between gap-4">
<Link href="/" className="inline-flex items-center gap-3 min-w-0">
<span className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-border bg-card shadow-[var(--cp-shadow-1)]">
<Logo size={20} />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-tight truncate">
Assist Solutions
</span>
<span className="block text-xs text-muted-foreground leading-tight truncate">
Customer Portal
</span>
</span>
</Link>
<nav className="flex items-center gap-2">
<Link
href="/support"
className="hidden sm:inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
Support
</Link>
<Link
href="/auth/login"
className="inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover transition-colors"
>
Sign in
</Link>
</nav>
</div>
</header>
<main className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-[var(--cp-space-3xl)]">
{children}
</div>
</main>
<footer className="border-t border-border bg-background">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Assist Solutions
</div>
<div className="flex items-center gap-4 text-sm">
<Link href="/support" className="text-muted-foreground hover:text-foreground">
Support
</Link>
<Link href="#" className="text-muted-foreground hover:text-foreground">
Privacy
</Link>
<Link href="#" className="text-muted-foreground hover:text-foreground">
Terms
</Link>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -8,3 +8,6 @@ export type { AuthLayoutProps } from "./AuthLayout/AuthLayout";
export { PageLayout } from "./PageLayout/PageLayout";
export type { BreadcrumbItem } from "./PageLayout/PageLayout";
export { PublicShell } from "./PublicShell/PublicShell";
export type { PublicShellProps } from "./PublicShell/PublicShell";

View File

@ -3,6 +3,8 @@
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import type { UserProfile } from "@customer-portal/domain/customer";
import { Button } from "@/components/atoms/button";
import { Input } from "@/components/atoms/input";
interface PersonalInfoCardProps {
data: UserProfile;
@ -25,20 +27,21 @@ export function PersonalInfoCard({
}: PersonalInfoCardProps) {
return (
<SubCard>
<div className="pb-5 border-b border-gray-200">
<div className="pb-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserIcon className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
<UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Personal Information</h2>
</div>
{!isEditing && (
<button
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors"
leftIcon={<PencilIcon className="h-4 w-4" />}
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit
</button>
</Button>
)}
</div>
</div>
@ -46,72 +49,81 @@ export function PersonalInfoCard({
<div className="pt-5">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-900 font-medium">
{data.firstname || <span className="text-gray-500 italic">Not provided</span>}
<label className="block text-sm font-medium text-muted-foreground mb-2">
First Name
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-sm text-foreground font-medium">
{data.firstname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
<p className="text-xs text-gray-500 mt-2">Name cannot be changed from the portal.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-900 font-medium">
{data.lastname || <span className="text-gray-500 italic">Not provided</span>}
<label className="block text-sm font-medium text-muted-foreground mb-2">
Last Name
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-sm text-foreground font-medium">
{data.lastname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
<p className="text-xs text-gray-500 mt-2">Name cannot be changed from the portal.</p>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-3">Email Address</label>
<label className="block text-sm font-medium text-muted-foreground mb-3">
Email Address
</label>
{isEditing ? (
<input
<Input
type="email"
value={data.email}
onChange={e => onChange("email", e.target.value)}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="bg-muted rounded-lg p-4 border border-border">
<div className="flex items-center justify-between">
<p className="text-base text-gray-900 font-medium">{data.email}</p>
<p className="text-base text-foreground font-medium">{data.email}</p>
</div>
<p className="text-xs text-gray-500 mt-2">Email can be updated from the portal.</p>
<p className="text-xs text-muted-foreground mt-2">
Email can be updated from the portal.
</p>
</div>
)}
</div>
</div>
{isEditing && (
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
<button
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={isSaving}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
<XMarkIcon className="h-4 w-4 mr-1" />
Cancel
</button>
<button
</Button>
<Button
size="sm"
onClick={onSave}
disabled={isSaving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={!isSaving ? <CheckIcon className="h-4 w-4" /> : undefined}
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<CheckIcon className="h-4 w-4 mr-1" />
Save Changes
</>
)}
</button>
Save Changes
</Button>
</div>
)}
</div>

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useCallback, useEffect, useState } from "react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import {
@ -16,6 +16,7 @@ import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
import { AddressForm } from "@/features/catalog/components/base/AddressForm";
import { Button } from "@/components/atoms/button";
import { useAddressEdit } from "@/features/account/hooks/useAddressEdit";
import { PageLayout } from "@/components/templates";
export default function ProfileContainer() {
const { user } = useAuthStore();
@ -23,7 +24,7 @@ export default function ProfileContainer() {
const [error, setError] = useState<string | null>(null);
const [editingProfile, setEditingProfile] = useState(false);
const [editingAddress, setEditingAddress] = useState(false);
const hasLoadedRef = useRef(false);
const [reloadKey, setReloadKey] = useState(0);
const profile = useProfileEdit({
email: user?.email || "",
@ -42,32 +43,35 @@ export default function ProfileContainer() {
phoneCountryCode: "",
});
useEffect(() => {
// Only load data once on mount
if (hasLoadedRef.current) return;
hasLoadedRef.current = true;
// Extract stable setValue functions to avoid infinite re-render loop.
// The hook objects (address, profile) are recreated every render, but
// the setValue callbacks inside them are stable (memoized with useCallback).
const setAddressValue = address.setValue;
const setProfileValue = profile.setValue;
const loadProfile = useCallback(() => {
void (async () => {
try {
setError(null);
setLoading(true);
const [addr, prof] = await Promise.all([
accountService.getAddress().catch(() => null),
accountService.getProfile().catch(() => null),
]);
if (addr) {
address.setValue("address1", addr.address1 ?? "");
address.setValue("address2", addr.address2 ?? "");
address.setValue("city", addr.city ?? "");
address.setValue("state", addr.state ?? "");
address.setValue("postcode", addr.postcode ?? "");
address.setValue("country", addr.country ?? "");
address.setValue("countryCode", addr.countryCode ?? "");
address.setValue("phoneNumber", addr.phoneNumber ?? "");
address.setValue("phoneCountryCode", addr.phoneCountryCode ?? "");
setAddressValue("address1", addr.address1 ?? "");
setAddressValue("address2", addr.address2 ?? "");
setAddressValue("city", addr.city ?? "");
setAddressValue("state", addr.state ?? "");
setAddressValue("postcode", addr.postcode ?? "");
setAddressValue("country", addr.country ?? "");
setAddressValue("countryCode", addr.countryCode ?? "");
setAddressValue("phoneNumber", addr.phoneNumber ?? "");
setAddressValue("phoneCountryCode", addr.phoneCountryCode ?? "");
}
if (prof) {
profile.setValue("email", prof.email || "");
profile.setValue("phonenumber", prof.phonenumber || "");
setProfileValue("email", prof.email || "");
setProfileValue("phonenumber", prof.phonenumber || "");
useAuthStore.setState(state => ({
...state,
user: state.user
@ -80,26 +84,35 @@ export default function ProfileContainer() {
}));
}
} catch (e) {
// Keep message customer-safe (no internal details)
setError(e instanceof Error ? e.message : "Failed to load profile data");
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [setAddressValue, setProfileValue]);
useEffect(() => {
loadProfile();
}, [loadProfile, reloadKey]);
if (loading) {
return (
<div className="py-6">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8 space-y-8">
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<PageLayout
icon={<UserIcon />}
title="Profile"
description="Manage your account information"
loading
>
<div className="space-y-8">
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-6 w-6 bg-blue-200 rounded" />
<div className="h-6 w-40 bg-gray-200 rounded" />
<div className="h-6 w-6 bg-muted rounded" />
<div className="h-6 w-40 bg-muted rounded" />
</div>
<div className="h-8 w-20 bg-gray-200 rounded" />
<div className="h-8 w-20 bg-muted rounded" />
</div>
</div>
<div className="p-6">
@ -112,7 +125,7 @@ export default function ProfileContainer() {
))}
<div className="sm:col-span-2">
<Skeleton className="h-4 w-28 mb-3" />
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="bg-muted rounded-lg p-4 border border-border">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-5 w-24" />
@ -121,25 +134,25 @@ export default function ProfileContainer() {
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
</div>
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<div className="bg-card border border-border rounded-xl shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-6 w-6 bg-blue-200 rounded" />
<div className="h-6 w-48 bg-gray-200 rounded" />
<div className="h-6 w-6 bg-muted rounded" />
<div className="h-6 w-48 bg-muted rounded" />
</div>
<div className="h-8 w-20 bg-gray-200 rounded" />
<div className="h-8 w-20 bg-muted rounded" />
</div>
</div>
<div className="p-6">
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="bg-muted rounded-lg p-4 border border-border">
<div className="space-y-2">
<Skeleton className="h-4 w-60" />
<Skeleton className="h-4 w-48" />
@ -154,150 +167,251 @@ export default function ProfileContainer() {
</div>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<div className="py-6">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
{error && (
<AlertBanner variant="error" title="Error" className="mb-6">
{error}
</AlertBanner>
)}
<PageLayout
icon={<UserIcon />}
title="Profile"
description="Manage your account information"
error={error}
onRetry={() => setReloadKey(k => k + 1)}
>
{error && (
<AlertBanner variant="error" title="Unable to load profile" className="mb-6" elevated>
{error}
</AlertBanner>
)}
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserIcon className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<UserIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Personal Information</h2>
</div>
{!editingProfile && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
)}
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
First Name
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-base text-foreground font-medium">
{user?.firstname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
</div>
{!editingProfile && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Last Name
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-base text-foreground font-medium">
{user?.lastname || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-muted-foreground mb-2">
Email Address
</label>
{editingProfile ? (
<input
type="email"
value={profile.values.email}
onChange={e => profile.setValue("email", e.target.value)}
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
) : (
<div className="bg-muted rounded-lg p-4 border border-border">
<div className="flex items-center justify-between">
<p className="text-base text-foreground font-medium">{user?.email}</p>
</div>
<p className="text-xs text-muted-foreground mt-2">
Email can be updated from the portal.
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Customer Number
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-base text-foreground font-medium">
{user?.sfNumber || (
<span className="text-muted-foreground italic">Not available</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">Customer number is read-only.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Date of Birth
</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-base text-foreground font-medium">
{user?.dateOfBirth || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Date of birth is stored in billing profile.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Phone Number
</label>
{editingProfile ? (
<input
type="tel"
value={profile.values.phonenumber}
onChange={e => profile.setValue("phonenumber", e.target.value)}
placeholder="+81 XX-XXXX-XXXX"
className="block w-full px-4 py-2.5 border border-input rounded-lg bg-background text-foreground shadow-[var(--cp-shadow-1)] focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
) : (
<p className="text-base text-foreground py-2">
{user?.phonenumber || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">Gender</label>
<div className="bg-muted rounded-lg p-4 border border-border">
<p className="text-base text-foreground font-medium">
{user?.gender || (
<span className="text-muted-foreground italic">Not provided</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-2">
Gender is stored in billing profile.
</p>
</div>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">First Name</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.firstname || <span className="text-gray-500 italic">Not provided</span>}
</p>
<p className="text-xs text-gray-500 mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Last Name</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.lastname || <span className="text-gray-500 italic">Not provided</span>}
</p>
<p className="text-xs text-gray-500 mt-2">
Name cannot be changed from the portal.
</p>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
{editingProfile ? (
<input
type="email"
value={profile.values.email}
onChange={e => profile.setValue("email", e.target.value)}
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between">
<p className="text-base text-gray-900 font-medium">{user?.email}</p>
</div>
<p className="text-xs text-gray-500 mt-2">
Email can be updated from the portal.
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Customer Number
</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.sfNumber || <span className="text-gray-500 italic">Not available</span>}
</p>
<p className="text-xs text-gray-500 mt-2">Customer number is read-only.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date of Birth
</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.dateOfBirth || (
<span className="text-gray-500 italic">Not provided</span>
)}
</p>
<p className="text-xs text-gray-500 mt-2">
Date of birth is stored in billing profile.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
{editingProfile ? (
<input
type="tel"
value={profile.values.phonenumber}
onChange={e => profile.setValue("phonenumber", e.target.value)}
placeholder="+81 XX-XXXX-XXXX"
className="block w-full px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
) : (
<p className="text-base text-gray-900 py-2">
{user?.phonenumber || (
<span className="text-gray-500 italic">Not provided</span>
)}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Gender</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-base text-gray-900 font-medium">
{user?.gender || <span className="text-gray-500 italic">Not provided</span>}
</p>
<p className="text-xs text-gray-500 mt-2">Gender is stored in billing profile.</p>
</div>
</div>
{editingProfile && (
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-border mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(false)}
disabled={profile.isSubmitting}
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
void profile
.handleSubmit()
.then(() => {
setEditingProfile(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
isLoading={profile.isSubmitting}
leftIcon={!profile.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
>
{profile.isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
)}
</div>
</div>
{editingProfile && (
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200 mt-6">
<div className="bg-card text-card-foreground rounded-xl border border-border shadow-[var(--cp-shadow-1)]">
<div className="px-6 py-5 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-6 w-6 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Address Information</h2>
</div>
{!editingAddress && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
)}
</div>
</div>
<div className="p-6">
{editingAddress ? (
<div className="space-y-6">
<AddressForm
initialAddress={{
address1: address.values.address1,
address2: address.values.address2,
city: address.values.city,
state: address.values.state,
postcode: address.values.postcode,
country: address.values.country,
countryCode: address.values.countryCode,
phoneNumber: address.values.phoneNumber,
phoneCountryCode: address.values.phoneCountryCode,
}}
onChange={a => {
address.setValue("address1", a.address1 ?? "");
address.setValue("address2", a.address2 ?? "");
address.setValue("city", a.city ?? "");
address.setValue("state", a.state ?? "");
address.setValue("postcode", a.postcode ?? "");
address.setValue("country", a.country ?? "");
address.setValue("countryCode", a.countryCode ?? "");
address.setValue("phoneNumber", a.phoneNumber ?? "");
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
}}
title="Mailing Address"
/>
<div className="flex items-center justify-end space-x-3 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(false)}
disabled={profile.isSubmitting}
onClick={() => setEditingAddress(false)}
disabled={address.isSubmitting}
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
Cancel
@ -305,143 +419,62 @@ export default function ProfileContainer() {
<Button
size="sm"
onClick={() => {
void profile
void address
.handleSubmit()
.then(() => {
setEditingProfile(false);
setEditingAddress(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
isLoading={profile.isSubmitting}
leftIcon={!profile.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
isLoading={address.isSubmitting}
leftIcon={!address.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
>
{profile.isSubmitting ? "Saving..." : "Save Changes"}
{address.isSubmitting ? "Saving..." : "Save Address"}
</Button>
</div>
)}
</div>
</div>
<div className="bg-white shadow-sm rounded-xl border border-gray-200 mt-8">
<div className="px-6 py-5 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
</div>
{!editingAddress && (
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Edit
</Button>
{address.submitError && (
<AlertBanner variant="error" title="Address Error">
{address.submitError}
</AlertBanner>
)}
</div>
</div>
<div className="p-6">
{editingAddress ? (
<div className="space-y-6">
<AddressForm
initialAddress={{
address1: address.values.address1,
address2: address.values.address2,
city: address.values.city,
state: address.values.state,
postcode: address.values.postcode,
country: address.values.country,
countryCode: address.values.countryCode,
phoneNumber: address.values.phoneNumber,
phoneCountryCode: address.values.phoneCountryCode,
}}
onChange={a => {
address.setValue("address1", a.address1 ?? "");
address.setValue("address2", a.address2 ?? "");
address.setValue("city", a.city ?? "");
address.setValue("state", a.state ?? "");
address.setValue("postcode", a.postcode ?? "");
address.setValue("country", a.country ?? "");
address.setValue("countryCode", a.countryCode ?? "");
address.setValue("phoneNumber", a.phoneNumber ?? "");
address.setValue("phoneCountryCode", a.phoneCountryCode ?? "");
}}
title="Mailing Address"
/>
<div className="flex items-center justify-end space-x-3 pt-2">
) : (
<div>
{address.values.address1 || address.values.city ? (
<div className="bg-muted rounded-lg p-5 border border-border">
<div className="text-foreground space-y-1.5">
{address.values.address1 && (
<p className="font-medium text-base">{address.values.address1}</p>
)}
{address.values.address2 && (
<p className="text-muted-foreground">{address.values.address2}</p>
)}
<p className="text-muted-foreground">
{[address.values.city, address.values.state, address.values.postcode]
.filter(Boolean)
.join(", ")}
</p>
<p className="text-muted-foreground">{address.values.country}</p>
</div>
</div>
) : (
<div className="text-center py-12">
<MapPinIcon className="h-12 w-12 text-muted-foreground/60 mx-auto mb-4" />
<p className="text-muted-foreground mb-4">No address on file</p>
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(false)}
disabled={address.isSubmitting}
leftIcon={<XMarkIcon className="h-4 w-4" />}
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
void address
.handleSubmit()
.then(() => {
setEditingAddress(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
isLoading={address.isSubmitting}
leftIcon={!address.isSubmitting ? <CheckIcon className="h-4 w-4" /> : undefined}
>
{address.isSubmitting ? "Saving..." : "Save Address"}
Add Address
</Button>
</div>
{address.submitError && (
<AlertBanner variant="error" title="Address Error">
{address.submitError}
</AlertBanner>
)}
</div>
) : (
<div>
{address.values.address1 || address.values.city ? (
<div className="bg-gray-50 rounded-lg p-5 border border-gray-200">
<div className="text-gray-900 space-y-1.5">
{address.values.address1 && (
<p className="font-medium text-base">{address.values.address1}</p>
)}
{address.values.address2 && (
<p className="text-gray-700">{address.values.address2}</p>
)}
<p className="text-gray-700">
{[address.values.city, address.values.state, address.values.postcode]
.filter(Boolean)
.join(", ")}
</p>
<p className="text-gray-700">{address.values.country}</p>
</div>
</div>
) : (
<div className="text-center py-12">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No address on file</p>
<Button
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
Add Address
</Button>
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
</PageLayout>
);
}

View File

@ -89,7 +89,11 @@ export function PasswordResetForm({
},
});
// Get the current form based on mode
// Extract stable reset functions to avoid unnecessary effect runs.
// The form objects change when internal state changes, but reset is stable.
const requestFormReset = requestForm.reset;
const resetFormReset = resetForm.reset;
// Handle errors from auth hooks
useEffect(() => {
if (error) {
@ -100,15 +104,19 @@ export function PasswordResetForm({
// Clear errors when switching modes
useEffect(() => {
clearError();
requestForm.reset();
resetForm.reset();
}, [mode, clearError, requestForm, resetForm]);
requestFormReset();
resetFormReset();
}, [mode, clearError, requestFormReset, resetFormReset]);
if (mode === "request") {
return (
<div className={`space-y-6 ${className}`}>
<form onSubmit={event => void requestForm.handleSubmit(event)} className="space-y-4">
<FormField label="Email address" error={requestForm.touched.email ? requestForm.errors.email : undefined} required>
<FormField
label="Email address"
error={requestForm.touched.email ? requestForm.errors.email : undefined}
required
>
<Input
type="email"
placeholder="Enter your email"
@ -136,7 +144,7 @@ export function PasswordResetForm({
<div className="text-center">
<Link
href="/auth/login"
className="text-sm text-blue-600 hover:text-blue-500 font-medium transition-colors duration-200"
className="text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"
>
Back to login
</Link>
@ -150,7 +158,11 @@ export function PasswordResetForm({
return (
<div className={`space-y-6 ${className}`}>
<form onSubmit={event => void resetForm.handleSubmit(event)} className="space-y-4">
<FormField label="New password" error={resetForm.touched.password ? resetForm.errors.password : undefined} required>
<FormField
label="New password"
error={resetForm.touched.password ? resetForm.errors.password : undefined}
required
>
<Input
type="password"
placeholder="Enter new password"
@ -162,7 +174,11 @@ export function PasswordResetForm({
/>
</FormField>
<FormField label="Confirm password" error={resetForm.touched.confirmPassword ? resetForm.errors.confirmPassword : undefined} required>
<FormField
label="Confirm password"
error={resetForm.touched.confirmPassword ? resetForm.errors.confirmPassword : undefined}
required
>
<Input
type="password"
placeholder="Confirm new password"
@ -190,7 +206,7 @@ export function PasswordResetForm({
<div className="text-center">
<Link
href="/auth/login"
className="text-sm text-blue-600 hover:text-blue-500 font-medium transition-colors duration-200"
className="text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"
>
Back to login
</Link>

View File

@ -32,7 +32,12 @@ interface SetPasswordFormProps {
className?: string;
}
export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) {
export function SetPasswordForm({
email = "",
onSuccess,
onError,
className = "",
}: SetPasswordFormProps) {
const { setPassword, loading, error, clearError } = useWhmcsLink();
const form = useZodForm({
@ -60,9 +65,14 @@ export function SetPasswordForm({ email = "", onSuccess, onError, className = ""
if (error) onError?.(error);
}, [error, onError]);
// Extract stable setValue to avoid re-running effect on every render.
// The form object is recreated each render, but setValue is memoized.
const formSetValue = form.setValue;
const formEmailValue = form.values.email;
useEffect(() => {
if (email && email !== form.values.email) form.setValue("email", email);
}, [email, form]);
if (email && email !== formEmailValue) formSetValue("email", email);
}, [email, formEmailValue, formSetValue]);
return (
<form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
@ -73,12 +83,18 @@ export function SetPasswordForm({ email = "", onSuccess, onError, className = ""
onChange={e => !isEmailProvided && form.setValue("email", e.target.value)}
disabled={isLoading || isEmailProvided}
readOnly={isEmailProvided}
className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""}
className={isEmailProvided ? "bg-muted text-muted-foreground" : ""}
/>
{isEmailProvided && <p className="mt-1 text-xs text-gray-500">Verified during account transfer</p>}
{isEmailProvided && (
<p className="mt-1 text-xs text-muted-foreground">Verified during account transfer</p>
)}
</FormField>
<FormField label="New Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField
label="New Password"
error={form.touched.password ? form.errors.password : undefined}
required
>
<Input
type="password"
value={form.values.password}
@ -94,23 +110,36 @@ export function SetPasswordForm({ email = "", onSuccess, onError, className = ""
{form.values.password && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full transition-all ${colorClass}`}
style={{ width: `${strength}%` }}
/>
</div>
<span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
<span
className={`text-xs font-medium ${isValid ? "text-success" : "text-muted-foreground"}`}
>
{label}
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{requirements.map(r => (
<div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>{r.met ? "✓" : "○"}</span>
<span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
<span className={r.met ? "text-success" : "text-muted-foreground/60"}>
{r.met ? "✓" : "○"}
</span>
<span className={r.met ? "text-success" : "text-muted-foreground"}>{r.label}</span>
</div>
))}
</div>
</div>
)}
<FormField label="Confirm Password" error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined} required>
<FormField
label="Confirm Password"
error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={form.values.confirmPassword}
@ -123,19 +152,26 @@ export function SetPasswordForm({ email = "", onSuccess, onError, className = ""
</FormField>
{form.values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
<p className={`text-sm ${passwordsMatch ? "text-success" : "text-destructive"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button type="submit" className="w-full" disabled={isLoading || !form.isValid} loading={isLoading}>
<Button
type="submit"
className="w-full"
disabled={isLoading || !form.isValid}
loading={isLoading}
>
Set Password & Continue
</Button>
<div className="text-center">
<Link href="/auth/login" className="text-sm text-blue-600 hover:text-blue-500">Back to login</Link>
<Link href="/auth/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</div>
</form>
);

View File

@ -65,23 +65,19 @@ export function MultiStepForm({
transition-all duration-200
${
isCompleted
? "bg-green-500 text-white"
? "bg-success text-success-foreground"
: isCurrent
? "bg-blue-600 text-white ring-4 ring-blue-100"
: "bg-gray-200 text-gray-500"
? "bg-primary text-primary-foreground ring-4 ring-primary/15"
: "bg-muted text-muted-foreground"
}
`}
>
{isCompleted ? (
<CheckIcon className="w-4 h-4" />
) : (
idx + 1
)}
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
</div>
{idx < totalSteps - 1 && (
<div
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
isCompleted ? "bg-green-500" : "bg-gray-200"
isCompleted ? "bg-success" : "bg-border"
}`}
/>
)}
@ -92,15 +88,15 @@ export function MultiStepForm({
{/* Step Title & Description */}
<div className="text-center pb-2">
<h3 className="text-xl font-semibold text-gray-900">{step?.title}</h3>
<p className="text-sm text-gray-500 mt-1">{step?.description}</p>
<h3 className="text-xl font-semibold text-foreground">{step?.title}</h3>
<p className="text-sm text-muted-foreground mt-1">{step?.description}</p>
</div>
{/* Step Content */}
<div className="min-h-[350px]">{step?.content}</div>
{/* Navigation Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-100">
<div className="flex gap-3 pt-4 border-t border-border">
<Button
type="button"
variant="outline"
@ -119,11 +115,7 @@ export function MultiStepForm({
loading={isSubmitting && isLastStep}
className="flex-1 h-11"
>
{isLastStep
? isSubmitting
? "Creating Account..."
: "Create Account"
: "Continue"}
{isLastStep ? (isSubmitting ? "Creating Account..." : "Create Account") : "Continue"}
</Button>
</div>
</div>

View File

@ -264,7 +264,7 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
return (
<div className={`w-full max-w-lg mx-auto ${className}`}>
<div className="bg-white shadow-lg rounded-2xl border border-gray-100 p-6 sm:p-8">
<div className="bg-card text-card-foreground shadow-[var(--cp-shadow-2)] rounded-2xl border border-border p-6 sm:p-8">
<MultiStepForm
steps={steps}
currentStep={step}
@ -276,24 +276,26 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
/>
{error && (
<ErrorMessage className="mt-4 text-center p-3 bg-red-50 rounded-lg">{error}</ErrorMessage>
<ErrorMessage className="mt-4 text-center p-3 bg-destructive-soft rounded-lg">
{error}
</ErrorMessage>
)}
<div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-3">
<p className="text-sm text-gray-600">
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/auth/login"
className="font-medium text-blue-600 hover:text-blue-700 transition-colors"
className="font-medium text-primary hover:underline transition-colors"
>
Sign in
</Link>
</p>
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Existing customer?{" "}
<Link
href="/auth/link-whmcs"
className="font-medium text-blue-600 hover:text-blue-700 transition-colors"
className="font-medium text-primary hover:underline transition-colors"
>
Migrate your account
</Link>

View File

@ -34,7 +34,7 @@ export function AccountStep({ form }: AccountStepProps) {
return (
<div className="space-y-5">
{/* Customer Number - Highlighted */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-4">
<div className="bg-info-soft border border-info/25 rounded-xl p-4">
<FormField
label="Customer Number"
error={getError("sfNumber")}
@ -46,7 +46,6 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="e.g., AST-123456"
className="bg-white"
autoFocus
/>
</FormField>
@ -149,12 +148,12 @@ export function AccountStep({ form }: AccountStepProps) {
onChange={e => setValue("gender", e.target.value || undefined)}
onBlur={() => setTouchedField("gender")}
className={[
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm",
"ring-offset-background placeholder:text-gray-500 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
getError("gender")
? "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2"
? "border-destructive focus-visible:ring-destructive focus-visible:ring-offset-2"
: "",
].join(" ")}
aria-invalid={Boolean(getError("gender")) || undefined}

View File

@ -1,6 +1,6 @@
/**
* Address Step - Japanese address input for WHMCS
*
*
* Field mapping to WHMCS:
* - postcode postcode
* - state state (prefecture)
@ -94,13 +94,13 @@ export function AddressStep({ form }: AddressStepProps) {
<select
name="address-level1"
value={address.state}
onChange={(e) => updateAddress("state", e.target.value)}
onChange={e => updateAddress("state", e.target.value)}
onBlur={markTouched}
className="block w-full h-10 px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="block w-full h-10 px-3 py-2 border border-input rounded-md shadow-sm bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
autoComplete="address-level1"
>
<option value="">Select prefecture</option>
{JAPAN_PREFECTURES.map((p) => (
{JAPAN_PREFECTURES.map(p => (
<option key={p.value} value={p.value}>
{p.label}
</option>
@ -118,7 +118,7 @@ export function AddressStep({ form }: AddressStepProps) {
<Input
name="address-level2"
value={address.city}
onChange={(e) => updateAddress("city", e.target.value)}
onChange={e => updateAddress("city", e.target.value)}
onBlur={markTouched}
placeholder="Shibuya-ku"
autoComplete="address-level2"
@ -135,7 +135,7 @@ export function AddressStep({ form }: AddressStepProps) {
<Input
name="address-line1"
value={address.address1}
onChange={(e) => updateAddress("address1", e.target.value)}
onChange={e => updateAddress("address1", e.target.value)}
onBlur={markTouched}
placeholder="3-8-2 Higashi Azabu"
autoComplete="address-line1"
@ -151,14 +151,14 @@ export function AddressStep({ form }: AddressStepProps) {
<Input
name="address-line2"
value={address.address2 ?? ""}
onChange={(e) => updateAddress("address2", e.target.value)}
onChange={e => updateAddress("address2", e.target.value)}
onBlur={markTouched}
placeholder="3F Azabu Maruka Bldg"
autoComplete="address-line2"
/>
</FormField>
<p className="text-sm text-gray-600 bg-blue-50 border border-blue-100 rounded-lg p-3">
<p className="text-sm text-muted-foreground bg-info-soft border border-info/25 rounded-lg p-3">
Please input your address in Japan. This will be used for service delivery and setup.
</p>
</div>

View File

@ -38,11 +38,7 @@ export function PasswordStep({ form }: PasswordStepProps) {
aria-hidden="true"
/>
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
>
<FormField label="Password" error={touched.password ? errors.password : undefined} required>
<Input
name="new-password"
type="password"
@ -57,18 +53,25 @@ export function PasswordStep({ form }: PasswordStepProps) {
{values.password && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full transition-all ${colorClass}`}
style={{ width: `${strength}%` }}
/>
</div>
<span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
<span
className={`text-xs font-medium ${isValid ? "text-success" : "text-muted-foreground"}`}
>
{label}
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{requirements.map(r => (
<div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>
<span className={r.met ? "text-success" : "text-muted-foreground/60"}>
{r.met ? "✓" : "○"}
</span>
<span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
<span className={r.met ? "text-success" : "text-muted-foreground"}>{r.label}</span>
</div>
))}
</div>
@ -92,7 +95,7 @@ export function PasswordStep({ form }: PasswordStepProps) {
</FormField>
{values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
<p className={`text-sm ${passwordsMatch ? "text-success" : "text-destructive"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}

View File

@ -48,11 +48,11 @@ function ReadyMessage({ errors }: { errors: FormErrors }) {
if (hasErrors) return null;
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800 flex items-start gap-3">
<div className="bg-info-soft border border-info/25 rounded-xl p-4 text-sm text-info flex items-start gap-3">
<span className="text-lg">🚀</span>
<div>
<p className="font-medium">Ready to create your account!</p>
<p className="text-blue-600 mt-1">
<p className="text-foreground/80 mt-1">
Click &quot;Create Account&quot; below to complete your registration.
</p>
</div>
@ -108,50 +108,50 @@ export function ReviewStep({ form }: ReviewStepProps) {
return (
<div className="space-y-6">
{/* Account Summary */}
<div className="bg-gray-50 rounded-xl p-5 border border-gray-200">
<h4 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-xs text-blue-600">
<div className="bg-muted rounded-xl p-5 border border-border">
<h4 className="text-sm font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs text-primary">
</span>
Account Summary
</h4>
<dl className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Customer Number</dt>
<dd className="text-gray-900 font-medium">{values.sfNumber}</dd>
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Customer Number</dt>
<dd className="text-foreground font-medium">{values.sfNumber}</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Name</dt>
<dd className="text-gray-900 font-medium">
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Name</dt>
<dd className="text-foreground font-medium">
{values.firstName} {values.lastName}
</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Email</dt>
<dd className="text-gray-900 font-medium break-all">{values.email}</dd>
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Email</dt>
<dd className="text-foreground font-medium break-all">{values.email}</dd>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Phone</dt>
<dd className="text-gray-900 font-medium">
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Phone</dt>
<dd className="text-foreground font-medium">
{values.phoneCountryCode} {values.phone}
</dd>
</div>
{values.company && (
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Company</dt>
<dd className="text-gray-900 font-medium">{values.company}</dd>
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Company</dt>
<dd className="text-foreground font-medium">{values.company}</dd>
</div>
)}
{values.dateOfBirth && (
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Date of Birth</dt>
<dd className="text-gray-900 font-medium">{values.dateOfBirth}</dd>
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Date of Birth</dt>
<dd className="text-foreground font-medium">{values.dateOfBirth}</dd>
</div>
)}
{values.gender && (
<div className="flex justify-between py-2 border-b border-gray-100">
<dt className="text-gray-500">Gender</dt>
<dd className="text-gray-900 font-medium">{values.gender}</dd>
<div className="flex justify-between py-2 border-b border-border/60">
<dt className="text-muted-foreground">Gender</dt>
<dd className="text-foreground font-medium">{values.gender}</dd>
</div>
)}
</dl>
@ -159,34 +159,34 @@ export function ReviewStep({ form }: ReviewStepProps) {
{/* Address Summary */}
{address?.address1 && (
<div className="bg-gray-50 rounded-xl p-5 border border-gray-200">
<h4 className="text-sm font-semibold text-gray-900 mb-4 flex items-center gap-2">
<span className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center text-xs text-green-600">
<div className="bg-muted rounded-xl p-5 border border-border">
<h4 className="text-sm font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="w-6 h-6 bg-success-soft rounded-full flex items-center justify-center text-xs text-success">
📍
</span>
Delivery Address
</h4>
<p className="text-sm text-gray-700 font-medium">{formattedAddress}</p>
<p className="text-sm text-foreground/80 font-medium">{formattedAddress}</p>
</div>
)}
{/* Terms & Conditions */}
<div className="space-y-4 bg-white rounded-xl p-5 border border-gray-200">
<h4 className="text-sm font-semibold text-gray-900">Terms & Agreements</h4>
<div className="space-y-4 bg-card rounded-xl p-5 border border-border">
<h4 className="text-sm font-semibold text-foreground">Terms & Agreements</h4>
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-gray-50 transition-colors">
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-muted transition-colors">
<input
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
/>
<span className="text-sm text-gray-700">
<span className="text-sm text-foreground/80">
I accept the{" "}
<Link
href="/terms"
className="text-blue-600 hover:underline font-medium"
className="text-primary hover:underline font-medium"
target="_blank"
>
Terms of Service
@ -194,7 +194,7 @@ export function ReviewStep({ form }: ReviewStepProps) {
and{" "}
<Link
href="/privacy"
className="text-blue-600 hover:underline font-medium"
className="text-primary hover:underline font-medium"
target="_blank"
>
Privacy Policy
@ -203,19 +203,21 @@ export function ReviewStep({ form }: ReviewStepProps) {
</span>
</label>
{errors.acceptTerms && (
<p className="text-sm text-red-600 ml-11 -mt-2">{errors.acceptTerms}</p>
<p className="text-sm text-destructive ml-11 -mt-2">{errors.acceptTerms}</p>
)}
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-gray-50 transition-colors">
<label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg hover:bg-muted transition-colors">
<input
type="checkbox"
checked={values.marketingConsent ?? false}
onChange={e => setValue("marketingConsent", e.target.checked)}
className="mt-0.5 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
/>
<span className="text-sm text-gray-700">
<span className="text-sm text-foreground/80">
Send me updates about new products and promotions
<span className="block text-xs text-gray-500 mt-0.5">You can unsubscribe anytime</span>
<span className="block text-xs text-muted-foreground mt-0.5">
You can unsubscribe anytime
</span>
</span>
</label>
</div>

View File

@ -20,21 +20,25 @@ export function LinkWhmcsView() {
>
<div className="space-y-6">
{/* What transfers */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm font-medium text-blue-800 mb-2">What gets transferred:</p>
<ul className="grid grid-cols-2 gap-1 text-sm text-blue-700">
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
<p className="text-sm font-medium text-foreground mb-2">What gets transferred:</p>
<ul className="grid grid-cols-2 gap-1 text-sm text-muted-foreground">
{MIGRATION_TRANSFER_ITEMS.map((item, i) => (
<li key={i} className="flex items-center gap-1.5">
<span className="text-blue-500"></span> {item}
<span className="text-info"></span> {item}
</li>
))}
</ul>
</div>
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Enter Legacy Portal Credentials</h2>
<p className="text-sm text-gray-600 mb-5">Use your previous Assist Solutions portal email and password.</p>
<div className="bg-card text-card-foreground rounded-lg border border-border p-6 shadow-[var(--cp-shadow-1)]">
<h2 className="text-lg font-semibold text-foreground mb-1">
Enter Legacy Portal Credentials
</h2>
<p className="text-sm text-muted-foreground mb-5">
Use your previous Assist Solutions portal email and password.
</p>
<LinkWhmcsForm
onTransferred={result => {
if (result.needsPasswordSet) {
@ -48,31 +52,40 @@ export function LinkWhmcsView() {
{/* Links */}
<div className="flex justify-center gap-6 text-sm">
<span className="text-gray-600">
New customer? <Link href="/auth/signup" className="text-blue-600 hover:underline">Create account</Link>
<span className="text-muted-foreground">
New customer?{" "}
<Link href="/auth/signup" className="text-primary hover:underline">
Create account
</Link>
</span>
<span className="text-gray-600">
Already transferred? <Link href="/auth/login" className="text-blue-600 hover:underline">Sign in</Link>
<span className="text-muted-foreground">
Already transferred?{" "}
<Link href="/auth/login" className="text-primary hover:underline">
Sign in
</Link>
</span>
</div>
{/* Steps */}
<div className="border-t pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-3">How it works</h3>
<div className="border-t border-border pt-6">
<h3 className="text-sm font-semibold text-foreground mb-3">How it works</h3>
<ol className="space-y-2">
{MIGRATION_STEPS.map((step, i) => (
<li key={i} className="flex items-start gap-3 text-sm">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-xs flex items-center justify-center font-medium">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 text-primary text-xs flex items-center justify-center font-medium">
{i + 1}
</span>
<span className="text-gray-600">{step}</span>
<span className="text-muted-foreground">{step}</span>
</li>
))}
</ol>
</div>
<p className="text-center text-sm text-gray-500">
Need help? <Link href="/support" className="text-blue-600 hover:underline">Contact support</Link>
<p className="text-center text-sm text-muted-foreground">
Need help?{" "}
<Link href="/support" className="text-primary hover:underline">
Contact support
</Link>
</p>
</div>
</AuthLayout>

View File

@ -51,9 +51,9 @@ export function InvoiceDetailContainer() {
title="Invoice"
description="Invoice details and actions"
>
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8 space-y-6">
<div className="space-y-6">
<LoadingCard />
<div className="bg-white rounded-2xl border p-6 space-y-4">
<div className="bg-card text-card-foreground rounded-2xl border border-border p-6 space-y-4 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
@ -100,33 +100,23 @@ export function InvoiceDetailContainer() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50/30 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Navigation */}
<div className="mb-8">
<Link
href="/billing/invoices"
className="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors group"
>
<svg
className="w-4 h-4 transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Invoices
</Link>
</div>
{/* Main Invoice Card */}
<div className="bg-white/80 backdrop-blur-sm rounded-3xl shadow-xl border border-white/20 overflow-hidden">
<PageLayout
icon={<DocumentTextIcon />}
title={`Invoice #${invoice.id}`}
description="Invoice details and actions"
breadcrumbs={[
{ label: "Billing", href: "/billing/invoices" },
{ label: "Invoices", href: "/billing/invoices" },
{ label: `#${invoice.id}` },
]}
actions={
<Link href="/billing/invoices" className="text-sm font-medium text-primary hover:underline">
Back to invoices
</Link>
}
>
<div className="max-w-4xl">
<div className="bg-card text-card-foreground rounded-3xl shadow-[var(--cp-card-shadow-lg)] border border-border overflow-hidden">
<InvoiceSummaryBar
invoice={invoice}
loadingDownload={loadingDownload}
@ -135,16 +125,15 @@ export function InvoiceDetailContainer() {
onPay={() => handleCreateSsoLink("pay")}
/>
{/* Success Banner for Paid Invoices */}
{invoice.status === "Paid" && (
<div className="px-8 py-4 bg-gradient-to-r from-emerald-50 to-teal-50 border-b border-emerald-100">
<div className="px-8 py-4 bg-success-soft border-b border-success/25">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<CheckCircleIcon className="w-6 h-6 text-emerald-600" />
<CheckCircleIcon className="w-6 h-6 text-success" />
</div>
<div>
<h3 className="text-sm font-semibold text-emerald-900">Payment Received</h3>
<p className="text-sm text-emerald-700">
<h3 className="text-sm font-semibold text-foreground">Payment received</h3>
<p className="text-sm text-muted-foreground">
Paid on {invoice.paidDate || invoice.issuedAt}
</p>
</div>
@ -152,14 +141,10 @@ export function InvoiceDetailContainer() {
</div>
)}
{/* Content */}
<div className="p-8">
<div className="space-y-8">
{/* Invoice Items */}
<InvoiceItems items={invoice.items} currency={invoice.currency} />
{/* Invoice Summary - Full Width */}
<div className="border-t border-slate-200 pt-8">
<div className="border-t border-border pt-8">
<InvoiceTotals
subtotal={invoice.subtotal}
tax={invoice.tax}
@ -170,7 +155,7 @@ export function InvoiceDetailContainer() {
</div>
</div>
</div>
</div>
</PageLayout>
);
}

View File

@ -221,7 +221,7 @@ export function AddressConfirmation({
return wrap(
<div className="flex items-center space-x-3">
<Skeleton className="h-4 w-4 rounded-full" />
<span className="text-gray-600">Loading address information...</span>
<span className="text-muted-foreground">Loading address information...</span>
</div>
);
}
@ -250,9 +250,9 @@ export function AddressConfirmation({
<>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-600" />
<MapPinIcon className="h-5 w-5 text-primary" />
<div>
<h3 className="text-lg font-semibold text-gray-900">
<h3 className="text-lg font-semibold text-foreground">
{isInternetOrder
? "Installation Address"
: billingInfo.isComplete
@ -275,7 +275,9 @@ export function AddressConfirmation({
{editing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Street Address *
</label>
<input
type="text"
value={editedAddress?.address1 || ""}
@ -283,14 +285,14 @@ export function AddressConfirmation({
setError(null); // Clear error on input
setEditedAddress(prev => (prev ? { ...prev, address1: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="123 Main Street"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
Street Address Line 2
</label>
<input
@ -300,14 +302,14 @@ export function AddressConfirmation({
setError(null);
setEditedAddress(prev => (prev ? { ...prev, address2: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Apartment, suite, etc. (optional)"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">City *</label>
<label className="block text-sm font-medium text-muted-foreground mb-1">City *</label>
<input
type="text"
value={editedAddress?.city || ""}
@ -315,13 +317,13 @@ export function AddressConfirmation({
setError(null);
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Tokyo"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
State/Prefecture *
</label>
<input
@ -331,13 +333,15 @@ export function AddressConfirmation({
setError(null);
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="Tokyo"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code *</label>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Postal Code *
</label>
<input
type="text"
value={editedAddress?.postcode || ""}
@ -345,14 +349,16 @@ export function AddressConfirmation({
setError(null);
setEditedAddress(prev => (prev ? { ...prev, postcode: e.target.value } : null));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
placeholder="100-0001"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Country *
</label>
<select
value={editedAddress?.country || ""}
onChange={e => {
@ -362,7 +368,7 @@ export function AddressConfirmation({
prev ? { ...prev, country: next, countryCode: next } : null
);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
>
<option value="">Select Country</option>
{COUNTRY_OPTIONS.map(option => (
@ -374,36 +380,33 @@ export function AddressConfirmation({
</div>
<div className="flex items-center space-x-3 pt-4">
<button
type="button"
onClick={handleSave}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<CheckIcon className="h-4 w-4" />
<span>Save Address</span>
</button>
<button
<Button type="button" onClick={handleSave} leftIcon={<CheckIcon className="h-4 w-4" />}>
Save Address
</Button>
<Button
type="button"
onClick={handleCancel}
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
variant="outline"
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
<XMarkIcon className="h-4 w-4" />
<span>Cancel</span>
</button>
Cancel
</Button>
</div>
</div>
) : (
<div>
{address?.address1 ? (
<div className="space-y-4">
<div className="text-gray-900 space-y-1">
<div className="text-foreground space-y-1">
<p className="font-semibold text-base">{address.address1}</p>
{address.address2 ? <p className="text-gray-700">{address.address2}</p> : null}
<p className="text-gray-700">
{address.address2 ? (
<p className="text-muted-foreground">{address.address2}</p>
) : null}
<p className="text-muted-foreground">
{[address.city, address.state].filter(Boolean).join(", ")}
{address.postcode ? ` ${address.postcode}` : ""}
</p>
{countryLabel && <p className="text-gray-600">{countryLabel}</p>}
{countryLabel && <p className="text-muted-foreground">{countryLabel}</p>}
</div>
{/* Status message for Internet orders when pending */}
@ -414,7 +417,7 @@ export function AddressConfirmation({
)}
{/* Action buttons */}
<div className="flex items-center gap-3 pt-4 border-t border-gray-100">
<div className="flex items-center gap-3 pt-4 border-t border-border">
{/* Primary action when pending for Internet orders */}
{isInternetOrder && !addressConfirmed && !editing && (
<Button
@ -447,11 +450,11 @@ export function AddressConfirmation({
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MapPinIcon className="h-8 w-8 text-gray-400" />
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4 border border-border shadow-[var(--cp-shadow-1)]">
<MapPinIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">No Address on File</h4>
<p className="text-gray-600 mb-6">
<h4 className="text-lg font-medium text-foreground mb-2">No Address on File</h4>
<p className="text-muted-foreground mb-6">
Please add your installation address to continue.
</p>
<Button type="button" onClick={handleEdit}>

View File

@ -157,37 +157,65 @@ export function AddressForm({
}
};
// Extract stable setValue to avoid stale closure issues.
// The form object is recreated each render, but setValue is memoized with useCallback.
const formSetValue = form.setValue;
const {
address1,
address2,
city,
state,
postcode,
country,
countryCode,
phoneNumber,
phoneCountryCode,
} = initialAddress ?? {};
const hasInitialAddress = !!initialAddress;
// Update form when initialAddress changes
useEffect(() => {
if (initialAddress) {
Object.entries(initialAddress).forEach(([key, value]) => {
if (value !== undefined) {
const normalizedValue =
key === "country" || key === "countryCode"
? normalizeCountryValue(value as string | undefined)
: value || "";
form.setValue(key as keyof Address, normalizedValue);
}
});
if (!hasInitialAddress) return;
const normalizedCountry = normalizeCountryValue(initialAddress.country);
const normalizedCountryCode = normalizeCountryValue(
initialAddress.countryCode ?? initialAddress.country
);
form.setValue("country", normalizedCountry);
form.setValue("countryCode", normalizedCountryCode);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const nextValues: Partial<Address> = {
address1,
address2,
city,
state,
postcode,
country,
countryCode,
phoneNumber,
phoneCountryCode,
};
Object.entries(nextValues).forEach(([key, value]) => {
if (value !== undefined) {
const normalizedValue =
key === "country" || key === "countryCode"
? normalizeCountryValue(value as string | undefined)
: value || "";
formSetValue(key as keyof Address, normalizedValue);
}
});
const normalizedCountry = normalizeCountryValue(country);
const normalizedCountryCode = normalizeCountryValue(countryCode ?? country);
formSetValue("country", normalizedCountry);
formSetValue("countryCode", normalizedCountryCode);
}, [
initialAddress?.address1,
initialAddress?.address2,
initialAddress?.city,
initialAddress?.state,
initialAddress?.postcode,
initialAddress?.country,
initialAddress?.countryCode,
initialAddress?.phoneNumber,
initialAddress?.phoneCountryCode,
formSetValue,
hasInitialAddress,
address1,
address2,
city,
state,
postcode,
country,
countryCode,
phoneNumber,
phoneCountryCode,
]);
// Notify parent of validation changes

View File

@ -50,10 +50,10 @@ export function ActivationForm({
return (
<label
key={option.type}
className={`p-6 rounded-xl border-2 text-left transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 cursor-pointer flex flex-col gap-3 ${
className={`p-6 rounded-xl border text-left transition-shadow duration-[var(--cp-duration-normal)] focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background cursor-pointer flex flex-col gap-3 ${
isSelected
? "border-blue-500 bg-blue-50 shadow-md"
: "border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 shadow-sm hover:shadow-md"
? "border-primary bg-primary-soft shadow-[var(--cp-shadow-2)]"
: "border-border hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]"
}`}
>
<input
@ -67,26 +67,28 @@ export function ActivationForm({
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-semibold text-gray-900 leading-tight">{option.title}</h4>
<h4 className="text-lg font-semibold text-foreground leading-tight">
{option.title}
</h4>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300 bg-white"
isSelected ? "bg-primary border-primary" : "border-border bg-card"
}`}
aria-hidden="true"
>
{isSelected && <div className="w-2 h-2 bg-white rounded-full" />}
{isSelected && <div className="w-2 h-2 bg-primary-foreground rounded-full" />}
</div>
</div>
<p className="text-sm text-gray-600">{option.description}</p>
<p className="text-sm text-muted-foreground">{option.description}</p>
{activationFee ? (
<div className="pt-3 border-t border-gray-200">
<div className="pt-3 border-t border-border">
<CardPricing alignment="left" size="md" oneTimePrice={activationFee.amount} />
</div>
) : (
<p className="text-sm text-gray-500">Activation fee shown at checkout</p>
<p className="text-sm text-muted-foreground">Activation fee shown at checkout</p>
)}
{option.type === "Scheduled" && (
@ -99,7 +101,7 @@ export function ActivationForm({
<div className="mt-3">
<label
htmlFor="scheduledActivationDate"
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-muted-foreground mb-1"
>
Preferred activation date *
</label>
@ -109,13 +111,17 @@ export function ActivationForm({
value={scheduledActivationDate}
onChange={e => onScheduledActivationDateChange(e.target.value)}
min={new Date().toISOString().split("T")[0]}
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
max={
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
{errors.scheduledActivationDate && (
<p className="text-red-600 text-sm mt-1">{errors.scheduledActivationDate}</p>
<p className="text-destructive text-sm mt-1">
{errors.scheduledActivationDate}
</p>
)}
<p className="text-xs text-blue-700 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Weekend or holiday requests may be processed on the next business day.
</p>
</div>

View File

@ -106,22 +106,22 @@ export function SimConfigureView({
>
<div className="max-w-4xl mx-auto space-y-8">
{/* Header card skeleton */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-start">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="h-5 w-5 bg-blue-200 rounded" />
<div className="h-5 w-48 bg-gray-200 rounded" />
<div className="h-5 w-24 bg-green-100 rounded-full" />
<div className="h-5 w-5 bg-accent rounded" />
<div className="h-5 w-48 bg-muted rounded" />
<div className="h-5 w-24 bg-success-soft rounded-full" />
</div>
<div className="flex items-center gap-4">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-4 w-28 bg-gray-200 rounded" />
<div className="h-4 w-24 bg-muted rounded" />
<div className="h-4 w-28 bg-muted rounded" />
</div>
</div>
<div className="text-right space-y-2">
<div className="h-7 w-24 bg-blue-200 rounded" />
<div className="h-4 w-28 bg-green-100 rounded" />
<div className="h-7 w-24 bg-accent rounded" />
<div className="h-4 w-28 bg-success-soft rounded" />
</div>
</div>
</div>
@ -130,20 +130,20 @@ export function SimConfigureView({
<div className="flex items-center justify-between max-w-2xl mx-auto">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex-1 flex items-center">
<div className="h-3 w-3 rounded-full bg-gray-300" />
{i < 3 && <div className="h-1 flex-1 bg-gray-200 mx-2 rounded" />}
<div className="h-3 w-3 rounded-full bg-border" />
{i < 3 && <div className="h-1 flex-1 bg-border mx-2 rounded" />}
</div>
))}
</div>
{/* Step 1 card skeleton */}
<div className="bg-white rounded-xl border border-gray-200 p-8">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)]">
<div className="mb-6">
<div className="h-5 w-48 bg-gray-200 rounded mb-2" />
<div className="h-4 w-72 bg-gray-200 rounded" />
<div className="h-5 w-48 bg-muted rounded mb-2" />
<div className="h-4 w-72 bg-muted rounded" />
</div>
<div className="h-10 w-full bg-gray-100 rounded mb-4" />
<div className="h-10 w-72 bg-gray-100 rounded ml-auto" />
<div className="h-10 w-full bg-muted rounded mb-4" />
<div className="h-10 w-72 bg-muted rounded ml-auto" />
</div>
</div>
</PageLayout>
@ -158,10 +158,10 @@ export function SimConfigureView({
icon={<ExclamationTriangleIcon className="h-6 w-6" />}
>
<div className="text-center py-12">
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-red-400 mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Plan Not Found</h2>
<p className="text-gray-600 mb-4">The selected plan could not be found</p>
<a href="/catalog/sim" className="text-blue-600 hover:text-blue-800 font-medium">
<ExclamationTriangleIcon className="h-12 w-12 mx-auto text-destructive mb-4" />
<h2 className="text-xl font-semibold text-foreground mb-2">Plan Not Found</h2>
<p className="text-muted-foreground mb-4">The selected plan could not be found</p>
<a href="/catalog/sim" className="text-primary hover:text-primary-hover font-medium">
Return to SIM Plans
</a>
</div>
@ -191,16 +191,16 @@ export function SimConfigureView({
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-2">
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
<h3 className="font-bold text-lg text-gray-900">{plan.name}</h3>
<DevicePhoneMobileIcon className="h-5 w-5 text-primary" />
<h3 className="font-bold text-lg text-foreground">{plan.name}</h3>
{plan.simHasFamilyDiscount && (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
<span className="bg-success-soft text-success text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
<UsersIcon className="h-3 w-3" />
Family Discount
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
<span>
<strong>Data:</strong> {plan.simDataSize}
</span>
@ -215,12 +215,12 @@ export function SimConfigureView({
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
<div className="text-2xl font-bold text-primary">
¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}
/mo
</div>
{plan.simHasFamilyDiscount && (
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
<div className="text-sm text-success font-medium">Discounted Price</div>
)}
</div>
</div>
@ -229,12 +229,12 @@ export function SimConfigureView({
<ProgressSteps steps={steps} currentStep={currentStep} />
{plan.name.toLowerCase().includes("platinum") && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
<div className="flex items-start">
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" />
<ExclamationTriangleIcon className="w-5 h-5 text-warning mt-0.5 flex-shrink-0" />
<div className="ml-3">
<h5 className="font-medium text-yellow-900">PLATINUM Plan Notice</h5>
<p className="text-sm text-yellow-800 mt-1">
<h5 className="font-medium text-foreground">PLATINUM Plan Notice</h5>
<p className="text-sm text-muted-foreground mt-1">
Additional device subscription fees may apply. Contact support for details.
</p>
</div>
@ -244,10 +244,7 @@ export function SimConfigureView({
<div className="space-y-8">
{currentStep === 1 && (
<AnimatedCard
variant="static"
className={`p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0`}
>
<AnimatedCard variant="static" className="p-8">
<div className="mb-6">
<StepHeader
stepNumber={1}
@ -342,7 +339,7 @@ export function SimConfigureView({
/>
) : (
<div className="text-center py-8">
<p className="text-gray-600">
<p className="text-muted-foreground">
{plan.simPlanType === "DataOnly"
? "No add-ons are available for data-only plans."
: "No add-ons are available for this plan."}
@ -409,10 +406,7 @@ export function SimConfigureView({
</div>
{currentStep === 5 && (
<AnimatedCard
variant="static"
className={`p-8 transition-all duration-500 ease-in-out transform opacity-100 translate-y-0`}
>
<AnimatedCard variant="static" className="p-8">
<div className="mb-6">
<StepHeader
stepNumber={5}
@ -421,45 +415,45 @@ export function SimConfigureView({
/>
</div>
<div className="max-w-lg mx-auto mb-8 bg-gradient-to-b from-white to-gray-50 shadow-xl rounded-lg border border-gray-200 p-6">
<div className="text-center border-b-2 border-dashed border-gray-300 pb-4 mb-6">
<h3 className="text-xl font-bold text-gray-900 mb-1">Order Summary</h3>
<p className="text-sm text-gray-500">Review your configuration</p>
<div className="max-w-lg mx-auto mb-8 bg-card shadow-[var(--cp-shadow-2)] rounded-lg border border-border p-6">
<div className="text-center border-b-2 border-dashed border-border/60 pb-4 mb-6">
<h3 className="text-xl font-bold text-foreground mb-1">Order Summary</h3>
<p className="text-sm text-muted-foreground">Review your configuration</p>
</div>
<div className="space-y-3 mb-6">
<div className="flex justify-between items-start">
<div>
<h4 className="font-semibold text-gray-900">{plan.name}</h4>
<p className="text-sm text-gray-600">{plan.simDataSize}</p>
<h4 className="font-semibold text-foreground">{plan.name}</h4>
<p className="text-sm text-muted-foreground">{plan.simDataSize}</p>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
<p className="font-semibold text-foreground">
¥{plan.monthlyPrice?.toLocaleString()}
</p>
<p className="text-xs text-gray-500">per month</p>
<p className="text-xs text-muted-foreground">per month</p>
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Configuration</h4>
<div className="border-t border-border pt-4 mb-6">
<h4 className="font-medium text-foreground mb-3">Configuration</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">SIM Type:</span>
<span className="text-gray-900">{simType || "Not selected"}</span>
<span className="text-muted-foreground">SIM Type:</span>
<span className="text-foreground">{simType || "Not selected"}</span>
</div>
{simType === "eSIM" && eid && (
<div className="flex justify-between">
<span className="text-gray-600">EID:</span>
<span className="text-gray-900 font-mono text-xs">
<span className="text-muted-foreground">EID:</span>
<span className="text-foreground font-mono text-xs">
{eid.substring(0, 12)}...
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-600">Activation:</span>
<span className="text-gray-900">
<span className="text-muted-foreground">Activation:</span>
<span className="text-foreground">
{activationType === "Scheduled" && scheduledActivationDate
? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
: activationType || "Not selected"}
@ -467,16 +461,16 @@ export function SimConfigureView({
</div>
{wantsMnp && (
<div className="flex justify-between">
<span className="text-gray-600">Number Porting:</span>
<span className="text-gray-900">Requested</span>
<span className="text-muted-foreground">Number Porting:</span>
<span className="text-foreground">Requested</span>
</div>
)}
</div>
</div>
{selectedAddons.length > 0 && (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Add-ons</h4>
<div className="border-t border-border pt-4 mb-6">
<h4 className="font-medium text-foreground mb-3">Add-ons</h4>
<div className="space-y-2">
{selectedAddons.map(addonSku => {
const addon = addons.find(a => a.sku === addonSku);
@ -488,10 +482,10 @@ export function SimConfigureView({
return (
<div key={addonSku} className="flex justify-between text-sm">
<span className="text-gray-600">{addon?.name || addonSku}</span>
<span className="text-gray-900">
<span className="text-muted-foreground">{addon?.name || addonSku}</span>
<span className="text-foreground">
¥{addonAmount.toLocaleString()}
<span className="text-xs text-gray-500 ml-1">
<span className="text-xs text-muted-foreground ml-1">
/{addon?.billingCycle === "Monthly" ? "mo" : "once"}
</span>
</span>
@ -503,18 +497,18 @@ export function SimConfigureView({
)}
{activationFeeDetails && (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
<div className="border-t border-border pt-4 mb-6">
<h4 className="font-medium text-foreground mb-3">One-time Fees</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">{activationFeeDetails.name}</span>
<span className="text-gray-900">
<span className="text-muted-foreground">{activationFeeDetails.name}</span>
<span className="text-foreground">
¥{activationFeeDetails.amount.toLocaleString()}
</span>
</div>
{(requiredActivationFee?.catalogMetadata?.autoAdd ||
requiredActivationFee?.catalogMetadata?.isDefault) && (
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
Required for all new SIM activations
</p>
)}
@ -522,16 +516,16 @@ export function SimConfigureView({
</div>
)}
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
<div className="border-t-2 border-dashed border-border/60 pt-4 bg-muted -mx-6 px-6 py-4 rounded-b-lg">
<div className="space-y-2">
<div className="flex justify-between text-xl font-bold">
<span className="text-gray-900">Monthly Total</span>
<span className="text-blue-600">¥{monthlyTotal.toLocaleString()}</span>
<span className="text-foreground">Monthly Total</span>
<span className="text-primary">¥{monthlyTotal.toLocaleString()}</span>
</div>
{oneTimeTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">One-time Total</span>
<span className="text-orange-600 font-semibold">
<span className="text-muted-foreground">One-time Total</span>
<span className="text-warning font-semibold">
¥{oneTimeTotal.toLocaleString()}
</span>
</div>
@ -540,7 +534,7 @@ export function SimConfigureView({
</div>
</div>
<div className="flex justify-between items-center pt-6 border-t">
<div className="flex justify-between items-center pt-6 border-t border-border">
<Button
onClick={() => setCurrentStep(4)}
variant="outline"

View File

@ -15,89 +15,91 @@ import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
export function CatalogHomeView() {
return (
<div className="min-h-screen bg-slate-50">
<PageLayout icon={<></>} title="" description="">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-full text-sm font-medium mb-6">
<Squares2X2Icon className="h-4 w-4" />
Services Catalog
</div>
<h1 className="text-5xl font-bold text-gray-900 mb-6 leading-tight">
Choose Your Perfect
<br />
<span className="text-blue-600">Connectivity Solution</span>
</h1>
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
Discover high-speed internet, mobile data/voice options, and secure VPN services.
<PageLayout
icon={<Squares2X2Icon />}
title="Catalog"
description="Choose services and place new orders"
>
<div className="max-w-6xl">
<div className="mb-8">
<div className="inline-flex items-center gap-2 bg-muted text-foreground px-4 py-2 rounded-full text-sm font-medium mb-4">
<Squares2X2Icon className="h-4 w-4 text-primary" />
Services Catalog
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-foreground leading-tight">
Choose your connectivity solution
</h2>
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed">
Discover high-speed internet, mobile data/voice options, and secure VPN services.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
<ServiceHeroCard
title="Internet Service"
description="Ultra-high-speed fiber internet with speeds up to 10Gbps."
icon={<ServerIcon className="h-10 w-10" />}
features={[
"Up to 10Gbps speeds",
"Fiber optic technology",
"Multiple access modes",
"Professional installation",
]}
href="/catalog/internet"
color="blue"
/>
<ServiceHeroCard
title="SIM & eSIM"
description="Data, SMS, and voice plans with both physical SIM and eSIM options."
icon={<DevicePhoneMobileIcon className="h-10 w-10" />}
features={[
"Physical SIM & eSIM",
"Data + SMS + Voice plans",
"Family discounts",
"Multiple data options",
]}
href="/catalog/sim"
color="green"
/>
<ServiceHeroCard
title="VPN Service"
description="Secure remote access solutions for business and personal use."
icon={<ShieldCheckIcon className="h-10 w-10" />}
features={[
"Secure encryption",
"Multiple locations",
"Business & personal",
"24/7 connectivity",
]}
href="/catalog/vpn"
color="purple"
/>
</div>
<div className="bg-card text-card-foreground rounded-2xl p-8 border border-border shadow-[var(--cp-shadow-1)]">
<div className="mb-6">
<h3 className="text-lg sm:text-xl font-semibold text-foreground mb-2">
Why choose our services?
</h3>
<p className="text-sm text-muted-foreground max-w-2xl leading-relaxed">
Personalized recommendations based on your location and account eligibility.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<ServiceHeroCard
title="Internet Service"
description="Ultra-high-speed fiber internet with speeds up to 10Gbps."
icon={<ServerIcon className="h-10 w-10" />}
features={[
"Up to 10Gbps speeds",
"Fiber optic technology",
"Multiple access modes",
"Professional installation",
]}
href="/catalog/internet"
color="blue"
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard
icon={<WifiIcon className="h-8 w-8 text-primary" />}
title="Location-Based Plans"
description="Internet plans tailored to your house type and infrastructure"
/>
<ServiceHeroCard
title="SIM & eSIM"
description="Data, SMS, and voice plans with both physical SIM and eSIM options."
icon={<DevicePhoneMobileIcon className="h-10 w-10" />}
features={[
"Physical SIM & eSIM",
"Data + SMS + Voice plans",
"Family discounts",
"Multiple data options",
]}
href="/catalog/sim"
color="green"
<FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
title="Seamless Integration"
description="Manage all services from a single account"
/>
<ServiceHeroCard
title="VPN Service"
description="Secure remote access solutions for business and personal use."
icon={<ShieldCheckIcon className="h-10 w-10" />}
features={[
"Secure encryption",
"Multiple locations",
"Business & personal",
"24/7 connectivity",
]}
href="/catalog/vpn"
color="purple"
/>
</div>
<div className="bg-white rounded-2xl p-10 border border-gray-200 shadow-sm">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-3">Why Choose Our Services?</h2>
<p className="text-base text-gray-600 max-w-2xl mx-auto leading-relaxed">
Personalized recommendations based on your location and account eligibility.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<FeatureCard
icon={<WifiIcon className="h-8 w-8 text-blue-600" />}
title="Location-Based Plans"
description="Internet plans tailored to your house type and infrastructure"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
title="Seamless Integration"
description="Manage all services from a single account"
/>
</div>
</div>
</div>
</PageLayout>
</div>
</div>
</PageLayout>
);
}

View File

@ -54,164 +54,159 @@ export function InternetPlansContainer() {
const getEligibilityColor = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return "text-blue-600 bg-blue-50 border-blue-200";
if (lower.includes("apartment")) return "text-green-600 bg-green-50 border-green-200";
return "text-gray-600 bg-gray-50 border-gray-200";
if (lower.includes("home")) return "text-info bg-info-soft border-info/25";
if (lower.includes("apartment")) return "text-success bg-success-soft border-success/25";
return "text-muted-foreground bg-muted border-border";
};
if (isLoading || error) {
return (
<div className="min-h-screen bg-slate-50">
<PageLayout
title="Internet Plans"
description="Loading your personalized plans..."
icon={<WifiIcon className="h-6 w-6" />}
>
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/catalog" label="Back to Services" />
<PageLayout
title="Internet Plans"
description="Loading your personalized plans..."
icon={<WifiIcon className="h-6 w-6" />}
>
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-6xl mx-auto px-4">
<CatalogBackLink href="/catalog" label="Back to Services" />
{/* Title + eligibility */}
<div className="text-center mb-12">
<div className="h-10 w-96 bg-gray-200 rounded mx-auto mb-4" />
<div className="mt-6 inline-flex items-center gap-2 px-6 py-3 rounded-2xl border">
<div className="h-5 w-5 bg-gray-200 rounded" />
<div className="h-4 w-56 bg-gray-200 rounded" />
</div>
<div className="h-4 w-[32rem] max-w-full bg-gray-200 rounded mx-auto mt-2" />
{/* Title + eligibility */}
<div className="text-center mb-12">
<div className="h-10 w-96 bg-muted rounded mx-auto mb-4" />
<div className="mt-6 inline-flex items-center gap-2 px-6 py-3 rounded-2xl border border-border bg-card">
<div className="h-5 w-5 bg-muted rounded" />
<div className="h-4 w-56 bg-muted rounded" />
</div>
{/* Active internet warning slot */}
<div className="mb-8 h-20 bg-yellow-50 border border-yellow-200 rounded-xl" />
{/* Plans grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
{/* Important Notes */}
<div className="mt-12 h-24 bg-blue-50 border border-blue-200 rounded-xl" />
<div className="h-4 w-[32rem] max-w-full bg-muted rounded mx-auto mt-2" />
</div>
</AsyncBlock>
</PageLayout>
</div>
{/* Active internet warning slot */}
<div className="mb-8 h-20 bg-warning-soft border border-warning/25 rounded-xl" />
{/* Plans grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 space-y-3 shadow-[var(--cp-shadow-1)]"
>
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
{/* Important Notes */}
<div className="mt-12 h-24 bg-info-soft border border-info/25 rounded-xl" />
</div>
</AsyncBlock>
</PageLayout>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<PageLayout
title="Internet Plans"
description="High-speed internet services for your home or business"
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<PageLayout
title="Internet Plans"
description="High-speed internet services for your home or business"
icon={<WifiIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href="/catalog" label="Back to Services" />
<CatalogHero
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
>
{eligibility && (
<div className="flex flex-col items-center gap-2 animate-in fade-in duration-300">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border-2 ${getEligibilityColor(eligibility)} shadow-sm`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span>
</div>
<p className="text-sm text-gray-600 text-center max-w-md">
Plans shown are tailored to your house type and local infrastructure.
</p>
<CatalogHero
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
>
{eligibility && (
<div className="flex flex-col items-center gap-2">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span>
</div>
)}
</CatalogHero>
{hasActiveInternet && (
<AlertBanner
variant="warning"
title="You already have an Internet subscription"
className="mb-8 animate-in fade-in duration-300"
>
<p>
You already have an Internet subscription with us. If you want another subscription
for a different residence, please{" "}
<a
href="/support/new"
className="underline text-blue-700 hover:text-blue-600 font-medium transition-colors"
>
contact us
</a>
.
<p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are tailored to your house type and local infrastructure.
</p>
</AlertBanner>
)}
{plans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{plans.map((plan, index) => (
<div
key={plan.id}
className="animate-in fade-in duration-300"
style={{ animationDelay: `${index * 50}ms` }}
>
<InternetPlanCard
plan={plan}
installations={installations}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: undefined
}
/>
</div>
))}
</div>
<div className="mt-16 animate-in fade-in duration-300">
<AlertBanner variant="info" title="Important Notes">
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans
(¥450/month + ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</div>
</>
) : (
<div className="text-center py-16 animate-in fade-in duration-300">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 max-w-md mx-auto">
<ServerIcon className="h-16 w-16 text-gray-400 mx-auto mb-6" />
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Plans Available</h3>
<p className="text-gray-600 mb-8">
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<CatalogBackLink
href="/catalog"
label="Back to Services"
align="center"
className="mt-0 mb-0"
/>
</div>
</div>
)}
</div>
</PageLayout>
</div>
</CatalogHero>
{hasActiveInternet && (
<AlertBanner
variant="warning"
title="You already have an Internet subscription"
className="mb-8"
>
<p>
You already have an Internet subscription with us. If you want another subscription
for a different residence, please{" "}
<a
href="/support/new"
className="underline text-primary hover:text-primary-hover font-medium transition-colors"
>
contact us
</a>
.
</p>
</AlertBanner>
)}
{plans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{plans.map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: undefined
}
/>
</div>
))}
</div>
<div className="mt-16">
<AlertBanner variant="info" title="Important Notes">
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans
(¥450/month + ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</div>
</>
) : (
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<ServerIcon className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<CatalogBackLink
href="/catalog"
label="Back to Services"
align="center"
className="mt-0 mb-0"
/>
</div>
</div>
)}
</div>
</PageLayout>
);
}

View File

@ -108,14 +108,14 @@ export function CheckoutContainer() {
{activeInternetWarning && (
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
<span className="text-sm text-gray-700">{activeInternetWarning}</span>
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
</AlertBanner>
)}
<div className="bg-gray-50 border border-gray-200 rounded-2xl p-6 md:p-7 shadow-sm">
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<ShieldCheckIcon className="w-6 h-6 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Confirm Details</h2>
<ShieldCheckIcon className="w-6 h-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Confirm Details</h2>
</div>
<div className="space-y-5">
<SubCard>
@ -129,7 +129,7 @@ export function CheckoutContainer() {
<SubCard
title="Billing & Payment"
icon={<CreditCardIcon className="w-5 h-5 text-blue-600" />}
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
right={
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<StatusPill label="Verified" variant="success" />
@ -137,7 +137,7 @@ export function CheckoutContainer() {
}
>
{paymentMethodsLoading ? (
<div className="text-sm text-gray-600">Checking payment methods...</div>
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
) : paymentMethodsError ? (
<AlertBanner
variant="warning"
@ -161,17 +161,17 @@ export function CheckoutContainer() {
) : paymentMethodList.length > 0 ? (
<div className="space-y-3">
{paymentMethodDisplay ? (
<div className="rounded-xl border border-blue-100 bg-white/80 p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-blue-600">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Default payment method
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
<p className="mt-1 text-sm font-semibold text-foreground">
{paymentMethodDisplay.title}
</p>
{paymentMethodDisplay.subtitle ? (
<p className="mt-1 text-xs text-gray-600">
<p className="mt-1 text-xs text-muted-foreground">
{paymentMethodDisplay.subtitle}
</p>
) : null}
@ -188,7 +188,7 @@ export function CheckoutContainer() {
</div>
</div>
) : null}
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
We securely charge your saved payment method after the order is approved. Need
to make changes? Visit Billing & Payments.
</p>
@ -213,18 +213,18 @@ export function CheckoutContainer() {
</div>
</div>
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6 md:p-7 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm">
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
<div className="bg-muted border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-card rounded-full flex items-center justify-center mx-auto mb-4 shadow-[var(--cp-shadow-1)] border border-border">
<ShieldCheckIcon className="w-8 h-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
<p className="text-gray-700 mb-4 max-w-xl mx-auto">
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
Youre almost done. Confirm your details above, then submit your order. Well review and
notify you when everything is ready.
</p>
<div className="bg-white rounded-lg p-4 border border-blue-200 text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-gray-900 mb-2">What to expect</h3>
<div className="text-sm text-gray-700 space-y-1">
<div className="bg-card rounded-lg p-4 border border-border text-left max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
<div className="text-sm text-muted-foreground space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
<p> We only charge your card after the order is approved</p>
@ -232,15 +232,15 @@ export function CheckoutContainer() {
</div>
</div>
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 max-w-2xl mx-auto">
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Estimated Total</span>
<span className="font-medium text-muted-foreground">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-gray-900">
<div className="text-xl font-bold text-foreground">
¥{totals.monthlyTotal.toLocaleString()}/mo
</div>
{totals.oneTimeTotal > 0 && (
<div className="text-sm text-orange-600 font-medium">
<div className="text-sm text-warning font-medium">
+ ¥{totals.oneTimeTotal.toLocaleString()} one-time
</div>
)}
@ -250,15 +250,17 @@ export function CheckoutContainer() {
</div>
<div className="flex gap-4">
<button
<Button
type="button"
variant="outline"
className="flex-1 py-4"
onClick={navigateBackToConfigure}
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
>
Back to Configuration
</button>
<button
</Button>
<Button
type="button"
className="flex-1 py-4 text-lg"
onClick={() => {
void handleSubmitOrder();
}}
@ -270,18 +272,17 @@ export function CheckoutContainer() {
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
}
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
isLoading={submitting}
loadingText="Submitting…"
>
{submitting
? "Submitting Order..."
: !addressConfirmed
? "📍 Confirm Installation Address"
: paymentMethodsLoading
? "⏳ Verifying Payment Method..."
: !paymentMethods || paymentMethods.paymentMethods.length === 0
? "💳 Add Payment Method to Continue"
: "📋 Submit Order"}
</button>
{!addressConfirmed
? "Confirm Installation Address"
: paymentMethodsLoading
? "Verifying Payment Method…"
: !paymentMethods || paymentMethods.paymentMethods.length === 0
? "Add Payment Method to Continue"
: "Submit Order"}
</Button>
</div>
</div>
</PageLayout>

View File

@ -4,16 +4,16 @@ import { CheckCircleIcon } from "@heroicons/react/24/outline";
export function AccountStatusCard() {
return (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">Account Status</h3>
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Account Status</h3>
</div>
<div className="p-6">
<div className="flex items-center">
<CheckCircleIcon className="h-8 w-8 text-green-500" />
<CheckCircleIcon className="h-8 w-8 text-success" />
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">Account Active</p>
<p className="text-xs text-gray-500">All systems operational</p>
<p className="text-sm font-medium text-foreground">Account Active</p>
<p className="text-xs text-muted-foreground">All systems operational</p>
</div>
</div>
</div>

View File

@ -35,33 +35,42 @@ export function DashboardActivityItem({ activity, onClick }: DashboardActivityIt
const gradient = getActivityIconGradient(activity.type);
const description = formatActivityDescription(activity);
const formattedDate = formatActivityDate(activity.date);
const Wrapper = onClick ? "button" : "div";
return (
<Wrapper
className={`flex items-start space-x-4 w-full text-left ${
onClick ? "p-3 -m-3 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer" : ""
}`}
onClick={onClick}
>
const content = (
<>
<div className="flex-shrink-0">
<div
className={`w-10 h-10 rounded-full bg-gradient-to-r ${gradient} flex items-center justify-center shadow-md`}
className={`w-10 h-10 rounded-full bg-gradient-to-r ${gradient} flex items-center justify-center shadow-[var(--cp-shadow-1)]`}
>
<Icon className="h-5 w-5 text-white" />
</div>
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium ${
onClick ? "text-blue-900 group-hover:text-blue-700" : "text-gray-900"
}`}
className={[
"text-sm font-medium",
onClick ? "text-foreground group-hover:text-primary" : "text-foreground",
].join(" ")}
>
{activity.title}
</p>
<p className="text-sm text-gray-500 mt-1">{description}</p>
<p className="text-xs text-gray-400 mt-2">{formattedDate}</p>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
<p className="text-xs text-muted-foreground/70 mt-2">{formattedDate}</p>
</div>
</Wrapper>
</>
);
if (onClick) {
return (
<button
type="button"
className="group flex items-start space-x-4 w-full text-left p-3 -m-3 rounded-lg hover:bg-muted transition-colors duration-[var(--cp-duration-normal)] cursor-pointer"
onClick={onClick}
>
{content}
</button>
);
}
return <div className="flex items-start space-x-4 w-full text-left">{content}</div>;
}

View File

@ -20,19 +20,19 @@ export function QuickAction({
return (
<Link
href={href}
className="flex items-center p-4 rounded-xl hover:bg-gray-50 transition-colors group"
className="flex items-center p-4 rounded-xl hover:bg-muted transition-colors duration-[var(--cp-duration-normal)] group"
>
<div className={`flex-shrink-0 p-2 rounded-lg ${bgColor}`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
<div className="ml-3 flex-1">
<p className="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
<p className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{title}
</p>
<p className="text-xs text-gray-500">{description}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<svg
className="h-4 w-4 text-gray-400 group-hover:text-blue-600 transition-colors"
className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors"
viewBox="0 0 20 20"
fill="currentColor"
>

View File

@ -21,15 +21,17 @@ export function StatCard({
const router = useRouter();
return (
<Link href={href} className="group">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden hover:shadow-xl transition-all duration-300 group-hover:scale-[1.02] min-h-[116px] flex">
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden hover:shadow-[var(--cp-card-shadow-lg)] transition-shadow duration-[var(--cp-duration-normal)] min-h-[116px] flex">
<div className="p-6 flex-1">
<div className="flex items-center">
<div className={`flex-shrink-0 p-3 rounded-xl bg-gradient-to-r ${gradient}`}>
<Icon className="h-6 w-6 text-white" />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600 uppercase tracking-wide">{title}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
{title}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{value}</p>
{Number(value) === 0 && zeroHint ? (
<span
onClick={e => {
@ -38,7 +40,7 @@ export function StatCard({
e.stopPropagation();
router.push(zeroHint.href);
}}
className="mt-1 inline-flex items-center text-xs font-medium text-blue-600 hover:text-blue-700 cursor-pointer"
className="mt-1 inline-flex items-center text-xs font-medium text-primary hover:underline cursor-pointer"
>
{zeroHint.text}
</span>

View File

@ -25,6 +25,8 @@ import { useDashboardSummary } from "@/features/dashboard/hooks";
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
import { LoadingStats, LoadingTable } from "@/components/atoms";
import { ErrorState } from "@/components/atoms/error-state";
import { PageLayout } from "@/components/templates";
import { Button } from "@/components/atoms/button";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { log } from "@/lib/logger";
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
@ -78,209 +80,206 @@ export function DashboardView() {
if (authLoading || summaryLoading || !isAuthenticated) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-4">
<div className="space-y-6">
<LoadingStats />
<LoadingTable />
</div>
<PageLayout title="Dashboard" description="Overview of your account" loading>
<div className="space-y-6">
<LoadingStats />
<LoadingTable />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
</PageLayout>
);
}
// Handle error state
if (error) {
const errorMessage =
typeof error === "string"
? error
: error instanceof Error
? error.message
: typeof error === "object" && error && "message" in error
? String((error as { message?: unknown }).message)
: "An unexpected error occurred";
return (
<ErrorState
title="Error loading dashboard"
message={error instanceof Error ? error.message : "An unexpected error occurred"}
variant="page"
/>
<PageLayout title="Dashboard" description="Overview of your account">
<ErrorState title="Error loading dashboard" message={errorMessage} variant="page" />
</PageLayout>
);
}
return (
<>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] sm:px-6 lg:px-8 py-[var(--cp-space-2xl)]">
{/* Modern Header */}
<div className="mb-[var(--cp-space-3xl)]">
<div className="flex items-start justify-between gap-[var(--cp-space-xl)]">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold leading-tight text-gray-900">
Welcome back, {user?.firstname || user?.email?.split("@")[0] || "User"}!
</h1>
{/* Tasks chip */}
<TasksChip summaryLoading={summaryLoading} summary={summary} />
</div>
<div className="mt-1 text-sm text-gray-500">Portal / Dashboard</div>
</div>
{/* No duplicate page-level CTAs here per guidelines */}
</div>
<PageLayout
title="Dashboard"
description="Overview of your account"
actions={<TasksChip summaryLoading={summaryLoading} summary={summary} />}
>
{/* Greeting */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm text-muted-foreground">Welcome back</div>
<div className="text-xl sm:text-2xl font-semibold text-foreground truncate">
{user?.firstname || user?.email?.split("@")[0] || "User"}
</div>
</div>
</div>
{/* Modern Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--cp-space-2xl)] mb-[var(--cp-space-3xl)]">
<StatCard
title="Recent Orders"
value={summary?.stats?.recentOrders ?? 0}
icon={ClipboardDocumentListIconSolid}
gradient="from-gray-500 to-gray-600"
href="/orders"
/>
<StatCard
title="Pending Invoices"
value={summary?.stats?.unpaidInvoices || 0}
icon={CreditCardIconSolid}
gradient={
(summary?.stats?.unpaidInvoices ?? 0) > 0
? "from-amber-500 to-orange-500"
: "from-gray-500 to-gray-600"
}
href="/billing/invoices"
zeroHint={{ text: "Set up auto-pay", href: "/billing/payments" }}
/>
<StatCard
title="Active Services"
value={summary?.stats?.activeSubscriptions || 0}
icon={ServerIconSolid}
gradient="from-blue-500 to-cyan-500"
href="/subscriptions"
/>
<StatCard
title="Support Cases"
value={summary?.stats?.openCases || 0}
icon={ChatBubbleLeftRightIconSolid}
gradient={
(summary?.stats?.openCases ?? 0) > 0
? "from-blue-500 to-cyan-500"
: "from-gray-500 to-gray-600"
}
href="/support/cases"
zeroHint={{ text: "Open a ticket", href: "/support/new" }}
/>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-[var(--cp-space-2xl)]">
<StatCard
title="Recent Orders"
value={summary?.stats?.recentOrders ?? 0}
icon={ClipboardDocumentListIconSolid}
gradient="from-primary to-primary-hover"
href="/orders"
/>
<StatCard
title="Pending Invoices"
value={summary?.stats?.unpaidInvoices || 0}
icon={CreditCardIconSolid}
gradient={
(summary?.stats?.unpaidInvoices ?? 0) > 0
? "from-warning to-warning"
: "from-muted-foreground to-foreground"
}
href="/billing/invoices"
zeroHint={{ text: "Set up auto-pay", href: "/billing/payments" }}
/>
<StatCard
title="Active Services"
value={summary?.stats?.activeSubscriptions || 0}
icon={ServerIconSolid}
gradient="from-info to-primary"
href="/subscriptions"
/>
<StatCard
title="Support Cases"
value={summary?.stats?.openCases || 0}
icon={ChatBubbleLeftRightIconSolid}
gradient={
(summary?.stats?.openCases ?? 0) > 0
? "from-info to-primary"
: "from-muted-foreground to-foreground"
}
href="/support/cases"
zeroHint={{ text: "Open a ticket", href: "/support/new" }}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-[var(--cp-space-3xl)]">
{/* Main Content Area */}
<div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]">
{/* Upcoming Payment - compressed attention banner */}
{upcomingInvoice && (
<div
id="attention"
className="bg-white rounded-xl border border-orange-200 shadow-sm p-4"
>
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-md bg-gradient-to-r from-amber-500 to-orange-500 flex items-center justify-center">
<CalendarDaysIcon className="h-5 w-5 text-white" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<span className="font-semibold text-gray-900">Upcoming Payment</span>
<span className="text-gray-400"></span>
<span>Invoice #{upcomingInvoice.id}</span>
<span className="text-gray-400"></span>
<span title={format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}>
Due{" "}
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
addSuffix: true,
})}
</span>
</div>
<div className="mt-1 text-2xl font-bold text-gray-900">
{formatCurrency(upcomingInvoice.amount, {
currency: upcomingInvoice.currency,
})}
</div>
<div className="mt-1 text-xs text-gray-500">
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
</div>
</div>
<div className="flex flex-col items-end gap-2">
<button
onClick={() => handlePayNow(upcomingInvoice.id)}
disabled={paymentLoading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{paymentLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : null}
{paymentLoading ? "Opening Payment..." : "Pay Now"}
{!paymentLoading && <ChevronRightIcon className="ml-2 h-4 w-4" />}
</button>
<Link
href={`/billing/invoices/${upcomingInvoice.id}`}
className="text-blue-600 hover:text-blue-700 font-medium text-sm"
>
View invoice
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-[var(--cp-space-3xl)]">
{/* Main Content Area */}
<div className="lg:col-span-2 space-y-[var(--cp-space-3xl)]">
{/* Upcoming Payment */}
{upcomingInvoice && (
<div
id="attention"
className="bg-card border border-warning/35 rounded-xl p-4 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-md bg-gradient-to-r from-warning to-warning flex items-center justify-center">
<CalendarDaysIcon className="h-5 w-5 text-warning-foreground" />
</div>
</div>
)}
{/* Payment Error Display */}
{paymentError && (
<ErrorState
title="Payment Error"
message={paymentError}
variant="inline"
onRetry={() => setPaymentError(null)}
retryLabel="Dismiss"
/>
)}
{/* Recent Activity - filtered list */}
<RecentActivityCard
activities={summary?.recentActivity || []}
onItemClick={handleActivityClick}
/>
</div>
{/* Sidebar */}
<div className="space-y-[var(--cp-space-2xl)]">
{/* Quick Actions - simplified */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">Quick Actions</h3>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">Upcoming Payment</span>
<span className="text-muted-foreground/60"></span>
<span>Invoice #{upcomingInvoice.id}</span>
<span className="text-muted-foreground/60"></span>
<span title={format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}>
Due{" "}
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
addSuffix: true,
})}
</span>
</div>
<div className="mt-1 text-2xl font-bold text-foreground">
{formatCurrency(upcomingInvoice.amount, {
currency: upcomingInvoice.currency,
})}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
</div>
</div>
<div className="p-[var(--cp-space-2xl)] space-y-[var(--cp-space-lg)]">
<QuickAction
href="/billing/invoices"
title="View invoices"
description="Review and pay invoices"
icon={DocumentTextIcon}
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/subscriptions"
title="Manage services"
description="View active subscriptions"
icon={ServerIcon}
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/support/new"
title="Get support"
description="Open a support ticket"
icon={ChatBubbleLeftRightIcon}
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<div className="flex flex-col items-end gap-2">
<Button
size="sm"
onClick={() => handlePayNow(upcomingInvoice.id)}
isLoading={paymentLoading}
loadingText="Opening…"
rightIcon={
!paymentLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined
}
>
Pay now
</Button>
<Link
href={`/billing/invoices/${upcomingInvoice.id}`}
className="text-primary hover:underline font-medium text-sm"
>
View invoice
</Link>
</div>
</div>
</div>
)}
{/* Payment Error Display */}
{paymentError && (
<ErrorState
title="Payment Error"
message={paymentError}
variant="inline"
onRetry={() => setPaymentError(null)}
retryLabel="Dismiss"
/>
)}
{/* Recent Activity */}
<RecentActivityCard
activities={summary?.recentActivity || []}
onItemClick={handleActivityClick}
/>
</div>
{/* Sidebar */}
<div className="space-y-[var(--cp-space-2xl)]">
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-foreground">Quick Actions</h3>
</div>
<div className="p-[var(--cp-space-2xl)] space-y-[var(--cp-space-lg)]">
<QuickAction
href="/billing/invoices"
title="View invoices"
description="Review and pay invoices"
icon={DocumentTextIcon}
iconColor="text-primary"
bgColor="bg-primary/10"
/>
<QuickAction
href="/subscriptions"
title="Manage services"
description="View active subscriptions"
icon={ServerIcon}
iconColor="text-primary"
bgColor="bg-primary/10"
/>
<QuickAction
href="/support/new"
title="Get support"
description="Open a support ticket"
icon={ChatBubbleLeftRightIcon}
iconColor="text-primary"
bgColor="bg-primary/10"
/>
</div>
</div>
</div>
</div>
</>
</PageLayout>
);
}
@ -309,7 +308,7 @@ function TasksChip({
router.push(first.href);
}
}}
className="inline-flex items-center rounded-full bg-blue-50 text-blue-700 px-2.5 py-1 text-xs font-medium hover:bg-blue-100"
className="inline-flex items-center rounded-full bg-muted text-foreground px-2.5 py-1 text-xs font-medium hover:bg-muted/80 transition-colors"
title={tasks.map(t => t.label).join(" • ")}
>
{count} tasks
@ -333,11 +332,11 @@ function RecentActivityCard({
return true;
});
return (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<div className="bg-card text-card-foreground rounded-2xl shadow-[var(--cp-card-shadow)] border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<h3 className="text-lg font-semibold text-foreground">Recent Activity</h3>
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
{(
[
{ k: "all", label: "All" },
@ -351,8 +350,8 @@ function RecentActivityCard({
onClick={() => setFilter(opt.k)}
className={`px-2.5 py-1 text-xs rounded-md font-medium ${
filter === opt.k
? "bg-white text-gray-900 shadow"
: "text-gray-600 hover:text-gray-900"
? "bg-card text-foreground shadow-[var(--cp-shadow-1)]"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
@ -378,9 +377,11 @@ function RecentActivityCard({
</div>
) : (
<div className="text-center py-12">
<ArrowTrendingUpIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No recent activity</h3>
<p className="mt-1 text-sm text-gray-500">Your account activity will appear here.</p>
<ArrowTrendingUpIcon className="mx-auto h-12 w-12 text-muted-foreground/60" />
<h3 className="mt-2 text-sm font-medium text-foreground">No recent activity</h3>
<p className="mt-1 text-sm text-muted-foreground">
Your account activity will appear here.
</p>
</div>
)}
</div>

View File

@ -10,90 +10,64 @@ import {
export function PublicLandingView() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 via-indigo-50 to-purple-100">
{/* Header */}
<header className="bg-gradient-to-r from-white/90 via-blue-50/90 to-indigo-50/90 backdrop-blur-sm border-b border-indigo-100/50">
<div className="max-w-6xl mx-auto px-6">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-3">
<Logo size={32} />
<span className="text-lg font-semibold text-gray-900">Assist Solutions</span>
</div>
<div className="space-y-[var(--cp-space-3xl)]">
{/* Hero */}
<section className="text-center space-y-4">
<div className="inline-flex items-center justify-center h-14 w-14 rounded-2xl border border-border bg-card shadow-[var(--cp-shadow-1)] mx-auto">
<Logo size={28} />
</div>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight">Customer Portal</h1>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto">
Manage your services, billing, and support in one place.
</p>
</section>
<div className="flex items-center space-x-4">
<Link
href="/auth/login"
className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-2.5 rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
>
Login
</Link>
{/* Primary actions */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="cp-card">
<div className="flex items-start gap-4">
<div className="h-11 w-11 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
<UserIcon className="h-6 w-6 text-foreground/70" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold">Existing customers</h2>
<p className="text-sm text-muted-foreground mt-1">
Sign in or migrate your account from the old system.
</p>
<div className="mt-4 flex flex-col sm:flex-row gap-2">
<Link
href="/auth/login"
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover transition-colors"
>
Sign in
</Link>
<Link
href="/auth/link-whmcs"
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium border border-border bg-card hover:bg-muted transition-colors"
>
Migrate account
</Link>
</div>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="py-24 relative overflow-hidden">
{/* Subtle background decoration */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-32 w-96 h-96 bg-gradient-to-br from-blue-400/20 to-indigo-600/20 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-32 w-96 h-96 bg-gradient-to-tr from-indigo-400/20 to-purple-600/20 rounded-full blur-3xl"></div>
</div>
<div className="max-w-4xl mx-auto px-6 text-center relative">
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6">
Customer Portal
</h1>
<p className="text-xl text-gray-700 mb-12">
Manage your services, billing, and support in one place
</p>
</div>
</section>
{/* Access Options */}
<section className="py-16 bg-gradient-to-r from-blue-50/80 via-indigo-50/80 to-purple-50/80 backdrop-blur-sm">
<div className="max-w-4xl mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Existing Users */}
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-8 border border-white/50 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<UserIcon className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Existing Customers</h3>
<p className="text-gray-600 mb-6">
Access your account or migrate from the old system
</p>
<div className="space-y-3">
<Link
href="/auth/login"
className="block bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-3 rounded-lg hover:from-blue-700 hover:to-indigo-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
>
Login to Portal
</Link>
<Link
href="/auth/link-whmcs"
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
>
Migrate Account
</Link>
</div>
</div>
<div className="cp-card">
<div className="flex items-start gap-4">
<div className="h-11 w-11 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
<SparklesIcon className="h-6 w-6 text-foreground/70" />
</div>
{/* New Users */}
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-8 border border-white/50 shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
<SparklesIcon className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">New Customers</h3>
<p className="text-gray-600 mb-6">Create an account to get started</p>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold">New customers</h2>
<p className="text-sm text-muted-foreground mt-1">
Create an account to get started.
</p>
<div className="mt-4">
<Link
href="/auth/signup"
className="block bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-6 py-3 rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium bg-primary text-primary-foreground hover:bg-primary-hover transition-colors"
>
Create Account
Create account
</Link>
</div>
</div>
@ -101,88 +75,46 @@ export function PublicLandingView() {
</div>
</section>
{/* Key Features */}
<section className="py-16 bg-gradient-to-r from-indigo-50/80 via-purple-50/80 to-pink-50/80">
<div className="max-w-4xl mx-auto px-6">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-purple-900 bg-clip-text text-transparent mb-3">
Everything you need
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<CreditCardIcon className="w-6 h-6 text-white" />
</div>
<h3 className="font-semibold text-gray-900 mb-2">Billing</h3>
<p className="text-sm text-gray-600">Automated invoicing and payments</p>
</div>
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Cog6ToothIcon className="w-6 h-6 text-white" />
</div>
<h3 className="font-semibold text-gray-900 mb-2">Services</h3>
<p className="text-sm text-gray-600">Manage all your subscriptions</p>
</div>
<div className="text-center bg-white/70 backdrop-blur-sm rounded-xl p-6 border border-white/50 shadow-lg hover:shadow-xl transition-all duration-300">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
<PhoneIcon className="w-6 h-6 text-white" />
</div>
<h3 className="font-semibold text-gray-900 mb-2">Support</h3>
<p className="text-sm text-gray-600">Get help when you need it</p>
</div>
</div>
</div>
</section>
{/* Support Section */}
<section className="py-16 bg-gradient-to-br from-purple-50/80 via-pink-50/80 to-rose-50/80">
<div className="max-w-2xl mx-auto px-6 text-center">
<h2 className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-indigo-900 bg-clip-text text-transparent mb-4">
{/* Feature highlights */}
<section className="cp-card">
<div className="flex items-center justify-between gap-4 flex-wrap mb-6">
<h2 className="text-lg font-semibold">Everything you need</h2>
<Link href="/support" className="text-sm font-medium text-primary hover:underline">
Need help?
</h2>
<p className="text-gray-700 mb-8">
Our support team is here to assist you with any questions
</p>
<Link
href="/support"
className="inline-block bg-white/80 backdrop-blur-sm border-2 border-purple-200 text-purple-700 px-8 py-3 rounded-lg hover:bg-purple-50 hover:border-purple-300 transition-all duration-200 font-medium shadow-lg hover:shadow-xl"
>
Contact Support
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-gradient-to-r from-gray-900 via-indigo-900 to-purple-900 text-gray-300 py-8">
<div className="max-w-4xl mx-auto px-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center space-x-3">
<Logo size={24} />
<span className="text-white font-medium">Assist Solutions</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-xl border border-border bg-card p-5">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<CreditCardIcon className="h-5 w-5 text-foreground/70" />
</div>
<div className="flex gap-6 text-sm">
<Link href="/support" className="hover:text-white transition-colors duration-200">
Support
</Link>
<Link href="#" className="hover:text-white transition-colors duration-200">
Privacy
</Link>
<Link href="#" className="hover:text-white transition-colors duration-200">
Terms
</Link>
<div className="font-semibold">Billing</div>
<div className="text-sm text-muted-foreground mt-1">
View invoices, payments, and billing history.
</div>
</div>
<div className="mt-6 pt-6 border-t border-gray-700/50 text-center text-sm">
<p>&copy; {new Date().getFullYear()} Assist Solutions. All rights reserved.</p>
<div className="rounded-xl border border-border bg-card p-5">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<Cog6ToothIcon className="h-5 w-5 text-foreground/70" />
</div>
<div className="font-semibold">Services</div>
<div className="text-sm text-muted-foreground mt-1">
Manage subscriptions and service details.
</div>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<PhoneIcon className="h-5 w-5 text-foreground/70" />
</div>
<div className="font-semibold">Support</div>
<div className="text-sm text-muted-foreground mt-1">
Create cases and track responses in one place.
</div>
</div>
</div>
</footer>
</section>
</div>
);
}

View File

@ -97,24 +97,24 @@ const ITEM_VISUAL_STYLES: Record<
}
> = {
service: {
container: "border-blue-200 bg-white",
icon: "bg-blue-50 text-blue-600",
container: "border-info/30 bg-card",
icon: "bg-info-soft text-info",
},
installation: {
container: "border-green-200 bg-white",
icon: "bg-green-50 text-green-600",
container: "border-success/25 bg-card",
icon: "bg-success-soft text-success",
},
addon: {
container: "border-purple-200 bg-white",
icon: "bg-purple-50 text-purple-600",
container: "border-border bg-card",
icon: "bg-muted text-foreground/70",
},
activation: {
container: "border-green-200 bg-white",
icon: "bg-green-50 text-green-600",
container: "border-success/25 bg-card",
icon: "bg-success-soft text-success",
},
other: {
container: "border-slate-200 bg-white",
icon: "bg-slate-50 text-slate-600",
container: "border-border bg-card",
icon: "bg-muted text-muted-foreground",
},
};
@ -286,27 +286,27 @@ export function OrderDetailContainer() {
size="sm"
variant="ghost"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
className="text-gray-600 hover:text-gray-900"
className="text-muted-foreground hover:text-foreground"
>
Back to orders
</Button>
</div>
{error && <div className="mb-4 text-sm text-red-600">{error}</div>}
{error && <div className="mb-4 text-sm text-destructive">{error}</div>}
{isNewOrder && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-5 py-4 shadow-sm">
<div className="mb-6 rounded-2xl border border-success/25 bg-success-soft px-5 py-4 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-3">
<CheckCircleIcon className="h-6 w-6 text-green-600" />
<CheckCircleIcon className="h-6 w-6 text-success" />
<div className="space-y-3">
<div>
<h3 className="text-lg font-semibold text-green-900">Order Submitted!</h3>
<p className="text-sm text-green-800">
<h3 className="text-lg font-semibold text-foreground">Order submitted</h3>
<p className="text-sm text-muted-foreground">
We&apos;ve received your order and started the review process. Status updates will
appear here automatically.
</p>
</div>
<ul className="list-disc space-y-1 pl-5 text-sm text-green-700">
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
<li>We&apos;ll confirm everything within 1 business day.</li>
<li>You&apos;ll get an email as soon as the order is approved.</li>
<li>Installation scheduling happens right after approval.</li>
@ -318,24 +318,24 @@ export function OrderDetailContainer() {
{data ? (
<>
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
<div className="rounded-3xl border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
{/* Header Section */}
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
<div className="border-b border-border bg-card px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
{/* Left: Title & Date */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-muted text-muted-foreground">
{serviceIcon}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2>
<h2 className="text-2xl font-bold text-foreground">{serviceLabel}</h2>
{statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)}
</div>
{placedDate && <p className="text-sm text-gray-500">{placedDate}</p>}
{placedDate && <p className="text-sm text-muted-foreground">{placedDate}</p>}
</div>
</div>
</div>
@ -344,20 +344,20 @@ export function OrderDetailContainer() {
<div className="flex items-start gap-6 sm:gap-8">
{totals.monthlyTotal > 0 && (
<div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Monthly
</p>
<p className="text-3xl font-bold text-gray-900">
<p className="text-3xl font-bold text-foreground">
{yenFormatter.format(totals.monthlyTotal)}
</p>
</div>
)}
{totals.oneTimeTotal > 0 && (
<div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
One-Time
</p>
<p className="text-3xl font-bold text-gray-900">
<p className="text-3xl font-bold text-foreground">
{yenFormatter.format(totals.oneTimeTotal)}
</p>
</div>
@ -371,14 +371,14 @@ export function OrderDetailContainer() {
{/* Order Items Section */}
<div>
<h3
className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700"
className="mb-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground"
style={{ letterSpacing: "0.1em" }}
>
Order Details
</h3>
<div className="space-y-4">
{displayItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500">
<div className="rounded-xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No items found on this order.
</div>
) : (
@ -409,10 +409,10 @@ export function OrderDetailContainer() {
</div>
<div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center">
<div className="min-w-0 flex-1">
<h4 className="font-semibold leading-snug text-gray-900">
<h4 className="font-semibold leading-snug text-foreground">
{item.name}
</h4>
<p className="mt-0.5 text-xs font-semibold text-gray-600">
<p className="mt-0.5 text-xs font-semibold text-muted-foreground">
{categoryConfig.label}
</p>
</div>
@ -426,10 +426,10 @@ export function OrderDetailContainer() {
key={`${item.id}-charge-${index}`}
className="whitespace-nowrap text-lg"
>
<span className="font-bold text-gray-900">
<span className="font-bold text-foreground">
{yenFormatter.format(charge.amount)}
</span>
<span className="ml-2 text-xs font-medium text-gray-500">
<span className="ml-2 text-xs font-medium text-muted-foreground">
{descriptor}
</span>
</div>
@ -439,7 +439,7 @@ export function OrderDetailContainer() {
return (
<div
key={`${item.id}-charge-${index}`}
className="text-xs font-medium text-gray-500"
className="text-xs font-medium text-muted-foreground"
>
Included {descriptor}
</div>
@ -458,12 +458,14 @@ export function OrderDetailContainer() {
{/* Next Steps Section */}
{statusDescriptor?.nextAction && (
<div className="border-l-4 border-blue-500 bg-blue-50 px-4 py-3">
<div className="border-l-4 border-info bg-info-soft px-4 py-3">
<div className="flex items-start gap-3">
<ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" />
<ClockIcon className="h-5 w-5 flex-shrink-0 text-info" />
<div>
<p className="text-sm font-semibold text-blue-900">Next Steps</p>
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
<p className="text-sm font-semibold text-foreground">Next Steps</p>
<p className="mt-1 text-sm text-muted-foreground">
{statusDescriptor.nextAction}
</p>
</div>
</div>
</div>
@ -492,7 +494,7 @@ export function OrderDetailContainer() {
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white/40 p-6 text-sm text-gray-500">
<div className="rounded-2xl border border-dashed border-border bg-muted/40 p-6 text-sm text-muted-foreground">
Loading order details
</div>
)}

View File

@ -3,7 +3,7 @@
import { Suspense, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { OrderCard } from "@/features/orders/components/OrderCard";
@ -11,26 +11,16 @@ import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleto
import { EmptyState } from "@/components/atoms/empty-state";
import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
import { isApiError } from "@/lib/api";
import { Button } from "@/components/atoms/button";
function OrdersSuccessBanner() {
const searchParams = useSearchParams();
const showSuccess = searchParams.get("status") === "success";
if (!showSuccess) return null;
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
<div className="flex items-start">
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h3 className="text-lg font-semibold text-green-900 mb-2">
Order Submitted Successfully!
</h3>
<p className="text-green-800">
Your order has been created and is now being processed. You can track its progress
below.
</p>
</div>
</div>
</div>
<AlertBanner variant="success" title="Order submitted" className="mb-6" elevated>
Your order has been created and is now being processed. You can track its progress below.
</AlertBanner>
);
}
@ -73,14 +63,16 @@ export function OrdersListContainer() {
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm">{errorMessage}</span>
{showRetry && (
<button
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void refetch()}
className="inline-flex items-center rounded-lg border border-blue-200 px-3 py-1.5 text-sm font-medium text-blue-700 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isFetching}
isLoading={isFetching}
loadingText="Retrying…"
>
{isFetching ? "Retrying…" : "Retry"}
</button>
Retry
</Button>
)}
</div>
</AlertBanner>

View File

@ -37,22 +37,22 @@ function ServiceManagementContent({ subscriptionId, productName }: ServiceManage
}, [searchParams]);
const renderHeader = () => (
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border flex items-center justify-between">
<div className="flex items-center">
<WrenchScrewdriverIcon className="h-6 w-6 text-blue-600 mr-2" />
<WrenchScrewdriverIcon className="h-6 w-6 text-primary mr-2" />
<div>
<h3 className="text-lg font-medium text-gray-900">Service Management</h3>
<p className="text-sm text-gray-500">Manage settings for your subscription</p>
<h3 className="text-lg font-medium text-foreground">Service Management</h3>
<p className="text-sm text-muted-foreground">Manage settings for your subscription</p>
</div>
</div>
<div className="flex items-center space-x-2">
<label htmlFor="service-selector" className="text-sm text-gray-600">
<label htmlFor="service-selector" className="text-sm text-muted-foreground">
Service
</label>
<select
id="service-selector"
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
className="block w-48 rounded-[var(--cp-radius-md)] border border-input bg-background text-foreground shadow-sm focus:border-primary focus:ring-2 focus:ring-ring text-sm outline-none"
value={selectedService}
onChange={e => setSelectedService(e.target.value as ServiceKey)}
>
@ -74,11 +74,11 @@ function ServiceManagementContent({ subscriptionId, productName }: ServiceManage
title: string;
description: string;
}) => (
<div className="px-6 py-10 text-center text-gray-600">
<Icon className="mx-auto h-12 w-12 text-gray-400" />
<h4 className="mt-4 text-base font-medium text-gray-900">{title}</h4>
<div className="px-[var(--cp-space-6)] py-[var(--cp-space-10)] text-center text-muted-foreground">
<Icon className="mx-auto h-12 w-12 text-muted-foreground" />
<h4 className="mt-4 text-base font-medium text-foreground">{title}</h4>
<p className="mt-2 text-sm">{description}</p>
<span className="mt-3 inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-700">
<span className="mt-3 inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-muted text-muted-foreground">
Coming soon
</span>
</div>
@ -86,20 +86,26 @@ function ServiceManagementContent({ subscriptionId, productName }: ServiceManage
return (
<div className="space-y-6 mb-6">
<div className="bg-white shadow rounded-lg">{renderHeader()}</div>
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border overflow-hidden">
{renderHeader()}
</div>
{selectedService === "SIM" ? (
isSimService ? (
<SimManagementSection subscriptionId={subscriptionId} />
) : (
<div className="bg-white shadow rounded-lg p-6 text-center">
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
<h4 className="mt-2 text-sm font-medium text-gray-900">SIM management not available</h4>
<p className="mt-1 text-sm text-gray-500">This subscription is not a SIM service.</p>
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border p-[var(--cp-card-padding)] text-center">
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-muted-foreground" />
<h4 className="mt-2 text-sm font-medium text-foreground">
SIM management not available
</h4>
<p className="mt-1 text-sm text-muted-foreground">
This subscription is not a SIM service.
</p>
</div>
)
) : selectedService === "INTERNET" ? (
<div className="bg-white shadow rounded-lg">
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border">
<ComingSoon
icon={GlobeAltIcon}
title="Internet Service Management"
@ -107,7 +113,7 @@ function ServiceManagementContent({ subscriptionId, productName }: ServiceManage
/>
</div>
) : selectedService === "NETGEAR" ? (
<div className="bg-white shadow rounded-lg">
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border">
<ComingSoon
icon={ShieldCheckIcon}
title="Netgear Device Management"
@ -115,7 +121,7 @@ function ServiceManagementContent({ subscriptionId, productName }: ServiceManage
/>
</div>
) : (
<div className="bg-white shadow rounded-lg">
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border">
<ComingSoon
icon={LockClosedIcon}
title="VPN Service Management"
@ -133,20 +139,20 @@ export function ServiceManagementSection(props: ServiceManagementSectionProps) {
<Suspense
fallback={
<div className="space-y-6 mb-6">
<div className="bg-white shadow rounded-lg px-6 py-4">
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border px-[var(--cp-space-6)] py-[var(--cp-space-4)]">
<div className="flex items-center">
<WrenchScrewdriverIcon className="h-6 w-6 text-blue-600 mr-2" />
<WrenchScrewdriverIcon className="h-6 w-6 text-primary mr-2" />
<div>
<h3 className="text-lg font-medium text-gray-900">Service Management</h3>
<p className="text-sm text-gray-500">Loading...</p>
<h3 className="text-lg font-medium text-foreground">Service Management</h3>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
</div>
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-10 animate-pulse">
<div className="h-12 w-12 bg-gray-200 rounded-full mx-auto" />
<div className="mt-4 h-4 bg-gray-200 rounded w-48 mx-auto" />
<div className="mt-2 h-3 bg-gray-200 rounded w-64 mx-auto" />
<div className="bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border">
<div className="px-[var(--cp-space-6)] py-[var(--cp-space-10)] animate-pulse">
<div className="h-12 w-12 bg-muted rounded-full mx-auto" />
<div className="mt-4 h-4 bg-muted rounded-[var(--cp-radius-md)] w-48 mx-auto" />
<div className="mt-2 h-3 bg-muted rounded-[var(--cp-radius-md)] w-64 mx-auto" />
</div>
</div>
</div>

View File

@ -3,6 +3,8 @@
import React, { useMemo, useState } from "react";
import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
type SimKind = "physical" | "esim";
@ -22,7 +24,8 @@ const IMPORTANT_POINTS: string[] = [
"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.";
const EID_HELP =
"Enter the 32-digit EID (numbers only). Leave blank to reuse Freebit's generated EID.";
export function ReissueSimModal({
subscriptionId,
@ -74,7 +77,12 @@ export function ReissueSimModal({
});
onSuccess();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to submit reissue request";
const message =
process.env.NODE_ENV === "development"
? error instanceof Error
? error.message
: "Failed to submit reissue request"
: "Failed to submit reissue request. Please try again.";
onError(message);
} finally {
setSubmitting(false);
@ -83,25 +91,26 @@ export function ReissueSimModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-gray-500 bg-opacity-75" aria-hidden="true" />
<div className="absolute inset-0 bg-background/70 backdrop-blur-sm" aria-hidden="true" />
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-2xl">
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-border bg-card text-card-foreground shadow-[var(--cp-shadow-3)]">
<div className="px-6 pt-6 pb-4 sm:px-8 sm:pb-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
<ArrowPathIcon className="h-6 w-6 text-green-600" />
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-success-soft border border-success/25">
<ArrowPathIcon className="h-6 w-6 text-success" />
</span>
<div>
<h3 className="text-lg font-semibold text-gray-900">Reissue SIM</h3>
<p className="text-sm text-gray-600">
Submit a reissue request for your SIM. Review the important information before continuing.
<h3 className="text-lg font-semibold text-foreground">Reissue SIM</h3>
<p className="text-sm text-muted-foreground">
Submit a reissue request for your SIM. Review the important information before
continuing.
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 transition hover:text-gray-600"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Close reissue SIM modal"
type="button"
>
@ -109,68 +118,74 @@ export function ReissueSimModal({
</button>
</div>
<div className="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
<h4 className="text-sm font-semibold text-amber-800">Important information</h4>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-amber-900">
{IMPORTANT_POINTS.map(point => (
<li key={point}>{point}</li>
))}
</ul>
<div className="mt-6">
<AlertBanner variant="warning" title="Important information" elevated>
<ul className="list-disc space-y-1 pl-5 text-sm">
{IMPORTANT_POINTS.map(point => (
<li key={point}>{point}</li>
))}
</ul>
</AlertBanner>
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">Select SIM type</label>
<label className="block text-sm font-medium text-muted-foreground">
Select SIM type
</label>
<div className="mt-3 space-y-2">
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
<input
type="radio"
name="sim-type"
value="physical"
checked={selectedSimType === "physical"}
onChange={() => setSelectedSimType("physical")}
className="mt-1"
className="mt-1 text-primary focus:ring-ring"
/>
<div>
<p className="text-sm font-medium text-gray-900">Physical SIM</p>
<p className="text-xs text-gray-500">
Well ship a replacement SIM card. Currently, online requests are not available; contact support to proceed.
<p className="text-sm font-medium text-foreground">Physical SIM</p>
<p className="text-xs text-muted-foreground">
Well ship a replacement SIM card. Currently, online requests are not
available; contact support to proceed.
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
<label className="flex items-start gap-3 rounded-lg border border-border bg-background p-3">
<input
type="radio"
name="sim-type"
value="esim"
checked={selectedSimType === "esim"}
onChange={() => setSelectedSimType("esim")}
className="mt-1"
className="mt-1 text-primary focus:ring-ring"
/>
<div>
<p className="text-sm font-medium text-gray-900">eSIM</p>
<p className="text-xs text-gray-500">
Generate a new eSIM activation profile. Youll receive new QR code details once processing completes.
<p className="text-sm font-medium text-foreground">eSIM</p>
<p className="text-xs text-muted-foreground">
Generate a new eSIM activation profile. Youll receive new QR code details
once processing completes.
</p>
</div>
</label>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 text-sm text-gray-600">
<div className="rounded-lg border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
<p>
Current SIM type: <strong className="uppercase">{currentSimType}</strong>
</p>
<p className="mt-2">
The selection above lets you specify which type of replacement you need. If you choose a physical SIM, a support agent will contact you to finalise the process.
The selection above lets you specify which type of replacement you need. If you
choose a physical SIM, a support agent will contact you to finalise the process.
</p>
</div>
</div>
{isEsimSelected && (
<div className="mt-6">
<label htmlFor="new-eid" className="block text-sm font-medium text-gray-700">
<label htmlFor="new-eid" className="block text-sm font-medium text-muted-foreground">
New EID (optional)
</label>
<input
@ -184,37 +199,34 @@ export function ReissueSimModal({
setValidationError(null);
}}
placeholder="Enter 32-digit EID"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="mt-1 block w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="mt-1 text-xs text-gray-500">{EID_HELP}</p>
<p className="mt-1 text-xs text-muted-foreground">{EID_HELP}</p>
</div>
)}
{validationError && (
<p className="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{validationError}
</p>
<div className="mt-4">
<AlertBanner variant="error" title="Please review" size="sm">
{validationError}
</AlertBanner>
</div>
)}
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 p-4 sm:flex-row sm:justify-end sm:px-6">
<button
<div className="flex flex-col gap-3 border-t border-border bg-muted p-4 sm:flex-row sm:justify-end sm:px-6">
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={disableSubmit || submitting}
className="inline-flex justify-center rounded-md px-4 py-2 text-sm font-semibold text-white shadow-sm disabled:cursor-not-allowed disabled:opacity-70"
style={{ background: "linear-gradient(90deg, #16a34a, #15803d)" }}
>
{submitting ? "Submitting..." : isPhysicalSelected ? "Contact Support" : "Confirm Reissue"}
</button>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled={disableSubmit}
loading={submitting}
loadingText="Submitting…"
>
{isPhysicalSelected ? "Contact Support" : "Confirm Reissue"}
</Button>
<Button type="button" onClick={onClose} disabled={submitting} variant="outline">
Cancel
</button>
</Button>
</div>
</div>
</div>

View File

@ -7,12 +7,13 @@ import {
ArrowPathIcon,
XMarkIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from "./ChangePlanModal";
import { ReissueSimModal } from "./ReissueSimModal";
import { apiClient } from "@/lib/api";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
interface SimActionsProps {
subscriptionId: number;
@ -75,7 +76,13 @@ export function SimActions({
setShowCancelConfirm(false);
onCancelSuccess?.();
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
setError(
process.env.NODE_ENV === "development"
? error instanceof Error
? error.message
: "Failed to cancel SIM service"
: "Unable to cancel SIM service right now. Please try again."
);
} finally {
setLoading(null);
}
@ -96,15 +103,15 @@ export function SimActions({
return (
<div
id="sim-actions"
className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}
className={`${embedded ? "" : "bg-card shadow-[var(--cp-shadow-1)] rounded-xl border border-border"}`}
>
{/* Header */}
{!embedded && (
<div className="px-6 py-6 border-b border-gray-200">
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
<div className="px-6 py-6 border-b border-border">
<h3 className="text-lg font-semibold tracking-tight text-foreground mb-1">
SIM Management Actions
</h3>
<p className="text-sm text-slate-600">Manage your SIM service</p>
<p className="text-sm text-muted-foreground">Manage your SIM service</p>
</div>
)}
@ -112,31 +119,26 @@ export function SimActions({
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
{/* Status Messages */}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
<p className="text-sm text-green-800">{success}</p>
</div>
<div className="mb-4">
<AlertBanner variant="success" title="Success" size="sm" elevated>
{success}
</AlertBanner>
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
<p className="text-sm text-red-800">{error}</p>
</div>
<div className="mb-4">
<AlertBanner variant="error" title="Unable to complete action" size="sm" elevated>
{error}
</AlertBanner>
</div>
)}
{!isActive && (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
<p className="text-sm text-yellow-800">
SIM management actions are only available for active services.
</p>
</div>
<div className="mb-4">
<AlertBanner variant="warning" title="Not available" size="sm" elevated>
SIM management actions are only available for active services.
</AlertBanner>
</div>
)}
@ -153,10 +155,10 @@ export function SimActions({
}
}}
disabled={!canTopUp || loading !== null}
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] ${
canTopUp && loading === null
? "text-white bg-blue-600 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 cursor-not-allowed"
? "text-primary-foreground bg-primary hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted cursor-not-allowed"
}`}
>
<div className="flex items-center">
@ -181,10 +183,10 @@ export function SimActions({
}
}}
disabled={!isActive || loading !== null}
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] ${
isActive && loading === null
? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 cursor-not-allowed"
? "text-secondary-foreground bg-secondary hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted cursor-not-allowed"
}`}
>
<div className="flex items-center">
@ -205,10 +207,10 @@ export function SimActions({
setShowReissueModal(true);
}}
disabled={!canReissue || loading !== null}
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-all duration-200 ${
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] ${
canReissue && loading === null
? "border-green-200 bg-green-50 text-green-900 hover:bg-green-100 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 border-gray-200 cursor-not-allowed"
? "border-success/30 bg-success-soft text-foreground hover:bg-success-soft/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted border-border cursor-not-allowed"
}`}
>
<div className="flex w-full items-center justify-between">
@ -223,7 +225,7 @@ export function SimActions({
</div>
</div>
{!canReissue && reissueDisabledReason && (
<div className="mt-3 w-full rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
<div className="mt-3 w-full rounded-md border border-warning/25 bg-warning-soft px-3 py-2 text-xs text-muted-foreground">
{reissueDisabledReason}
</div>
)}
@ -241,10 +243,10 @@ export function SimActions({
}
}}
disabled={!canCancel || loading !== null}
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] ${
canCancel && loading === null
? "text-red-700 bg-white border border-red-200 hover:bg-red-50 hover:border-red-300 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 cursor-not-allowed"
? "text-destructive bg-destructive-soft border border-destructive/30 hover:bg-destructive-soft/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted border border-border cursor-not-allowed"
}`}
>
<div className="flex items-center">
@ -261,10 +263,10 @@ export function SimActions({
{/* Action Description (contextual) */}
{activeInfo && (
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="mt-6 text-sm text-muted-foreground bg-muted border border-border rounded-lg p-4">
{activeInfo === "topup" && (
<div className="flex items-start">
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<PlusIcon className="h-4 w-4 text-primary mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You
can choose the amount and schedule it for later if needed.
@ -273,15 +275,17 @@ export function SimActions({
)}
{activeInfo === "reissue" && (
<div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<ArrowPathIcon className="h-4 w-4 text-success mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Reissue SIM:</strong> Submit a replacement request for either a physical SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the replacement profile.
<strong>Reissue SIM:</strong> Submit a replacement request for either a physical
SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the
replacement profile.
</div>
</div>
)}
{activeInfo === "cancel" && (
<div className="flex items-start">
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<XMarkIcon className="h-4 w-4 text-destructive mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action
cannot be undone and will terminate your service immediately.
@ -291,7 +295,7 @@ export function SimActions({
{activeInfo === "changePlan" && (
<div className="flex items-start">
<svg
className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0"
className="h-4 w-4 text-primary mr-2 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -305,7 +309,7 @@ export function SimActions({
</svg>
<div>
<strong>Change Plan:</strong> Switch to a different data plan.{" "}
<span className="text-red-600 font-medium">
<span className="text-warning font-medium">
Important: Plan changes must be requested before the 25th of the month. Changes
will take effect on the 1st of the following month.
</span>
@ -375,19 +379,19 @@ export function SimActions({
{showCancelConfirm && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="fixed inset-0 bg-background/70 backdrop-blur-sm transition-opacity"></div>
<div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-[var(--cp-shadow-3)] border border-border transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-card px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-destructive-soft sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-destructive" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">
<h3 className="text-lg leading-6 font-medium text-foreground">
Cancel SIM Service
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
<p className="text-sm text-muted-foreground">
Are you sure you want to cancel this SIM service? This action cannot be
undone and will permanently terminate your service.
</p>
@ -395,26 +399,25 @@ export function SimActions({
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
<div className="bg-muted px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-3">
<Button
variant="destructive"
onClick={() => void handleCancelSim()}
disabled={loading === "cancel"}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
loading={loading === "cancel"}
loadingText="Processing…"
>
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
</button>
<button
type="button"
Cancel SIM
</Button>
<Button
variant="outline"
onClick={() => {
setShowCancelConfirm(false);
setActiveInfo(null);
}}
disabled={loading === "cancel"}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Back
</button>
</Button>
</div>
</div>
</div>

View File

@ -53,30 +53,30 @@ export function SimDetailsCard({
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
return <CheckCircleIcon className="h-6 w-6 text-success" />;
case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
return <ExclamationTriangleIcon className="h-6 w-6 text-warning" />;
case "cancelled":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
return <XCircleIcon className="h-6 w-6 text-destructive" />;
case "pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
return <ClockIcon className="h-6 w-6 text-info" />;
default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
return <DevicePhoneMobileIcon className="h-6 w-6 text-muted-foreground" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "active":
return "bg-green-100 text-green-800";
return "bg-success-soft text-success";
case "suspended":
return "bg-yellow-100 text-yellow-800";
return "bg-warning-soft text-warning";
case "cancelled":
return "bg-red-100 text-red-800";
return "bg-destructive-soft text-destructive";
case "pending":
return "bg-blue-100 text-blue-800";
return "bg-info-soft text-info";
default:
return "bg-gray-100 text-gray-800";
return "bg-muted text-muted-foreground";
}
};
@ -103,20 +103,20 @@ export function SimDetailsCard({
if (isLoading) {
const Skeleton = (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border hover:shadow-md transition-shadow duration-[var(--cp-transition-normal)] "}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}
>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
<div className="rounded-full bg-gradient-to-br from-primary-soft to-accent-soft h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
<div className="h-5 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-1/2"></div>
</div>
</div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)]"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-4/6"></div>
</div>
</div>
</div>
@ -127,14 +127,14 @@ export function SimDetailsCard({
if (error) {
return (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-destructive-soft "}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}
>
<div className="text-center">
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
<div className="bg-destructive-soft rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-destructive mx-auto" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600 text-sm">{error}</p>
<h3 className="text-lg font-semibold text-foreground mb-2">Error Loading SIM Details</h3>
<p className="text-destructive text-sm">{error}</p>
</div>
</div>
);
@ -161,35 +161,42 @@ export function SimDetailsCard({
cy={size / 2}
r={radius}
fill="none"
stroke="rgb(241 245 249)"
stroke="currentColor"
strokeWidth="8"
className="text-muted"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgb(59 130 246)"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-300"
className="text-primary transition-all duration-[var(--cp-transition-normal)]"
/>
</svg>
<div className="absolute text-center">
<div className="text-3xl font-semibold text-slate-900">{remainingGB.toFixed(1)}</div>
<div className="text-sm text-slate-500 -mt-1">GB remaining</div>
<div className="text-xs text-slate-400 mt-1">{usagePercentage.toFixed(1)}% used</div>
<div className="text-3xl font-semibold text-foreground">{remainingGB.toFixed(1)}</div>
<div className="text-sm text-muted-foreground -mt-1">GB remaining</div>
<div className="text-xs text-muted-foreground mt-1">
{usagePercentage.toFixed(1)}% used
</div>
</div>
</div>
);
};
return (
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border"}`}
>
{/* Compact Header Bar */}
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div
className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
@ -197,22 +204,22 @@ export function SimDetailsCard({
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
<span className="text-lg font-semibold text-slate-900">
<span className="text-lg font-semibold text-foreground">
{formatPlan(simDetails.planCode)}
</span>
</div>
</div>
<div className="text-sm text-slate-600 mt-1">{simDetails.msisdn}</div>
<div className="text-sm text-muted-foreground mt-1">{simDetails.msisdn}</div>
</div>
<div className={`${embedded ? "" : "px-6 py-6"}`}>
<div className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-6)]"}`}>
{/* Usage Visualization */}
<div className="flex justify-center mb-6">
<UsageDonut size={160} />
</div>
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-medium text-slate-900 mb-3">Recent Usage History</h4>
<div className="border-t border-border pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">Recent Usage History</h4>
<div className="space-y-2">
{[
{ date: "Sep 29", usage: "0 MB" },
@ -220,8 +227,8 @@ export function SimDetailsCard({
{ date: "Sep 27", usage: "0 MB" },
].map((entry, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-slate-600">{entry.date}</span>
<span className="text-slate-900">{entry.usage}</span>
<span className="text-muted-foreground">{entry.date}</span>
<span className="text-foreground">{entry.usage}</span>
</div>
))}
</div>
@ -233,17 +240,21 @@ export function SimDetailsCard({
// Default view for physical SIM cards
return (
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border"}`}
>
{/* Header */}
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div
className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-2xl mr-3">
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
<DevicePhoneMobileIcon className="h-8 w-8 text-primary" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
<h3 className="text-lg font-medium text-foreground">Physical SIM Details</h3>
<p className="text-sm text-muted-foreground">
{formatPlan(simDetails.planCode)} {`${simDetails.simType} SIM`}
</p>
</div>
@ -260,42 +271,42 @@ export function SimDetailsCard({
</div>
{/* Content */}
<div className={`${embedded ? "" : "px-6 py-4"}`}>
<div className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)]"}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */}
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
SIM Information
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Phone Number</label>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
<label className="text-xs text-muted-foreground">Phone Number</label>
<p className="text-sm font-medium text-foreground">{simDetails.msisdn}</p>
</div>
<div>
<label className="text-xs text-gray-500">ICCID</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
<label className="text-xs text-muted-foreground">ICCID</label>
<p className="text-sm font-mono text-foreground break-all">{simDetails.iccid}</p>
</div>
{simDetails.eid && (
<div>
<label className="text-xs text-gray-500">EID (eSIM)</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
<label className="text-xs text-muted-foreground">EID (eSIM)</label>
<p className="text-sm font-mono text-foreground break-all">{simDetails.eid}</p>
</div>
)}
{simDetails.imsi && (
<div>
<label className="text-xs text-gray-500">IMSI</label>
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
<label className="text-xs text-muted-foreground">IMSI</label>
<p className="text-sm font-mono text-foreground">{simDetails.imsi}</p>
</div>
)}
{simDetails.activatedAt && (
<div>
<label className="text-xs text-gray-500">Service Start Date</label>
<p className="text-sm text-gray-900">{formatDate(simDetails.activatedAt)}</p>
<label className="text-xs text-muted-foreground">Service Start Date</label>
<p className="text-sm text-foreground">{formatDate(simDetails.activatedAt)}</p>
</div>
)}
</div>
@ -304,13 +315,13 @@ export function SimDetailsCard({
{/* Service Features */}
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">
<label className="text-xs text-muted-foreground">Data Remaining</label>
<p className="text-lg font-semibold text-success">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
@ -318,20 +329,20 @@ export function SimDetailsCard({
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${simDetails.voiceMailEnabled ? "text-green-500" : "text-gray-400"}`}
className={`h-4 w-4 mr-1 ${simDetails.voiceMailEnabled ? "text-success" : "text-muted-foreground"}`}
/>
<span
className={`text-sm ${simDetails.voiceMailEnabled ? "text-green-600" : "text-gray-500"}`}
className={`text-sm ${simDetails.voiceMailEnabled ? "text-success" : "text-muted-foreground"}`}
>
Voicemail {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${simDetails.callWaitingEnabled ? "text-green-500" : "text-gray-400"}`}
className={`h-4 w-4 mr-1 ${simDetails.callWaitingEnabled ? "text-success" : "text-muted-foreground"}`}
/>
<span
className={`text-sm ${simDetails.callWaitingEnabled ? "text-green-600" : "text-gray-500"}`}
className={`text-sm ${simDetails.callWaitingEnabled ? "text-success" : "text-muted-foreground"}`}
>
Call Waiting {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
@ -340,8 +351,8 @@ export function SimDetailsCard({
{simDetails.internationalRoamingEnabled && (
<div className="flex items-center">
<WifiIcon className="h-4 w-4 mr-1 text-green-500" />
<span className="text-sm text-green-600">International Roaming Enabled</span>
<WifiIcon className="h-4 w-4 mr-1 text-success" />
<span className="text-sm text-success">International Roaming Enabled</span>
</div>
)}
</div>
@ -351,10 +362,12 @@ export function SimDetailsCard({
{/* Expiry Date */}
{simDetails.expiresAt && (
<div className="mt-6 pt-6 border-t border-gray-200">
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center text-sm">
<ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
<span className="text-amber-800">Expires on {formatDate(simDetails.expiresAt)}</span>
<ClockIcon className="h-4 w-4 text-warning mr-2" />
<span className="text-warning-foreground">
Expires on {formatDate(simDetails.expiresAt)}
</span>
</div>
</div>
)}

View File

@ -2,6 +2,8 @@
import React, { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
interface SimFeatureTogglesProps {
subscriptionId: number;
@ -84,7 +86,13 @@ export function SimFeatureToggles({
setSuccess("Changes submitted successfully");
onChanged?.();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit changes");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to submit changes"
: "Unable to submit changes right now. Please try again."
);
} finally {
setLoading(false);
setTimeout(() => setSuccess(null), 3000);
@ -94,25 +102,27 @@ export function SimFeatureToggles({
return (
<div className="space-y-6">
{/* Service Options */}
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
<div
className={`${embedded ? "" : "bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)]"}`}
>
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
{/* Voice Mail */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">Voice Mail</div>
<div className="text-xs text-slate-500">¥300/month</div>
<div className="text-sm font-medium text-foreground">Voice Mail</div>
<div className="text-xs text-muted-foreground">¥300/month</div>
</div>
<button
type="button"
role="switch"
aria-checked={vm}
onClick={() => setVm(!vm)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
vm ? "bg-blue-600" : "bg-gray-200"
className={`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 ${
vm ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition duration-[var(--cp-duration-normal)] ease-in-out ${
vm ? "translate-x-5" : "translate-x-0"
}`}
/>
@ -122,20 +132,20 @@ export function SimFeatureToggles({
{/* Call Waiting */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">Call Waiting</div>
<div className="text-xs text-slate-500">¥300/month</div>
<div className="text-sm font-medium text-foreground">Call Waiting</div>
<div className="text-xs text-muted-foreground">¥300/month</div>
</div>
<button
type="button"
role="switch"
aria-checked={cw}
onClick={() => setCw(!cw)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
cw ? "bg-blue-600" : "bg-gray-200"
className={`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 ${
cw ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition duration-[var(--cp-duration-normal)] ease-in-out ${
cw ? "translate-x-5" : "translate-x-0"
}`}
/>
@ -145,32 +155,35 @@ export function SimFeatureToggles({
{/* International Roaming */}
<div className="flex items-center justify-between py-4">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900">International Roaming</div>
<div className="text-xs text-slate-500">Global connectivity</div>
<div className="text-sm font-medium text-foreground">International Roaming</div>
<div className="text-xs text-muted-foreground">Global connectivity</div>
</div>
<button
type="button"
role="switch"
aria-checked={ir}
onClick={() => setIr(!ir)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
ir ? "bg-blue-600" : "bg-gray-200"
className={`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 ${
ir ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition duration-[var(--cp-duration-normal)] ease-in-out ${
ir ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
<div className="border-t border-gray-200 pt-6">
<div className="border-t border-border pt-6">
<div className="mb-4">
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
<div className="text-xs text-red-600 mt-1">
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.
<div className="text-sm font-medium text-foreground mb-1">Network Type</div>
<div className="text-xs text-muted-foreground">
Choose your preferred connectivity
</div>
<div className="text-xs text-destructive mt-1">
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.
</div>
</div>
<div className="flex gap-4">
@ -182,9 +195,9 @@ export function SimFeatureToggles({
value="4G"
checked={nt === "4G"}
onChange={() => setNt("4G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
className="h-4 w-4 text-primary focus:ring-ring border-input"
/>
<label htmlFor="4g" className="text-sm text-slate-700">
<label htmlFor="4g" className="text-sm text-foreground/80">
4G
</label>
</div>
@ -196,152 +209,72 @@ export function SimFeatureToggles({
value="5G"
checked={nt === "5G"}
onChange={() => setNt("5G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
className="h-4 w-4 text-primary focus:ring-ring border-input"
/>
<label htmlFor="5g" className="text-sm text-slate-700">
<label htmlFor="5g" className="text-sm text-foreground/80">
5G
</label>
</div>
</div>
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
<p className="text-xs text-muted-foreground mt-2">
5G connectivity for enhanced speeds
</p>
</div>
</div>
</div>
{/* Notes and Actions */}
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Important Notes
</h4>
<ul className="text-xs text-blue-800 space-y-1">
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes will take effect instantaneously (approx. 30min)
<div
className={`${embedded ? "" : "bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"}`}
>
<AlertBanner variant="info" title="Important Notes" size="sm">
<ul className="space-y-1 text-xs">
<li>Changes take effect approximately 30 minutes after submission.</li>
<li>You may need to restart your device after changes are applied.</li>
<li>
<span className="font-medium">
Voice, network, and plan changes must be requested at least 30 minutes apart.
</span>
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
May require smartphone device restart after changes are applied
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
<li>
Voice Mail / Call Waiting changes must be requested before the 25th of the month.
</li>
</ul>
</div>
</AlertBanner>
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<svg
className="h-5 w-5 text-green-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm font-medium text-green-800">{success}</p>
</div>
<div className="mb-4">
<AlertBanner variant="success" title="Success" size="sm" elevated>
{success}
</AlertBanner>
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg
className="h-5 w-5 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-sm font-medium text-red-800">{error}</p>
</div>
<div className="mb-4">
<AlertBanner variant="error" title="Unable to apply changes" size="sm" elevated>
{error}
</AlertBanner>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
<Button
className="flex-1"
onClick={() => void applyChanges()}
disabled={loading}
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
loading={loading}
loadingText="Applying…"
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
className="opacity-25"
></circle>
<path
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"
className="opacity-75"
></path>
</svg>
Applying Changes...
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Apply Changes
</>
)}
</button>
<button
Apply Changes
</Button>
<Button
className="flex-1 sm:flex-none"
variant="outline"
onClick={() => reset()}
disabled={loading}
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Reset
</button>
</Button>
</div>
</div>
</div>

View File

@ -15,6 +15,7 @@ import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptio
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
import { format } from "date-fns";
import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms/button";
const { formatCurrency } = Formatting;
@ -108,7 +109,13 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (hasStatus(err) && err.status === 400) {
setError("This subscription is not a SIM service");
} else {
setError(err instanceof Error ? err.message : "Failed to load SIM information");
setError(
process.env.NODE_ENV === "development"
? err instanceof Error
? err.message
: "Failed to load SIM information"
: "Unable to load SIM information right now. Please try again."
);
}
} finally {
setLoading(false);
@ -142,10 +149,10 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (loading) {
return (
<div className="space-y-6">
<div className="bg-white/90 backdrop-blur-sm shadow-sm rounded-lg border border-gray-200 p-8">
<div className="bg-card shadow-[var(--cp-shadow-1)] rounded-lg border border-border p-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
<div className="h-6 bg-muted rounded-lg w-3/4" />
<div className="h-48 bg-muted rounded-xl" />
</div>
</div>
</div>
@ -154,22 +161,18 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (error) {
return (
<div className="bg-white/90 backdrop-blur-sm shadow-sm rounded-lg border border-red-200 p-8">
<div className="text-center py-12">
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
<div className="bg-card shadow-[var(--cp-shadow-1)] rounded-lg border border-border p-8">
<div className="text-center py-10 max-w-xl mx-auto">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-destructive-soft flex items-center justify-center border border-destructive/25">
<ExclamationTriangleIcon className="h-10 w-10 text-destructive" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
<h3 className="text-xl font-semibold text-foreground mb-3">
Unable to Load SIM Information
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
<button
onClick={handleRefresh}
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
>
<ArrowPathIcon className="h-5 w-5 mr-2" />
<p className="text-muted-foreground mb-6">{error}</p>
<Button onClick={handleRefresh} leftIcon={<ArrowPathIcon className="h-5 w-5" />}>
Retry
</button>
</Button>
</div>
</div>
);
@ -195,31 +198,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
return (
<div id="sim-management" className="space-y-6">
{/* Main Content Card */}
<div className="bg-white/90 backdrop-blur-sm shadow-sm rounded-lg border border-gray-200 p-6">
<div className="bg-card shadow-[var(--cp-shadow-1)] rounded-lg border border-border p-6">
{/* Top Section with Details and Data Circle */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{/* Left: Subscription Details */}
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Monthly Cost:{" "}
<span className="font-semibold text-gray-900">
<span className="font-semibold text-foreground">
{subscription ? formatCurrency(subscription.amount) : "Loading..."}
</span>
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Next Billing:{" "}
<span className="font-semibold text-gray-900">
<span className="font-semibold text-foreground">
{subscription?.nextDue ? formatDate(subscription.nextDue) : "N/A"}
</span>
</p>
</div>
<div>
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
Registered:{" "}
<span className="font-semibold text-gray-900">
<span className="font-semibold text-foreground">
{subscription?.registrationDate
? formatDate(subscription.registrationDate)
: "N/A"}
@ -229,33 +232,27 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
{/* Latest Invoice Section - Mobile View */}
{latestInvoice && (
<div className="lg:hidden mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-xs text-gray-600 mb-1">Latest Invoice</p>
<p className="text-2xl font-bold text-gray-900 mb-3">
<div className="lg:hidden mt-6 p-4 bg-muted rounded-lg border border-border">
<p className="text-xs text-muted-foreground mb-1">Latest Invoice</p>
<p className="text-2xl font-bold text-foreground mb-3">
{formatCurrency(latestInvoice.total)}
</p>
<button
<Button
onClick={handlePayInvoice}
disabled={createSsoLink.isPending || latestInvoice.status === "Paid"}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
loading={createSsoLink.isPending}
loadingText="Loading…"
>
{latestInvoice.status === "Paid"
? "PAID"
: createSsoLink.isPending
? "Loading..."
: "PAY"}
</button>
{latestInvoice.status === "Paid" ? "Paid" : "Pay"}
</Button>
</div>
)}
{/* Top Up Data Button */}
<div className="pt-4">
<button
onClick={navigateToTopUp}
className="w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
>
<Button onClick={navigateToTopUp} className="w-full">
Top Up Data
</button>
</Button>
</div>
</div>
@ -263,24 +260,32 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
<svg className="w-full h-full transform -rotate-90">
<circle cx="96" cy="96" r="88" fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="96"
cy="96"
r="88"
fill="none"
stroke="#3b82f6"
stroke="currentColor"
className="text-border"
strokeWidth="12"
/>
<circle
cx="96"
cy="96"
r="88"
fill="none"
stroke="currentColor"
className="text-primary transition-all duration-500"
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<p className="text-xs text-gray-600">Remaining data</p>
<p className="text-4xl font-bold text-gray-900">{remainingGB} GB</p>
<p className="text-sm text-blue-600">-{usedGB} GB</p>
<p className="text-xs text-muted-foreground">Remaining data</p>
<p className="text-4xl font-bold text-foreground">{remainingGB} GB</p>
<p className="text-sm text-muted-foreground">-{usedGB} GB</p>
</div>
</div>
</div>
@ -288,55 +293,55 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
{/* SIM Management Actions - 2x2 Grid on Desktop, 2x2 on Mobile */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">SIM Management Actions</h3>
<h3 className="text-lg font-semibold text-foreground mb-4">SIM Management Actions</h3>
<div className="grid grid-cols-2 gap-4">
<button
onClick={navigateToCallHistory}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
className="flex flex-col items-center justify-center p-6 bg-card border border-border rounded-lg hover:bg-muted hover:shadow-[var(--cp-shadow-2)] transition-colors duration-[var(--cp-duration-normal)]"
>
<PhoneIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Call History</span>
<PhoneIcon className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm font-medium text-foreground">Call History</span>
</button>
<button
onClick={navigateToChangePlan}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
className="flex flex-col items-center justify-center p-6 bg-card border border-border rounded-lg hover:bg-muted hover:shadow-[var(--cp-shadow-2)] transition-colors duration-[var(--cp-duration-normal)]"
>
<ArrowsRightLeftIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Change Plan</span>
<ArrowsRightLeftIcon className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm font-medium text-foreground">Change Plan</span>
</button>
<button
onClick={navigateToReissue}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:shadow-md transition-all duration-200"
className="flex flex-col items-center justify-center p-6 bg-card border border-border rounded-lg hover:bg-muted hover:shadow-[var(--cp-shadow-2)] transition-colors duration-[var(--cp-duration-normal)]"
>
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Reissue SIM</span>
<ArrowPathRoundedSquareIcon className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm font-medium text-foreground">Reissue SIM</span>
</button>
<button
onClick={navigateToCancel}
className="flex flex-col items-center justify-center p-6 bg-white border-2 border-gray-200 rounded-lg hover:border-red-500 hover:shadow-md transition-all duration-200"
className="flex flex-col items-center justify-center p-6 bg-card border border-destructive/25 rounded-lg hover:bg-destructive-soft hover:shadow-[var(--cp-shadow-2)] transition-colors duration-[var(--cp-duration-normal)]"
>
<XCircleIcon className="h-8 w-8 text-gray-700 mb-2" />
<span className="text-sm font-medium text-gray-900">Cancel SIM</span>
<XCircleIcon className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm font-medium text-foreground">Cancel SIM</span>
</button>
</div>
</div>
{/* Voice Status Section */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Voice Status</h3>
<h3 className="text-lg font-semibold text-foreground mb-4">Voice Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-white border border-gray-200 rounded-lg">
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Voice Mail</p>
<p className="font-medium text-foreground">Voice Mail</p>
{simInfo.details.voiceMailEnabled && (
<p className="text-xs text-blue-600">
<p className="text-xs text-success">
On
<br />
<span className="text-blue-600">Enabled</span>
<span className="text-success">Enabled</span>
</p>
)}
</div>
@ -347,16 +352,16 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
className="sr-only peer"
readOnly
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<div className="w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="p-4 bg-white border border-gray-200 rounded-lg">
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Network Type</p>
<p className="text-xs text-gray-600">
<p className="font-medium text-foreground">Network Type</p>
<p className="text-xs text-muted-foreground">
{simInfo.details.networkType ? simInfo.details.networkType : "Disabled"}
</p>
</div>
@ -367,15 +372,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
className="sr-only peer"
readOnly
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<div className="w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="p-4 bg-white border border-gray-200 rounded-lg">
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Call Waiting</p>
<p className="font-medium text-foreground">Call Waiting</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@ -384,15 +389,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
className="sr-only peer"
readOnly
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<div className="w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="p-4 bg-white border border-gray-200 rounded-lg">
<div className="p-4 bg-card border border-border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">International Roaming</p>
<p className="font-medium text-foreground">International Roaming</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@ -401,7 +406,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
className="sr-only peer"
readOnly
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<div className="w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
@ -409,9 +414,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</div>
{/* Important Notes */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Important Notes</h3>
<ul className="space-y-2 text-sm text-gray-700">
<div className="bg-muted border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold text-foreground mb-3">Important Notes</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start">
<span className="mr-2"></span>
<span>Changes will take effect instantaneously (approx. 30min)</span>

View File

@ -17,11 +17,11 @@ export type SubscriptionStatusVariant = "success" | "info" | "warning" | "neutra
const STATUS_ICON_MAP: Record<SubscriptionStatus, ReactNode> = {
[SUBSCRIPTION_STATUS.ACTIVE]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
[SUBSCRIPTION_STATUS.INACTIVE]: <ServerIcon className="h-6 w-6 text-gray-500" />,
[SUBSCRIPTION_STATUS.INACTIVE]: <ServerIcon className="h-6 w-6 text-muted-foreground" />,
[SUBSCRIPTION_STATUS.PENDING]: <ClockIcon className="h-6 w-6 text-blue-500" />,
[SUBSCRIPTION_STATUS.SUSPENDED]: <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />,
[SUBSCRIPTION_STATUS.TERMINATED]: <XCircleIcon className="h-6 w-6 text-red-500" />,
[SUBSCRIPTION_STATUS.CANCELLED]: <XCircleIcon className="h-6 w-6 text-gray-500" />,
[SUBSCRIPTION_STATUS.CANCELLED]: <XCircleIcon className="h-6 w-6 text-muted-foreground" />,
[SUBSCRIPTION_STATUS.COMPLETED]: <CheckCircleIcon className="h-6 w-6 text-green-500" />,
};
@ -36,10 +36,12 @@ const STATUS_VARIANT_MAP: Record<SubscriptionStatus, SubscriptionStatusVariant>
};
export function getSubscriptionStatusIcon(status: SubscriptionStatus): ReactNode {
return STATUS_ICON_MAP[status] ?? <ServerIcon className="h-6 w-6 text-gray-500" />;
return STATUS_ICON_MAP[status] ?? <ServerIcon className="h-6 w-6 text-muted-foreground" />;
}
export function getSubscriptionStatusVariant(status: SubscriptionStatus): SubscriptionStatusVariant {
export function getSubscriptionStatusVariant(
status: SubscriptionStatus
): SubscriptionStatusVariant {
return STATUS_VARIANT_MAP[status] ?? "neutral";
}

View File

@ -35,17 +35,17 @@ function Pagination({
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
className="px-3 py-1 rounded border border-border text-sm text-foreground bg-background disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600">
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 rounded border border-gray-300 text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
className="px-3 py-1 rounded border border-border text-sm text-foreground bg-background disabled:opacity-50 disabled:cursor-not-allowed hover:bg-muted transition-colors"
>
Next
</button>
@ -65,7 +65,8 @@ export function SimCallHistoryContainer() {
// Data states
const [domesticData, setDomesticData] = useState<DomesticCallHistoryResponse | null>(null);
const [internationalData, setInternationalData] = useState<InternationalCallHistoryResponse | null>(null);
const [internationalData, setInternationalData] =
useState<InternationalCallHistoryResponse | null>(null);
const [smsData, setSmsData] = useState<SmsHistoryResponse | null>(null);
// Pagination states
@ -75,7 +76,6 @@ export function SimCallHistoryContainer() {
// Fetch data when tab changes
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
@ -107,7 +107,13 @@ export function SimCallHistoryContainer() {
setSmsData(data);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load history");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to load history"
: "Unable to load your history right now. Please try again."
);
} finally {
setLoading(false);
}
@ -133,7 +139,7 @@ export function SimCallHistoryContainer() {
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
className="text-primary hover:underline"
>
Back to SIM Management
</Link>
@ -141,32 +147,32 @@ export function SimCallHistoryContainer() {
<SubCard>
{/* Current Month Display */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="mb-6 p-4 bg-info-soft border border-info/25 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-blue-900">
<span className="text-sm font-medium text-foreground">
Showing data for: September 2025
</span>
</div>
<p className="text-xs text-blue-700 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Call/SMS history is available approximately 2 months after the calls are made.
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="border-b border-border mb-6">
<nav className="flex -mb-px space-x-8">
<button
onClick={() => setActiveTab("domestic")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "domestic"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<PhoneIcon className="h-5 w-5" />
Domestic Calls
{domesticData && (
<span className="ml-1 text-xs text-gray-400">
<span className="ml-1 text-xs text-muted-foreground/70">
({domesticData.pagination.total})
</span>
)}
@ -175,14 +181,14 @@ export function SimCallHistoryContainer() {
onClick={() => setActiveTab("international")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "international"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<GlobeAltIcon className="h-5 w-5" />
International Calls
{internationalData && (
<span className="ml-1 text-xs text-gray-400">
<span className="ml-1 text-xs text-muted-foreground/70">
({internationalData.pagination.total})
</span>
)}
@ -191,14 +197,14 @@ export function SimCallHistoryContainer() {
onClick={() => setActiveTab("sms")}
className={`flex items-center gap-2 py-4 px-1 border-b-2 text-sm font-medium transition-colors ${
activeTab === "sms"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
<ChatBubbleLeftIcon className="h-5 w-5" />
SMS History
{smsData && (
<span className="ml-1 text-xs text-gray-400">
<span className="ml-1 text-xs text-muted-foreground/70">
({smsData.pagination.total})
</span>
)}
@ -219,7 +225,7 @@ export function SimCallHistoryContainer() {
{loading && (
<div className="animate-pulse space-y-2">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-12 bg-gray-100 rounded"></div>
<div key={i} className="h-12 bg-muted rounded"></div>
))}
</div>
)}
@ -228,47 +234,47 @@ export function SimCallHistoryContainer() {
{!loading && activeTab === "domestic" && domesticData && (
<>
{domesticData.calls.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="text-center py-12 text-muted-foreground">
No domestic calls found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Called To
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Call Length
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Call Charge (¥)
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-card divide-y divide-border">
{domesticData.calls.map(call => (
<tr key={call.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
<tr key={call.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{call.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
<td className="px-4 py-3 whitespace-nowrap text-sm text-muted-foreground">
{call.time}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground font-mono">
{call.calledTo}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
<td className="px-4 py-3 whitespace-nowrap text-sm text-muted-foreground">
{call.callLength}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground text-right font-medium">
{formatCurrency(call.callCharge)}
</td>
</tr>
@ -289,53 +295,53 @@ export function SimCallHistoryContainer() {
{!loading && activeTab === "international" && internationalData && (
<>
{internationalData.calls.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="text-center py-12 text-muted-foreground">
No international calls found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Start Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Stop Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Country
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Called To
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">
Call Charge (¥)
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-card divide-y divide-border">
{internationalData.calls.map(call => (
<tr key={call.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
<tr key={call.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{call.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
<td className="px-4 py-3 whitespace-nowrap text-sm text-muted-foreground">
{call.startTime}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
<td className="px-4 py-3 whitespace-nowrap text-sm text-muted-foreground">
{call.stopTime || "—"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{call.country || "—"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground font-mono">
{call.calledTo}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground text-right font-medium">
{formatCurrency(call.callCharge)}
</td>
</tr>
@ -356,46 +362,46 @@ export function SimCallHistoryContainer() {
{!loading && activeTab === "sms" && smsData && (
<>
{smsData.messages.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="text-center py-12 text-muted-foreground">
No SMS found for this month
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Date
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Sent To
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Type
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-card divide-y divide-border">
{smsData.messages.map(msg => (
<tr key={msg.id} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
<tr key={msg.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground">
{msg.date}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
<td className="px-4 py-3 whitespace-nowrap text-sm text-muted-foreground">
{msg.time}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
<td className="px-4 py-3 whitespace-nowrap text-sm text-foreground font-mono">
{msg.sentTo}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
msg.type === "International SMS"
? "bg-purple-100 text-purple-800"
: "bg-blue-100 text-blue-800"
? "bg-accent-soft text-accent-foreground"
: "bg-info-soft text-info"
}`}
>
{msg.type}
@ -416,10 +422,13 @@ export function SimCallHistoryContainer() {
)}
{/* Info Note */}
<div className="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 mb-2">Important Notes</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> Call/SMS history is updated approximately 2 months after the calls/messages are made</li>
<div className="mt-6 p-4 bg-muted border border-border rounded-lg">
<h4 className="text-sm font-medium text-foreground mb-2">Important Notes</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>
Call/SMS history is updated approximately 2 months after the calls/messages are
made
</li>
<li> The history shows approximately 3 months of records</li>
<li> Call charges shown are based on the carrier billing data</li>
</ul>
@ -431,4 +440,3 @@ export function SimCallHistoryContainer() {
}
export default SimCallHistoryContainer;

View File

@ -3,15 +3,23 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { simActionsService, type CancellationPreview } from "@/features/subscriptions/services/sim-actions.service";
import {
simActionsService,
type CancellationPreview,
} from "@/features/subscriptions/services/sim-actions.service";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
type Step = 1 | 2 | 3;
function Notice({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded p-4">
<div className="text-sm font-semibold text-yellow-900 mb-2">{title}</div>
<div className="text-sm text-yellow-800 leading-relaxed">{children}</div>
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
<div className="text-sm font-semibold text-foreground mb-2">{title}</div>
<div className="text-sm text-muted-foreground leading-relaxed">{children}</div>
</div>
);
}
@ -19,8 +27,8 @@ function Notice({ title, children }: { title: string; children: ReactNode }) {
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xs text-gray-500">{label}</div>
<div className="text-sm font-medium text-gray-900">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-medium text-foreground">{value}</div>
</div>
);
}
@ -49,7 +57,13 @@ export function SimCancelContainer() {
const data = await simActionsService.getCancellationPreview(subscriptionId);
setPreview(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load cancellation information");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to load cancellation information"
: "Unable to load cancellation information right now. Please try again."
);
} finally {
setLoadingPreview(false);
}
@ -90,316 +104,325 @@ export function SimCancelContainer() {
setMessage("Cancellation request submitted. You will receive a confirmation email.");
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 2000);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit cancellation");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to submit cancellation"
: "Unable to submit your cancellation right now. Please try again."
);
} finally {
setLoading(false);
}
};
if (loadingPreview) {
return (
<div className="max-w-3xl mx-auto p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
const isBlockingError = !loadingPreview && !preview && Boolean(error);
const pageError = isBlockingError ? error : null;
return (
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
<div className="flex items-center gap-2 mt-2">
{[1, 2, 3].map(s => (
<div
key={s}
className={`h-2 flex-1 rounded-full ${
s <= step ? "bg-blue-600" : "bg-gray-200"
}`}
></div>
))}
</div>
<div className="text-sm text-gray-500 mt-1">Step {step} of 3</div>
</div>
{error && (
<div className="text-red-700 bg-red-50 border border-red-200 rounded p-4 mb-4">{error}</div>
)}
{message && (
<div className="text-green-700 bg-green-50 border border-green-200 rounded p-4 mb-4">
{message}
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
<p className="text-sm text-gray-600 mb-6">
Cancel your SIM subscription. Please read all the information carefully before proceeding.
</p>
{/* Minimum Contract Warning */}
{preview?.isWithinMinimumTerm && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="text-sm font-semibold text-red-900 mb-1">Minimum Contract Term Warning</div>
<div className="text-sm text-red-800">
Your subscription is still within the minimum contract period (ends {preview.minimumContractEndDate}).
Early cancellation may result in additional charges for the remaining months.
<PageLayout
icon={<DevicePhoneMobileIcon />}
title="Cancel SIM"
description="Cancel your SIM subscription"
breadcrumbs={[
{ label: "Subscriptions", href: "/subscriptions" },
{ label: "SIM Management", href: `/subscriptions/${subscriptionId}#sim-management` },
{ label: "Cancel SIM" },
]}
loading={loadingPreview}
error={pageError}
>
{preview ? (
<div className="max-w-3xl mx-auto space-y-4">
<div className="mb-2">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-primary hover:underline"
>
Back to SIM Management
</Link>
<div className="flex items-center gap-2 mt-2">
{[1, 2, 3].map(s => (
<div
key={s}
className={`h-2 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-border"}`}
/>
))}
</div>
<div className="text-sm text-muted-foreground mt-1">Step {step} of 3</div>
</div>
)}
{step === 1 && (
<div className="space-y-6">
{/* SIM Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
</div>
{error && !isBlockingError ? (
<AlertBanner variant="error" title="Unable to proceed" elevated>
{error}
</AlertBanner>
) : null}
{message ? (
<AlertBanner variant="success" title="Request submitted" elevated>
{message}
</AlertBanner>
) : null}
{/* Month Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Cancellation Month
</label>
<select
value={selectedMonth}
onChange={e => {
setSelectedMonth(e.target.value);
setConfirmMonthEnd(false);
}}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select month</option>
{preview?.availableMonths.map(month => (
<option key={month.value} value={month.value}>
{month.label}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Your subscription will be cancelled at the end of the selected month.
</p>
</div>
<SubCard>
<h1 className="text-xl font-semibold text-foreground mb-2">Cancel SIM</h1>
<p className="text-sm text-muted-foreground mb-6">
Cancel your SIM subscription. Please read all the information carefully before
proceeding.
</p>
<div className="flex justify-end">
<button
disabled={!canProceedStep2}
onClick={() => setStep(2)}
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
>
Next
</button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="space-y-4">
<Notice title="[Cancellation Procedure]">
Online cancellations must be made from this website by the 25th of the desired
cancellation month. Once a request of a cancellation of the SONIXNET SIM is accepted
from this online form, a confirmation email containing details of the SIM plan will
be sent to the registered email address. The SIM card is a rental piece of hardware
and must be returned to Assist Solutions upon cancellation. The cancellation request
through this website retains to your SIM subscriptions only. To cancel any other
services with Assist Solutions (home internet etc.) please contact Assist Solutions
at info@asolutions.co.jp
</Notice>
<Notice title="[Minimum Contract Term]">
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
month is not included in the minimum term of three months; ie. sign-up in January =
minimum term is February, March, April). If the minimum contract term is not
fulfilled, the monthly fees of the remaining months will be charged upon
cancellation.
</Notice>
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
Cancellation of option services only (Voice Mail, Call Waiting) while keeping the
base plan active is not possible from this online form. Please contact Assist
Solutions Customer Support (info@asolutions.co.jp) for more information. Upon
cancelling the base plan, all additional options associated with the requested SIM
plan will be cancelled.
</Notice>
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
Upon cancellation the SIM phone number will be lost. In order to keep the phone
number active to be used with a different cellular provider, a request for an MNP
transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be requested
from this online form. Please contact Assist Solutions Customer Support
(info@asolutions.co.jp) for more information.
</Notice>
</div>
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-start gap-3">
<input
id="acceptTerms"
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
/>
<label htmlFor="acceptTerms" className="text-sm text-gray-700">
I have read and accepted the conditions above.
</label>
{/* Minimum Contract Warning */}
{preview?.isWithinMinimumTerm && (
<div className="bg-destructive-soft border border-destructive/25 rounded-lg p-4 mb-6">
<div className="text-sm font-semibold text-foreground mb-1">
Minimum Contract Term Warning
</div>
<div className="text-sm text-muted-foreground">
Your subscription is still within the minimum contract period (ends{" "}
{preview.minimumContractEndDate}). Early cancellation may result in additional
charges for the remaining months.
</div>
</div>
<div className="flex items-start gap-3">
<input
id="confirmMonthEnd"
type="checkbox"
checked={confirmMonthEnd}
onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!selectedMonth}
className="h-4 w-4 text-blue-600 border-gray-300 rounded mt-0.5"
/>
<label htmlFor="confirmMonthEnd" className="text-sm text-gray-700">
I would like to cancel my SonixNet SIM subscription at the end of{" "}
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
</label>
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setStep(1)}
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Back
</button>
<button
disabled={!canProceedStep3}
onClick={() => setStep(3)}
className="px-6 py-2 rounded-md bg-blue-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-blue-700 transition-colors"
>
Next
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
{/* Voice SIM Notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-sm font-semibold text-blue-900 mb-2">
For Voice-enabled SIM subscriptions:
</div>
<div className="text-sm text-blue-800">
Calling charges are post payment. Your bill for the final month&apos;s calling charges
will be charged on your credit card on file during the first week of the second month
after the cancellation.
</div>
<div className="text-sm text-blue-800 mt-2">
If you would like to make the payment with a different credit card, please contact
Assist Solutions at info@asolutions.co.jp
</div>
</div>
{/* Registered Email */}
<div className="text-sm text-gray-800">
Your registered email address is:{" "}
<span className="font-medium">{preview?.customerEmail || "—"}</span>
</div>
<div className="text-sm text-gray-600">
You will receive a cancellation confirmation email. If you would like to receive this
email on a different address, please enter the address below.
</div>
{/* Alternative Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email address:
</label>
<input
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
value={alternativeEmail}
onChange={e => setAlternativeEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">(Confirm):</label>
<input
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
value={alternativeEmail2}
onChange={e => setAlternativeEmail2(e.target.value)}
placeholder="you@example.com"
/>
</div>
</div>
{emailProvided && !emailValid && (
<div className="text-xs text-red-600">Please enter a valid email address in both fields.</div>
)}
{emailProvided && emailValid && !emailsMatch && (
<div className="text-xs text-red-600">Email addresses do not match.</div>
)}
{/* Comments */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
If you have any other questions/comments/requests regarding your cancellation, please
note them below and an Assist Solutions staff will contact you shortly.
</label>
<textarea
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
rows={4}
value={comments}
onChange={e => setComments(e.target.value)}
placeholder="Optional: Enter any questions or requests here."
/>
</div>
{step === 1 && (
<div className="space-y-6">
{/* SIM Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-muted border border-border rounded-lg">
<InfoRow label="SIM Number" value={preview?.simNumber || "—"} />
<InfoRow label="Serial #" value={preview?.serialNumber || "—"} />
<InfoRow label="Start Date" value={preview?.startDate || "—"} />
</div>
{/* Final Warning */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-sm font-semibold text-red-900 mb-1">
Your cancellation request is not confirmed yet.
</div>
<div className="text-sm text-red-800">
This is the final page. To finalize your cancellation request please proceed from
REQUEST CANCELLATION below.
</div>
</div>
{/* Month Selection */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Select Cancellation Month
</label>
<select
value={selectedMonth}
onChange={e => {
setSelectedMonth(e.target.value);
setConfirmMonthEnd(false);
}}
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
>
<option value="">Select month</option>
{preview?.availableMonths.map(month => (
<option key={month.value} value={month.value}>
{month.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
Your subscription will be cancelled at the end of the selected month.
</p>
</div>
<div className="flex justify-between">
<button
onClick={() => setStep(2)}
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
Back
</button>
<button
onClick={() => {
if (
window.confirm(
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
)
) {
void submit();
}
}}
disabled={loading || !canProceedStep3}
className="px-6 py-2 rounded-md bg-red-600 text-white text-sm font-medium disabled:opacity-50 hover:bg-red-700 transition-colors"
>
{loading ? "Processing…" : "REQUEST CANCELLATION"}
</button>
</div>
</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button disabled={!canProceedStep2} onClick={() => setStep(2)}>
Next
</Button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="space-y-4">
<Notice title="[Cancellation Procedure]">
Online cancellations must be made from this website by the 25th of the desired
cancellation month. Once a request of a cancellation of the SONIXNET SIM is
accepted from this online form, a confirmation email containing details of the
SIM plan will be sent to the registered email address. The SIM card is a rental
piece of hardware and must be returned to Assist Solutions upon cancellation.
The cancellation request through this website retains to your SIM subscriptions
only. To cancel any other services with Assist Solutions (home internet etc.)
please contact Assist Solutions at info@asolutions.co.jp
</Notice>
<Notice title="[Minimum Contract Term]">
The SONIXNET SIM has a minimum contract term agreement of three months (sign-up
month is not included in the minimum term of three months; ie. sign-up in
January = minimum term is February, March, April). If the minimum contract term
is not fulfilled, the monthly fees of the remaining months will be charged upon
cancellation.
</Notice>
<Notice title="[Cancellation of Option Services (for Data+SMS/Voice Plan)]">
Cancellation of option services only (Voice Mail, Call Waiting) while keeping
the base plan active is not possible from this online form. Please contact
Assist Solutions Customer Support (info@asolutions.co.jp) for more information.
Upon cancelling the base plan, all additional options associated with the
requested SIM plan will be cancelled.
</Notice>
<Notice title="[MNP Transfer (for Data+SMS/Voice Plan)]">
Upon cancellation the SIM phone number will be lost. In order to keep the phone
number active to be used with a different cellular provider, a request for an
MNP transfer (administrative fee ¥1,000+tax) is necessary. The MNP cannot be
requested from this online form. Please contact Assist Solutions Customer
Support (info@asolutions.co.jp) for more information.
</Notice>
</div>
<div className="space-y-3 bg-muted border border-border rounded-lg p-4">
<div className="flex items-start gap-3">
<input
id="acceptTerms"
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
/>
<label htmlFor="acceptTerms" className="text-sm text-foreground/80">
I have read and accepted the conditions above.
</label>
</div>
<div className="flex items-start gap-3">
<input
id="confirmMonthEnd"
type="checkbox"
checked={confirmMonthEnd}
onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!selectedMonth}
className="h-4 w-4 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-ring"
/>
<label htmlFor="confirmMonthEnd" className="text-sm text-foreground/80">
I would like to cancel my SonixNet SIM subscription at the end of{" "}
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
</label>
</div>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<Button disabled={!canProceedStep3} onClick={() => setStep(3)}>
Next
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
{/* Voice SIM Notice */}
<div className="bg-info-soft border border-info/25 rounded-lg p-4">
<div className="text-sm font-semibold text-foreground mb-2">
For Voice-enabled SIM subscriptions:
</div>
<div className="text-sm text-muted-foreground">
Calling charges are post payment. Your bill for the final month&apos;s calling
charges will be charged on your credit card on file during the first week of the
second month after the cancellation.
</div>
<div className="text-sm text-muted-foreground mt-2">
If you would like to make the payment with a different credit card, please
contact Assist Solutions at info@asolutions.co.jp
</div>
</div>
{/* Registered Email */}
<div className="text-sm text-foreground/80">
Your registered email address is:{" "}
<span className="font-medium">{preview?.customerEmail || "—"}</span>
</div>
<div className="text-sm text-muted-foreground">
You will receive a cancellation confirmation email. If you would like to receive
this email on a different address, please enter the address below.
</div>
{/* Alternative Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Email address:
</label>
<input
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
value={alternativeEmail}
onChange={e => setAlternativeEmail(e.target.value)}
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
(Confirm):
</label>
<input
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
value={alternativeEmail2}
onChange={e => setAlternativeEmail2(e.target.value)}
placeholder="you@example.com"
/>
</div>
</div>
{emailProvided && !emailValid && (
<div className="text-xs text-destructive">
Please enter a valid email address in both fields.
</div>
)}
{emailProvided && emailValid && !emailsMatch && (
<div className="text-xs text-destructive">Email addresses do not match.</div>
)}
{/* Comments */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
If you have any other questions/comments/requests regarding your cancellation,
please note them below and an Assist Solutions staff will contact you shortly.
</label>
<textarea
className="w-full border border-input rounded-md px-3 py-2 text-sm bg-background text-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
rows={4}
value={comments}
onChange={e => setComments(e.target.value)}
placeholder="Optional: Enter any questions or requests here."
/>
</div>
{/* Final Warning */}
<div className="bg-destructive-soft border border-destructive/25 rounded-lg p-4">
<div className="text-sm font-semibold text-foreground mb-1">
Your cancellation request is not confirmed yet.
</div>
<div className="text-sm text-muted-foreground">
This is the final page. To finalize your cancellation request please proceed
from REQUEST CANCELLATION below.
</div>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(2)}>
Back
</Button>
<Button
variant="destructive"
onClick={() => {
if (
window.confirm(
`Are you sure you want to cancel your SIM subscription? This will take effect at the end of ${selectedMonthInfo?.label || selectedMonth}.`
)
) {
void submit();
}
}}
disabled={loading || !canProceedStep3}
loading={loading}
loadingText="Processing…"
>
REQUEST CANCELLATION
</Button>
</div>
</div>
)}
</SubCard>
</div>
) : null}
</PageLayout>
);
}

View File

@ -6,9 +6,13 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { simActionsService, type AvailablePlan } from "@/features/subscriptions/services/sim-actions.service";
import {
simActionsService,
type AvailablePlan,
} from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Formatting } from "@customer-portal/domain/toolkit";
import { Button } from "@/components/atoms";
const { formatCurrency } = Formatting;
@ -29,7 +33,13 @@ export function SimChangePlanContainer() {
const availablePlans = await simActionsService.getAvailablePlans(subscriptionId);
setPlans(availablePlans);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load available plans");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to load available plans"
: "Unable to load available plans right now. Please try again."
);
} finally {
setLoadingPlans(false);
}
@ -58,7 +68,13 @@ export function SimChangePlanContainer() {
setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`);
setSelectedPlan(null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to change plan");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to change plan"
: "Unable to submit your plan change right now. Please try again."
);
} finally {
setLoading(false);
}
@ -74,7 +90,7 @@ export function SimChangePlanContainer() {
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
className="text-primary hover:underline"
>
Back to SIM Management
</Link>
@ -82,10 +98,10 @@ export function SimChangePlanContainer() {
<SubCard>
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Change Your Plan</h2>
<p className="text-sm text-gray-600">
Select a new plan below. Plan changes will take effect on the 1st of the following month.
Changes must be requested before the 25th of the current month.
<h2 className="text-lg font-semibold text-foreground mb-2">Change Your Plan</h2>
<p className="text-sm text-muted-foreground">
Select a new plan below. Plan changes will take effect on the 1st of the following
month. Changes must be requested before the 25th of the current month.
</p>
</div>
@ -107,25 +123,27 @@ export function SimChangePlanContainer() {
{loadingPlans ? (
<div className="animate-pulse space-y-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 bg-gray-100 rounded-lg"></div>
<div key={i} className="h-24 bg-muted rounded-lg"></div>
))}
</div>
) : (
<form onSubmit={e => void submit(e)} className="space-y-6">
{/* Current Plan */}
{currentPlan && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="bg-info-soft border border-info/25 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-blue-600 font-medium uppercase">Current Plan</div>
<div className="text-lg font-semibold text-blue-900">{currentPlan.name}</div>
<div className="text-sm text-blue-700">{currentPlan.simDataSize}</div>
<div className="text-xs text-info font-medium uppercase">Current Plan</div>
<div className="text-lg font-semibold text-foreground">
{currentPlan.name}
</div>
<div className="text-sm text-muted-foreground">{currentPlan.simDataSize}</div>
</div>
<div className="text-right">
<div className="text-xl font-bold text-blue-900">
<div className="text-xl font-bold text-foreground">
{currentPlan.monthlyPrice ? formatCurrency(currentPlan.monthlyPrice) : "—"}
</div>
<div className="text-xs text-blue-600">/month</div>
<div className="text-xs text-muted-foreground">/month</div>
</div>
</div>
</div>
@ -133,17 +151,19 @@ export function SimChangePlanContainer() {
{/* Available Plans */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">Select a New Plan</label>
<label className="block text-sm font-medium text-muted-foreground">
Select a New Plan
</label>
<div className="grid gap-3">
{plans
.filter(p => !p.isCurrentPlan)
.map(plan => (
<label
key={plan.id}
className={`relative flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-all ${
className={`relative flex items-center justify-between p-4 border rounded-lg cursor-pointer transition-colors ${
selectedPlan?.id === plan.id
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 bg-white"
? "border-primary bg-primary-soft"
: "border-border hover:bg-muted bg-card"
}`}
>
<div className="flex items-center">
@ -157,26 +177,28 @@ export function SimChangePlanContainer() {
/>
<div
className={`w-5 h-5 rounded-full border-2 mr-4 flex items-center justify-center ${
selectedPlan?.id === plan.id ? "border-blue-500" : "border-gray-300"
selectedPlan?.id === plan.id ? "border-primary" : "border-border"
}`}
>
{selectedPlan?.id === plan.id && (
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<div className="w-3 h-3 rounded-full bg-primary"></div>
)}
</div>
<div>
<div className="font-medium text-gray-900">{plan.name}</div>
<div className="text-sm text-gray-500">{plan.simDataSize} {plan.simPlanType}</div>
<div className="font-medium text-foreground">{plan.name}</div>
<div className="text-sm text-muted-foreground">
{plan.simDataSize} {plan.simPlanType}
</div>
</div>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-gray-900">
<div className="text-lg font-semibold text-foreground">
{plan.monthlyPrice ? formatCurrency(plan.monthlyPrice) : "—"}
</div>
<div className="text-xs text-gray-500">/month</div>
<div className="text-xs text-muted-foreground">/month</div>
</div>
{selectedPlan?.id === plan.id && (
<CheckCircleIcon className="absolute top-2 right-2 h-5 w-5 text-blue-500" />
<CheckCircleIcon className="absolute top-2 right-2 h-5 w-5 text-primary" />
)}
</label>
))}
@ -184,23 +206,23 @@ export function SimChangePlanContainer() {
</div>
{/* Global IP Option */}
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center p-4 bg-muted border border-border rounded-lg">
<input
id="globalip"
type="checkbox"
checked={assignGlobalIp}
onChange={e => setAssignGlobalIp(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
className="h-4 w-4 text-primary border-input rounded focus:ring-ring focus:ring-2"
/>
<label htmlFor="globalip" className="ml-3 text-sm text-gray-700">
<label htmlFor="globalip" className="ml-3 text-sm text-foreground/80">
Assign a global IP address (additional charges may apply)
</label>
</div>
{/* Info Box */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-yellow-900 mb-1">Important Notes</h3>
<ul className="text-sm text-yellow-800 space-y-1">
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
<h3 className="text-sm font-medium text-foreground mb-1">Important Notes</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Plan changes take effect on the 1st of the following month</li>
<li> Requests must be made before the 25th of the current month</li>
<li> Your current data balance will be reset when the new plan activates</li>
@ -209,19 +231,21 @@ export function SimChangePlanContainer() {
{/* Submit */}
<div className="flex gap-3">
<button
<Button
type="submit"
disabled={loading || !selectedPlan}
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
loading={loading}
loadingText="Processing…"
>
{loading ? "Processing…" : "Confirm Plan Change"}
</button>
<Link
Confirm Plan Change
</Button>
<Button
as="a"
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
variant="outline"
>
Cancel
</Link>
</Button>
</div>
</form>
)}

View File

@ -6,9 +6,13 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon, DeviceTabletIcon, CpuChipIcon } from "@heroicons/react/24/outline";
import { simActionsService, type ReissueSimRequest } from "@/features/subscriptions/services/sim-actions.service";
import {
simActionsService,
type ReissueSimRequest,
} from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import { Button } from "@/components/atoms";
type SimType = "physical" | "esim";
@ -33,7 +37,13 @@ export function SimReissueContainer() {
setCurrentEid(info.details.eid || null);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to load SIM details"
: "Unable to load SIM details right now. Please try again."
);
} finally {
setLoadingDetails(false);
}
@ -77,7 +87,13 @@ export function SimReissueContainer() {
);
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit reissue request");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to submit reissue request"
: "Unable to submit your request right now. Please try again."
);
} finally {
setLoading(false);
}
@ -93,7 +109,7 @@ export function SimReissueContainer() {
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
className="text-primary hover:underline"
>
Back to SIM Management
</Link>
@ -101,10 +117,10 @@ export function SimReissueContainer() {
<SubCard>
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Request SIM Reissue</h2>
<p className="text-sm text-gray-600">
If your SIM card is lost, damaged, or you need to switch between physical SIM and eSIM,
you can request a replacement here.
<h2 className="text-lg font-semibold text-foreground mb-2">Request SIM Reissue</h2>
<p className="text-sm text-muted-foreground">
If your SIM card is lost, damaged, or you need to switch between physical SIM and
eSIM, you can request a replacement here.
</p>
</div>
@ -125,33 +141,35 @@ export function SimReissueContainer() {
{loadingDetails ? (
<div className="animate-pulse space-y-4">
<div className="h-32 bg-gray-100 rounded-lg"></div>
<div className="h-32 bg-gray-100 rounded-lg"></div>
<div className="h-32 bg-muted rounded-lg"></div>
<div className="h-32 bg-muted rounded-lg"></div>
</div>
) : (
<form onSubmit={e => void submit(e)} className="space-y-6">
{/* Current SIM Info */}
{simDetails && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div className="text-xs text-gray-500 font-medium uppercase mb-2">Current SIM</div>
<div className="bg-muted border border-border rounded-lg p-4 mb-6">
<div className="text-xs text-muted-foreground font-medium uppercase mb-2">
Current SIM
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Number:</span>{" "}
<span className="text-muted-foreground">Number:</span>{" "}
<span className="font-medium">{simDetails.msisdn}</span>
</div>
<div>
<span className="text-gray-500">Type:</span>{" "}
<span className="text-muted-foreground">Type:</span>{" "}
<span className="font-medium capitalize">{simDetails.simType}</span>
</div>
{simDetails.iccid && (
<div className="col-span-2">
<span className="text-gray-500">ICCID:</span>{" "}
<span className="text-muted-foreground">ICCID:</span>{" "}
<span className="font-mono text-xs">{simDetails.iccid}</span>
</div>
)}
{currentEid && (
<div className="col-span-2">
<span className="text-gray-500">Current EID:</span>{" "}
<span className="text-muted-foreground">Current EID:</span>{" "}
<span className="font-mono text-xs">{currentEid}</span>
</div>
)}
@ -161,7 +179,7 @@ export function SimReissueContainer() {
{/* SIM Type Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-muted-foreground">
Select Replacement SIM Type
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -169,8 +187,8 @@ export function SimReissueContainer() {
<label
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
simType === "physical"
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 bg-white"
? "border-primary bg-primary-soft"
: "border-border hover:border-border-muted bg-card"
}`}
>
<input
@ -181,13 +199,13 @@ export function SimReissueContainer() {
onChange={() => setSimType("physical")}
className="sr-only"
/>
<DeviceTabletIcon className="h-12 w-12 text-gray-600 mb-3" />
<div className="text-lg font-medium text-gray-900">Physical SIM</div>
<div className="text-sm text-gray-500 text-center mt-1">
<DeviceTabletIcon className="h-12 w-12 text-muted-foreground mb-3" />
<div className="text-lg font-medium text-foreground">Physical SIM</div>
<div className="text-sm text-muted-foreground text-center mt-1">
A new physical SIM card will be shipped to you
</div>
{simType === "physical" && (
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@ -203,8 +221,8 @@ export function SimReissueContainer() {
<label
className={`relative flex flex-col items-center p-6 border-2 rounded-xl cursor-pointer transition-all ${
simType === "esim"
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300 bg-white"
? "border-primary bg-primary-soft"
: "border-border hover:border-border-muted bg-card"
}`}
>
<input
@ -215,13 +233,13 @@ export function SimReissueContainer() {
onChange={() => setSimType("esim")}
className="sr-only"
/>
<CpuChipIcon className="h-12 w-12 text-gray-600 mb-3" />
<div className="text-lg font-medium text-gray-900">eSIM</div>
<div className="text-sm text-gray-500 text-center mt-1">
<CpuChipIcon className="h-12 w-12 text-muted-foreground mb-3" />
<div className="text-lg font-medium text-foreground">eSIM</div>
<div className="text-sm text-muted-foreground text-center mt-1">
Download your eSIM profile instantly
</div>
{simType === "esim" && (
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@ -237,9 +255,9 @@ export function SimReissueContainer() {
{/* eSIM EID Input */}
{simType === "esim" && (
<div className="space-y-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="space-y-4 p-4 bg-info-soft border border-info/25 rounded-lg">
<div>
<label className="block text-sm font-medium text-blue-900 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
New EID (eSIM Identifier)
</label>
<input
@ -248,21 +266,21 @@ export function SimReissueContainer() {
onChange={e => setNewEid(e.target.value.replace(/\D/g, ""))}
placeholder="Enter your device's 32-digit EID"
maxLength={32}
className="w-full px-3 py-2 border border-blue-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono"
className="w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono bg-background text-foreground"
/>
<p className="text-xs text-blue-700 mt-1">
<p className="text-xs text-muted-foreground mt-1">
The EID is a 32-digit number unique to your device. You can find it in your
device settings under "About" or "SIM status".
</p>
{newEid && !isValidEid() && (
<p className="text-xs text-red-600 mt-1">
<p className="text-xs text-destructive mt-1">
Please enter exactly 32 digits ({newEid.length}/32)
</p>
)}
</div>
{currentEid && (
<div className="text-sm text-blue-800">
<div className="text-sm text-foreground">
<strong>Current EID:</strong>{" "}
<span className="font-mono text-xs">{currentEid}</span>
</div>
@ -272,9 +290,11 @@ export function SimReissueContainer() {
{/* Physical SIM Info */}
{simType === "physical" && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h3 className="text-sm font-medium text-yellow-900 mb-2">Physical SIM Information</h3>
<ul className="text-sm text-yellow-800 space-y-1">
<div className="p-4 bg-warning-soft border border-warning/25 rounded-lg">
<h3 className="text-sm font-medium text-foreground mb-2">
Physical SIM Information
</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li> A new physical SIM card will be shipped to your registered address</li>
<li> Typical delivery time: 3-5 business days</li>
<li> You will receive an email with tracking information</li>
@ -285,19 +305,21 @@ export function SimReissueContainer() {
{/* Submit Buttons */}
<div className="flex gap-3">
<button
<Button
type="submit"
disabled={loading || !simType || (simType === "esim" && !isValidEid())}
className="px-6 py-2 rounded-md bg-blue-600 text-white font-medium text-sm disabled:opacity-50 hover:bg-blue-700 transition-colors"
loading={loading}
loadingText="Processing…"
>
{loading ? "Processing…" : "Submit Reissue Request"}
</button>
<Link
Submit Reissue Request
</Button>
<Button
as="a"
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-6 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 transition-colors"
variant="outline"
>
Cancel
</Link>
</Button>
</div>
</form>
)}
@ -308,4 +330,3 @@ export function SimReissueContainer() {
}
export default SimReissueContainer;

View File

@ -8,6 +8,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms";
export function SimTopUpContainer() {
const params = useParams();
@ -46,7 +47,13 @@ export function SimTopUpContainer() {
await simActionsService.topUp(subscriptionId, { quotaMb: getCurrentAmountMb() });
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit top-up");
setError(
process.env.NODE_ENV === "development"
? e instanceof Error
? e.message
: "Failed to submit top-up"
: "Unable to submit your top-up right now. Please try again."
);
} finally {
setLoading(false);
}
@ -62,14 +69,14 @@ export function SimTopUpContainer() {
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
className="text-primary hover:underline"
>
Back to SIM Management
</Link>
</div>
<SubCard>
<p className="text-sm text-gray-600 mb-6">
<p className="text-sm text-muted-foreground mb-6">
Add additional data quota to your SIM service. Enter the amount of data you want to add.
</p>
@ -90,7 +97,9 @@ export function SimTopUpContainer() {
<form onSubmit={e => void handleSubmit(e)} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
<label className="block text-sm font-medium text-muted-foreground mb-2">
Amount (GB)
</label>
<div className="relative">
<input
type="number"
@ -100,56 +109,53 @@ export function SimTopUpContainer() {
min={1}
max={50}
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"
className="w-full px-3 py-2 border border-input rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring pr-12 bg-background text-foreground placeholder:text-muted-foreground"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<span className="text-gray-500 text-sm">GB</span>
<span className="text-muted-foreground text-sm">GB</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-muted-foreground mt-1">
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
</p>
</div>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="p-4 bg-info-soft rounded-lg border border-info/25">
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-blue-900">
<div className="text-sm font-medium text-foreground">
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
</div>
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
<div className="text-xs text-muted-foreground">= {getCurrentAmountMb()} MB</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-blue-900">
<div className="text-lg font-bold text-foreground">
¥{calculateCost().toLocaleString()}
</div>
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
<div className="text-xs text-muted-foreground">(1GB = ¥500)</div>
</div>
</div>
</div>
{!isValidAmount() && gbAmount && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700">
<div className="bg-destructive-soft border border-destructive/25 rounded-lg p-3">
<p className="text-sm text-destructive">
Please enter a valid whole number between 1 and 50 GB.
</p>
</div>
)}
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
{loading ? "Processing…" : "Submit Top Up"}
</button>
<Link
<Button type="submit" disabled={loading} loading={loading} loadingText="Processing…">
Submit Top Up
</Button>
<Button
as="a"
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
variant="outline"
>
Back
</Link>
</Button>
</div>
</form>
</SubCard>

View File

@ -1,22 +1,16 @@
"use client";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DetailHeader } from "@/components/molecules/DetailHeader/DetailHeader";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
ArrowLeftIcon,
ServerIcon,
ExclamationTriangleIcon,
CalendarIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline";
import { ServerIcon, CalendarIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
import { format } from "date-fns";
import { useSubscription } from "@/features/subscriptions/hooks";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { Formatting } from "@customer-portal/domain/toolkit";
import { PageLayout } from "@/components/templates/PageLayout";
const { formatCurrency: sharedFormatCurrency } = Formatting;
import { SimManagementSection } from "@/features/sim-management";
@ -25,7 +19,6 @@ import {
getSubscriptionStatusIcon,
getSubscriptionStatusVariant,
} from "@/features/subscriptions/utils/status-presenters";
import { AuroraBackground } from "@/components/ui/shadcn-io/aurora-background";
export function SubscriptionDetailContainer() {
const params = useParams();
@ -68,170 +61,133 @@ export function SubscriptionDetailContainer() {
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
if (isLoading) {
return (
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 space-y-6">
<LoadingCard />
<div className="bg-white rounded-xl border p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
<LoadingCard />
</div>
</div>
);
}
const pageError =
error || !subscription
? process.env.NODE_ENV === "development"
? error instanceof Error
? error.message
: "Subscription not found"
: "Unable to load subscription details. Please try again."
: null;
if (error || !subscription) {
return (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading subscription</h3>
<div className="mt-2 text-sm text-red-700">
{error instanceof Error ? error.message : "Subscription not found"}
</div>
<div className="mt-4">
<Link href="/subscriptions" className="text-red-700 hover:text-red-600 font-medium">
Back to subscriptions
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
const isSimService = Boolean(subscription?.productName?.toLowerCase().includes("sim"));
return (
<div className="relative min-h-screen">
<AuroraBackground className="fixed inset-0 -z-10" showRadialGradient={false}>
<div className="h-full" />
</AuroraBackground>
<div className="relative z-10 py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link href="/subscriptions" className="mr-4 text-gray-600 hover:text-gray-900">
<ArrowLeftIcon className="h-6 w-6" />
</Link>
<div className="flex items-center">
<ServerIcon className="h-8 w-8 text-blue-600 mr-3" />
<PageLayout
icon={<ServerIcon className="h-6 w-6" />}
title={subscription?.productName ?? "Subscription"}
description={
subscription ? `Service ID: ${subscription.serviceId}` : "View your subscription details"
}
breadcrumbs={[
{ label: "Subscriptions", href: "/subscriptions" },
{ label: subscription?.productName ?? "Subscription" },
]}
loading={isLoading}
error={pageError}
>
{subscription ? (
<div className="max-w-7xl mx-auto">
<SubCard className="mb-6">
<DetailHeader
title="Subscription Details"
subtitle="Service subscription information"
leftIcon={getSubscriptionStatusIcon(subscription.status)}
status={{
label: subscription.status,
variant: getSubscriptionStatusVariant(subscription.status),
}}
/>
<div className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{subscription.productName}</h1>
<p className="text-gray-600">Service ID: {subscription.serviceId}</p>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Billing Amount
</h4>
<div className="mt-2 flex items-baseline gap-2">
<p className="text-2xl font-bold text-foreground">
{formatCurrency(subscription.amount)}
</p>
<span className="text-sm text-muted-foreground">
{getBillingCycleLabel(subscription.cycle)}
</span>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Next Due Date
</h4>
<div className="flex items-center mt-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground/70 mr-2" />
<p className="text-lg font-medium text-foreground">
{formatDate(subscription.nextDue)}
</p>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Registration Date
</h4>
<div className="flex items-center mt-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground/70 mr-2" />
<p className="text-lg font-medium text-foreground">
{formatDate(subscription.registrationDate)}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</SubCard>
<SubCard className="mb-6 bg-white/90 backdrop-blur-sm">
<DetailHeader
title="Subscription Details"
subtitle="Service subscription information"
leftIcon={getSubscriptionStatusIcon(subscription.status)}
status={{
label: subscription.status,
variant: getSubscriptionStatusVariant(subscription.status),
}}
/>
<div className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Billing Amount
</h4>
<div className="mt-2 flex items-baseline gap-2">
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(subscription.amount)}
</p>
<span className="text-sm text-gray-500">
{getBillingCycleLabel(subscription.cycle)}
</span>
{isSimService && (
<div className="mb-8">
<SubCard>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h3 className="text-xl font-semibold text-foreground">Service Management</h3>
<p className="text-sm text-muted-foreground mt-1">
Switch between billing and SIM management views
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 bg-muted rounded-xl p-2 border border-border/60">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-colors min-w-[140px] text-center ${
showSimManagement
? "bg-card text-primary shadow-[var(--cp-shadow-1)]"
: "text-muted-foreground hover:text-foreground hover:bg-card/60"
}`}
>
<ServerIcon className="h-4 w-4 inline mr-2" />
SIM Management
</Link>
<Link
href={`/subscriptions/${subscriptionId}`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-colors min-w-[120px] text-center ${
showInvoices
? "bg-card text-primary shadow-[var(--cp-shadow-1)]"
: "text-muted-foreground hover:text-foreground hover:bg-card/60"
}`}
>
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
Invoices
</Link>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Next Due Date
</h4>
<div className="flex items-center mt-2">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
<p className="text-lg font-medium text-gray-900">
{formatDate(subscription.nextDue)}
</p>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Registration Date
</h4>
<div className="flex items-center mt-2">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
<p className="text-lg font-medium text-gray-900">
{formatDate(subscription.registrationDate)}
</p>
</div>
</div>
</SubCard>
</div>
</div>
</SubCard>
)}
{subscription.productName.toLowerCase().includes("sim") && (
<div className="mb-8">
<SubCard className="bg-white/90 backdrop-blur-sm">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div>
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
<p className="text-sm text-gray-600 mt-1">
Switch between billing and SIM management views
</p>
</div>
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${showSimManagement ? "bg-white text-blue-600 shadow-md hover:shadow-lg" : "text-gray-600 hover:text-gray-900 hover:bg-gray-200"}`}
>
<ServerIcon className="h-4 w-4 inline mr-2" />
SIM Management
</Link>
<Link
href={`/subscriptions/${subscriptionId}`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${showInvoices ? "bg-white text-blue-600 shadow-md hover:shadow-lg" : "text-gray-600 hover:text-gray-900 hover:bg-gray-200"}`}
>
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
Invoices
</Link>
</div>
</div>
</SubCard>
</div>
)}
{showSimManagement && (
<div className="mb-10">
<SimManagementSection subscriptionId={subscriptionId} />
</div>
)}
{showSimManagement && (
<div className="mb-10">
<SimManagementSection subscriptionId={subscriptionId} />
</div>
)}
{showInvoices && <InvoicesList subscriptionId={subscriptionId} pageSize={5} />}
{showInvoices && <InvoicesList subscriptionId={subscriptionId} pageSize={5} />}
</div>
</div>
</div>
) : null}
</PageLayout>
);
}

View File

@ -71,11 +71,11 @@ export function SubscriptionsListContainer() {
<SubCard key={i}>
<div className="flex items-center">
<div className="flex-shrink-0">
<ServerIcon className="h-8 w-8 text-gray-300" />
<ServerIcon className="h-8 w-8 text-muted-foreground/60" />
</div>
<div className="ml-5 w-0 flex-1 space-y-2">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-5 w-12 bg-gray-200 rounded" />
<div className="h-4 w-24 bg-muted rounded" />
<div className="h-5 w-12 bg-muted rounded" />
</div>
</div>
</SubCard>
@ -105,12 +105,12 @@ export function SubscriptionsListContainer() {
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-green-600" />
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
<dt className="text-sm font-medium text-muted-foreground truncate">Active</dt>
<dd className="text-lg font-medium text-foreground">{stats.active}</dd>
</dl>
</div>
</div>
@ -118,12 +118,14 @@ export function SubscriptionsListContainer() {
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-8 w-8 text-blue-600" />
<CheckCircleIcon className="h-8 w-8 text-primary" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Completed</dt>
<dd className="text-lg font-medium text-gray-900">{stats.completed}</dd>
<dt className="text-sm font-medium text-muted-foreground truncate">
Completed
</dt>
<dd className="text-lg font-medium text-foreground">{stats.completed}</dd>
</dl>
</div>
</div>
@ -131,12 +133,14 @@ export function SubscriptionsListContainer() {
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<XCircleIcon className="h-8 w-8 text-gray-600" />
<XCircleIcon className="h-8 w-8 text-muted-foreground" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
<dt className="text-sm font-medium text-muted-foreground truncate">
Cancelled
</dt>
<dd className="text-lg font-medium text-foreground">{stats.cancelled}</dd>
</dl>
</div>
</div>
@ -146,7 +150,7 @@ export function SubscriptionsListContainer() {
<div className="space-y-4">
{/* Search/Filter Header */}
<div className="bg-white/80 backdrop-blur-sm rounded-xl border border-gray-200/60 px-5 py-4 shadow-sm">
<div className="bg-card rounded-xl border border-border px-5 py-4 shadow-[var(--cp-shadow-1)]">
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
@ -159,7 +163,7 @@ export function SubscriptionsListContainer() {
</div>
{/* Subscriptions Table */}
<div className="bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden">
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<SubscriptionTable
subscriptions={filteredSubscriptions}
loading={isLoading}

View File

@ -15,10 +15,7 @@ import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { useCreateCase } from "@/features/support/hooks/useCreateCase";
import {
SUPPORT_CASE_PRIORITY,
type SupportCasePriority,
} from "@customer-portal/domain/support";
import { SUPPORT_CASE_PRIORITY, type SupportCasePriority } from "@customer-portal/domain/support";
export function NewSupportCaseView() {
const router = useRouter();
@ -43,7 +40,13 @@ export function NewSupportCaseView() {
router.push("/support/cases?created=true");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create support case");
setError(
process.env.NODE_ENV === "development"
? err instanceof Error
? err.message
: "Failed to create support case"
: "Unable to create your support case right now. Please try again."
);
}
};
@ -67,31 +70,29 @@ export function NewSupportCaseView() {
icon={<TicketIconSolid />}
title="Create Support Case"
description="Get help from our support team"
breadcrumbs={[
{ label: "Support", href: "/support" },
{ label: "Create Case" },
]}
breadcrumbs={[{ label: "Support", href: "/support" }, { label: "Create Case" }]}
>
{/* AI Chat Suggestion */}
<AnimatedCard className="overflow-hidden" variant="highlighted">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-5">
<div className="bg-primary p-5">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
<SparklesIcon className="h-6 w-6 text-white" />
<div className="w-12 h-12 bg-primary-foreground/10 rounded-xl flex items-center justify-center">
<SparklesIcon className="h-6 w-6 text-primary-foreground" />
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white mb-1">
<h3 className="text-lg font-semibold text-primary-foreground mb-1">
Try our AI Assistant first
</h3>
<p className="text-blue-100 text-sm">
Get instant answers to common questions. If the AI can&apos;t help, it will create
a case for you automatically.
<p className="text-primary-foreground/80 text-sm">
Get instant answers to common questions. If the AI can&apos;t help, it will create a
case for you automatically.
</p>
</div>
<Button
className="w-full sm:w-auto bg-white text-blue-600 hover:bg-blue-50"
variant="secondary"
className="w-full sm:w-auto"
leftIcon={<ChatBubbleLeftRightIcon className="h-5 w-5" />}
>
Start Chat
@ -112,7 +113,10 @@ export function NewSupportCaseView() {
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Subject */}
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-900 mb-2">
<label
htmlFor="subject"
className="block text-sm font-medium text-muted-foreground mb-2"
>
Subject <span className="text-red-500">*</span>
</label>
<input
@ -121,7 +125,7 @@ export function NewSupportCaseView() {
value={formData.subject}
onChange={event => handleInputChange("subject", event.target.value)}
placeholder="Brief description of your issue"
className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="block w-full px-4 py-3 border border-input rounded-xl text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
maxLength={255}
required
/>
@ -129,14 +133,17 @@ export function NewSupportCaseView() {
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-900 mb-2">
<label
htmlFor="priority"
className="block text-sm font-medium text-muted-foreground mb-2"
>
Priority
</label>
<select
id="priority"
value={formData.priority}
onChange={event => handleInputChange("priority", event.target.value)}
className="block w-full sm:w-1/2 px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="block w-full sm:w-1/2 px-4 py-3 border border-input rounded-xl text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
>
{priorityOptions.map(option => (
<option key={option.value} value={option.value}>
@ -148,7 +155,10 @@ export function NewSupportCaseView() {
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-900 mb-2">
<label
htmlFor="description"
className="block text-sm font-medium text-muted-foreground mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<textarea
@ -157,19 +167,19 @@ export function NewSupportCaseView() {
value={formData.description}
onChange={event => handleInputChange("description", event.target.value)}
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
className="block w-full px-4 py-3 border border-input rounded-xl text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors resize-none"
required
/>
<p className="mt-2 text-xs text-gray-500">
<p className="mt-2 text-xs text-muted-foreground">
The more details you provide, the faster we can help you.
</p>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-4 border-t border-gray-100">
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border">
<Link
href="/support"
className="text-sm text-gray-600 hover:text-gray-900 font-medium"
className="text-sm text-muted-foreground hover:text-foreground font-medium"
>
Cancel
</Link>
@ -189,28 +199,40 @@ export function NewSupportCaseView() {
{/* Contact Options */}
<AnimatedCard className="overflow-hidden" variant="static">
<div className="p-5 border-b border-gray-100">
<h3 className="text-base font-semibold text-gray-900">Need immediate assistance?</h3>
<p className="text-sm text-gray-500 mt-1">Contact us directly for urgent matters</p>
<div className="p-5 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Need immediate assistance?</h3>
<p className="text-sm text-muted-foreground mt-1">
Contact us directly for urgent matters
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-gray-100">
<div className="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border">
{/* Phone Support */}
<a
href="tel:0120660470"
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors"
className="group flex items-center gap-4 p-5 hover:bg-muted transition-colors"
>
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
<div className="w-12 h-12 bg-success rounded-xl flex items-center justify-center flex-shrink-0 shadow-[var(--cp-shadow-1)]">
<svg
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 group-hover:text-emerald-600 transition-colors">
<p className="text-sm font-semibold text-foreground transition-colors">
Phone Support
</p>
<p className="text-2xl font-bold text-gray-900 mt-0.5">0120-660-470</p>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
<p className="text-2xl font-bold text-foreground mt-0.5">0120-660-470</p>
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-success rounded-full"></span>
Mon-Fri, 9:30-18:00 JST
</p>
</div>
@ -219,25 +241,35 @@ export function NewSupportCaseView() {
{/* AI Chat */}
<button
type="button"
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors text-left w-full"
className="group flex items-center gap-4 p-5 hover:bg-muted transition-colors text-left w-full"
>
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
<SparklesIcon className="h-6 w-6 text-white" />
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center flex-shrink-0 shadow-[var(--cp-shadow-1)]">
<SparklesIcon className="h-6 w-6 text-primary-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
<p className="text-sm font-semibold text-foreground transition-colors">
AI Chat Assistant
</p>
<p className="text-lg font-semibold text-gray-900 mt-0.5">Get instant answers</p>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
<p className="text-lg font-semibold text-foreground mt-0.5">Get instant answers</p>
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-primary rounded-full"></span>
Available 24/7
</p>
</div>
<div className="flex-shrink-0">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-muted text-muted-foreground group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</span>
</div>

View File

@ -19,6 +19,10 @@ interface SupportCaseDetailViewProps {
export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
const { data: supportCase, isLoading, error, refetch } = useSupportCase(caseId);
const pageError =
error && process.env.NODE_ENV !== "development"
? "Unable to load this case right now. Please try again."
: error;
if (!isLoading && !supportCase && !error) {
return (
@ -45,7 +49,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
title={supportCase ? `Case #${supportCase.caseNumber}` : "Loading..."}
description={supportCase?.subject}
loading={isLoading}
error={error}
error={pageError}
onRetry={() => void refetch()}
breadcrumbs={[
{ label: "Support", href: "/support" },
@ -66,17 +70,19 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
{supportCase && (
<div className="space-y-6">
{/* Header Card - Status & Key Info */}
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
<div className="border border-border rounded-xl bg-card overflow-hidden shadow-[var(--cp-shadow-1)]">
{/* Top Section - Status and Priority */}
<div className="p-5 border-b border-gray-100">
<div className="p-5 border-b border-border">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5">{getCaseStatusIcon(supportCase.status, "md")}</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
<h2 className="text-lg font-semibold text-foreground leading-snug">
{supportCase.subject}
</h2>
<p className="text-sm text-gray-500 mt-0.5">Case #{supportCase.caseNumber}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Case #{supportCase.caseNumber}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
@ -95,26 +101,26 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
</div>
{/* Meta Info Row */}
<div className="px-5 py-3 bg-gray-50/50 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-600">
<CalendarIcon className="h-4 w-4 text-gray-400" />
<div className="px-5 py-3 bg-muted/40 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarIcon className="h-4 w-4 text-muted-foreground/70" />
<span>Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")}</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<ClockIcon className="h-4 w-4 text-gray-400" />
<div className="flex items-center gap-2 text-muted-foreground">
<ClockIcon className="h-4 w-4 text-muted-foreground/70" />
<span>
Updated{" "}
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</span>
</div>
{supportCase.category && (
<div className="flex items-center gap-2 text-gray-600">
<TagIcon className="h-4 w-4 text-gray-400" />
<div className="flex items-center gap-2 text-muted-foreground">
<TagIcon className="h-4 w-4 text-muted-foreground/70" />
<span>{supportCase.category}</span>
</div>
)}
{supportCase.closedAt && (
<div className="flex items-center gap-2 text-green-600">
<div className="flex items-center gap-2 text-success">
<span> Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")}</span>
</div>
)}
@ -122,22 +128,22 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
</div>
{/* Description */}
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
<div className="px-5 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-900">Description</h3>
<div className="border border-border rounded-xl bg-card overflow-hidden shadow-[var(--cp-shadow-1)]">
<div className="px-5 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">Description</h3>
</div>
<div className="p-5">
<div className="prose prose-sm max-w-none text-gray-600">
<div className="prose prose-sm max-w-none text-muted-foreground">
<p className="whitespace-pre-wrap leading-relaxed m-0">{supportCase.description}</p>
</div>
</div>
</div>
{/* Help Text */}
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3">
<div className="rounded-lg bg-info-soft border border-info/25 px-4 py-3">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
@ -145,9 +151,9 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
/>
</svg>
</div>
<div className="text-sm text-blue-700">
<div className="text-sm text-muted-foreground">
<p className="font-medium">Need to update this case?</p>
<p className="mt-0.5 text-blue-600">
<p className="mt-0.5 text-foreground/80">
Reply via email and your response will be added to this case automatically.
</p>
</div>

View File

@ -90,16 +90,9 @@ export function SupportCasesView() {
loading={isLoading}
error={error}
onRetry={() => void refetch()}
breadcrumbs={[
{ label: "Support", href: "/support" },
{ label: "Cases" },
]}
breadcrumbs={[{ label: "Support", href: "/support" }, { label: "Cases" }]}
actions={
<Button
as="a"
href="/support/new"
leftIcon={<TicketIcon className="h-4 w-4" />}
>
<Button as="a" href="/support/new" leftIcon={<TicketIcon className="h-4 w-4" />}>
New Case
</Button>
}
@ -107,26 +100,26 @@ export function SupportCasesView() {
{/* Summary Strip */}
<div className="flex flex-wrap items-center gap-6 px-1 text-sm">
<div className="flex items-center gap-2">
<ChatBubbleLeftRightIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">Total</span>
<span className="font-semibold text-gray-900">{summary.total}</span>
<ChatBubbleLeftRightIcon className="h-4 w-4 text-muted-foreground/70" />
<span className="text-muted-foreground">Total</span>
<span className="font-semibold text-foreground">{summary.total}</span>
</div>
<div className="flex items-center gap-2">
<ClockIcon className="h-4 w-4 text-blue-500" />
<span className="text-gray-600">Open</span>
<span className="font-semibold text-blue-600">{summary.open}</span>
<ClockIcon className="h-4 w-4 text-primary" />
<span className="text-muted-foreground">Open</span>
<span className="font-semibold text-primary">{summary.open}</span>
</div>
{summary.highPriority > 0 && (
<div className="flex items-center gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
<span className="text-gray-600">High Priority</span>
<span className="font-semibold text-amber-600">{summary.highPriority}</span>
<ExclamationTriangleIcon className="h-4 w-4 text-warning" />
<span className="text-muted-foreground">High Priority</span>
<span className="font-semibold text-warning">{summary.highPriority}</span>
</div>
)}
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="text-gray-600">Resolved</span>
<span className="font-semibold text-green-600">{summary.resolved}</span>
<CheckCircleIcon className="h-4 w-4 text-success" />
<span className="text-muted-foreground">Resolved</span>
<span className="font-semibold text-success">{summary.resolved}</span>
</div>
</div>
@ -135,14 +128,14 @@ export function SupportCasesView() {
{/* Search */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
<MagnifyingGlassIcon className="h-4 w-4 text-muted-foreground/70" />
</div>
<input
type="text"
placeholder="Search by case number or subject..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
className="block w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
className="block w-full pl-9 pr-3 py-2 border border-input rounded-lg text-sm bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors"
/>
</div>
@ -151,7 +144,7 @@ export function SupportCasesView() {
<select
value={statusFilter}
onChange={event => setStatusFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
className="appearance-none pl-3 pr-8 py-2 border border-input rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors cursor-pointer"
>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
@ -163,7 +156,7 @@ export function SupportCasesView() {
<select
value={priorityFilter}
onChange={event => setPriorityFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
className="appearance-none pl-3 pr-8 py-2 border border-input rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring transition-colors cursor-pointer"
>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
@ -175,7 +168,7 @@ export function SupportCasesView() {
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
@ -186,27 +179,23 @@ export function SupportCasesView() {
{/* Cases List */}
{cases.length > 0 ? (
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
<div className="border border-border rounded-xl bg-card divide-y divide-border overflow-hidden shadow-[var(--cp-shadow-1)]">
{cases.map(supportCase => (
<div
key={supportCase.id}
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
className="flex items-center gap-4 p-4 hover:bg-muted cursor-pointer transition-colors group"
>
{/* Status Icon */}
<div className="flex-shrink-0">
{getCaseStatusIcon(supportCase.status)}
</div>
<div className="flex-shrink-0">{getCaseStatusIcon(supportCase.status)}</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-blue-600">
<span className="text-sm font-medium text-primary">
#{supportCase.caseNumber}
</span>
<span className="text-sm text-gray-900 truncate">
{supportCase.subject}
</span>
<span className="text-sm text-foreground truncate">{supportCase.subject}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
@ -220,22 +209,21 @@ export function SupportCasesView() {
{supportCase.priority}
</span>
{supportCase.category && (
<span className="text-xs text-gray-500">
{supportCase.category}
</span>
<span className="text-xs text-muted-foreground">{supportCase.category}</span>
)}
</div>
</div>
{/* Timestamp */}
<div className="hidden sm:block text-right flex-shrink-0">
<p className="text-xs text-gray-400">
Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
<p className="text-xs text-muted-foreground/70">
Updated{" "}
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</p>
</div>
{/* Arrow */}
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-muted-foreground flex-shrink-0 transition-colors" />
</div>
))}
</div>

View File

@ -37,17 +37,17 @@ export function SupportHomeView() {
{/* Quick Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* AI Assistant Card */}
<div className="bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-5 text-white">
<div className="bg-card text-card-foreground rounded-xl p-5 border border-border shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center flex-shrink-0">
<SparklesIcon className="h-5 w-5" />
<div className="w-10 h-10 bg-muted rounded-lg flex items-center justify-center flex-shrink-0">
<SparklesIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold mb-1">AI Assistant</h3>
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
<h3 className="font-semibold mb-1 text-foreground">AI Assistant</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
Get instant answers to common questions about your account.
</p>
<Button size="sm" className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm">
<Button size="sm" variant="secondary">
Start Chat
</Button>
</div>
@ -55,14 +55,14 @@ export function SupportHomeView() {
</div>
{/* Create Case Card */}
<div className="border border-gray-200 rounded-xl p-5 bg-white">
<div className="border border-border rounded-xl p-5 bg-card text-card-foreground shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<PlusIcon className="h-5 w-5 text-gray-600" />
<div className="w-10 h-10 bg-muted rounded-lg flex items-center justify-center flex-shrink-0">
<PlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 mb-1">Create Support Case</h3>
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
<h3 className="font-semibold text-foreground mb-1">Create Support Case</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
Our team typically responds within 24 hours.
</p>
<Button as="a" href="/support/new" size="sm" variant="outline">
@ -77,16 +77,16 @@ export function SupportHomeView() {
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-gray-900">Your Cases</h3>
<h3 className="text-lg font-semibold text-foreground">Your Cases</h3>
{summary.total > 0 && (
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1.5 text-gray-500">
<ClockIcon className="h-4 w-4 text-blue-500" />
<span className="font-medium text-blue-600">{summary.open}</span> open
<span className="flex items-center gap-1.5 text-muted-foreground">
<ClockIcon className="h-4 w-4 text-primary" />
<span className="font-medium text-primary">{summary.open}</span> open
</span>
<span className="flex items-center gap-1.5 text-gray-500">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="font-medium text-green-600">{summary.resolved}</span> resolved
<span className="flex items-center gap-1.5 text-muted-foreground">
<CheckCircleIcon className="h-4 w-4 text-success" />
<span className="font-medium text-success">{summary.resolved}</span> resolved
</span>
</div>
)}
@ -94,7 +94,7 @@ export function SupportHomeView() {
{summary.total > 0 && (
<Link
href="/support/cases"
className="text-sm text-blue-600 hover:text-blue-700 font-medium inline-flex items-center gap-1"
className="text-sm text-primary hover:underline font-medium inline-flex items-center gap-1"
>
View all
<ChevronRightIcon className="h-4 w-4" />
@ -103,17 +103,17 @@ export function SupportHomeView() {
</div>
{recentCases.length > 0 ? (
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
<div className="border border-border rounded-xl bg-card divide-y divide-border overflow-hidden shadow-[var(--cp-shadow-1)]">
{recentCases.map(supportCase => (
<div
key={supportCase.id}
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
className="flex items-center gap-4 p-4 hover:bg-muted cursor-pointer transition-colors group"
>
<div className="flex-shrink-0">{getCaseStatusIcon(supportCase.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-gray-900">
<span className="text-sm font-medium text-foreground">
#{supportCase.caseNumber}
</span>
<span
@ -122,12 +122,12 @@ export function SupportHomeView() {
{supportCase.status}
</span>
</div>
<p className="text-sm text-gray-600 truncate">{supportCase.subject}</p>
<p className="text-sm text-muted-foreground truncate">{supportCase.subject}</p>
</div>
<div className="hidden sm:block text-xs text-gray-400 flex-shrink-0">
<div className="hidden sm:block text-xs text-muted-foreground/70 flex-shrink-0">
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</div>
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/60 group-hover:text-muted-foreground flex-shrink-0 transition-colors" />
</div>
))}
</div>

View File

@ -232,19 +232,40 @@ export function useZodForm<TValues extends Record<string, unknown>>({
const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);
return {
values,
errors,
touched,
submitError,
isSubmitting,
isValid,
setValue,
setTouched,
setTouchedField,
validate,
validateField,
handleSubmit,
reset,
};
// Memoize the return object to provide stable references.
// State values (values, errors, touched, etc.) will still trigger re-renders when they change,
// but the object identity remains stable, preventing issues when used in dependency arrays.
// Note: Callbacks (setValue, setTouched, etc.) are already stable via useCallback.
return useMemo(
() => ({
values,
errors,
touched,
submitError,
isSubmitting,
isValid,
setValue,
setTouched,
setTouchedField,
validate,
validateField,
handleSubmit,
reset,
}),
[
values,
errors,
touched,
submitError,
isSubmitting,
isValid,
setValue,
setTouched,
setTouchedField,
validate,
validateField,
handleSubmit,
reset,
]
);
}

View File

@ -2,13 +2,13 @@
/* ============= SPACE ============= */
/* Single, consistent scale (4px base) */
--cp-space-1: 0.25rem; /* 4px */
--cp-space-2: 0.5rem; /* 8px */
--cp-space-2: 0.5rem; /* 8px */
--cp-space-3: 0.75rem; /* 12px */
--cp-space-4: 1rem; /* 16px */
--cp-space-4: 1rem; /* 16px */
--cp-space-5: 1.25rem; /* 20px */
--cp-space-6: 1.5rem; /* 24px */
--cp-space-8: 2rem; /* 32px */
--cp-space-12: 3rem; /* 48px */
--cp-space-6: 1.5rem; /* 24px */
--cp-space-8: 2rem; /* 32px */
--cp-space-12: 3rem; /* 48px */
/* Friendly aliases (optional) */
--cp-space-xs: var(--cp-space-1);
@ -31,22 +31,24 @@
--cp-page-max-width: var(--cp-container-xl);
--cp-page-padding-x: var(--cp-space-6);
--cp-page-padding-y: var(--cp-space-6);
/* Compat alias (some components reference this older name) */
--cp-page-padding: var(--cp-page-padding-x);
/* Sidebar */
--cp-sidebar-width: 16rem; /* 256px */
--cp-sidebar-width-collapsed: 4rem; /* 64px */
--cp-sidebar-width: 16rem; /* 256px */
--cp-sidebar-width-collapsed: 4rem; /* 64px */
/* ============= TYPOGRAPHY ============= */
/* Base sizing */
--cp-text-xs: 0.75rem; /* 12px */
--cp-text-sm: 0.875rem; /* 14px */
--cp-text-base: 1rem; /* 16px */
--cp-text-lg: 1.125rem; /* 18px */
--cp-text-xl: 1.25rem; /* 20px */
--cp-text-xs: 0.75rem; /* 12px */
--cp-text-sm: 0.875rem; /* 14px */
--cp-text-base: 1rem; /* 16px */
--cp-text-lg: 1.125rem; /* 18px */
--cp-text-xl: 1.25rem; /* 20px */
/* Fluid display sizes (modern, but safe) */
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 2024px */
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 2430px */
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 2024px */
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 2430px */
/* Weights & leading */
--cp-font-light: 300;
@ -61,15 +63,20 @@
/* ============= RADII ============= */
--cp-radius-sm: 0.375rem; /* 6px */
--cp-radius-md: 0.5rem; /* 8px */
--cp-radius-lg: 1rem; /* 16px */
--cp-radius-xl: 1.5rem; /* 24px */
--cp-radius-md: 0.5rem; /* 8px */
--cp-radius-lg: 1rem; /* 16px */
--cp-radius-xl: 1.5rem; /* 24px */
--cp-radius-pill: 9999px;
/* Components derive from base radii */
--cp-card-radius: var(--cp-radius-lg);
--cp-card-radius-lg: var(--cp-radius-xl);
/* Card chrome (semantic, used by cp-card utilities/components) */
--cp-card-border: var(--cp-border);
--cp-card-shadow: var(--cp-shadow-2);
--cp-card-shadow-lg: var(--cp-shadow-3);
/* ============= SHADOWS ============= */
--cp-shadow-1: 0 1px 2px 0 rgb(0 0 0 / 0.06);
--cp-shadow-2: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.08);
@ -80,22 +87,26 @@
--cp-duration-normal: 200ms;
--cp-duration-slow: 300ms;
--cp-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
/* Compat aliases (some components reference these names) */
--cp-transition-fast: var(--cp-duration-fast);
--cp-transition-normal: var(--cp-duration-normal);
--cp-transition-slow: var(--cp-duration-slow);
/* ============= COLOR (LIGHT) ============= */
/* Core neutrals (light) */
--cp-bg: oklch(0.98 0 0);
--cp-fg: oklch(0.16 0 0);
--cp-surface: oklch(0.95 0 0); /* panels/strips */
--cp-muted: oklch(0.93 0 0); /* chips/subtle */
--cp-border: oklch(0.90 0 0);
--cp-surface: oklch(0.95 0 0); /* panels/strips */
--cp-muted: oklch(0.93 0 0); /* chips/subtle */
--cp-border: oklch(0.9 0 0);
--cp-border-muted: oklch(0.88 0 0);
/* Brand & focus (azure) */
--cp-primary: oklch(0.62 0.17 255);
--cp-on-primary: oklch(0.99 0 0);
--cp-primary-hover: oklch(0.58 0.17 255);
--cp-primary-hover: oklch(0.58 0.17 255);
--cp-primary-active: oklch(0.54 0.17 255);
--cp-ring: oklch(0.68 0.16 255);
--cp-ring: oklch(0.68 0.16 255);
/* Semantic */
--cp-success: oklch(0.67 0.14 150);
@ -147,8 +158,8 @@
--cp-focus-ring-offset: 2px;
/* Skeletons */
--cp-skeleton-base: rgb(243 244 246); /* gray-100 */
--cp-skeleton-shimmer: rgb(249 250 251);/* gray-50 */
--cp-skeleton-base: rgb(243 244 246); /* gray-100 */
--cp-skeleton-shimmer: rgb(249 250 251); /* gray-50 */
}
/* ============= DARK MODE OVERRIDES ============= */
@ -162,9 +173,9 @@
--cp-primary: oklch(0.74 0.16 255);
--cp-on-primary: oklch(0.15 0 0);
--cp-primary-hover: oklch(0.70 0.16 255);
--cp-primary-hover: oklch(0.7 0.16 255);
--cp-primary-active: oklch(0.66 0.16 255);
--cp-ring: oklch(0.78 0.13 255);
--cp-ring: oklch(0.78 0.13 255);
--cp-success: oklch(0.76 0.14 150);
--cp-on-success: oklch(0.15 0 0);
@ -174,7 +185,7 @@
--cp-on-warning: oklch(0.15 0 0);
--cp-warning-soft: oklch(0.26 0.07 90);
--cp-error: oklch(0.70 0.21 27);
--cp-error: oklch(0.7 0.21 27);
--cp-on-error: oklch(0.15 0 0);
--cp-error-soft: oklch(0.25 0.05 27);
@ -184,7 +195,7 @@
--cp-sidebar-bg: var(--cp-surface);
--cp-sidebar-hover-bg: oklch(0.24 0 0);
--cp-sidebar-text: oklch(0.90 0 0);
--cp-sidebar-text: oklch(0.9 0 0);
--cp-sidebar-text-hover: oklch(0.98 0 0);
--cp-sidebar-active-bg: color-mix(in oklch, var(--cp-primary) 18%, transparent);
--cp-sidebar-active-text: var(--cp-primary);

View File

@ -12,3 +12,4 @@ Start with `system-overview.md`, then jump into the feature you care about:
- `billing-and-payments.md` — invoices, payment methods, and how billing links are handled.
- `subscriptions.md` — how active services are read and refreshed.
- `support-cases.md` — how cases are created/read in Salesforce from the portal.
- `ui-design-system.md` — UI tokens, page shells, and component patterns to keep pages consistent.

View File

@ -0,0 +1,36 @@
## Portal UI Design System (Frontend)
This project uses **Tailwind CSS v4** with a **token-based theme** so pages look cohesive and remain easy to maintain.
### Principles
- **Use semantic tokens** instead of raw colors (`bg-card`, `text-foreground`, `border-border`, `text-muted-foreground`, `ring-ring`).
- **Use shared shells and templates**:
- Public pages: `PublicShell` via `apps/portal/src/app/(public)/layout.tsx`
- Authenticated pages: `AppShell` via `apps/portal/src/app/(authenticated)/layout.tsx`
- Authenticated page content: `PageLayout` for titles/breadcrumbs/loading/error
- **Prefer shared primitives** (`Button`, `AlertBanner`, `ErrorState`, `Loading*`, `SubCard`) over ad-hoc markup.
- **Keep motion subtle** (shadow/color transitions; avoid big gradients/translate/scale).
### Quick reference: what to use
- **Page wrapper**: `PageLayout`
- Use `loading`, `error`, `onRetry`, and `breadcrumbs` for consistent states.
- **Surfaces**
- Use `bg-card border-border shadow-[var(--cp-shadow-1)]` for panels.
- Use `.cp-card` for simple card surfaces when you dont need a component.
- **Typography**
- Titles: `text-foreground`
- Secondary text: `text-muted-foreground`
- **Inputs**
- Use `Input` / `FormField` to get correct borders/focus/invalid states.
- **Buttons**
- Use `Button` variants (`default`, `outline`, `secondary`, `ghost`, `link`).
- **Status/feedback**
- Use `AlertBanner` (`success|info|warning|error`) and `StatusPill`.
### Token sources
- Global semantic colors: `apps/portal/src/app/globals.css`
- Portal-specific tokens/spacing/shadows: `apps/portal/src/styles/tokens.css`
- Semantic utility classes: `apps/portal/src/styles/utilities.css`