diff --git a/apps/portal/src/app/account/layout.tsx b/apps/portal/src/app/account/layout.tsx index 7275d838..4e175a30 100644 --- a/apps/portal/src/app/account/layout.tsx +++ b/apps/portal/src/app/account/layout.tsx @@ -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 }) { - {children} + }>{children} ); } diff --git a/apps/portal/src/components/atoms/badge.tsx b/apps/portal/src/components/atoms/badge.tsx index 9550d09f..7eccadb2 100644 --- a/apps/portal/src/components/atoms/badge.tsx +++ b/apps/portal/src/components/atoms/badge.tsx @@ -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 = { + 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 = { + 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( - ({ 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 ( {dot && ( - + )} {icon && {icon}} {children} @@ -63,14 +88,8 @@ const Badge = forwardRef( 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" > diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index 99669e04..4bc5f190 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -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 ( + + {loading ? : leftIcon} + {loading ? (loadingText ?? children) : children} + {!loading && rightIcon && ( + + {rightIcon} + + )} + + ); +} + interface ButtonExtras { leftIcon?: ReactNode; rightIcon?: ReactNode; @@ -69,52 +97,34 @@ const Button = forwardRef((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 ( - } - aria-busy={loading || undefined} - {...anchorProps} - > - - {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} - {!loading && rightIcon ? ( - - {rightIcon} - - ) : null} - + } {...anchorProps}> + ); } return ( } - aria-busy={loading || undefined} {...(anchorProps as Omit)} > - - {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} - {!loading && rightIcon ? ( - - {rightIcon} - - ) : null} - + ); } @@ -128,6 +138,7 @@ const Button = forwardRef((p ...buttonProps } = rest as ButtonAsButtonProps; void _as; + return ( ); }); diff --git a/apps/portal/src/components/molecules/FormField/FormField.tsx b/apps/portal/src/components/molecules/FormField/FormField.tsx index 9b735a84..85c98761 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.tsx @@ -39,9 +39,39 @@ const FormField = forwardRef( 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 ( + + ); + } + + if (isValidElement(children)) { + return cloneElement(children, { + id, + "aria-invalid": error ? "true" : undefined, + "aria-describedby": describedBy, + } as Record); + } + + return children; + }; + return (
{label && ( @@ -55,37 +85,14 @@ const FormField = forwardRef( {...(labelProps ? { ...labelProps, className: undefined } : undefined)} > {label} - {required ? ( + {required && ( - ) : null} + )} )} - {children ? ( - isValidElement(children) ? ( - cloneElement(children, { - id, - "aria-invalid": error ? "true" : undefined, - "aria-describedby": cn(errorId, helperTextId) || undefined, - } as Record) - ) : ( - children - ) - ) : ( - - )} + {renderInput()} {error && {error}} {helperText && !error && (

diff --git a/apps/portal/src/components/molecules/SubCard/SubCard.tsx b/apps/portal/src/components/molecules/SubCard/SubCard.tsx index c0fb07ee..3b84c9dc 100644 --- a/apps/portal/src/components/molecules/SubCard/SubCard.tsx +++ b/apps/portal/src/components/molecules/SubCard/SubCard.tsx @@ -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

{header}
; } @@ -64,7 +72,7 @@ export const SubCard = forwardRef( className )} > - {renderSubCardHeader(header, title, icon, right, headerClassName)} + {renderSubCardHeader({ header, title, icon, right, headerClassName })}
{children}
{footer ?
{footer}
: null}
diff --git a/apps/portal/src/components/molecules/error-fallbacks.tsx b/apps/portal/src/components/molecules/error-fallbacks.tsx new file mode 100644 index 00000000..3b808d11 --- /dev/null +++ b/apps/portal/src/components/molecules/error-fallbacks.tsx @@ -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 ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Please refresh the page. +

+ +
+
+ ); +} + +/** + * Content area fallback - keeps nav/sidebar functional + * Used for errors within the main content area + */ +export function PageErrorFallback() { + return ( +
+
+

Something went wrong

+

+ This section encountered an error. Please try again. +

+ +
+
+ ); +} diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index deb20ef7..1f64544e 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -31,3 +31,4 @@ export * from "./StatusBadge"; // Performance and lazy loading utilities export { ErrorBoundary } from "./error-boundary"; +export { GlobalErrorFallback, PageErrorFallback } from "./error-fallbacks"; diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 3a6753fe..f2b0c542 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -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(); + 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(); - 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 { diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index db6a9618..a663c41b 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -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 (
diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 51e925be..d94bc9f9 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -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
; +} + +function NavIcon({ + icon: Icon, + isActive, + variant = "default", +}: { + icon: ComponentType>; + isActive: boolean; + variant?: "default" | "logout"; +}) { + if (variant === "logout") { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} 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; +}) { + const isActive = + item.children?.some((child: NavigationChild) => + pathname.startsWith((child.href || "").split(/[?#]/)[0] ?? "") + ) ?? false; + + return ( +
+ + +
+
+ {item.children?.map((child: NavigationChild) => { + const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; + return ( + 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 && } +
+ {child.name} + + ); + })} +
+
+
+ ); +} + +function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) { + return ( + + ); +} + +function SimpleNavItem({ + item, + isActive, + router, +}: { + item: NavigationItem; + isActive: boolean; + router: ReturnType; +}) { + return ( + item.href && item.href !== "#" && void router.prefetch(item.href)} + className={`${navItemBaseClass} ${isActive ? activeClass : inactiveClass}`} + aria-current={isActive ? "page" : undefined} + > + {isActive && } + + {item.name} + + ); +} + 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 ( -
- - -
-
- {item.children?.map((child: NavigationChild) => { - const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; - return ( - { - 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 && ( -
- )} - -
- - {child.name} - - ); - })} -
-
-
+ ); } if (item.isLogout) { - return ( - - ); + const handleLogout = () => void logout().then(() => router.push("/")); + return ; } - return ( - { - 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 && ( -
- )} - -
- -
- - {item.name} - - ); + const isActive = item.href ? pathname === item.href : false; + return ; }); diff --git a/apps/portal/src/core/api/runtime/client.ts b/apps/portal/src/core/api/runtime/client.ts index d6769fde..6e17b14d 100644 --- a/apps/portal/src/core/api/runtime/client.ts +++ b/apps/portal/src/core/api/runtime/client.ts @@ -300,6 +300,41 @@ class CsrfTokenManager { const SAFE_METHODS = new Set(["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 { + 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 => { + 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 ( 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 { diff --git a/apps/portal/src/core/providers/QueryProvider.tsx b/apps/portal/src/core/providers/QueryProvider.tsx index 8eff695c..da28f031 100644 --- a/apps/portal/src/core/providers/QueryProvider.tsx +++ b/apps/portal/src/core/providers/QueryProvider.tsx @@ -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 ( - {children} + }>{children} {process.env["NODE_ENV"] === "development" && } ); diff --git a/apps/portal/src/features/account/components/AddressCard.tsx b/apps/portal/src/features/account/components/AddressCard.tsx index b512c87e..7d71678c 100644 --- a/apps/portal/src/features/account/components/AddressCard.tsx +++ b/apps/portal/src/features/account/components/AddressCard.tsx @@ -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 ( +
+ {primaryLine &&

{primaryLine}

} + {secondaryLine &&

{secondaryLine}

} + {cityStateZip &&

{cityStateZip}

} + {countryLabel &&

{countryLabel}

} +
+ ); +} + +function SaveButton({ isSaving, onClick }: { isSaving: boolean; onClick: () => void }) { + return ( + + ); +} + 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 (
@@ -67,40 +103,11 @@ export function AddressCard({ Cancel - +
) : ( -
- {(address.address2 || address.address1) && ( -

{address.address2 || address.address1}

- )} - {address.address2 && address.address1 && ( -

{address.address1}

- )} - {(address.city || address.state || address.postcode) && ( -

- {[address.city, address.state, address.postcode].filter(Boolean).join(", ")} -

- )} - {countryLabel &&

{countryLabel}

} -
+ )}
diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 2ea1d090..550c908e 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -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); }; diff --git a/apps/portal/src/features/auth/stores/auth.store.ts b/apps/portal/src/features/auth/stores/auth.store.ts index 9d94be8a..573dfe15 100644 --- a/apps/portal/src/features/auth/stores/auth.store.ts +++ b/apps/portal/src/features/auth/stores/auth.store.ts @@ -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()((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()((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()((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; } diff --git a/apps/portal/src/features/auth/utils/logout-reason.ts b/apps/portal/src/features/auth/utils/logout-reason.ts index 14395724..a6635e2a 100644 --- a/apps/portal/src/features/auth/utils/logout-reason.ts +++ b/apps/portal/src/features/auth/utils/logout-reason.ts @@ -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 = { - "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 + ); } diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index 41832c25..7615b65e 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -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; } diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx index bd0c8637..ae7d3ec5 100644 --- a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx @@ -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]); diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index 33eebf91..00a927e3 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -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; } } diff --git a/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx index 93b90fc2..9dafae15 100644 --- a/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx +++ b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx @@ -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(""); } diff --git a/apps/portal/src/features/notifications/components/NotificationBell.tsx b/apps/portal/src/features/notifications/components/NotificationBell.tsx index 7a3030c2..409a278a 100644 --- a/apps/portal/src/features/notifications/components/NotificationBell.tsx +++ b/apps/portal/src/features/notifications/components/NotificationBell.tsx @@ -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" > diff --git a/apps/portal/src/features/services/components/base/PaymentForm.tsx b/apps/portal/src/features/services/components/base/PaymentForm.tsx index 2519a7ed..12dd0fe2 100644 --- a/apps/portal/src/features/services/components/base/PaymentForm.tsx +++ b/apps/portal/src/features/services/components/base/PaymentForm.tsx @@ -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 ( diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx index f314ca7f..7be4f61c 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx @@ -113,7 +113,7 @@ export function CompleteAccountStep() { setAccountErrors(errors); return Object.keys(errors).length === 0; - }, [accountData]); + }, [accountData, validatePassword]); const handleSubmit = async () => { if (!validateAccountForm()) return; diff --git a/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx index 9a1c01b4..99730672 100644 --- a/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/services/components/internet/configure/InternetConfigureContainer.tsx @@ -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); diff --git a/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts b/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts index a3eea43a..c03c464a 100644 --- a/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts +++ b/apps/portal/src/features/services/components/internet/configure/hooks/useConfigureState.ts @@ -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( diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx index 9fd4e01b..d48d2842 100644 --- a/apps/portal/src/features/services/views/InternetPlans.tsx +++ b/apps/portal/src/features/services/views/InternetPlans.tsx @@ -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 diff --git a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx index bc8be150..f7c784a7 100644 --- a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx +++ b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx @@ -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" }, ]} diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index 0fb0bd60..d4e5b287 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -134,8 +134,8 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) { icon={} 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={ diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts index e2dc2380..253199db 100644 --- a/packages/domain/opportunity/contract.ts +++ b/packages/domain/opportunity/contract.ts @@ -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 // ============================================================================ diff --git a/packages/domain/opportunity/schema.ts b/packages/domain/opportunity/schema.ts index b8f85b92..4830020e 100644 --- a/packages/domain/opportunity/schema.ts +++ b/packages/domain/opportunity/schema.ts @@ -184,13 +184,12 @@ export type CancellationFormData = z.infer; /** * 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;