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:
parent
0a0e2c6508
commit
0a5a33da98
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
39
apps/portal/src/components/molecules/error-fallbacks.tsx
Normal file
39
apps/portal/src/components/molecules/error-fallbacks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -31,3 +31,4 @@ export * from "./StatusBadge";
|
||||
|
||||
// Performance and lazy loading utilities
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export { GlobalErrorFallback, PageErrorFallback } from "./error-fallbacks";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />;
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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("");
|
||||
}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -113,7 +113,7 @@ export function CompleteAccountStep() {
|
||||
|
||||
setAccountErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [accountData]);
|
||||
}, [accountData, validatePassword]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateAccountForm()) return;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" },
|
||||
]}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user