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:
parent
540c0ba10c
commit
b99799c2fe
@ -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 || "";
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" : "";
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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} />}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 "Create Account" 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
You’re almost done. Confirm your details above, then submit your order. We’ll 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>© {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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'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'll confirm everything within 1 business day.</li>
|
||||
<li>You'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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
We’ll 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">
|
||||
We’ll 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. You’ll 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. You’ll 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'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'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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); /* 20–24px */
|
||||
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 24–30px */
|
||||
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 20–24px */
|
||||
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 24–30px */
|
||||
|
||||
/* 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);
|
||||
|
||||
@ -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.
|
||||
|
||||
36
docs/portal-guides/ui-design-system.md
Normal file
36
docs/portal-guides/ui-design-system.md
Normal 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 don’t 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`
|
||||
Loading…
x
Reference in New Issue
Block a user