Refactor conditional statements for improved readability and consistency

- Updated various components to use parentheses in conditional statements for clarity.
- Refactored `renderSubCardHeader` to use an options object for better parameter handling.
- Enhanced error handling messages across multiple components to provide clearer feedback.
- Adjusted query string handling in routing to improve readability.
- Made minor adjustments to ensure consistent formatting and style across the codebase.
This commit is contained in:
barsa 2026-01-19 15:14:39 +09:00
parent 0a0e2c6508
commit 0a5a33da98
30 changed files with 617 additions and 611 deletions

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { AppShell } from "@/components/organisms";
import { ErrorBoundary, PageErrorFallback } from "@/components/molecules";
import { AccountEventsListener } from "@/features/realtime/components/AccountEventsListener";
import { AccountRouteGuard } from "./AccountRouteGuard";
@ -8,7 +9,7 @@ export default function AccountLayout({ children }: { children: ReactNode }) {
<AppShell>
<AccountRouteGuard />
<AccountEventsListener />
{children}
<ErrorBoundary fallback={<PageErrorFallback />}>{children}</ErrorBoundary>
</AppShell>
);
}

View File

@ -2,6 +2,38 @@ import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
type BadgeVariant =
| "default"
| "secondary"
| "success"
| "warning"
| "error"
| "info"
| "outline"
| "ghost";
const dotColorMap: Record<BadgeVariant, string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-danger",
info: "bg-info",
default: "bg-primary-foreground",
secondary: "bg-secondary-foreground",
outline: "bg-muted-foreground",
ghost: "bg-muted-foreground",
};
const removeButtonColorMap: Record<BadgeVariant, string> = {
default: "text-primary-foreground hover:bg-primary-foreground/10",
secondary: "text-secondary-foreground hover:bg-black/10",
success: "hover:bg-black/10",
warning: "hover:bg-black/10",
error: "hover:bg-black/10",
info: "hover:bg-black/10",
outline: "hover:bg-black/10",
ghost: "hover:bg-black/10",
};
const badgeVariants = cva(
"inline-flex items-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
@ -38,23 +70,16 @@ interface BadgeProps
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant, size, icon, dot, removable, onRemove, children, ...props }, ref) => {
(
{ className, variant = "default", size, icon, dot, removable, onRemove, children, ...props },
ref
) => {
const resolvedVariant = variant as BadgeVariant;
return (
<span ref={ref} className={cn(badgeVariants({ variant, size }), className)} {...props}>
{dot && (
<span
className={cn(
"mr-1.5 h-1.5 w-1.5 rounded-full",
variant === "success" && "bg-success",
variant === "warning" && "bg-warning",
variant === "error" && "bg-danger",
variant === "info" && "bg-info",
variant === "default" && "bg-primary-foreground",
variant === "secondary" && "bg-secondary-foreground",
variant === "outline" && "bg-muted-foreground",
variant === "ghost" && "bg-muted-foreground"
)}
/>
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", dotColorMap[resolvedVariant])} />
)}
{icon && <span className="mr-1">{icon}</span>}
{children}
@ -63,14 +88,8 @@ 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-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" ||
variant === "info") &&
"hover:bg-black/10"
"ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring/20",
removeButtonColorMap[resolvedVariant]
)}
aria-label="Remove"
>

View File

@ -34,6 +34,34 @@ const buttonVariants = cva(
}
);
interface ButtonContentProps {
loading: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loadingText?: ReactNode;
children?: ReactNode;
}
function ButtonContent({
loading,
leftIcon,
rightIcon,
loadingText,
children,
}: ButtonContentProps) {
return (
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon && (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
</span>
)}
</span>
);
}
interface ButtonExtras {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
@ -69,52 +97,34 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
} = props;
const loading = loadingProp ?? isLoading ?? false;
const contentProps = { loading, leftIcon, rightIcon, loadingText, children };
if (props.as === "a") {
const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps;
void _as;
const isExternal = href.startsWith("http") || href.startsWith("mailto:");
const commonProps = {
className: cn(buttonVariants({ variant, size, className })),
"aria-busy": loading || undefined,
};
if (isExternal) {
return (
<a
className={cn(buttonVariants({ variant, size, className }))}
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
aria-busy={loading || undefined}
{...anchorProps}
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
</span>
) : null}
</span>
<a {...commonProps} href={href} ref={ref as React.Ref<HTMLAnchorElement>} {...anchorProps}>
<ButtonContent {...contentProps} />
</a>
);
}
return (
<Link
className={cn(buttonVariants({ variant, size, className }))}
{...commonProps}
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
aria-busy={loading || undefined}
{...(anchorProps as Omit<typeof anchorProps, "onMouseEnter" | "onTouchStart" | "onClick">)}
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
</span>
) : null}
</span>
<ButtonContent {...contentProps} />
</Link>
);
}
@ -128,6 +138,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
...buttonProps
} = rest as ButtonAsButtonProps;
void _as;
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
@ -136,15 +147,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
aria-busy={loading || undefined}
{...buttonProps}
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
</span>
) : null}
</span>
<ButtonContent {...contentProps} />
</button>
);
});

View File

@ -39,9 +39,39 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
const id = fieldId || generatedId;
const errorId = error ? `${id}-error` : undefined;
const helperTextId = helperText ? `${id}-helper` : undefined;
const describedBy = cn(errorId, helperTextId) || undefined;
const { className: inputPropsClassName, ...restInputProps } = inputProps;
const renderInput = () => {
if (!children) {
return (
<Input
id={id}
ref={ref}
aria-invalid={error ? "true" : undefined}
aria-describedby={describedBy}
className={cn(
error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2",
inputClassName,
inputPropsClassName
)}
{...restInputProps}
/>
);
}
if (isValidElement(children)) {
return cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": describedBy,
} as Record<string, unknown>);
}
return children;
};
return (
<div className={cn("space-y-1", containerClassName)}>
{label && (
@ -55,37 +85,14 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
>
<span>{label}</span>
{required ? (
{required && (
<span aria-hidden="true" className="ml-1 text-danger">
*
</span>
) : null}
)}
</Label>
)}
{children ? (
isValidElement(children) ? (
cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": cn(errorId, helperTextId) || undefined,
} as Record<string, unknown>)
) : (
children
)
) : (
<Input
id={id}
ref={ref}
aria-invalid={error ? "true" : undefined}
aria-describedby={cn(errorId, helperTextId) || undefined}
className={cn(
error && "border-danger focus-visible:ring-danger focus-visible:ring-offset-2",
inputClassName,
inputPropsClassName
)}
{...restInputProps}
/>
)}
{renderInput()}
{error && <ErrorMessage id={errorId}>{error}</ErrorMessage>}
{helperText && !error && (
<p id={helperTextId} className="text-sm text-muted-foreground">

View File

@ -15,13 +15,21 @@ export interface SubCardProps {
interactive?: boolean;
}
function renderSubCardHeader(
header: ReactNode | undefined,
title: string | undefined,
icon: ReactNode | undefined,
right: ReactNode | undefined,
headerClassName: string
): ReactNode {
interface SubCardHeaderOptions {
header: ReactNode | undefined;
title: string | undefined;
icon: ReactNode | undefined;
right: ReactNode | undefined;
headerClassName: string;
}
function renderSubCardHeader({
header,
title,
icon,
right,
headerClassName,
}: SubCardHeaderOptions): ReactNode {
if (header) {
return <div className={`${headerClassName || "mb-5"}`}>{header}</div>;
}
@ -64,7 +72,7 @@ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
className
)}
>
{renderSubCardHeader(header, title, icon, right, headerClassName)}
{renderSubCardHeader({ header, title, icon, right, headerClassName })}
<div className={bodyClassName}>{children}</div>
{footer ? <div className="mt-5 pt-5 border-t border-border/60">{footer}</div> : null}
</div>

View File

@ -0,0 +1,39 @@
"use client";
import { Button } from "@/components/atoms/button";
/**
* Full-page fallback for root-level errors
* Used when the entire application crashes
*/
export function GlobalErrorFallback() {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
<h1 className="text-2xl font-semibold">Something went wrong</h1>
<p className="text-muted-foreground">
An unexpected error occurred. Please refresh the page.
</p>
<Button onClick={() => window.location.reload()}>Refresh Page</Button>
</div>
</div>
);
}
/**
* Content area fallback - keeps nav/sidebar functional
* Used for errors within the main content area
*/
export function PageErrorFallback() {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-4">
<h2 className="text-lg font-semibold text-danger">Something went wrong</h2>
<p className="text-muted-foreground">
This section encountered an error. Please try again.
</p>
<Button onClick={() => window.location.reload()}>Try again</Button>
</div>
</div>
);
}

View File

@ -31,3 +31,4 @@ export * from "./StatusBadge";
// Performance and lazy loading utilities
export { ErrorBoundary } from "./error-boundary";
export { GlobalErrorFallback, PageErrorFallback } from "./error-fallbacks";

View File

@ -7,13 +7,26 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks";
import { accountService } from "@/features/account/api/account.api";
import { Sidebar } from "./Sidebar";
import { Header } from "./Header";
import { computeNavigation } from "./navigation";
import { computeNavigation, type NavigationItem } from "./navigation";
import type { Subscription } from "@customer-portal/domain/subscriptions";
interface AppShellProps {
children: React.ReactNode;
}
function collectPrefetchUrls(navigation: NavigationItem[]): string[] {
const hrefs = new Set<string>();
for (const item of navigation) {
if (item.href && item.href !== "#") hrefs.add(item.href);
if (!item.children || item.children.length === 0) continue;
// Prefetch only the first few children to avoid heavy prefetch
for (const child of item.children.slice(0, 5)) {
if (child.href && child.href !== "#") hrefs.add(child.href);
}
}
return [...hrefs];
}
// Sidebar and navigation are modularized in ./Sidebar and ./navigation
export function AppShell({ children }: AppShellProps) {
@ -121,17 +134,8 @@ export function AppShell({ children }: AppShellProps) {
// Proactively prefetch primary routes to speed up first navigation
useEffect(() => {
try {
const hrefs = new Set<string>();
for (const item of navigation) {
if (item.href && item.href !== "#") hrefs.add(item.href);
if (item.children && item.children.length > 0) {
// Prefetch only the first few children to avoid heavy prefetch
for (const child of item.children.slice(0, 5)) {
if (child.href && child.href !== "#") hrefs.add(child.href);
}
}
}
for (const href of hrefs) {
const urls = collectPrefetchUrls(navigation);
for (const href of urls) {
try {
router.prefetch(href);
} catch {

View File

@ -5,24 +5,38 @@ import { memo } from "react";
import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { NotificationBell } from "@/features/notifications";
interface UserInfo {
firstName?: string | null;
lastName?: string | null;
email?: string | null;
}
function getDisplayName(user: UserInfo | null, profileReady: boolean): string {
const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(" ");
const emailPrefix = user?.email?.split("@")[0];
if (profileReady) {
return fullName || emailPrefix || "Account";
}
return emailPrefix || "Account";
}
function getInitials(user: UserInfo | null, profileReady: boolean, displayName: string): string {
if (profileReady && user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
}
return displayName.slice(0, 2).toUpperCase();
}
interface HeaderProps {
onMenuClick: () => void;
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
user: UserInfo | null;
profileReady: boolean;
}
export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
const displayName = profileReady
? [user?.firstName, user?.lastName].filter(Boolean).join(" ") ||
user?.email?.split("@")[0] ||
"Account"
: user?.email?.split("@")[0] || "Account";
// Get initials for avatar
const initials =
profileReady && user?.firstName && user?.lastName
? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
: displayName.slice(0, 2).toUpperCase();
const displayName = getDisplayName(user, profileReady);
const initials = getInitials(user, profileReady, displayName);
return (
<div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl">

View File

@ -6,6 +6,47 @@ import { useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { Logo } from "@/components/atoms/logo";
import type { NavigationChild, NavigationItem } from "./navigation";
import type { ComponentType, SVGProps } from "react";
// Shared navigation item styling
const navItemBaseClass =
"group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/30";
const activeClass = "text-white bg-white/20 shadow-sm";
const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10";
function ActiveIndicator({ small = false }: { small?: boolean }) {
const size = small ? "w-0.5 h-4" : "w-1 h-6";
const rounded = small ? "rounded-full" : "rounded-r-full";
return <div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-white ${rounded}`} />;
}
function NavIcon({
icon: Icon,
isActive,
variant = "default",
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
isActive: boolean;
variant?: "default" | "logout";
}) {
if (variant === "logout") {
return (
<div className="p-1.5 rounded-md mr-3 text-red-300 group-hover:text-red-100 transition-colors duration-[var(--cp-duration-normal)]">
<Icon className="h-5 w-5" />
</div>
);
}
return (
<div
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive ? "bg-white/20 text-white" : "text-white/80 group-hover:text-white"
}`}
>
<Icon className="h-5 w-5" />
</div>
);
}
interface SidebarProps {
navigation: NavigationItem[];
@ -52,6 +93,123 @@ export const Sidebar = memo(function Sidebar({
);
});
function ExpandableNavItem({
item,
pathname,
isExpanded,
toggleExpanded,
router,
}: {
item: NavigationItem;
pathname: string;
isExpanded: boolean;
toggleExpanded: (name: string) => void;
router: ReturnType<typeof useRouter>;
}) {
const isActive =
item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0] ?? "")
) ?? false;
return (
<div className="relative">
<button
onClick={() => toggleExpanded(item.name)}
aria-expanded={isExpanded}
className={`${navItemBaseClass} text-left ${isActive ? activeClass : inactiveClass}`}
>
{isActive && <ActiveIndicator />}
<NavIcon icon={item.icon} isActive={isActive} />
<span className="flex-1">{item.name}</span>
<svg
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${
isActive ? "text-white" : "text-white/60 group-hover:text-white/80"
}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-out ${
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="mt-1 ml-6 space-y-0.5 border-l border-white/30 pl-4">
{item.children?.map((child: NavigationChild) => {
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
return (
<Link
key={child.href || child.name}
href={child.href}
prefetch
onMouseEnter={() => child.href && void router.prefetch(child.href)}
className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${
isChildActive
? "text-white bg-white/20 font-semibold"
: "text-white/80 hover:text-white hover:bg-white/10 font-medium"
}`}
title={child.tooltip || child.name}
aria-current={isChildActive ? "page" : undefined}
>
{isChildActive && <ActiveIndicator small />}
<div
className={`w-1.5 h-1.5 rounded-full mr-3 transition-colors duration-200 ${
isChildActive ? "bg-white" : "bg-white/40 group-hover:bg-white/70"
}`}
/>
<span className="truncate">{child.name}</span>
</Link>
);
})}
</div>
</div>
</div>
);
}
function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
return (
<button
onClick={onLogout}
className="group w-full flex items-center px-3 py-2.5 text-sm font-semibold text-red-300 hover:text-red-100 hover:bg-red-500/25 rounded-lg transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-red-400/30"
>
<NavIcon icon={item.icon} isActive={false} variant="logout" />
<span>{item.name}</span>
</button>
);
}
function SimpleNavItem({
item,
isActive,
router,
}: {
item: NavigationItem;
isActive: boolean;
router: ReturnType<typeof useRouter>;
}) {
return (
<Link
href={item.href || "#"}
prefetch
onMouseEnter={() => item.href && item.href !== "#" && void router.prefetch(item.href)}
className={`${navItemBaseClass} ${isActive ? activeClass : inactiveClass}`}
aria-current={isActive ? "page" : undefined}
>
{isActive && <ActiveIndicator />}
<NavIcon icon={item.icon} isActive={isActive} />
<span className="truncate">{item.name}</span>
</Link>
);
}
const NavigationItem = memo(function NavigationItem({
item,
pathname,
@ -67,150 +225,24 @@ const NavigationItem = memo(function NavigationItem({
const router = useRouter();
const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren
? item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0] ?? "")
) || false
: item.href
? pathname === item.href
: false;
const handleLogout = () => {
void logout().then(() => {
router.push("/");
});
};
if (hasChildren) {
return (
<div className="relative">
<button
onClick={() => toggleExpanded(item.name)}
aria-expanded={isExpanded}
className={`group w-full flex items-center px-3 py-2.5 text-left text-sm font-semibold rounded-lg transition-all duration-200 relative ${
isActive
? "text-white bg-white/20 shadow-sm"
: "text-white/90 hover:text-white hover:bg-white/10"
} focus:outline-none focus:ring-2 focus:ring-white/30`}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-white rounded-r-full" />
)}
<div
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive ? "bg-white/20 text-white" : "text-white/80 group-hover:text-white"
}`}
>
<item.icon className="h-5 w-5" />
</div>
<span className="flex-1">{item.name}</span>
<svg
className={`h-4 w-4 transition-transform duration-200 ease-out ${
isExpanded ? "rotate-90" : ""
} ${isActive ? "text-white" : "text-white/60 group-hover:text-white/80"}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
<div
className={`overflow-hidden transition-all duration-300 ease-out ${
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="mt-1 ml-6 space-y-0.5 border-l border-white/30 pl-4">
{item.children?.map((child: NavigationChild) => {
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
return (
<Link
key={child.href || child.name}
href={child.href}
prefetch
onMouseEnter={() => {
if (child.href) {
void router.prefetch(child.href);
}
}}
className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${
isChildActive
? "text-white bg-white/20 font-semibold"
: "text-white/80 hover:text-white hover:bg-white/10 font-medium"
}`}
title={child.tooltip || child.name}
aria-current={isChildActive ? "page" : undefined}
>
{isChildActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-white rounded-full" />
)}
<div
className={`w-1.5 h-1.5 rounded-full mr-3 transition-colors duration-200 ${
isChildActive ? "bg-white" : "bg-white/40 group-hover:bg-white/70"
}`}
/>
<span className="truncate">{child.name}</span>
</Link>
);
})}
</div>
</div>
</div>
<ExpandableNavItem
item={item}
pathname={pathname}
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
router={router}
/>
);
}
if (item.isLogout) {
return (
<button
onClick={handleLogout}
className="group w-full flex items-center px-3 py-2.5 text-sm font-semibold text-red-300 hover:text-red-100 hover:bg-red-500/25 rounded-lg transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-red-400/30"
>
<div className="p-1.5 rounded-md mr-3 text-red-300 group-hover:text-red-100 transition-colors duration-[var(--cp-duration-normal)]">
<item.icon className="h-5 w-5" />
</div>
<span>{item.name}</span>
</button>
);
const handleLogout = () => void logout().then(() => router.push("/"));
return <LogoutNavItem item={item} onLogout={handleLogout} />;
}
return (
<Link
href={item.href || "#"}
prefetch
onMouseEnter={() => {
if (item.href && item.href !== "#") {
void router.prefetch(item.href);
}
}}
className={`group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative ${
isActive
? "text-white bg-white/20 shadow-sm"
: "text-white/90 hover:text-white hover:bg-white/10"
} focus:outline-none focus:ring-2 focus:ring-white/30`}
aria-current={isActive ? "page" : undefined}
>
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-white rounded-r-full" />
)}
<div
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive ? "bg-white/20 text-white" : "text-white/80 group-hover:text-white"
}`}
>
<item.icon className="h-5 w-5" />
</div>
<span className="truncate">{item.name}</span>
</Link>
);
const isActive = item.href ? pathname === item.href : false;
return <SimpleNavItem item={item} isActive={isActive} router={router} />;
});

View File

@ -300,6 +300,41 @@ class CsrfTokenManager {
const SAFE_METHODS = new Set<HttpMethod>(["GET", "HEAD", "OPTIONS"]);
interface SerializedBody {
body: BodyInit | undefined;
contentType: string | null;
}
function serializeRequestBody(body: unknown): SerializedBody {
if (body === undefined || body === null) {
return { body: undefined, contentType: null };
}
if (body instanceof FormData || body instanceof Blob) {
return { body: body as BodyInit, contentType: null };
}
return { body: JSON.stringify(body), contentType: "application/json" };
}
async function handleCsrfError(
response: Response,
csrfManager: CsrfTokenManager | null
): Promise<void> {
if (response.status !== 403 || !csrfManager) {
return;
}
try {
const bodyText = await response.clone().text();
if (bodyText.toLowerCase().includes("csrf")) {
csrfManager.clearToken();
}
} catch {
csrfManager.clearToken();
}
}
export function createClient(options: CreateClientOptions = {}): ApiClient {
const baseUrl = resolveBaseUrl(options.baseUrl);
const resolveAuthHeader = options.getAuthHeader;
@ -307,6 +342,39 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
const enableCsrf = options.enableCsrf ?? true;
const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null;
const applyAuthHeader = (headers: Headers): void => {
if (resolveAuthHeader && !headers.has("Authorization")) {
const headerValue = resolveAuthHeader();
if (headerValue) {
headers.set("Authorization", headerValue);
}
}
};
const applyCsrfToken = async (
headers: Headers,
method: HttpMethod,
disableCsrf?: boolean
): Promise<void> => {
const needsCsrf =
csrfManager && !disableCsrf && !SAFE_METHODS.has(method) && !headers.has("X-CSRF-Token");
if (!needsCsrf) {
return;
}
try {
const csrfToken = await csrfManager!.getToken();
headers.set("X-CSRF-Token", csrfToken);
} catch (error) {
logger.error("Failed to obtain CSRF token - blocking request", error);
throw new ApiError(
"CSRF protection unavailable. Please refresh the page and try again.",
new Response(null, { status: 403, statusText: "CSRF Token Required" })
);
}
};
const request = async <T>(
method: HttpMethod,
path: string,
@ -321,81 +389,33 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
}
const headers = new Headers(opts.headers);
const { body: serializedBody, contentType } = serializeRequestBody(opts.body);
if (contentType && !headers.has("Content-Type")) {
headers.set("Content-Type", contentType);
}
applyAuthHeader(headers);
await applyCsrfToken(headers, method, opts.disableCsrf);
const credentials = opts.credentials ?? "include";
const init: RequestInit = {
method,
headers,
credentials,
credentials: opts.credentials ?? "include",
signal: opts.signal ?? null,
body: serializedBody ?? null,
};
const body = opts.body;
if (body !== undefined && body !== null) {
if (body instanceof FormData || body instanceof Blob) {
init.body = body as BodyInit;
} else {
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
init.body = JSON.stringify(body);
}
}
if (resolveAuthHeader && !headers.has("Authorization")) {
const headerValue = resolveAuthHeader();
if (headerValue) {
headers.set("Authorization", headerValue);
}
}
if (
csrfManager &&
!opts.disableCsrf &&
!SAFE_METHODS.has(method) &&
!headers.has("X-CSRF-Token")
) {
try {
const csrfToken = await csrfManager.getToken();
headers.set("X-CSRF-Token", csrfToken);
} catch (error) {
// Don't proceed without CSRF protection for mutation endpoints
logger.error("Failed to obtain CSRF token - blocking request", error);
throw new ApiError(
"CSRF protection unavailable. Please refresh the page and try again.",
new Response(null, { status: 403, statusText: "CSRF Token Required" })
);
}
}
const response = await fetch(url.toString(), init);
if (!response.ok) {
if (response.status === 403 && csrfManager) {
try {
const bodyText = await response.clone().text();
if (bodyText.toLowerCase().includes("csrf")) {
csrfManager.clearToken();
}
} catch {
csrfManager.clearToken();
}
}
await handleCsrfError(response, csrfManager);
await handleError(response);
// If handleError does not throw, throw a default error to ensure rejection
throw new ApiError(`Request failed with status ${response.status}`, response);
}
const parsedBody = await parseResponseBody(response);
if (parsedBody === undefined || parsedBody === null) {
return {};
}
return {
data: parsedBody as T,
};
return parsedBody == null ? {} : { data: parsedBody as T };
};
return {

View File

@ -10,6 +10,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useEffect, useState } from "react";
import { isApiError } from "@/core/api/runtime/client";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { ErrorBoundary, GlobalErrorFallback } from "@/components/molecules";
interface QueryProviderProps {
children: React.ReactNode;
@ -63,7 +64,7 @@ export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ErrorBoundary fallback={<GlobalErrorFallback />}>{children}</ErrorBoundary>
{process.env["NODE_ENV"] === "development" && <ReactQueryDevtools />}
</QueryClientProvider>
);

View File

@ -6,6 +6,46 @@ import { AddressForm, type AddressFormProps } from "@/features/services/componen
import type { Address } from "@customer-portal/domain/customer";
import { getCountryName } from "@/shared/constants";
function AddressDisplay({ address }: { address: Address }) {
const primaryLine = address.address2 || address.address1;
const secondaryLine = address.address2 && address.address1 ? address.address1 : null;
const cityStateZip = [address.city, address.state, address.postcode].filter(Boolean).join(", ");
const countryLabel = address.country
? (getCountryName(address.country) ?? address.country)
: null;
return (
<div className="space-y-1.5 text-foreground">
{primaryLine && <p className="font-semibold text-base">{primaryLine}</p>}
{secondaryLine && <p className="text-muted-foreground">{secondaryLine}</p>}
{cityStateZip && <p className="text-muted-foreground">{cityStateZip}</p>}
{countryLabel && <p className="text-muted-foreground font-medium">{countryLabel}</p>}
</div>
);
}
function SaveButton({ isSaving, onClick }: { isSaving: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
disabled={isSaving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-primary-foreground bg-primary hover:bg-primary-hover disabled:opacity-50 transition-colors shadow-sm"
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-foreground mr-2" />
Saving...
</>
) : (
<>
<CheckIcon className="h-4 w-4 mr-2" />
Save Address
</>
)}
</button>
);
}
interface AddressCardProps {
address: Address;
isEditing: boolean;
@ -27,10 +67,6 @@ export function AddressCard({
onSave,
onAddressChange,
}: AddressCardProps) {
const countryLabel = address.country
? (getCountryName(address.country) ?? address.country)
: null;
return (
<SubCard>
<div className="pb-5 border-b border-border/60">
@ -67,40 +103,11 @@ export function AddressCard({
<XMarkIcon className="h-4 w-4 mr-2" />
Cancel
</button>
<button
onClick={onSave}
disabled={isSaving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-primary-foreground bg-primary hover:bg-primary-hover disabled:opacity-50 transition-colors shadow-sm"
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-foreground mr-2"></div>
Saving...
</>
) : (
<>
<CheckIcon className="h-4 w-4 mr-2" />
Save Address
</>
)}
</button>
<SaveButton isSaving={isSaving} onClick={onSave} />
</div>
</div>
) : (
<div className="space-y-1.5 text-foreground">
{(address.address2 || address.address1) && (
<p className="font-semibold text-base">{address.address2 || address.address1}</p>
)}
{address.address2 && address.address1 && (
<p className="text-muted-foreground">{address.address1}</p>
)}
{(address.city || address.state || address.postcode) && (
<p className="text-muted-foreground">
{[address.city, address.state, address.postcode].filter(Boolean).join(", ")}
</p>
)}
{countryLabel && <p className="text-muted-foreground font-medium">{countryLabel}</p>}
</div>
<AddressDisplay address={address} />
)}
</div>
</SubCard>

View File

@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { useAuthSession } from "@/features/auth/stores/auth.store";
import { Button } from "@/components/atoms/button";
import { useAuth } from "@/features/auth/hooks/use-auth";
import { LOGOUT_REASON } from "@/features/auth/utils/logout-reason";
interface SessionTimeoutWarningProps {
warningTime?: number; // Minutes before token expires to show warning
@ -41,7 +42,7 @@ export function SessionTimeoutWarning({
expiryRef.current = expiryTime;
if (Date.now() >= expiryTime) {
void logout({ reason: "session-expired" });
void logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
return;
}
@ -82,7 +83,7 @@ export function SessionTimeoutWarning({
const remaining = expiryTime - Date.now();
if (remaining <= 0) {
setTimeLeft(0);
void logout({ reason: "session-expired" });
void logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
return;
}
@ -103,7 +104,7 @@ export function SessionTimeoutWarning({
if (event.key === "Escape") {
event.preventDefault();
setShowWarning(false);
void logout({ reason: "session-expired" });
void logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
}
if (event.key === "Tab") {
@ -147,13 +148,13 @@ export function SessionTimeoutWarning({
setTimeLeft(0);
} catch (error) {
logger.error("Failed to extend session", error);
await logout({ reason: "session-expired" });
await logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
}
})();
};
const handleLogoutNow = () => {
void logout({ reason: "session-expired" });
void logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
setShowWarning(false);
};

View File

@ -21,6 +21,7 @@ import {
import type { AuthenticatedUser } from "@customer-portal/domain/customer";
import {
clearLogoutReason,
LOGOUT_REASON,
logoutReasonFromErrorCode,
setLogoutReason,
type LogoutReason,
@ -90,7 +91,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
} catch (error) {
logger.error("Failed to refresh session", error);
const parsed = parseError(error);
const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const);
const reason = logoutReasonFromErrorCode(parsed.code) ?? LOGOUT_REASON.SESSION_EXPIRED;
await get().logout({ reason });
throw error;
}
@ -120,7 +121,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
url: detail?.url,
status: detail?.status,
});
void get().logout({ reason: "session-expired" });
void get().logout({ reason: LOGOUT_REASON.SESSION_EXPIRED });
});
}
@ -362,7 +363,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
} catch (error) {
const parsed = parseError(error);
if (parsed.shouldLogout) {
const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const);
const reason = logoutReasonFromErrorCode(parsed.code) ?? LOGOUT_REASON.SESSION_EXPIRED;
await get().logout({ reason });
return;
}

View File

@ -1,4 +1,10 @@
export type LogoutReason = "session-expired" | "token-revoked" | "manual";
export const LOGOUT_REASON = {
SESSION_EXPIRED: "session-expired",
TOKEN_REVOKED: "token-revoked",
MANUAL: "manual",
} as const;
export type LogoutReason = (typeof LOGOUT_REASON)[keyof typeof LOGOUT_REASON];
export interface LogoutMessage {
title: string;
@ -9,17 +15,17 @@ export interface LogoutMessage {
const STORAGE_KEY = "customer-portal:lastLogoutReason";
const LOGOUT_MESSAGES: Record<LogoutReason, LogoutMessage> = {
"session-expired": {
[LOGOUT_REASON.SESSION_EXPIRED]: {
title: "Session Expired",
body: "For your security, your session expired. Please sign in again to continue.",
variant: "warning",
},
"token-revoked": {
[LOGOUT_REASON.TOKEN_REVOKED]: {
title: "Signed Out For Your Safety",
body: "We detected a security change and signed you out. Please sign in again to verify your session.",
variant: "error",
},
manual: {
[LOGOUT_REASON.MANUAL]: {
title: "Signed Out",
body: "You have been signed out. Sign in again whenever you're ready.",
variant: "info",
@ -62,18 +68,22 @@ export function logoutReasonFromErrorCode(code: string): LogoutReason | undefine
switch (code) {
case "TOKEN_REVOKED":
case "INVALID_REFRESH_TOKEN":
return "token-revoked";
return LOGOUT_REASON.TOKEN_REVOKED;
case "SESSION_EXPIRED":
return "session-expired";
return LOGOUT_REASON.SESSION_EXPIRED;
default:
return undefined;
}
}
export function resolveLogoutMessage(reason: LogoutReason): LogoutMessage {
return LOGOUT_MESSAGES[reason] ?? LOGOUT_MESSAGES.manual;
return LOGOUT_MESSAGES[reason] ?? LOGOUT_MESSAGES[LOGOUT_REASON.MANUAL];
}
export function isLogoutReason(value: string | null | undefined): value is LogoutReason {
return value === "session-expired" || value === "token-revoked" || value === "manual";
return (
value === LOGOUT_REASON.SESSION_EXPIRED ||
value === LOGOUT_REASON.TOKEN_REVOKED ||
value === LOGOUT_REASON.MANUAL
);
}

View File

@ -210,7 +210,8 @@ export function AccountCheckoutContainer() {
message.toLowerCase().includes("residence card submission required") ||
message.toLowerCase().includes("residence card submission was rejected")
) {
const next = `${pathname}${searchParams?.toString() ? `?${searchParams.toString()}` : ""}`;
const queryString = searchParams?.toString();
const next = pathname + (queryString ? `?${queryString}` : "");
router.push(`/account/settings/verification?returnTo=${encodeURIComponent(next)}`);
return;
}

View File

@ -174,11 +174,8 @@ export function CheckoutEntry() {
if (!shouldRedirectToLogin) return;
const currentUrl = pathname + (paramsKey ? `?${paramsKey}` : "");
const returnTo = encodeURIComponent(
pathname.startsWith("/account")
? currentUrl
: `/account/order${paramsKey ? `?${paramsKey}` : ""}`
);
const orderUrl = "/account/order" + (paramsKey ? `?${paramsKey}` : "");
const returnTo = encodeURIComponent(pathname.startsWith("/account") ? currentUrl : orderUrl);
router.replace(`/auth/login?returnTo=${returnTo}`);
}, [shouldRedirectToLogin, pathname, paramsKey, router]);

View File

@ -80,33 +80,41 @@ export function formatActivityDate(date: string): string {
}
}
function formatInvoiceActivity(activity: Activity): string | null {
const parsed = invoiceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (!parsed.success || typeof parsed.data.amount !== "number") {
return null;
}
const formattedAmount = formatCurrencyUtil(parsed.data.amount, parsed.data.currency);
if (!formattedAmount) {
return null;
}
return activity.type === "invoice_paid"
? `${formattedAmount} payment completed`
: `${formattedAmount} invoice generated`;
}
function formatServiceActivity(activity: Activity): string | null {
const parsed = serviceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (!parsed.success || !parsed.data.productName) {
return null;
}
return `${parsed.data.productName} is now active`;
}
export function formatActivityDescription(activity: Activity): string {
const fallback = activity.description ?? "";
switch (activity.type) {
case "invoice_created":
case "invoice_paid": {
const parsed = invoiceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (parsed.success && typeof parsed.data.amount === "number") {
const formattedAmount = formatCurrencyUtil(parsed.data.amount, parsed.data.currency);
if (formattedAmount) {
return activity.type === "invoice_paid"
? `${formattedAmount} payment completed`
: `${formattedAmount} invoice generated`;
}
}
return activity.description ?? "";
}
case "service_activated": {
const parsed = serviceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (parsed.success && parsed.data.productName) {
return `${parsed.data.productName} is now active`;
}
return activity.description ?? "";
}
case "case_created":
case "case_closed":
return activity.description ?? "";
case "invoice_paid":
return formatInvoiceActivity(activity) ?? fallback;
case "service_activated":
return formatServiceActivity(activity) ?? fallback;
default:
return activity.description ?? "";
return fallback;
}
}

View File

@ -39,7 +39,7 @@ export function OtpInput({
const [activeIndex, setActiveIndex] = useState(0);
// Split value into array of characters
const digits = value.split("").slice(0, length);
const digits = [...value].slice(0, length);
while (digits.length < length) {
digits.push("");
}

View File

@ -67,7 +67,7 @@ export const NotificationBell = memo(function NotificationBell({
isOpen && "bg-muted/60 text-foreground"
)}
onClick={toggleDropdown}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : "Notifications"}
aria-expanded={isOpen}
aria-haspopup="true"
>

View File

@ -90,8 +90,9 @@ export function PaymentForm({
const renderMethod = (method: PaymentMethod) => {
const methodId = String(method.id);
const isSelected = selectedMethod === methodId;
const cardLastFourDisplay = method.cardLastFour ? `•••• ${method.cardLastFour}` : "";
const label = method.cardType
? `${method.cardType.toUpperCase()} ${method.cardLastFour ? `•••• ${method.cardLastFour}` : ""}`.trim()
? `${method.cardType.toUpperCase()} ${cardLastFourDisplay}`.trim()
: (method.description ?? method.type);
return (

View File

@ -113,7 +113,7 @@ export function CompleteAccountStep() {
setAccountErrors(errors);
return Object.keys(errors).length === 0;
}, [accountData]);
}, [accountData, validatePassword]);
const handleSubmit = async () => {
if (!validateAccountForm()) return;

View File

@ -62,15 +62,15 @@ export function InternetConfigureContainer({
const [renderedStep, setRenderedStep] = useState(currentStep);
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
// Use local state ONLY for step validation, step management now in Zustand
const { canProceedFromStep } = useConfigureState(
const { canProceedFromStep } = useConfigureState({
plan,
installations,
addons,
mode,
selectedInstallation,
currentStep,
setCurrentStep
);
setCurrentStep,
});
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);

View File

@ -8,28 +8,39 @@ import type {
} from "@customer-portal/domain/services";
import type { AccessModeValue } from "@customer-portal/domain/orders";
export interface UseConfigureStateOptions {
plan: InternetPlanCatalogItem | null;
installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[];
mode: AccessModeValue | null;
selectedInstallation: InternetInstallationCatalogItem | null;
currentStep: number;
setCurrentStep: (step: number) => void;
}
/**
* Hook for managing configuration wizard UI state (step navigation and transitions)
* Now uses external currentStep from Zustand store for persistence
*
* @param plan - Selected internet plan
* @param installations - Available installation options
* @param addons - Available addon options
* @param mode - Currently selected access mode
* @param selectedInstallation - Currently selected installation
* @param currentStep - Current step from Zustand store
* @param setCurrentStep - Step setter from Zustand store
* @param options - Configuration state options
* @param options.plan - Selected internet plan
* @param options.installations - Available installation options
* @param options.addons - Available addon options
* @param options.mode - Currently selected access mode
* @param options.selectedInstallation - Currently selected installation
* @param options.currentStep - Current step from Zustand store
* @param options.setCurrentStep - Step setter from Zustand store
* @returns Step navigation helpers
*/
export function useConfigureState(
plan: InternetPlanCatalogItem | null,
_installations: InternetInstallationCatalogItem[],
_addons: InternetAddonCatalogItem[],
mode: AccessModeValue | null,
selectedInstallation: InternetInstallationCatalogItem | null,
currentStep: number,
setCurrentStep: (step: number) => void
) {
export function useConfigureState({
plan,
installations: _installations,
addons: _addons,
mode,
selectedInstallation,
currentStep,
setCurrentStep,
}: UseConfigureStateOptions) {
// UI validation - determines if user can proceed from current step
// Note: Real validation should happen on BFF during order submission
const canProceedFromStep = useCallback(

View File

@ -323,7 +323,8 @@ export function InternetPlansContainer() {
if (autoPlanSku) params.set("planSku", autoPlanSku);
params.set("autoEligibilityRequest", "1");
const query = params.toString();
router.replace(`${servicesBasePath}/internet/request${query.length > 0 ? `?${query}` : ""}`);
const queryString = query.length > 0 ? `?${query}` : "";
router.replace(`${servicesBasePath}/internet/request${queryString}`);
}, [autoEligibilityRequest, autoPlanSku, hasCheckedAuth, servicesBasePath, user, router]);
// Determine current status for the badge

View File

@ -20,6 +20,8 @@ import {
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { formatAddressLabel } from "@/shared/utils";
const SUBSCRIPTIONS_HREF = "/account/subscriptions";
// ============================================================================
// Pending Cancellation View (when Opportunity is already in △Cancelling)
// ============================================================================
@ -43,7 +45,7 @@ function CancellationPendingView({
title={title}
description={preview.serviceName}
breadcrumbs={[
{ label: "Subscriptions", href: "/account/subscriptions" },
{ label: "Subscriptions", href: SUBSCRIPTIONS_HREF },
{ label: preview.serviceName, href: `/account/subscriptions/${subscriptionId}` },
{ label: "Cancellation Status" },
]}
@ -192,10 +194,7 @@ export function CancelSubscriptionContainer() {
icon={icon}
title="Cancel Subscription"
description="Loading cancellation information..."
breadcrumbs={[
{ label: "Subscriptions", href: "/account/subscriptions" },
{ label: "Cancel" },
]}
breadcrumbs={[{ label: "Subscriptions", href: SUBSCRIPTIONS_HREF }, { label: "Cancel" }]}
loading={loading}
error={error}
>
@ -228,7 +227,7 @@ export function CancelSubscriptionContainer() {
title={title}
description={preview.serviceName}
breadcrumbs={[
{ label: "Subscriptions", href: "/account/subscriptions" },
{ label: "Subscriptions", href: SUBSCRIPTIONS_HREF },
{ label: preview.serviceName, href: `/account/subscriptions/${subscriptionId}` },
{ label: "Cancel" },
]}

View File

@ -134,8 +134,8 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
icon={<TicketIconSolid />}
title="Case Not Found"
breadcrumbs={[
{ label: "Support", href: "/account/support" },
{ label: "Cases", href: "/account/support" },
{ label: "Support", href: SUPPORT_HREF },
{ label: "Cases", href: SUPPORT_HREF },
{ label: "Not Found" },
]}
>
@ -156,8 +156,8 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
error={pageError}
onRetry={() => void refetch()}
breadcrumbs={[
{ label: "Support", href: "/account/support" },
{ label: "Cases", href: "/account/support" },
{ label: "Support", href: SUPPORT_HREF },
{ label: "Cases", href: SUPPORT_HREF },
{ label: supportCase ? `#${supportCase.caseNumber}` : "..." },
]}
actions={

View File

@ -266,97 +266,9 @@ export const CANCELLATION_DEADLINE_DAY = 25;
export const RENTAL_RETURN_DEADLINE_DAY = 10;
// ============================================================================
// Business Types
// Cancellation Opportunity Data Types
// ============================================================================
/**
* Opportunity record as returned from Salesforce
*/
export interface OpportunityRecord {
id: string;
name: string;
accountId: string;
stage: OpportunityStageValue;
closeDate: string;
/** CommodityType - existing Salesforce field for product categorization */
commodityType?: CommodityTypeValue;
/** Simplified product type - derived from commodityType */
productType?: OpportunityProductTypeValue;
source?: OpportunitySourceValue;
applicationStage?: ApplicationStageValue;
isClosed: boolean;
// Linked entities
// Note: Cases and Orders link TO Opportunity (not stored here)
// - Case.OpportunityId → for eligibility, ID verification, cancellation
// - Order.OpportunityId → for order tracking
whmcsServiceId?: number;
// Cancellation fields (updated by CS when processing cancellation Case)
cancellationNotice?: CancellationNoticeValue;
scheduledCancellationDate?: string;
lineReturnStatus?: LineReturnStatusValue;
// NOTE: alternativeContactEmail and cancellationComments are on the Cancellation Case
// Metadata
createdDate: string;
lastModifiedDate: string;
}
/**
* Request to create a new Opportunity
*
* Note: Opportunity Name is auto-generated by Salesforce workflow,
* so we don't need to provide account name. The service will use
* a placeholder that gets overwritten by Salesforce.
*/
export interface CreateOpportunityRequest {
accountId: string;
productType: OpportunityProductTypeValue;
stage: OpportunityStageValue;
source: OpportunitySourceValue;
/** Application stage, defaults to INTRO-1 */
applicationStage?: ApplicationStageValue;
/** Expected close date, defaults to 30 days from now */
closeDate?: string;
}
/**
* Request to update Opportunity stage
*/
export interface UpdateOpportunityStageRequest {
opportunityId: string;
stage: OpportunityStageValue;
/** Optional: reason for stage change (for audit) */
reason?: string;
}
/**
* Cancellation form data from customer
*/
export interface CancellationFormData {
/**
* Selected cancellation month (YYYY-MM format)
* Service ends at end of this month
*/
cancellationMonth: string;
/** Customer confirms they have read cancellation terms */
confirmTermsRead: boolean;
/** Customer confirms they understand month-end cancellation */
confirmMonthEndCancellation: boolean;
/** Optional alternative email for post-cancellation communication */
alternativeEmail?: string;
/** Optional customer comments/notes */
comments?: string;
}
/**
* Cancellation data to populate on Opportunity for Internet services
* Only core lifecycle fields - details go on Cancellation Case
@ -427,97 +339,6 @@ export interface CancellationCaseData {
comments?: string;
}
/**
* Cancellation eligibility check result
*/
export interface CancellationEligibility {
/** Whether cancellation can be requested now */
canCancel: boolean;
/** Earliest month available for cancellation (YYYY-MM) */
earliestCancellationMonth: string;
/** Available cancellation months (up to 12 months ahead) */
availableMonths: CancellationMonthOption[];
/** Deadline for requesting cancellation this month */
currentMonthDeadline: string | null;
/** If cannot cancel, the reason why */
reason?: string;
}
/**
* A month option for cancellation selection
*/
export interface CancellationMonthOption {
/** Value in YYYY-MM format */
value: string;
/** Display label (e.g., "January 2025") */
label: string;
/** End date of the month (service end date) */
serviceEndDate: string;
/** Rental return deadline (10th of following month) */
rentalReturnDeadline: string;
/** Whether this is the current month (may have deadline) */
isCurrentMonth: boolean;
}
/**
* Cancellation status for display in portal
*/
export interface CancellationStatus {
/** Current stage */
stage: OpportunityStageValue;
/** Whether cancellation is pending */
isPending: boolean;
/** Whether cancellation is complete */
isComplete: boolean;
/** Scheduled end date */
scheduledEndDate?: string;
/** Rental return status */
rentalReturnStatus?: LineReturnStatusValue;
/** Rental return deadline */
rentalReturnDeadline?: string;
/** Whether rental equipment needs to be returned */
hasRentalEquipment: boolean;
}
/**
* Result of Opportunity matching/resolution
*/
export interface OpportunityMatchResult {
/** The Opportunity ID (existing or newly created) */
opportunityId: string;
/** Whether a new Opportunity was created */
wasCreated: boolean;
/** Previous stage if updated */
previousStage?: OpportunityStageValue;
}
/**
* Lookup criteria for finding existing Opportunities
*/
export interface OpportunityLookupCriteria {
accountId: string;
productType: OpportunityProductTypeValue;
/** Only match Opportunities in these stages */
allowedStages?: OpportunityStageValue[];
}
// ============================================================================
// Customer-Facing Service Display Model
// ============================================================================

View File

@ -184,13 +184,12 @@ export type CancellationFormData = z.infer<typeof cancellationFormDataSchema>;
/**
* Schema for cancellation data to populate on Opportunity
* NOTE: alternativeEmail and comments are captured in the Cancellation Case, not Opportunity
*/
export const cancellationOpportunityDataSchema = z.object({
scheduledCancellationDate: z.string(),
cancellationNotice: z.enum(CANCELLATION_NOTICE_VALUES),
lineReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES),
alternativeEmail: z.string().email().optional(),
comments: z.string().max(2000).optional(),
});
export type CancellationOpportunityData = z.infer<typeof cancellationOpportunityDataSchema>;