diff --git a/.changeset/mighty-toes-march.md b/.changeset/mighty-toes-march.md new file mode 100644 index 00000000000..4ddcd95b7c3 --- /dev/null +++ b/.changeset/mighty-toes-march.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Introduce new, unified order capture dialog diff --git a/.changeset/whole-groups-happen.md b/.changeset/whole-groups-happen.md new file mode 100644 index 00000000000..4b9cc8c97ce --- /dev/null +++ b/.changeset/whole-groups-happen.md @@ -0,0 +1,12 @@ +--- +"saleor-dashboard": patch +--- + +Redesigned toast notifications for better user experience: + +- **Notifications now stack compactly** instead of flooding the screen +- **Refined visual design** that's less disruptive and better aligned with the dashboard aesthetic +- **Long messages expand on hover** with "Show more/less" toggle instead of being truncated +- **Error notifications persist** until manually dismissed + +**Next up:** Refining notification message copy for clarity and consistency across the dashboard. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 034decb58b0..6f1657f57ba 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -427,6 +427,10 @@ "context": "column header", "string": "Title" }, + "0YOedO": { + "context": "label for already charged amount", + "string": "Captured so far" + }, "0YjGFG": { "context": "alert message", "string": "For subscription" @@ -446,6 +450,10 @@ "context": "ProductTypeDeleteWarningDialog multiple consent label", "string": "Yes, I want to delete those products types and assigned products" }, + "0f6YvV": { + "context": "dialog title", + "string": "Capture Payment" + }, "0iMYc+": { "context": "field is optional", "string": "(Optional)" @@ -1018,6 +1026,10 @@ "4YJHut": { "string": "Clear search" }, + "4YyeCx": { + "context": "label for order total amount", + "string": "Order total" + }, "4Z0O2B": { "context": "section header title", "string": "Gift Card Timeline" @@ -1608,6 +1620,10 @@ "context": "ordered product sku", "string": "SKU" }, + "8JEG80": { + "context": "warning when authorized is less than total", + "string": "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment." + }, "8LWaFr": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to unpublish this model?} other{Are you sure you want to unpublish {displayQuantity} models?}}" @@ -2061,6 +2077,10 @@ "context": "no warehouses info", "string": "There are no warehouses set up for this product. To add stock quantity to the product configure a warehouse or use existing one by clicking button below." }, + "BJRu4V": { + "context": "pill status for partially captured outcome", + "string": "Partially captured" + }, "BJtUQI": { "context": "button", "string": "Add" @@ -2135,10 +2155,6 @@ "Bl6896": { "string": "Refund amount" }, - "BnB/7Y": { - "context": "callout, title", - "string": "Info" - }, "Bphmwe": { "context": "header", "string": "Translation Collection \"{collectionName}\" - {languageCode}" @@ -2847,6 +2863,10 @@ "context": "tooltip", "string": "Checkout reservation time threshold is enabled in settings." }, + "G9y5Ze": { + "context": "pill status for fully captured outcome", + "string": "Fully captured" + }, "GA+Djy": { "string": "Are you sure you want to delete these voucher codes?" }, @@ -3037,6 +3057,10 @@ "context": "dialog content", "string": "You are not able to modify this group members. Solve this problem to continue with request." }, + "H0eCbU": { + "context": "label for transaction authorized amount", + "string": "Transaction authorized" + }, "H1L1cc": { "context": "url", "string": "URL" @@ -3102,6 +3126,10 @@ "context": "cta button label", "string": "Get in touch" }, + "HSYM17": { + "context": "outcome prediction showing resulting order status after capture", + "string": "This will result in {status} order" + }, "HSmg1/": { "context": "gift cards section name", "string": "Gift Cards" @@ -3175,6 +3203,10 @@ "HvJPcU": { "string": "Category deleted" }, + "HwIhau": { + "context": "status pill for fully authorized payment", + "string": "Fully Authorized" + }, "HwTMFL": { "string": "Go to channels" }, @@ -3275,6 +3307,10 @@ "ITYiRy": { "string": "Go to collections" }, + "IU1lif": { + "context": "radio option for custom capture amount", + "string": "Custom amount" + }, "IUWJKt": { "context": "order was discounted event title", "string": "Order was discounted" @@ -3690,6 +3726,10 @@ "L87bp7": { "string": "Order payment successfully voided" }, + "L8J/jr": { + "context": "status pill when order is fully paid", + "string": "Fully Captured" + }, "L8seEc": { "string": "Subtotal" }, @@ -3923,10 +3963,18 @@ "context": "customer input label", "string": "Customer" }, + "MhlYkx": { + "context": "label for available authorization amount", + "string": "Available to capture (authorized)" + }, "MjUyhA": { "context": "section subheader", "string": "Active member since {date}" }, + "Mm/Stj": { + "context": "hint showing maximum allowed custom amount", + "string": "Max: {amount}" + }, "MmGlkp": { "context": "dialog header", "string": "Unassign Collections From Voucher" @@ -4211,6 +4259,10 @@ "context": "tab name", "string": "All staff members" }, + "OUMqG1": { + "context": "label for remaining balance to capture", + "string": "Remaining balance" + }, "OUX4LB": { "context": "input label", "string": "Model type" @@ -4639,6 +4691,10 @@ "context": "add new refund button", "string": "New refund" }, + "R/YHMH": { + "context": "label for amount already captured from this transaction", + "string": "Already captured" + }, "R4IIw1": { "context": "tracking number of the shipment", "string": "Tracking number" @@ -4903,6 +4959,10 @@ "context": "product filter label", "string": "Product" }, + "SnV3LR": { + "context": "error when no authorization exists", + "string": "No payment has been authorized for this order. The full amount of {amount} cannot be captured." + }, "SpngiS": { "context": "sale status", "string": "Status" @@ -5100,6 +5160,10 @@ "context": "order expiration card description", "string": "The time in days after expired orders will be deleted. Allowed range between 1 and 120." }, + "U0IK0G": { + "context": "label for authorized amount", + "string": "Authorized" + }, "U2DyeR": { "string": "Are you sure you want to delete structure {menuName}?" }, @@ -5187,10 +5251,6 @@ "context": "filters error messages unknown error", "string": "Unknown error occurred" }, - "UUVUyy": { - "context": "callout, title", - "string": "Warning" - }, "UVDfTs": { "context": "discount type shipping", "string": "Shipping" @@ -5749,6 +5809,10 @@ "context": "product pricing, section header", "string": "Pricing" }, + "XrliJg": { + "context": "label for amount selection", + "string": "Select amount to capture:" + }, "Xsh2Pa": { "context": "column picker search input placeholder", "string": "Search for columns" @@ -6051,6 +6115,10 @@ "context": "empty state message", "string": "No refunds made for this order." }, + "ZUYQ+C": { + "context": "status pill for partial authorization", + "string": "Partial authorisation" + }, "ZXOpCJ": { "string": "An unexpected issue occurred when parsing manifest. Please contact support. ({errorCode})" }, @@ -6381,6 +6449,10 @@ "context": "button", "string": "Create permission group" }, + "bRXgSC": { + "context": "capture button with amount", + "string": "Capture {amount}" + }, "bS7A8u": { "context": "add tracking button", "string": "Add tracking" @@ -7566,6 +7638,10 @@ "context": "product price", "string": "Select channel" }, + "jhyt3I": { + "context": "label for max capturable amount when partial authorization", + "string": "Remaining max (authorized)" + }, "jiXbx5": { "context": "fulfillment status canceled", "string": "Canceled" @@ -8103,6 +8179,10 @@ "mvVmbJ": { "string": "Install extension from manifest" }, + "mxGY7T": { + "context": "status pill for no authorization", + "string": "No Authorization" + }, "mxtAFx": { "string": "Are you sure you want to delete draft #{orderNumber}?" }, @@ -8732,6 +8812,10 @@ "context": "bulk delete label", "string": "Delete" }, + "qlfssi": { + "context": "label for remaining amount customer owes", + "string": "Balance due" + }, "qov29K": { "context": "dialog content", "string": "Select one of customer addresses or add a new address:" @@ -8933,10 +9017,6 @@ "context": "search label", "string": "Search products" }, - "s8FlDW": { - "context": "hide error log label in notification", - "string": "Hide log" - }, "s9sOcC": { "context": "button", "string": "OK" @@ -9106,6 +9186,10 @@ "tR+UuE": { "string": "User doesn't exist. Please check your email in URL" }, + "tS2K/N": { + "context": "radio option for capturing order total", + "string": "Order total" + }, "tTuCYj": { "context": "all gift cards label", "string": "All Gift Cards" @@ -9225,6 +9309,10 @@ "context": "shipping zones configuration", "string": "Change default weight unit" }, + "u7ShY+": { + "context": "pill status for overcaptured outcome", + "string": "Overcaptured" + }, "u9/vj9": { "context": "webhook input label", "string": "Target URL" @@ -9408,6 +9496,10 @@ "v3WWK+": { "string": "Status is invalid" }, + "v8e93p": { + "context": "hint for order total option", + "string": "Matches what customer owes" + }, "v9D1pm": { "context": "automatic completion cut-off date info message", "string": "Setting a cut-off date will not stop checkouts that are already in the process of being completed." @@ -9622,10 +9714,6 @@ "w7jT4W": { "string": "No channels selected" }, - "w9xgN9": { - "context": "see error log label in notification", - "string": "See error log" - }, "wAGThK": { "context": "dialog header", "string": "Delete structures" @@ -9689,6 +9777,9 @@ "wbsq7O": { "string": "Usage" }, + "we4Lby": { + "string": "Info" + }, "werrDz": { "context": "refunded event title", "string": "Products were refunded" diff --git a/package.json b/package.json index 843a202690c..45b9301a683 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "react-sortable-hoc": "^1.11.0", "react-transition-group": "4.4.5", "slugify": "^1.6.6", + "sonner": "^2.0.7", "tslib": "^2.8.1", "url-join": "^4.0.1", "use-react-router": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40c0d21cf8e..a30d8c3de45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: slugify: specifier: ^1.6.6 version: 1.6.6 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -11790,6 +11793,15 @@ packages: integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==, } + sonner@2.0.7: + resolution: + { + integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==, + } + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sort-object-keys@1.1.3: resolution: { @@ -21658,6 +21670,11 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + sort-object-keys@1.1.3: {} sort-package-json@3.4.0: diff --git a/src/auth/hooks/useAuthProvider.ts b/src/auth/hooks/useAuthProvider.ts index 8b4b0d00da9..b1c3d1fa05e 100644 --- a/src/auth/hooks/useAuthProvider.ts +++ b/src/auth/hooks/useAuthProvider.ts @@ -1,5 +1,5 @@ import { ApolloClient, ApolloError } from "@apollo/client"; -import { IMessageContext } from "@dashboard/components/messages"; +import { INotificationCallback } from "@dashboard/components/notifications"; import { AccountErrorCode, useUserDetailsQuery } from "@dashboard/graphql"; import useLocalStorage from "@dashboard/hooks/useLocalStorage"; import useNavigator from "@dashboard/hooks/useNavigator"; @@ -29,7 +29,7 @@ import { useLastLoginMethod } from "./useLastLoginMethod"; interface UseAuthProviderOpts { intl: IntlShape; - notify: IMessageContext; + notify: INotificationCallback; apolloClient: ApolloClient; } type AuthErrorCodes = `${AccountErrorCode}`; diff --git a/src/auth/utils.ts b/src/auth/utils.ts index 19efcb42f03..61fc3dd29bb 100644 --- a/src/auth/utils.ts +++ b/src/auth/utils.ts @@ -1,5 +1,5 @@ import { ApolloError, ServerError } from "@apollo/client/core"; -import { IMessage, IMessageContext } from "@dashboard/components/messages"; +import { INotification, INotificationCallback } from "@dashboard/components/notifications"; import { commonMessages } from "@dashboard/intl"; import { getMutationErrors, parseLogMessage } from "@dashboard/misc"; import { getAppMountUriForRedirect } from "@dashboard/utils/urls"; @@ -41,7 +41,7 @@ export const showAllErrors = ({ notify, error, }: { - notify: IMessageContext; + notify: INotificationCallback; error: ApolloError; }) => { getAllErrorMessages(error).forEach(message => { @@ -60,7 +60,7 @@ export const handleNestedMutationErrors = ({ }: { data: any; intl: IntlShape; - notify: (message: IMessage) => void; + notify: (notification: INotification) => void; }) => { const mutationErrors = getMutationErrors({ data }); @@ -82,7 +82,7 @@ export const handleNestedMutationErrors = ({ export async function handleQueryAuthError( error: ApolloError, - notify: IMessageContext, + notify: INotificationCallback, logout: () => void, intl: IntlShape, ) { diff --git a/src/components/Callout/Callout.tsx b/src/components/Callout/Callout.tsx index dbf42137d42..c4493ef5a8d 100644 --- a/src/components/Callout/Callout.tsx +++ b/src/components/Callout/Callout.tsx @@ -1,58 +1,66 @@ -import { ExclamationIcon } from "@dashboard/icons/ExclamationIcon"; -import { Box } from "@saleor/macaw-ui-next"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { getStatusColor, PillStatusType } from "@dashboard/misc"; +import { Box, Text, useTheme } from "@saleor/macaw-ui-next"; +import { AlertTriangle, CircleAlert, Info, LucideIcon } from "lucide-react"; import { ReactNode } from "react"; -import { DashboardCard } from "../Card"; +type CalloutType = "info" | "warning" | "error"; -type CalloutType = "info" | "warning"; +interface CalloutStyles { + status: PillStatusType; + iconColor: "warning1" | "critical1" | "default1"; + Icon: LucideIcon; +} -const warningStylesBox = { - backgroundColor: "warning1", - borderColor: "warning1", -} as const; - -const warningStylesIcon = { - color: "warning1", -} as const; - -const gridTemplate = ` - "icon title" - "empty content" -`; +const calloutStylesMap: Record = { + warning: { + status: "warning", + iconColor: "warning1", + Icon: CircleAlert, + }, + error: { + status: "error", + iconColor: "critical1", + Icon: AlertTriangle, + }, + info: { + status: "neutral", + iconColor: "default1", + Icon: Info, + }, +}; -export const Callout = ({ - children, - title, - type, -}: { - children: ReactNode; +interface CalloutProps { + children?: ReactNode; title: ReactNode; type: CalloutType; -}) => { - const boxStyles = type === "warning" ? warningStylesBox : null; - const iconStyles = type === "warning" ? warningStylesIcon : null; +} + +export const Callout = ({ children, title, type }: CalloutProps): JSX.Element => { + const { theme: currentTheme } = useTheme(); + const { status, iconColor, Icon } = calloutStylesMap[type]; + const backgroundColor = getStatusColor({ status, currentTheme }).base; return ( - - - - - - {title} - - - {children} - - + + + + + {title} + {children && ( + + {children} + + )} + + ); }; diff --git a/src/components/Callout/messages.ts b/src/components/Callout/messages.ts index 012b5169465..33c76528603 100644 --- a/src/components/Callout/messages.ts +++ b/src/components/Callout/messages.ts @@ -1,14 +1,7 @@ -import { defineMessages } from "react-intl"; +import { commonMessages } from "@dashboard/intl"; -export const calloutTitleMessages = defineMessages({ - info: { - defaultMessage: "Info", - description: "callout, title", - id: "BnB/7Y", - }, - warning: { - defaultMessage: "Warning", - description: "callout, title", - id: "UUVUyy", - }, -}); +// Re-export common messages for backward compatibility +export const calloutTitleMessages = { + info: commonMessages.info, + warning: commonMessages.warning, +}; diff --git a/src/components/DevTools/DevToolsPanel.tsx b/src/components/DevTools/DevToolsPanel.tsx new file mode 100644 index 00000000000..49e101fb281 --- /dev/null +++ b/src/components/DevTools/DevToolsPanel.tsx @@ -0,0 +1,140 @@ +import { Box, Text } from "@saleor/macaw-ui-next"; +import { GripVertical, X } from "lucide-react"; +import { useRef, useState } from "react"; + +import { useDevTools } from "./DevToolsProvider"; + +export const DevToolsPanel = () => { + const { isVisible, toggle, panels, activePanel, setActivePanel } = useDevTools(); + + const [position, setPosition] = useState({ x: 16, y: 16 }); + const [isDragging, setIsDragging] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + dragOffset.current = { + x: window.innerWidth - e.clientX - position.x, + y: window.innerHeight - e.clientY - position.y, + }; + + const handleMouseMove = (e: MouseEvent) => { + setPosition({ + x: Math.max(0, window.innerWidth - e.clientX - dragOffset.current.x), + y: Math.max(0, window.innerHeight - e.clientY - dragOffset.current.y), + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + if (!isVisible || panels.length === 0) { + return null; + } + + const ActiveComponent = panels.find(p => p.id === activePanel)?.component; + + return ( + + {/* Header with drag handle */} + + + + + 🔧 DevTools + + + + + + + + {/* Tabs */} + + {panels.map(panel => ( + setActivePanel(panel.id)} + paddingX={2} + paddingY={1} + borderRadius={2} + cursor="pointer" + borderWidth={0} + backgroundColor={activePanel === panel.id ? "default2" : "transparent"} + style={{ outline: "none" }} + > + + {panel.label} + + + ))} + + + {/* Content */} + + {ActiveComponent && } + + + ); +}; diff --git a/src/components/DevTools/DevToolsProvider.tsx b/src/components/DevTools/DevToolsProvider.tsx new file mode 100644 index 00000000000..f334274ac07 --- /dev/null +++ b/src/components/DevTools/DevToolsProvider.tsx @@ -0,0 +1,95 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +export interface DevToolPanel { + id: string; + label: string; + component: React.ComponentType; +} + +interface DevToolsContextValue { + isVisible: boolean; + toggle: () => void; + panels: DevToolPanel[]; + register: (panel: DevToolPanel) => () => void; + activePanel: string | null; + setActivePanel: (id: string) => void; +} + +const DevToolsContext = createContext(null); + +export const useDevTools = () => { + const context = useContext(DevToolsContext); + + if (!context) { + throw new Error("useDevTools must be used within DevToolsProvider"); + } + + return context; +}; + +interface DevToolsProviderProps { + children: ReactNode; +} + +export const DevToolsProvider = ({ children }: DevToolsProviderProps) => { + const [isVisible, setIsVisible] = useState(false); + const [panels, setPanels] = useState([]); + const [activePanel, setActivePanel] = useState(null); + + const toggle = useCallback(() => setIsVisible(prev => !prev), []); + + const register = useCallback((panel: DevToolPanel) => { + setPanels(prev => { + // Don't add duplicates + if (prev.some(p => p.id === panel.id)) { + return prev; + } + + return [...prev, panel]; + }); + + // Set first panel as active + setActivePanel(current => current ?? panel.id); + + // Return unregister function + return () => { + setPanels(prev => prev.filter(p => p.id !== panel.id)); + }; + }, []); + + // Keyboard shortcut: Cmd+Shift+D (or Ctrl+Shift+D) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.code === "KeyD") { + e.preventDefault(); + toggle(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => document.removeEventListener("keydown", handleKeyDown); + }, [toggle]); + + const value = useMemo( + () => ({ + isVisible, + toggle, + panels, + register, + activePanel, + setActivePanel, + }), + [isVisible, toggle, panels, register, activePanel], + ); + + return {children}; +}; diff --git a/src/components/DevTools/index.ts b/src/components/DevTools/index.ts new file mode 100644 index 00000000000..9dc35af0d92 --- /dev/null +++ b/src/components/DevTools/index.ts @@ -0,0 +1,4 @@ +export { DevToolsPanel } from "./DevToolsPanel"; +export type { DevToolPanel } from "./DevToolsProvider"; +export { DevToolsProvider, useDevTools } from "./DevToolsProvider"; +export { useRegisterDevTool } from "./useRegisterDevTool"; diff --git a/src/components/DevTools/useRegisterDevTool.ts b/src/components/DevTools/useRegisterDevTool.ts new file mode 100644 index 00000000000..5414fc2bf66 --- /dev/null +++ b/src/components/DevTools/useRegisterDevTool.ts @@ -0,0 +1,24 @@ +import { useEffect } from "react"; + +import { DevToolPanel, useDevTools } from "./DevToolsProvider"; + +/** + * Hook to register a dev tool panel. Automatically unregisters on unmount. + * + * @example + * useRegisterDevTool({ + * id: "notifications", + * label: "Notifications", + * component: NotificationsDebugPanel, + * }); + */ +export const useRegisterDevTool = (panel: DevToolPanel) => { + const { register } = useDevTools(); + + useEffect(() => { + const unregister = register(panel); + + return unregister; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [panel.id]); +}; diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts new file mode 100644 index 00000000000..3fde43f2ffd --- /dev/null +++ b/src/components/PriceField/utils.test.ts @@ -0,0 +1,113 @@ +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + normalizeDecimalSeparator, + parseDecimalValue, +} from "./utils"; + +describe("normalizeDecimalSeparator", () => { + it("converts comma to dot", () => { + expect(normalizeDecimalSeparator("10,50")).toBe("10.50"); + }); + + it("leaves dot unchanged", () => { + expect(normalizeDecimalSeparator("10.50")).toBe("10.50"); + }); + + it("handles integers without separator", () => { + expect(normalizeDecimalSeparator("100")).toBe("100"); + }); + + it("handles empty string", () => { + expect(normalizeDecimalSeparator("")).toBe(""); + }); +}); + +describe("parseDecimalValue", () => { + it("parses dot-separated value", () => { + expect(parseDecimalValue("10.50")).toBe(10.5); + }); + + it("parses comma-separated value", () => { + expect(parseDecimalValue("10,50")).toBe(10.5); + }); + + it("parses integer", () => { + expect(parseDecimalValue("100")).toBe(100); + }); + + it("returns 0 for empty string", () => { + expect(parseDecimalValue("")).toBe(0); + }); + + it("returns 0 for invalid input", () => { + expect(parseDecimalValue("abc")).toBe(0); + }); + + it("handles negative values", () => { + expect(parseDecimalValue("-10.50")).toBe(-10.5); + }); +}); + +describe("limitDecimalPlaces", () => { + it("limits decimal places with dot separator", () => { + expect(limitDecimalPlaces("10.12345", 2)).toBe("10.12"); + }); + + it("limits decimal places with comma separator", () => { + expect(limitDecimalPlaces("10,12345", 2)).toBe("10,12"); + }); + + it("preserves original separator when limiting", () => { + expect(limitDecimalPlaces("10,999", 2)).toBe("10,99"); + expect(limitDecimalPlaces("10.999", 2)).toBe("10.99"); + }); + + it("returns integer when maxDecimalPlaces is 0", () => { + expect(limitDecimalPlaces("10.50", 0)).toBe("10"); + expect(limitDecimalPlaces("10,50", 0)).toBe("10"); + }); + + it("returns value unchanged if decimal places are within limit", () => { + expect(limitDecimalPlaces("10.12", 2)).toBe("10.12"); + expect(limitDecimalPlaces("10.1", 2)).toBe("10.1"); + }); + + it("returns value unchanged if no decimal part", () => { + expect(limitDecimalPlaces("100", 2)).toBe("100"); + }); + + it("handles three decimal places for currencies like KWD", () => { + expect(limitDecimalPlaces("10.1234", 3)).toBe("10.123"); + }); + + it("handles zero decimal places for currencies like JPY", () => { + expect(limitDecimalPlaces("1000.99", 0)).toBe("1000"); + }); +}); + +describe("getCurrencyDecimalPoints", () => { + it("returns 2 for USD", () => { + expect(getCurrencyDecimalPoints("USD")).toBe(2); + }); + + it("returns 2 for EUR", () => { + expect(getCurrencyDecimalPoints("EUR")).toBe(2); + }); + + it("returns 0 for JPY (Japanese Yen)", () => { + expect(getCurrencyDecimalPoints("JPY")).toBe(0); + }); + + it("returns 3 for KWD (Kuwaiti Dinar)", () => { + expect(getCurrencyDecimalPoints("KWD")).toBe(3); + }); + + it("returns 2 as fallback for undefined currency", () => { + expect(getCurrencyDecimalPoints(undefined)).toBe(2); + }); + + it("returns 2 as fallback for invalid currency code", () => { + expect(getCurrencyDecimalPoints("INVALID")).toBe(2); + }); +}); diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index a966bd3073a..bcd41c3597a 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -32,3 +32,40 @@ export const getCurrencyDecimalPoints = (currency?: string) => { export const findPriceSeparator = (input: string) => SEPARATOR_CHARACTERS.find(separator => input.includes(separator)); + +/** + * Normalizes decimal separator to JavaScript standard (dot). + * Converts comma to dot for locales that use comma as decimal separator. + */ +export const normalizeDecimalSeparator = (value: string): string => value.replace(",", "."); + +/** + * Parses a decimal string value to a number, handling locale-specific separators. + * Returns 0 if the value cannot be parsed. + */ +export const parseDecimalValue = (value: string): number => + parseFloat(normalizeDecimalSeparator(value)) || 0; + +/** + * Limits decimal places in a string value, preserving the user's original separator. + * Useful for input validation while typing. + */ +export const limitDecimalPlaces = (value: string, maxDecimalPlaces: number): string => { + const normalized = normalizeDecimalSeparator(value); + const separator = value.includes(",") ? "," : "."; + const [integerPart, decimalPart] = normalized.split("."); + + if (!decimalPart) { + return value; + } + + if (maxDecimalPlaces === 0) { + return integerPart; + } + + if (decimalPart.length > maxDecimalPlaces) { + return `${integerPart}${separator}${decimalPart.slice(0, maxDecimalPlaces)}`; + } + + return value; +}; diff --git a/src/components/messages/Container.tsx b/src/components/messages/Container.tsx deleted file mode 100644 index 41803c7259e..00000000000 --- a/src/components/messages/Container.tsx +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-strict-ignore -import { useStyles } from "./styles"; - -const Container = ({ children }) => { - const classes = useStyles({}); - - return !!children.length &&
{children}
; -}; - -export default Container; diff --git a/src/components/messages/MessageDisplay.tsx b/src/components/messages/MessageDisplay.tsx deleted file mode 100644 index ba7969464a4..00000000000 --- a/src/components/messages/MessageDisplay.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { commonMessages } from "@dashboard/intl"; -import { Portal } from "@radix-ui/react-portal"; -import { ApiMessageData, Notification } from "@saleor/macaw-ui"; -import { useIntl } from "react-intl"; -import { TransitionGroup } from "react-transition-group"; - -import Container from "./Container"; -import { messages as notificationMessages } from "./messages"; -import { useStyles } from "./styles"; -import Transition from "./Transition"; -import { MessageComponentValues } from "./useMessageState"; - -export const MessageDisplay = ({ - notifications, - pauseTimer, - resumeTimer, -}: MessageComponentValues) => { - const classes = useStyles(); - const intl = useIntl(); - - return ( - - - {notifications?.map(notification => ( - - pauseTimer(notification), - onMouseLeave: () => resumeTimer(notification), - } - : {})} - onClose={notification.close} - title={ - (notification.message.apiMessage && !notification.message.title - ? intl.formatMessage(commonMessages.defaultErrorTitle) - : notification.message.title) as string - } - type={notification.message.status || "info"} - content={notification.message.text} - apiMessage={ - (notification.message.apiMessage && { - apiMessageContent: ( -
-                      {notification.message.apiMessage}
-                    
- ), - hideApiLabel: intl.formatMessage(notificationMessages.hideError), - showApiLabel: intl.formatMessage(notificationMessages.seeError), - }) as ApiMessageData - } - {...(notification.message.actionBtn - ? { - action: { - label: notification.message.actionBtn.label, - onClick: notification.message.actionBtn.action, - }, - } - : {})} - className={classes.notification} - /> -
- ))} -
-
- ); -}; diff --git a/src/components/messages/MessageManagerProvider.test.tsx b/src/components/messages/MessageManagerProvider.test.tsx deleted file mode 100644 index fbaad5b10d9..00000000000 --- a/src/components/messages/MessageManagerProvider.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import { PropsWithChildren, useContext } from "react"; - -import { MessageContext } from "."; -import MessageManagerProvider from "./MessageManagerProvider"; - -// Mock MessageDisplay component since we're not testing its functionality -jest.mock("./MessageDisplay", () => ({ - MessageDisplay: () =>
Message Display
, -})); - -describe("MessageManagerProvider", () => { - test("should provide MessageContext to children", () => { - // Arrange - const { result } = renderHook(() => useContext(MessageContext), { - wrapper: ({ children }: PropsWithChildren) => ( - {children} - ), - }); - - // Assert: context exists - expect(result.current).toBeTruthy(); - expect(typeof result.current?.show).toBe("function"); - expect(typeof result.current?.remove).toBe("function"); - expect(typeof result.current?.clearErrorNotifications).toBe("function"); - }); -}); diff --git a/src/components/messages/MessageManagerProvider.tsx b/src/components/messages/MessageManagerProvider.tsx deleted file mode 100644 index ee5754ebda9..00000000000 --- a/src/components/messages/MessageManagerProvider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { PropsWithChildren } from "react"; - -import { MessageContext } from "."; -import { MessageDisplay } from "./MessageDisplay"; -import { useMessageState } from "./useMessageState"; - -const MessageManagerProvider = ({ children }: PropsWithChildren) => { - const { context, componentState } = useMessageState(); - - return ( - <> - {/* This component is used in main `App` component, to pass context */} - {children} - - - ); -}; - -export default MessageManagerProvider; diff --git a/src/components/messages/Transition.tsx b/src/components/messages/Transition.tsx deleted file mode 100644 index afa4cd0aa9f..00000000000 --- a/src/components/messages/Transition.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-strict-ignore -import { Transition as MessageManagerTransition } from "react-transition-group"; - -const duration = 250; -const defaultStyle = { - opacity: 0, - transition: `opacity ${duration}ms ease`, -}; -const transitionStyles = { - entered: { opacity: 1 }, - entering: { opacity: 0 }, -}; -const Transition = ({ children, ...props }) => ( - - {state => ( -
- {children} -
- )} -
-); - -export default Transition; diff --git a/src/components/messages/index.ts b/src/components/messages/index.ts deleted file mode 100644 index a0e50a179aa..00000000000 --- a/src/components/messages/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createContext } from "react"; - -type Status = "success" | "error" | "info" | "warning"; -export interface IMessage { - actionBtn?: { - label: string; - action: () => void; - }; - autohide?: number; - expandText?: string; - title?: string; - text?: React.ReactNode; - onUndo?: () => void; - status?: Status; - apiMessage?: string; -} - -export interface INotification { - id: number; - message: IMessage; - timeout: number; - close: () => void; -} - -export interface ITimer { - id: number; - notification: INotification; - remaining: number; - start: number; - timeoutId: number; -} - -export interface INotificationContext { - show: (message: IMessage, timeout?: number | null) => void; - remove: (notificationId: number) => void; - clearErrorNotifications: () => void; -} - -export type IMessageContext = (message: IMessage) => void; -export const MessageContext = createContext(null); - -export * from "./MessageManagerProvider"; -export { default } from "./MessageManagerProvider"; diff --git a/src/components/messages/messages.ts b/src/components/messages/messages.ts deleted file mode 100644 index 4928db95848..00000000000 --- a/src/components/messages/messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineMessages } from "react-intl"; - -export const messages = defineMessages({ - seeError: { - id: "w9xgN9", - defaultMessage: "See error log", - description: "see error log label in notification", - }, - hideError: { - id: "s8FlDW", - defaultMessage: "Hide log", - description: "hide error log label in notification", - }, -}); diff --git a/src/components/messages/styles.ts b/src/components/messages/styles.ts deleted file mode 100644 index 07658bda2b3..00000000000 --- a/src/components/messages/styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; - -export const useStyles = makeStyles( - theme => ({ - container: { - display: "grid", - justifyContent: "end", - right: 0, - pointerEvents: "auto", - position: "fixed", - top: 0, - width: "auto", - maxHeight: "100vh", - overflowY: "auto", - zIndex: 10000, - }, - notification: { - // Parent container has disabled pointer events so we need to turn them on - // for action and timer pausing to work - pointerEvents: "all", - margin: theme.spacing(2), - }, - }), - { name: "MessageManager" }, -); diff --git a/src/components/messages/useMessageState.test.tsx b/src/components/messages/useMessageState.test.tsx deleted file mode 100644 index d35e42aca8f..00000000000 --- a/src/components/messages/useMessageState.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@dashboard/config"; -import { act, renderHook } from "@testing-library/react-hooks"; - -import { useMessageState } from "./useMessageState"; - -describe("useMessageState", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - test("should initialize with empty notifications", () => { - // Arrange - const { result } = renderHook(() => useMessageState()); - - // Assert - expect(result.current.componentState.notifications).toEqual([]); - }); - - test("should add a notification when show is called", () => { - // Arrange - const { result } = renderHook(() => useMessageState()); - - // Act - act(() => { - result.current.context.show({ text: "Test notification" }); - }); - - // Assert - expect(result.current.componentState.notifications).toHaveLength(1); - expect(result.current.componentState.notifications[0].message.text).toBe("Test notification"); - }); - - test("should remove a notification when remove is called", () => { - // Arrange - const { result } = renderHook(() => useMessageState()); - - // Act - act(() => { - result.current.context.show({ text: "Test notification" }); - }); - - act(() => { - result.current.context.remove(result.current.componentState.notifications[0].id); - }); - - // Assert - expect(result.current.componentState.notifications).toHaveLength(0); - }); - - test("should auto-remove notification after timeout", () => { - // Arrange - const { result } = renderHook(() => useMessageState()); - - // Act - act(() => { - result.current.context.show({ text: "Test notification" }, 1000); - }); - - // Assert - expect(result.current.componentState.notifications).toHaveLength(1); - - // Act - act(() => { - jest.advanceTimersByTime(1000); - }); - - // Assert - expect(result.current.componentState.notifications).toHaveLength(0); - }); - - test("should pause and resume timer correctly", () => { - // Arrange - const { result } = renderHook(() => useMessageState()); - - // Act - act(() => { - result.current.context.show({ text: "Test notification" }, 2000); - }); - - act(() => { - // Advance time partially - jest.advanceTimersByTime(10); - }); - - // Assert: Notification should still be there - expect(result.current.componentState.notifications).toHaveLength(1); - - // Act - act(() => { - // Pause the timer - result.current.componentState.pauseTimer(result.current.componentState.notifications[0]); - }); - - act(() => { - // Advance time beyond original timeout - jest.advanceTimersByTime(DEFAULT_NOTIFICATION_SHOW_TIME); - }); - - // Assert: Notification should still be there because timer was paused - expect(result.current.componentState.notifications).toHaveLength(1); - - // Act - act(() => { - // Resume the timer - result.current.componentState.resumeTimer(result.current.componentState.notifications[0]); - }); - - // Act - act(() => { - // Advance time to complete the remaining time - jest.advanceTimersByTime(DEFAULT_NOTIFICATION_SHOW_TIME); - }); - - // Assert: Notification should be removed now - expect(result.current.componentState.notifications).toHaveLength(0); - }); -}); diff --git a/src/components/messages/useMessageState.tsx b/src/components/messages/useMessageState.tsx deleted file mode 100644 index d7892887636..00000000000 --- a/src/components/messages/useMessageState.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@dashboard/config"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { INotification, INotificationContext, ITimer } from "."; - -export type MessageComponentValues = ReturnType["componentState"]; - -export const useMessageState = () => { - const timer = useRef(0); - const timersArr = useRef([]); - const [notifications, setNotifications] = useState([]); - - useEffect(() => { - const timersArrRef = timersArr.current; - - return () => { - timersArrRef.forEach(timer => clearTimeout(timer.timeoutId)); - }; - }, []); - - const timerCallback = (notification: INotification) => { - remove(notification.id); - timersArr.current = timersArr.current.filter(timer => timer.id !== notification.id); - }; - const remove = useCallback((notificationId: number) => { - setNotifications(currentNotifications => - currentNotifications.filter(n => n.id !== notificationId), - ); - }, []); - const clearErrorNotifications = useCallback(() => { - setNotifications(notifications => - notifications.filter(notification => notification.message.status !== "error"), - ); - }, []); - - const show = useCallback((message = {}, timeout = DEFAULT_NOTIFICATION_SHOW_TIME) => { - const id = timer.current; - - timer.current += 1; - - const notification = { - close: () => remove(id), - id, - message, - timeout, - }; - - if (timeout !== null) { - const timeoutId = window.setTimeout(() => { - timerCallback(notification); - }, timeout); - - timersArr.current.push({ - id: notification.id, - notification, - remaining: timeout, - start: new Date().getTime(), - timeoutId, - }); - } - - setNotifications(state => [notification, ...state]); - - return notification; - }, []); - - const getCurrentTimer = (notification: INotification) => { - const currentTimerIndex = timersArr.current.findIndex(timer => timer.id === notification.id); - - return timersArr.current[currentTimerIndex]; - }; - const pauseTimer = (notification: INotification) => { - const currentTimer = getCurrentTimer(notification); - - if (currentTimer) { - currentTimer.remaining = currentTimer.remaining - (new Date().getTime() - currentTimer.start); - window.clearTimeout(currentTimer.timeoutId); - } - }; - const resumeTimer = (notification: INotification) => { - const currentTimer = getCurrentTimer(notification); - - if (currentTimer) { - currentTimer.start = new Date().getTime(); - currentTimer.timeoutId = window.setTimeout( - () => timerCallback(notification), - currentTimer.remaining, - ); - } - }; - - const context = { - remove, - show, - clearErrorNotifications, - } as INotificationContext; - - const componentState = { - pauseTimer, - resumeTimer, - notifications, - }; - - return { - context, - componentState, - } as const; -}; diff --git a/src/components/notifications/NotificationProvider.test.tsx b/src/components/notifications/NotificationProvider.test.tsx new file mode 100644 index 00000000000..e81c12a8578 --- /dev/null +++ b/src/components/notifications/NotificationProvider.test.tsx @@ -0,0 +1,37 @@ +import { ThemeProvider } from "@saleor/macaw-ui-next"; +import { renderHook } from "@testing-library/react-hooks"; +import { PropsWithChildren, useContext } from "react"; + +import { DevToolsProvider } from "../DevTools"; +import { NotificationContext } from "."; +import NotificationProvider from "./NotificationProvider"; + +// Mock sonner +jest.mock("sonner", () => ({ + toast: { + custom: jest.fn(), + dismiss: jest.fn(), + }, + Toaster: () => null, +})); + +describe("NotificationProvider", () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + test("should provide NotificationContext to children", () => { + // Arrange & Act + const { result } = renderHook(() => useContext(NotificationContext), { wrapper }); + + // Assert + expect(result.current).toBeTruthy(); + expect(typeof result.current?.show).toBe("function"); + expect(typeof result.current?.remove).toBe("function"); + expect(typeof result.current?.clearErrorNotifications).toBe("function"); + }); +}); diff --git a/src/components/notifications/NotificationProvider.tsx b/src/components/notifications/NotificationProvider.tsx new file mode 100644 index 00000000000..3b5f5e1141a --- /dev/null +++ b/src/components/notifications/NotificationProvider.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren, useMemo } from "react"; +import { toast, Toaster } from "sonner"; + +import { useRegisterDevTool } from "../DevTools"; +import { INotificationContext, NotificationContext } from "."; +import { NotificationsDebugPanel } from "./NotificationsDebugPanel"; + +const NotificationProvider = ({ children }: PropsWithChildren) => { + useRegisterDevTool({ + id: "notifications", + label: "Notifications", + component: NotificationsDebugPanel, + }); + + const context = useMemo( + () => ({ + show: () => {}, + remove: (id: number) => { + toast.dismiss(id); + }, + clearErrorNotifications: () => { + toast.dismiss(); + }, + }), + [], + ); + + return ( + + {children} + + + ); +}; + +export default NotificationProvider; diff --git a/src/components/notifications/NotificationsDebugPanel.tsx b/src/components/notifications/NotificationsDebugPanel.tsx new file mode 100644 index 00000000000..1d2e8170476 --- /dev/null +++ b/src/components/notifications/NotificationsDebugPanel.tsx @@ -0,0 +1,153 @@ +/** + * Debug panel for previewing toast notifications + * Accessible via DevTools (Cmd+Shift+D) + */ +import { Box, Button, Text } from "@saleor/macaw-ui-next"; +import { toast } from "sonner"; + +import { Toast } from "./Toast"; + +const showToast = ( + type: "success" | "error" | "warning" | "info", + title: string, + description?: string, + action?: { label: string; onClick: () => void }, +) => { + toast.custom( + id => , + { + duration: type === "error" ? Infinity : 5000, + }, + ); +}; + +export const NotificationsDebugPanel = () => { + return ( + + + + SUCCESS + + + + + + + + + + ERRORS + + + + + + + + + + OTHER + + + + + + + + + + LENGTH TEST + + + + + + + + + + ); +}; diff --git a/src/components/notifications/Toast.test.tsx b/src/components/notifications/Toast.test.tsx new file mode 100644 index 00000000000..b4f5b53325a --- /dev/null +++ b/src/components/notifications/Toast.test.tsx @@ -0,0 +1,226 @@ +import { ThemeProvider } from "@saleor/macaw-ui-next"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { toast } from "sonner"; + +import { Toast, ToastProps } from "./Toast"; + +jest.mock("sonner", () => ({ + toast: { + dismiss: jest.fn(), + }, +})); + +const mockToastDismiss = toast.dismiss as jest.Mock; + +const defaultProps: ToastProps = { + id: "test-toast-1", + type: "info", + title: "Test Title", +}; + +const renderToast = (props: Partial = {}) => + render( + + + , + ); + +describe("Toast", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("rendering", () => { + it("renders toast with title", () => { + // Arrange & Act + renderToast({ title: "Success Message" }); + + // Assert + expect(screen.getByText("Success Message")).toBeInTheDocument(); + }); + + it("renders toast with description", () => { + // Arrange & Act + renderToast({ + title: "Title", + description: "This is a detailed description", + }); + + // Assert + expect(screen.getByText("This is a detailed description")).toBeInTheDocument(); + }); + + it("renders without description when not provided", () => { + // Arrange & Act + renderToast({ title: "Title Only" }); + + // Assert + expect(screen.getByText("Title Only")).toBeInTheDocument(); + expect(screen.queryByText("description")).not.toBeInTheDocument(); + }); + + it("renders action button when action is provided", () => { + // Arrange & Act + renderToast({ + action: { + label: "Undo", + onClick: jest.fn(), + }, + }); + + // Assert + expect(screen.getByRole("button", { name: "Undo" })).toBeInTheDocument(); + }); + + it("does not render action button when action is not provided", () => { + // Arrange & Act + renderToast(); + + // Assert + expect(screen.queryByRole("button", { name: "Undo" })).not.toBeInTheDocument(); + }); + }); + + describe("toast types", () => { + it.each([ + ["success", "Success"], + ["error", "Error"], + ["warning", "Warning"], + ["info", "Info"], + ] as const)("renders %s toast with title", (type, title) => { + // Arrange & Act + renderToast({ type, title }); + + // Assert + expect(screen.getByText(title)).toBeInTheDocument(); + }); + }); + + describe("close functionality", () => { + it("calls toast.dismiss when close button is clicked", () => { + // Arrange + renderToast({ id: "dismiss-test-id" }); + + // Act + const closeButtons = screen.getAllByRole("button"); + const closeButton = closeButtons.find(btn => btn.querySelector("svg")); + + fireEvent.click(closeButton!); + + // Assert + expect(mockToastDismiss).toHaveBeenCalledWith("dismiss-test-id"); + }); + }); + + describe("action button", () => { + it("calls action onClick when action button is clicked", () => { + // Arrange + const onClickMock = jest.fn(); + + renderToast({ + action: { + label: "Retry", + onClick: onClickMock, + }, + }); + + // Act + const actionButton = screen.getByRole("button", { name: "Retry" }); + + fireEvent.click(actionButton); + + // Assert + expect(onClickMock).toHaveBeenCalledTimes(1); + }); + + it("dismisses toast after action button is clicked", () => { + // Arrange + const onClickMock = jest.fn(); + + renderToast({ + id: "action-dismiss-test", + action: { + label: "Retry", + onClick: onClickMock, + }, + }); + + // Act + const actionButton = screen.getByRole("button", { name: "Retry" }); + + fireEvent.click(actionButton); + + // Assert + expect(mockToastDismiss).toHaveBeenCalledWith("action-dismiss-test"); + }); + }); + + describe("description truncation", () => { + it("renders long description", () => { + // Arrange + const longDescription = + "This is a very long description that might get truncated when displayed in the toast notification. It contains multiple sentences to ensure it exceeds the maximum allowed lines for display."; + + // Act + renderToast({ description: longDescription }); + + // Assert + expect(screen.getByText(longDescription)).toBeInTheDocument(); + }); + + it("renders description container with overflow styles", () => { + // Arrange + const longDescription = "This is a very long description that spans multiple lines. ".repeat( + 10, + ); + + // Act + renderToast({ description: longDescription.trim() }); + + // Assert - verify the container has the truncation styles applied + const descriptionElement = screen.getByText(longDescription.trim()); + + expect(descriptionElement).toBeInTheDocument(); + expect(descriptionElement).toHaveStyle({ overflow: "hidden" }); + }); + + it("responds to mouse enter events on description container", async () => { + // Arrange + const description = "A description that can be hovered"; + + renderToast({ description }); + + // Act + const descriptionElement = screen.getByText(description); + const descriptionContainer = descriptionElement.closest('[class*="_18fs8ps"]'); + + if (descriptionContainer) { + fireEvent.mouseEnter(descriptionContainer); + fireEvent.mouseLeave(descriptionContainer); + } + + // Assert - the component handles mouse events without errors + await waitFor(() => { + expect(descriptionElement).toBeInTheDocument(); + }); + }); + }); + + describe("ReactNode description", () => { + it("renders JSX elements as description", () => { + // Arrange + const jsxDescription = ( + + Bold and italic text + + ); + + // Act + renderToast({ description: jsxDescription }); + + // Assert + expect(screen.getByText("Bold")).toBeInTheDocument(); + expect(screen.getByText("italic")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/notifications/Toast.tsx b/src/components/notifications/Toast.tsx new file mode 100644 index 00000000000..449af681b83 --- /dev/null +++ b/src/components/notifications/Toast.tsx @@ -0,0 +1,253 @@ +import { Box, Button, Text, useTheme } from "@saleor/macaw-ui-next"; +import { AlertTriangle, CheckCircle2, Info, LucideIcon, X, XCircle } from "lucide-react"; +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +type ToastType = "success" | "error" | "warning" | "info"; + +interface ToastStyleConfig { + iconColor: "success1" | "critical1" | "warning1" | "default1"; + Icon: LucideIcon; +} + +const TOAST_STYLES: Record = { + success: { iconColor: "success1", Icon: CheckCircle2 }, + error: { iconColor: "critical1", Icon: XCircle }, + warning: { iconColor: "warning1", Icon: AlertTriangle }, + info: { iconColor: "default1", Icon: Info }, +}; + +const MAX_LINES = 2; +const COLLAPSED_MAX_HEIGHT = "3.2em"; +const EXPANDED_MAX_HEIGHT = "500px"; +const ANIMATION_TIMING = "0.15s cubic-bezier(0.4, 0, 0.2, 1)"; + +export interface ToastProps { + id: string | number; + type: ToastType; + title: string; + description?: ReactNode; + action?: { + label: string; + onClick: () => void; + }; +} + +export const Toast = ({ id, type, title, description, action }: ToastProps) => { + const { theme, themeValues } = useTheme(); + const { iconColor, Icon } = TOAST_STYLES[type]; + + const [isExpanded, setIsExpanded] = useState(false); + const [isTruncated, setIsTruncated] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [isToggleHovering, setIsToggleHovering] = useState(false); + + const containerRef = useRef(null); + const textRef = useRef(null); + + const isDark = theme === "defaultDark"; + + const backgroundColor = useMemo(() => { + if (type === "success") { + return isDark ? "oklch(25% 0.04 145)" : "oklch(97% 0.04 145)"; + } + + return themeValues?.colors.background.default1; + }, [type, isDark, themeValues]); + + const handleDismiss = useCallback(() => { + toast.dismiss(id); + }, [id]); + + const handleExpand = useCallback(() => { + if (isTruncated && !isExpanded) { + setIsExpanded(true); + } + }, [isTruncated, isExpanded]); + + const handleToggleExpand = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(prev => !prev); + }, []); + + const handleActionClick = useCallback(() => { + action?.onClick(); + handleDismiss(); + }, [action, handleDismiss]); + + const handleMouseEnter = useCallback(() => setIsHovering(true), []); + const handleMouseLeave = useCallback(() => setIsHovering(false), []); + const handleToggleMouseEnter = useCallback(() => setIsToggleHovering(true), []); + const handleToggleMouseLeave = useCallback(() => setIsToggleHovering(false), []); + + // Check if description text is truncated + useEffect(() => { + const textEl = textRef.current; + + if (!textEl) return; + + requestAnimationFrame(() => { + const isOverflowing = textEl.scrollHeight > textEl.clientHeight + 1; + + if (isOverflowing) { + setIsTruncated(true); + } + }); + }, [description]); + + // Auto-collapse text when Sonner stack collapses (peek mode) + useEffect(() => { + const toastWrapper = containerRef.current?.closest("[data-sonner-toast]"); + + if (!toastWrapper) return; + + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (mutation.attributeName === "data-expanded") { + const isStackExpanded = toastWrapper.getAttribute("data-expanded") === "true"; + + if (!isStackExpanded) { + setIsExpanded(false); + } + } + } + }); + + observer.observe(toastWrapper, { attributes: true }); + + return () => observer.disconnect(); + }, []); + + const showToggle = (isTruncated && isHovering) || isExpanded; + + return ( + + {/* Icon */} + + + + + {/* Content */} + + + {title} + + + {description && ( + + } + size={2} + color="default2" + style={{ + transition: `opacity ${ANIMATION_TIMING}`, + opacity: isTruncated && !isExpanded && isHovering ? 0.7 : 1, + ...(!isExpanded && { + display: "-webkit-box", + WebkitLineClamp: MAX_LINES, + WebkitBoxOrient: "vertical" as const, + overflow: "hidden", + }), + }} + > + {description} + + + {isTruncated && !isExpanded && ( + + )} + + {showToggle && ( + + + {isExpanded ? "Show less" : "Show more"} + + + )} + + )} + + {action && ( + + + + )} + + + {/* Close button */} + + + + + ); +}; diff --git a/src/components/notifications/index.ts b/src/components/notifications/index.ts new file mode 100644 index 00000000000..1d23b2c70b4 --- /dev/null +++ b/src/components/notifications/index.ts @@ -0,0 +1,29 @@ +import { createContext } from "react"; + +type Status = "success" | "error" | "info" | "warning"; + +export interface INotification { + actionBtn?: { + label: string; + action: () => void; + }; + autohide?: number; + title?: string; + text?: React.ReactNode; + status?: Status; + apiMessage?: string; +} + +export interface INotificationContext { + show: (notification: INotification, timeout?: number | null) => void; + remove: (notificationId: number) => void; + clearErrorNotifications: () => void; +} + +export type INotificationCallback = (notification: INotification) => void; +export const NotificationContext = createContext(null); + +export * from "./NotificationProvider"; +export { default } from "./NotificationProvider"; +export type { ToastProps } from "./Toast"; +export { Toast } from "./Toast"; diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx index 41ec289b81e..bfb16986321 100644 --- a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx @@ -1,6 +1,6 @@ // @ts-strict-ignore import { ApolloClient, useApolloClient } from "@apollo/client"; -import { IMessageContext } from "@dashboard/components/messages"; +import { INotificationCallback } from "@dashboard/components/notifications"; import useNotifier from "@dashboard/hooks/useNotifier"; import { ReactNode, useEffect, useRef } from "react"; import { IntlShape, useIntl } from "react-intl"; @@ -14,7 +14,7 @@ export const backgroundTasksRefreshTime = 15 * 1000; export function useBackgroundTasks( apolloClient: Pick, "query">, - notify: IMessageContext, + notify: INotificationCallback, intl: IntlShape, ) { const idCounter = useRef(0); diff --git a/src/containers/BackgroundTasks/tasks.ts b/src/containers/BackgroundTasks/tasks.ts index 64e50ba8ea9..790b7014a8e 100644 --- a/src/containers/BackgroundTasks/tasks.ts +++ b/src/containers/BackgroundTasks/tasks.ts @@ -1,6 +1,6 @@ // @ts-strict-ignore import { ApolloQueryResult } from "@apollo/client"; -import { IMessageContext } from "@dashboard/components/messages"; +import { INotificationCallback } from "@dashboard/components/notifications"; import { CheckExportFileStatusQuery, CheckOrderInvoicesStatusQuery, @@ -76,7 +76,7 @@ export function queueInvoiceGenerate( generateInvoice: InvoiceGenerateParams, tasks: React.MutableRefObject, fetch: () => Promise>, - notify: IMessageContext, + notify: INotificationCallback, intl: IntlShape, ) { if (!generateInvoice) { @@ -117,7 +117,7 @@ export function queueExport( id: number, tasks: React.MutableRefObject, fetch: () => Promise>, - notify: IMessageContext, + notify: INotificationCallback, intl: IntlShape, ) { tasks.current = [ diff --git a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx index f0818f0815e..7a2a0b79487 100644 --- a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx +++ b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@saleor/macaw-ui-next"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as React from "react"; @@ -8,6 +9,10 @@ import { usePermissions } from "./hooks/usePermissions"; import { useUserAppCreationPermissions } from "./hooks/useUserAppCreationPermissions"; import { useUserPermissionSet } from "./hooks/useUserPermissionMap"; +const Wrapper = ({ children }: React.PropsWithChildren<{}>) => ( + {children} +); + // Mock ResizeObserver used by Radix checkbox class ResizeObserverMock { observe() { @@ -61,7 +66,7 @@ describe("AddCustomExtension", () => { it("renders the component with all required elements", () => { // Arrange - render(); + render(, { wrapper: Wrapper }); // Assert expect(screen.getByPlaceholderText("Extension Name")).toBeInTheDocument(); @@ -73,7 +78,7 @@ describe("AddCustomExtension", () => { it("displays validation error when submitting empty form", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); // Act await userEvent.click(screen.getByText("save")); @@ -84,7 +89,7 @@ describe("AddCustomExtension", () => { it("creates app without permissions", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); @@ -107,7 +112,7 @@ describe("AddCustomExtension", () => { it("creates app with some permissions when checked by user", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); @@ -135,7 +140,7 @@ describe("AddCustomExtension", () => { it("creates app with all permissions when toggled 'Grant full access'", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -166,7 +171,7 @@ describe("AddCustomExtension", () => { it("creates app with no permissions when toggling between 'Grant full access'", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -211,7 +216,7 @@ describe("AddCustomExtension", () => { (useUserAppCreationPermissions as jest.Mock).mockReturnValue(true); // Act - render(); + render(, { wrapper: Wrapper }); // Assert expect(screen.getByText(/warning/i)).toBeInTheDocument(); @@ -225,7 +230,7 @@ describe("AddCustomExtension", () => { const availablePermissions = new Set(["MANAGE_ORDERS"]); (useUserPermissionSet as jest.Mock).mockReturnValue(availablePermissions); - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); diff --git a/src/giftCards/GiftCardBulkCreateDialog/GiftCardBulkCreateDialog.tsx b/src/giftCards/GiftCardBulkCreateDialog/GiftCardBulkCreateDialog.tsx index 034df35c95d..35cee9859e5 100644 --- a/src/giftCards/GiftCardBulkCreateDialog/GiftCardBulkCreateDialog.tsx +++ b/src/giftCards/GiftCardBulkCreateDialog/GiftCardBulkCreateDialog.tsx @@ -1,5 +1,5 @@ -import { IMessage } from "@dashboard/components/messages"; import { DashboardModal } from "@dashboard/components/Modal"; +import { INotification } from "@dashboard/components/notifications"; import { GiftCardBulkCreateInput, useGiftCardBulkCreateMutation } from "@dashboard/graphql"; import { useCurrentDate } from "@dashboard/hooks/useCurrentDate"; import useNotifier from "@dashboard/hooks/useNotifier"; @@ -54,7 +54,7 @@ export const GiftCardBulkCreateDialog = ({ onClose, open }: DialogProps) => { onCompleted: data => { const errors = data?.giftCardBulkCreate?.errors; const cardsAmount = data?.giftCardBulkCreate?.giftCards?.length || 0; - const giftCardsBulkIssueSuccessMessage: IMessage = { + const giftCardsBulkIssueSuccessMessage: INotification = { status: "success", title: intl.formatMessage(messages.createdSuccessAlertTitle), text: intl.formatMessage(messages.createdSuccessAlertDescription, { diff --git a/src/giftCards/GiftCardCreateDialog/utils.ts b/src/giftCards/GiftCardCreateDialog/utils.ts index 1623e0bc7d6..1714e1ce9a5 100644 --- a/src/giftCards/GiftCardCreateDialog/utils.ts +++ b/src/giftCards/GiftCardCreateDialog/utils.ts @@ -1,5 +1,5 @@ // @ts-strict-ignore -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { GiftCardCreateMutation, TimePeriodTypeEnum } from "@dashboard/graphql"; import commonErrorMessages from "@dashboard/utils/errors/common"; import moment from "moment-timezone"; @@ -34,7 +34,7 @@ export const getExpiryPeriodTerminationDate = ( } }; -const getGiftCardExpiryError = (intl: IntlShape): IMessage => ({ +const getGiftCardExpiryError = (intl: IntlShape): INotification => ({ title: intl.formatMessage(giftCardUpdateFormMessages.giftCardInvalidExpiryDateHeader), text: intl.formatMessage(giftCardUpdateFormMessages.giftCardInvalidExpiryDateContent), status: "error", @@ -43,8 +43,8 @@ const getGiftCardExpiryError = (intl: IntlShape): IMessage => ({ export const getGiftCardCreateOnCompletedMessage = ( errors: GiftCardCreateMutation["giftCardCreate"]["errors"], intl: IntlShape, - successMessage?: IMessage, -): IMessage => { + successMessage?: INotification, +): INotification => { const hasExpiryError = errors.some(error => error.field === "expiryDate"); const successGiftCardMessage = successMessage || { status: "success", diff --git a/src/giftCards/GiftCardUpdate/GiftCardResendCodeDialog/GiftCardResendCodeDialog.tsx b/src/giftCards/GiftCardUpdate/GiftCardResendCodeDialog/GiftCardResendCodeDialog.tsx index 898a32c50d4..502dcafbf4a 100644 --- a/src/giftCards/GiftCardUpdate/GiftCardResendCodeDialog/GiftCardResendCodeDialog.tsx +++ b/src/giftCards/GiftCardUpdate/GiftCardResendCodeDialog/GiftCardResendCodeDialog.tsx @@ -2,7 +2,7 @@ import ActionDialog from "@dashboard/components/ActionDialog"; import { useChannelsSearch } from "@dashboard/components/ChannelsAvailabilityDialog/utils"; import { Combobox } from "@dashboard/components/Combobox"; -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { useGiftCardPermissions } from "@dashboard/giftCards/hooks/useGiftCardPermissions"; import { useChannelsQuery, useGiftCardResendMutation } from "@dashboard/graphql"; import useForm from "@dashboard/hooks/useForm"; @@ -63,7 +63,7 @@ const GiftCardResendCodeDialog = ({ open, onClose }: DialogProps) => { const [resendGiftCardCode, resendGiftCardCodeOpts] = useGiftCardResendMutation({ onCompleted: data => { const errors = data?.giftCardResend?.errors; - const notifierData: IMessage = errors?.length + const notifierData: INotification = errors?.length ? { status: "error", text: intl.formatMessage(commonErrorMessages.unknownError), diff --git a/src/giftCards/GiftCardUpdate/GiftCardUpdateBalanceDialog/GiftCardUpdateBalanceDialog.tsx b/src/giftCards/GiftCardUpdate/GiftCardUpdateBalanceDialog/GiftCardUpdateBalanceDialog.tsx index d92abf0c630..0c42e050251 100644 --- a/src/giftCards/GiftCardUpdate/GiftCardUpdateBalanceDialog/GiftCardUpdateBalanceDialog.tsx +++ b/src/giftCards/GiftCardUpdate/GiftCardUpdateBalanceDialog/GiftCardUpdateBalanceDialog.tsx @@ -1,7 +1,7 @@ // @ts-strict-ignore import ActionDialog from "@dashboard/components/ActionDialog"; import CardSpacer from "@dashboard/components/CardSpacer"; -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { useGiftCardPermissions } from "@dashboard/giftCards/hooks/useGiftCardPermissions"; import { useGiftCardUpdateMutation } from "@dashboard/graphql"; import useForm from "@dashboard/hooks/useForm"; @@ -37,7 +37,7 @@ const GiftCardUpdateBalanceDialog = ({ open, onClose }: DialogProps) => { const [updateGiftCardBalance, updateGiftCardBalanceOpts] = useGiftCardUpdateMutation({ onCompleted: data => { const errors = data?.giftCardUpdate?.errors; - const notifierData: IMessage = errors?.length + const notifierData: INotification = errors?.length ? { status: "error", text: intl.formatMessage(commonErrorMessages.unknownError), diff --git a/src/giftCards/GiftCardsList/GiftCardListBulkActions/GiftCardListBulkActions.tsx b/src/giftCards/GiftCardsList/GiftCardListBulkActions/GiftCardListBulkActions.tsx index 36d6c2eb65a..f0ccf67ac92 100644 --- a/src/giftCards/GiftCardsList/GiftCardListBulkActions/GiftCardListBulkActions.tsx +++ b/src/giftCards/GiftCardsList/GiftCardListBulkActions/GiftCardListBulkActions.tsx @@ -1,5 +1,5 @@ import { ConfirmButton } from "@dashboard/components/ConfirmButton"; -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { useGiftCardBulkActivateMutation, useGiftCardBulkDeactivateMutation, @@ -30,7 +30,7 @@ export const GiftCardListBulkActions = () => { .every(({ isActive }) => !isActive); const [activateGiftCards, activateGiftCardsOpts] = useGiftCardBulkActivateMutation({ onCompleted: data => { - const notifierData: IMessage = data?.giftCardBulkActivate?.errors?.length + const notifierData: INotification = data?.giftCardBulkActivate?.errors?.length ? { status: "error", text: intl.formatMessage(messages.errorActivateAlertText, { @@ -54,7 +54,7 @@ export const GiftCardListBulkActions = () => { }); const [deactivateGiftCards, deactivateGiftCardsOpts] = useGiftCardBulkDeactivateMutation({ onCompleted: data => { - const notifierData: IMessage = data?.giftCardBulkDeactivate?.errors?.length + const notifierData: INotification = data?.giftCardBulkDeactivate?.errors?.length ? { status: "error", text: intl.formatMessage(messages.errorDeactivateAlertText, { diff --git a/src/hooks/useHandleFormSubmit.ts b/src/hooks/useHandleFormSubmit.ts index 6368652e731..0ccc6b97715 100644 --- a/src/hooks/useHandleFormSubmit.ts +++ b/src/hooks/useHandleFormSubmit.ts @@ -1,5 +1,5 @@ import { FormId, useExitFormDialog } from "@dashboard/components/Form"; -import { MessageContext } from "@dashboard/components/messages"; +import { NotificationContext } from "@dashboard/components/notifications"; import { SubmitPromise } from "@dashboard/hooks/useForm"; import { useContext } from "react"; @@ -15,13 +15,13 @@ function useHandleFormSubmit({ const { setIsSubmitting, setIsDirty } = useExitFormDialog({ formId, }); - const messageContext = useContext(MessageContext); + const notificationContext = useContext(NotificationContext); async function handleFormSubmit(data: TData): Promise { setIsSubmitting(true); - if (messageContext?.clearErrorNotifications) { - messageContext.clearErrorNotifications(); + if (notificationContext?.clearErrorNotifications) { + notificationContext.clearErrorNotifications(); } const result = onSubmit ? onSubmit(data) : null; diff --git a/src/hooks/useNotifier/useNotifier.test.tsx b/src/hooks/useNotifier/useNotifier.test.tsx new file mode 100644 index 00000000000..062b23ebfd9 --- /dev/null +++ b/src/hooks/useNotifier/useNotifier.test.tsx @@ -0,0 +1,401 @@ +import { INotification } from "@dashboard/components/notifications"; +import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@dashboard/config"; +import { ThemeProvider } from "@saleor/macaw-ui-next"; +import { act, renderHook } from "@testing-library/react-hooks"; +import { PropsWithChildren } from "react"; +import { IntlProvider } from "react-intl"; +import { toast } from "sonner"; + +import useNotifier from "./useNotifier"; + +jest.mock("sonner", () => ({ + toast: { + custom: jest.fn(), + }, +})); + +const mockToastCustom = toast.custom as jest.Mock; + +const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +); + +describe("useNotifier", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("basic functionality", () => { + it("returns notify function", () => { + // Arrange & Act + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Assert + expect(typeof result.current).toBe("function"); + }); + + it("calls toast.custom when notify is invoked", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + const notification: INotification = { + title: "Test", + status: "success", + }; + + // Act + act(() => { + result.current(notification); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledTimes(1); + }); + }); + + describe("duration handling", () => { + it("uses DEFAULT_NOTIFICATION_SHOW_TIME for success notifications", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "success", title: "Success" }); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledWith(expect.any(Function), { + duration: DEFAULT_NOTIFICATION_SHOW_TIME, + }); + }); + + it("uses Infinity duration for error notifications", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "error", title: "Error" }); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledWith(expect.any(Function), { + duration: Infinity, + }); + }); + + it("uses custom autohide value when provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + const customAutohide = 5000; + + // Act + act(() => { + result.current({ + status: "success", + title: "Custom Duration", + autohide: customAutohide, + }); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledWith(expect.any(Function), { + duration: customAutohide, + }); + }); + + it("uses DEFAULT_NOTIFICATION_SHOW_TIME for info notifications", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "info", title: "Info" }); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledWith(expect.any(Function), { + duration: DEFAULT_NOTIFICATION_SHOW_TIME, + }); + }); + + it("uses DEFAULT_NOTIFICATION_SHOW_TIME for warning notifications", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "warning", title: "Warning" }); + }); + + // Assert + expect(mockToastCustom).toHaveBeenCalledWith(expect.any(Function), { + duration: DEFAULT_NOTIFICATION_SHOW_TIME, + }); + }); + }); + + describe("title fallback", () => { + it("uses provided title when given", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ title: "Custom Title", status: "success" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Custom Title"); + }); + + it("falls back to 'Success' for success status without title", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "success" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Success"); + }); + + it("falls back to 'Error' for error status without title", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "error" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Error"); + }); + + it("falls back to 'Warning' for warning status without title", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "warning" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Warning"); + }); + + it("falls back to 'Info' for info status without title", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ status: "info" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Info"); + }); + + it("falls back to 'Info' when no status is provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({}); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.title).toBe("Info"); + }); + }); + + describe("description handling", () => { + it("uses text as description when provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ + title: "Title", + text: "Description text", + status: "info", + }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.description).toBe("Description text"); + }); + + it("uses apiMessage as description fallback when text is not provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ + title: "Title", + apiMessage: "API error message", + status: "error", + }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.description).toBe("API error message"); + }); + + it("prefers text over apiMessage when both are provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ + title: "Title", + text: "Primary description", + apiMessage: "Fallback description", + status: "info", + }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.description).toBe("Primary description"); + }); + }); + + describe("action button", () => { + it("passes action configuration to Toast", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + const actionFn = jest.fn(); + + // Act + act(() => { + result.current({ + title: "Title", + status: "info", + actionBtn: { + label: "Undo", + action: actionFn, + }, + }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.action).toEqual({ + label: "Undo", + onClick: actionFn, + }); + }); + + it("does not pass action when actionBtn is not provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ title: "Title", status: "info" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.action).toBeUndefined(); + }); + }); + + describe("toast type mapping", () => { + it.each([ + ["success", "success"], + ["error", "error"], + ["warning", "warning"], + ["info", "info"], + ] as const)("maps status '%s' to toast type '%s'", (status, expectedType) => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ title: "Test", status }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.type).toBe(expectedType); + }); + + it("defaults to 'info' type when status is not provided", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ title: "Test" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("test-id"); + + expect(renderedElement.props.type).toBe("info"); + }); + }); + + describe("toast id", () => { + it("passes toast id to Toast component", () => { + // Arrange + const { result } = renderHook(() => useNotifier(), { wrapper }); + + // Act + act(() => { + result.current({ title: "Test", status: "success" }); + }); + + // Assert + const renderFn = mockToastCustom.mock.calls[0][0]; + const renderedElement = renderFn("unique-toast-id"); + + expect(renderedElement.props.id).toBe("unique-toast-id"); + }); + }); +}); diff --git a/src/hooks/useNotifier/useNotifier.ts b/src/hooks/useNotifier/useNotifier.ts deleted file mode 100644 index 78acba1b29c..00000000000 --- a/src/hooks/useNotifier/useNotifier.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IMessage, IMessageContext, MessageContext } from "@dashboard/components/messages"; -import { useContext } from "react"; - -export type UseNotifierResult = IMessageContext; - -function useNotifier(): UseNotifierResult { - const notificationContext = useContext(MessageContext); - const notify = (options: IMessage) => { - const timeout = options.status === "error" ? null : options.autohide; - - notificationContext?.show(options, timeout); - }; - - return notify; -} -export default useNotifier; diff --git a/src/hooks/useNotifier/useNotifier.tsx b/src/hooks/useNotifier/useNotifier.tsx new file mode 100644 index 00000000000..cc8523aa8c6 --- /dev/null +++ b/src/hooks/useNotifier/useNotifier.tsx @@ -0,0 +1,70 @@ +import { INotification, INotificationCallback } from "@dashboard/components/notifications"; +import { Toast } from "@dashboard/components/notifications/Toast"; +import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@dashboard/config"; +import { commonMessages } from "@dashboard/intl"; +import { useCallback } from "react"; +import { useIntl } from "react-intl"; +import { toast } from "sonner"; + +export type UseNotifierResult = INotificationCallback; + +function useNotifier(): UseNotifierResult { + const intl = useIntl(); + + const notify = useCallback( + (options: INotification) => { + const duration = + options.status === "error" + ? Infinity + : (options.autohide ?? DEFAULT_NOTIFICATION_SHOW_TIME); + + // Build description - use apiMessage as fallback if no text + const description = options.text || options.apiMessage; + + // Determine title with fallback to localized default + const getDefaultTitle = () => { + switch (options.status) { + case "success": + return intl.formatMessage(commonMessages.success); + case "error": + return intl.formatMessage(commonMessages.error); + case "warning": + return intl.formatMessage(commonMessages.warning); + case "info": + default: + return intl.formatMessage(commonMessages.info); + } + }; + + const title = options.title || getDefaultTitle(); + const type = options.status || "info"; + + toast.custom( + id => ( + + ), + { + duration, + }, + ); + }, + [intl], + ); + + return notify; +} + +export default useNotifier; diff --git a/src/hooks/useNotifier/utils.ts b/src/hooks/useNotifier/utils.ts index 9c4ab96cca3..8e8ad37ea0f 100644 --- a/src/hooks/useNotifier/utils.ts +++ b/src/hooks/useNotifier/utils.ts @@ -1,9 +1,12 @@ -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { commonMessages } from "@dashboard/intl"; import commonErrorMessages from "@dashboard/utils/errors/common"; import { IntlShape } from "react-intl"; -export const getDefaultNotifierSuccessErrorData = (errors: any[], intl: IntlShape): IMessage => +export const getDefaultNotifierSuccessErrorData = ( + errors: any[], + intl: IntlShape, +): INotification => !errors.length ? { status: "success", diff --git a/src/index.tsx b/src/index.tsx index f31ad94f014..8285baffdb5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,12 +31,13 @@ import { channelsSection } from "./channels/urls"; import AppLayout from "./components/AppLayout"; import useAppChannel, { AppChannelProvider } from "./components/AppLayout/AppChannelContext"; import { DevModeProvider } from "./components/DevModePanel/DevModeProvider"; +import { DevToolsPanel, DevToolsProvider } from "./components/DevTools"; import ErrorPage from "./components/ErrorPage"; import ExitFormDialogProvider from "./components/Form/ExitFormDialogProvider"; import { legacyRedirects } from "./components/LegacyRedirects"; import { LocaleProvider } from "./components/Locale"; -import MessageManagerProvider from "./components/messages"; import { NavigatorSearchProvider } from "./components/NavigatorSearch/NavigatorSearchProvider"; +import NotificationProvider from "./components/notifications"; import { ProductAnalytics } from "./components/ProductAnalytics"; import { SavebarRefProvider } from "./components/Savebar/SavebarRefContext"; import { ShopProvider } from "./components/Shop"; @@ -99,7 +100,7 @@ errorTracker.init(history); Handle legacy theming toggle. Since we use new and old macaw, we need to handle both theme swticher for a while. */ -const handleLegacyTheming = () => { +const handleLegacyTheming = (): void => { const activeTheme = localStorage.getItem("activeMacawUITheme"); if (activeTheme === "defaultDark") { @@ -113,7 +114,7 @@ const handleLegacyTheming = () => { handleLegacyTheming(); -const App = () => ( +const App = (): JSX.Element => ( // @ts-expect-error legacy types @@ -122,33 +123,36 @@ const App = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/intl.ts b/src/intl.ts index 7d1d0ef715e..bfb56b4d71e 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -5,6 +5,18 @@ export const commonMessages = defineMessages({ id: "hOxIeP", defaultMessage: "Availability", }, + success: { + id: "xrKHS6", + defaultMessage: "Success", + }, + info: { + id: "we4Lby", + defaultMessage: "Info", + }, + warning: { + id: "3SVI5p", + defaultMessage: "Warning", + }, products: { id: "7NFfmz", defaultMessage: "Products", diff --git a/src/misc.ts b/src/misc.ts index cff05c1bfcf..7f9c88d8789 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -293,7 +293,16 @@ export function getMutationStatus( diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx new file mode 100644 index 00000000000..fecaa540693 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -0,0 +1,474 @@ +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { OrderErrorCode, OrderErrorFragment } from "@dashboard/graphql"; +import { IMoney } from "@dashboard/utils/intl"; +import Wrapper from "@test/wrapper"; +import { fireEvent, render, screen, within } from "@testing-library/react"; + +import { OrderCaptureDialog, OrderCaptureDialogProps } from "./OrderCaptureDialog"; + +const createMoney = (amount: number, currency = "USD"): IMoney => ({ + amount, + currency, +}); + +const defaultProps: OrderCaptureDialogProps = { + open: true, + confirmButtonState: "default" as ConfirmButtonTransitionState, + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onClose: jest.fn(), + onSubmit: jest.fn(), +}; + +const renderDialog = (props: Partial = {}) => + render( + + + , + ); + +describe("OrderCaptureDialog", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("rendering", () => { + it("renders the dialog when open is true", () => { + // Arrange & Act + renderDialog(); + + // Assert + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Capture Payment")).toBeInTheDocument(); + }); + + it("does not render the dialog when open is false", () => { + // Arrange & Act + renderDialog({ open: false }); + + // Assert + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("displays order total label", () => { + // Arrange & Act + renderDialog({ orderTotal: createMoney(150) }); + + // Assert + expect(screen.getByText("Order total")).toBeInTheDocument(); + }); + + it("displays available to capture label", () => { + // Arrange & Act + renderDialog({ authorizedAmount: createMoney(80) }); + + // Assert + expect(screen.getByText("Available to capture (authorized)")).toBeInTheDocument(); + }); + }); + + describe("authorization status", () => { + it("shows 'Fully Authorized' pill when authorized >= remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Authorized")).toBeInTheDocument(); + }); + + it("shows 'Partial authorisation' pill when authorized < remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Partial authorisation")).toBeInTheDocument(); + }); + + it("shows warning callout for partial authorization with shortfall", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect( + screen.getByText(/The remaining authorization doesn't cover the balance/), + ).toBeInTheDocument(); + }); + + it("shows 'No Authorization' pill when authorized is 0", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText("No Authorization")).toBeInTheDocument(); + }); + + it("shows error callout when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText(/No payment has been authorized for this order/)).toBeInTheDocument(); + }); + + it("shows 'Fully Captured' pill when order is fully paid", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + chargedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Captured")).toBeInTheDocument(); + }); + }); + + describe("radio options", () => { + it("defaults to first option for full authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const selectedRadio = within(radioGroup).getByRole("radio", { checked: true }); + + expect(selectedRadio).toHaveAttribute("value", "orderTotal"); + }); + + it("shows 'Remaining max (authorized)' label for partial authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Remaining max (authorized)")).toBeInTheDocument(); + }); + + it("disables radio options when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const radios = within(radioGroup).getAllByRole("radio"); + + radios.forEach(radio => { + expect(radio).toBeDisabled(); + }); + }); + + it("allows selecting custom amount option", async () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + expect(customRadio).toBeChecked(); + }); + }); + + describe("custom amount input", () => { + const getCustomInput = () => { + const dialog = screen.getByRole("dialog"); + + return dialog.querySelector('input[type="text"]') as HTMLInputElement; + }; + + it("is disabled when custom option is not selected", () => { + // Arrange & Act + renderDialog(); + + // Assert + const input = getCustomInput(); + + expect(input).toBeDisabled(); + }); + + it("is enabled when custom option is selected", () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + const input = getCustomInput(); + + expect(input).not.toBeDisabled(); + }); + + it("shows max capturable hint", () => { + // Arrange & Act + renderDialog({ + authorizedAmount: createMoney(75), + }); + + // Assert + expect(screen.getByText(/Max:/)).toBeInTheDocument(); + }); + + it("accepts valid custom amount input", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50" } }); + + // Assert + expect(input).toHaveValue("50"); + }); + + it("limits decimal places based on currency", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50.999" } }); + + // Assert - USD has 2 decimal places + expect(input).toHaveValue("50.99"); + }); + }); + + describe("submit button", () => { + it("shows capture amount in button text", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByRole("button", { name: /Capture/i })).toBeInTheDocument(); + }); + + it("calls onSubmit with correct amount for full authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(100); + }); + + it("calls onSubmit with max available for partial authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(50); + }); + + it("calls onSubmit with custom amount when selected", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ onSubmit }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "25" } }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(25); + }); + + it("is disabled when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + + it("is disabled when custom amount exceeds max", () => { + // Arrange + renderDialog({ + authorizedAmount: createMoney(50), + }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "100" } }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + }); + + describe("close button", () => { + it("calls onClose when back button is clicked", () => { + // Arrange + const onClose = jest.fn(); + + renderDialog({ onClose }); + + // Act + const backButton = screen.getByRole("button", { name: /back/i }); + + fireEvent.click(backButton); + + // Assert + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("displays error messages when provided", () => { + // Arrange + const errors = [ + { + __typename: "OrderError" as const, + code: OrderErrorCode.CAPTURE_INACTIVE_PAYMENT, + field: null, + addressType: null, + message: null, + orderLines: null, + }, + ] as OrderErrorFragment[]; + + // Act + renderDialog({ errors }); + + // Assert + // The error message will be rendered by getOrderErrorMessage utility + const dialog = screen.getByRole("dialog"); + + expect(dialog).toBeInTheDocument(); + }); + }); + + describe("outcome prediction", () => { + it("shows outcome message when capturing full balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); + }); + + it("shows outcome message when capturing partial balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); + }); + }); + + describe("with charged amount", () => { + it("displays 'Captured so far' label when there is prior charged amount", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Captured so far")).toBeInTheDocument(); + }); + + it("displays 'Balance due' label with prior charges", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Balance due")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx new file mode 100644 index 00000000000..bed32dd629f --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -0,0 +1,555 @@ +import BackButton from "@dashboard/components/BackButton"; +import { Callout } from "@dashboard/components/Callout/Callout"; +import { ConfirmButton, ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { DashboardModal } from "@dashboard/components/Modal"; +import Money from "@dashboard/components/Money"; +import { Pill } from "@dashboard/components/Pill"; +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + parseDecimalValue, +} from "@dashboard/components/PriceField/utils"; +import { OrderErrorFragment, TransactionRequestActionErrorFragment } from "@dashboard/graphql"; +import getOrderErrorMessage from "@dashboard/utils/errors/order"; +import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transaction"; +import { IMoney } from "@dashboard/utils/intl"; +import { Box, Input, RadioGroup, Text } from "@saleor/macaw-ui-next"; +import { AlertTriangle, Box as BoxIcon, CheckCircle2, CircleAlert, CreditCard } from "lucide-react"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "./messages"; + +type CaptureError = OrderErrorFragment | TransactionRequestActionErrorFragment; + +const isTransactionError = ( + error: CaptureError, +): error is TransactionRequestActionErrorFragment => { + // TransactionRequestActionErrorFragment has a different __typename + return error.__typename === "TransactionRequestActionError"; +}; + +export type CaptureAmountOption = "orderTotal" | "custom"; + +type AuthorizationStatus = "full" | "partial" | "none" | "charged"; + +export interface OrderCaptureDialogProps { + open: boolean; + confirmButtonState: ConfirmButtonTransitionState; + orderTotal: IMoney; + authorizedAmount: IMoney; + /** Amount already charged/captured - used for display */ + chargedAmount?: IMoney; + /** + * Order's total balance (for multi-transaction orders). + * Negative = customer owes money, Positive = overpaid. + * When provided, used instead of (orderTotal - chargedAmount) for remaining calculation. + */ + orderBalance?: IMoney; + /** When true, shows "Transaction authorized" instead of "Authorized" */ + isTransaction?: boolean; + /** Server errors from the capture mutation (supports both Legacy and Transactions API errors) */ + errors?: CaptureError[]; + onClose: () => void; + onSubmit: (amount: number) => void; +} + +export const OrderCaptureDialog = ({ + open, + confirmButtonState, + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, + isTransaction: _isTransaction = false, + errors = [], + onClose, + onSubmit, +}: OrderCaptureDialogProps): JSX.Element => { + const intl = useIntl(); + + const totalAmount = orderTotal.amount; + const authAmount = authorizedAmount.amount; // Available to capture (bucket model) + const alreadyCharged = chargedAmount?.amount ?? 0; + const currency = orderTotal.currency; + + // With bucket model: authorizedAmount = what's available to capture + // (funds move from authorizedAmount to chargedAmount when captured) + const availableToCapture = authAmount; + + // Calculate what customer still owes: + // - If orderBalance provided (multi-transaction): use it (negative balance = owes money) + // - Otherwise: simple calculation from order total minus charged + const remainingToPay = orderBalance + ? Math.max(0, -orderBalance.amount) // Convert negative balance to positive amount owed + : totalAmount - alreadyCharged; + + // Order-wide captured amount (for display in Order section) + // = order total minus what's still owed + const orderTotalCaptured = totalAmount - remainingToPay; + + // Determine authorization status + const getAuthorizationStatus = (): AuthorizationStatus => { + // Check if fully charged first (nothing left to pay) + if (remainingToPay <= 0) { + return "charged"; + } + + if (availableToCapture <= 0) { + return "none"; + } + + if (availableToCapture >= remainingToPay) { + return "full"; + } + + return "partial"; + }; + + const authStatus = getAuthorizationStatus(); + const maxCapturable = Math.max(0, availableToCapture); + const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; + const shortfall = remainingToPay - availableToCapture; + + // Default selection: always prefer "orderTotal" unless it's disabled + const isFirstOptionDisabled = authStatus === "none" || authStatus === "charged"; + const getDefaultOption = (): CaptureAmountOption => { + // Always default to orderTotal (first option) unless it's disabled + return isFirstOptionDisabled ? "custom" : "orderTotal"; + }; + + const getDefaultCustomAmount = (): string => { + if (authStatus === "none" || authStatus === "charged") { + return "0"; + } + + if (authStatus === "partial") { + // Default to max capturable (remaining auth) + return String(availableToCapture); + } + + // Default to remaining amount to pay + return String(remainingToPay); + }; + + const [selectedOption, setSelectedOption] = useState(getDefaultOption); + const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + + // Reset state when dialog opens to ensure correct defaults based on current props + useEffect(() => { + if (open) { + setSelectedOption(getDefaultOption()); + setCustomAmount(getDefaultCustomAmount()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Get max decimal places for this currency (e.g., 2 for USD, 0 for JPY, 3 for KWD) + const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); + + const handleCustomAmountChange = (e: ChangeEvent): void => { + const limitedValue = limitDecimalPlaces(e.target.value, maxDecimalPlaces); + + setCustomAmount(limitedValue); + }; + + const getSelectedAmount = (): number => { + switch (selectedOption) { + case "orderTotal": + // For partial auth, capture max available; for full, capture remaining balance + return authStatus === "partial" ? availableToCapture : remainingToPay; + case "custom": + return parseDecimalValue(customAmount); + } + }; + + const selectedAmount = getSelectedAmount(); + const customAmountValue = parseDecimalValue(customAmount); + const isCustomAmountInRange = customAmountValue > 0 && customAmountValue <= maxCapturable; + const isCustomAmountValid = selectedOption !== "custom" || isCustomAmountInRange; + const showCustomAmountError = + selectedOption === "custom" && + authStatus !== "none" && + authStatus !== "charged" && + !isCustomAmountInRange; + const canSubmit = + authStatus !== "none" && authStatus !== "charged" && isCustomAmountValid && selectedAmount > 0; + + const handleSubmit = (): void => { + if (canSubmit) { + onSubmit(selectedAmount); + } + }; + + const formatMoney = (amount: number): JSX.Element => ( + + + + ); + + // Calculate predicted outcome status after capture (order-wide) + type OutcomeStatus = "fullyCharged" | "partiallyCharged" | "overcharged"; + + const getOutcomeStatus = (): OutcomeStatus => { + const totalAfterCapture = orderTotalCaptured + selectedAmount; + + if (totalAfterCapture > totalAmount) { + return "overcharged"; + } else if (totalAfterCapture >= totalAmount) { + return "fullyCharged"; + } else { + return "partiallyCharged"; + } + }; + + const outcomeStatus = getOutcomeStatus(); + + const getStatusPill = (): JSX.Element => { + switch (authStatus) { + case "charged": + return ( + } + /> + ); + case "full": + return ( + } + /> + ); + case "partial": + return ( + } + /> + ); + case "none": + return ( + } + /> + ); + } + }; + + type AuthorizationColor = "success1" | "warning1" | "critical1"; + + const authStatusColorMap: Record = { + charged: "success1", + full: "success1", + partial: "warning1", + none: "critical1", + }; + + const authorizedAmountColor = authStatusColorMap[authStatus]; + + return ( + + + + + + {getStatusPill()} + + + + + {/* Summary box with order and payment sections */} + + {/* Order section */} + + + + + + + + + {formatMoney(totalAmount)} + + {orderTotalCaptured > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(orderTotalCaptured)} + + )} + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(remainingToPay)} + + + + {/* Transaction section */} + + + + + + + + + + + {formatMoney(availableToCapture)} + + + {alreadyCharged > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(alreadyCharged)} + + )} + + + {/* Warning/Error messages */} + {authStatus === "partial" && ( + + {formatMoney(shortfall)}, + }} + /> + + } + /> + )} + + {authStatus === "none" && ( + + {formatMoney(remainingToPay)}, + }} + /> + + } + /> + )} + + + + {/* Radio options - primary section */} + + + + + + setSelectedOption(value as CaptureAmountOption)} + > + + {/* Order Total / Remaining Balance / Remaining Max option */} + + + + + 0 + ? messages.remainingBalance + : messages.optionOrderTotal)} + /> + + + + {formatMoney(authStatus === "partial" ? availableToCapture : remainingToPay)} + + + {canCaptureOrderTotal && ( + + + + + + )} + + + {/* Custom amount option */} + + + + + + + + + + + + + {currency} + + } + /> + + + + + + + + + + + + {/* Outcome prediction */} + {canSubmit && selectedAmount > 0 && ( + + + + ), + }} + /> + + + )} + + {errors.length > 0 && ( + + {errors.map((error, index) => ( + + {isTransactionError(error) + ? getOrderTransactionErrorMessage(error, intl) + : getOrderErrorMessage(error, intl)} + + ))} + + )} + + + + + + + + + + ); +}; + +OrderCaptureDialog.displayName = "OrderCaptureDialog"; diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts new file mode 100644 index 00000000000..2a3b21831b4 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -0,0 +1,136 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + title: { + id: "0f6YvV", + defaultMessage: "Capture Payment", + description: "dialog title", + }, + statusFullyAuthorized: { + id: "HwIhau", + defaultMessage: "Fully Authorized", + description: "status pill for fully authorized payment", + }, + statusPartial: { + id: "ZUYQ+C", + defaultMessage: "Partial authorisation", + description: "status pill for partial authorization", + }, + statusNoAuthorization: { + id: "mxGY7T", + defaultMessage: "No Authorization", + description: "status pill for no authorization", + }, + statusFullyCaptured: { + id: "L8J/jr", + defaultMessage: "Fully Captured", + description: "status pill when order is fully paid", + }, + orderTotal: { + id: "4YyeCx", + defaultMessage: "Order total", + description: "label for order total amount", + }, + authorized: { + id: "U0IK0G", + defaultMessage: "Authorized", + description: "label for authorized amount", + }, + transactionAuthorized: { + id: "H0eCbU", + defaultMessage: "Transaction authorized", + description: "label for transaction authorized amount", + }, + capturedSoFar: { + id: "0YOedO", + defaultMessage: "Captured so far", + description: "label for already charged amount", + }, + balanceDue: { + id: "qlfssi", + defaultMessage: "Balance due", + description: "label for remaining amount customer owes", + }, + availableToCapture: { + id: "MhlYkx", + defaultMessage: "Available to capture (authorized)", + description: "label for available authorization amount", + }, + transactionCaptured: { + id: "R/YHMH", + defaultMessage: "Already captured", + description: "label for amount already captured from this transaction", + }, + remainingBalance: { + id: "OUMqG1", + defaultMessage: "Remaining balance", + description: "label for remaining balance to capture", + }, + remainingMax: { + id: "jhyt3I", + defaultMessage: "Remaining max (authorized)", + description: "label for max capturable amount when partial authorization", + }, + selectAmount: { + id: "XrliJg", + defaultMessage: "Select amount to capture:", + description: "label for amount selection", + }, + optionOrderTotal: { + id: "tS2K/N", + defaultMessage: "Order total", + description: "radio option for capturing order total", + }, + optionOrderTotalHint: { + id: "v8e93p", + defaultMessage: "Matches what customer owes", + description: "hint for order total option", + }, + optionCustom: { + id: "IU1lif", + defaultMessage: "Custom amount", + description: "radio option for custom capture amount", + }, + customAmountMax: { + id: "Mm/Stj", + defaultMessage: "Max: {amount}", + description: "hint showing maximum allowed custom amount", + }, + captureButton: { + id: "bRXgSC", + defaultMessage: "Capture {amount}", + description: "capture button with amount", + }, + warningPartialAuthorization: { + id: "8JEG80", + defaultMessage: + "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment.", + description: "warning when authorized is less than total", + }, + errorNoAuthorization: { + id: "SnV3LR", + defaultMessage: + "No payment has been authorized for this order. The full amount of {amount} cannot be captured.", + description: "error when no authorization exists", + }, + outcomeMessage: { + id: "HSYM17", + defaultMessage: "This will result in {status} order", + description: "outcome prediction showing resulting order status after capture", + }, + statusFullyCapturedPill: { + id: "G9y5Ze", + defaultMessage: "Fully captured", + description: "pill status for fully captured outcome", + }, + statusPartiallyCapturedPill: { + id: "BJRu4V", + defaultMessage: "Partially captured", + description: "pill status for partially captured outcome", + }, + statusOvercapturedPill: { + id: "u7ShY+", + defaultMessage: "Overcaptured", + description: "pill status for overcaptured outcome", + }, +}); diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index 120fe6eff2a..1cee81838c1 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -26,7 +26,7 @@ export const LegacyPaymentsApiButtons = ({ onLegacyPaymentsApiCapture, onLegacyPaymentsApiRefund, onLegacyPaymentsApiVoid, -}: Props) => { +}: Props): JSX.Element | null => { const intl = useIntl(); const showButtons = diff --git a/src/orders/urls.ts b/src/orders/urls.ts index a0ebbf59dc6..fe1755ee71c 100644 --- a/src/orders/urls.ts +++ b/src/orders/urls.ts @@ -209,6 +209,7 @@ export type OrderUrlDialog = | "mark-paid" | "void" | "transaction-action" + | "transaction-charge-action" | "invoice-send" | "add-manual-transaction" | "view-order-line-metadata" @@ -216,7 +217,7 @@ export type OrderUrlDialog = | "view-fulfillment-metadata"; interface TransactionAction { - action: "transaction-action"; + action: "transaction-action" | "transaction-charge-action"; id: string; type: TransactionActionEnum; } diff --git a/src/orders/views/OrderDetails/OrderDetails.tsx b/src/orders/views/OrderDetails/OrderDetails.tsx index 5c5b43609df..cdfb1b2f3e5 100644 --- a/src/orders/views/OrderDetails/OrderDetails.tsx +++ b/src/orders/views/OrderDetails/OrderDetails.tsx @@ -138,7 +138,12 @@ const OrderDetails = ({ id, params }: OrderDetailsProps) => { } }} onInvoiceSend={orderMessages.handleInvoiceSend} - onTransactionActionSend={orderMessages.handleTransactionAction} + onTransactionActionSend={async data => { + await apolloClient.refetchQueries({ + include: [OrderDetailsWithMetadataDocument], + }); + orderMessages.handleTransactionAction(data); + }} onManualTransactionAdded={async data => { await apolloClient.refetchQueries({ include: [OrderDetailsWithMetadataDocument], diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 0315f611185..dffac2305d1 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -15,6 +15,7 @@ import { OrderTransactionRequestActionMutationVariables, OrderUpdateMutation, OrderUpdateMutationVariables, + TransactionActionEnum, useCustomerAddressesQuery, useWarehouseListQuery, } from "@dashboard/graphql"; @@ -53,11 +54,11 @@ import { customerUrl } from "../../../../customers/urls"; import { productUrl } from "../../../../products/urls"; import OrderAddressFields from "../../../components/OrderAddressFields/OrderAddressFields"; import OrderCancelDialog from "../../../components/OrderCancelDialog"; +import { OrderCaptureDialog } from "../../../components/OrderCaptureDialog/OrderCaptureDialog"; import OrderDetailsPage from "../../../components/OrderDetailsPage/OrderDetailsPage"; import OrderFulfillmentCancelDialog from "../../../components/OrderFulfillmentCancelDialog"; import OrderFulfillmentTrackingDialog from "../../../components/OrderFulfillmentTrackingDialog"; import OrderMarkAsPaidDialog from "../../../components/OrderMarkAsPaidDialog/OrderMarkAsPaidDialog"; -import OrderPaymentDialog from "../../../components/OrderPaymentDialog"; import OrderPaymentVoidDialog from "../../../components/OrderPaymentVoidDialog"; import { orderFulfillUrl, @@ -231,11 +232,19 @@ export const OrderNormalDetails = ({ onOrderShowMetadata={() => openModal("view-order-metadata")} onFulfillmentShowMetadata={id => openModal("view-fulfillment-metadata", { id })} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderFulfill={() => navigate(orderFulfillUrl(id))} onFulfillmentApprove={fulfillmentId => @@ -303,6 +312,40 @@ export const OrderNormalDetails = ({ }) } /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-charge-action" && ( + t.id === params.id)?.authorizedAmount ?? { + amount: 0, + currency: "USD", + } + } + chargedAmount={ + order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? { + amount: 0, + currency: "USD", + } + } + orderBalance={order?.totalBalance ?? { amount: 0, currency: "USD" }} + isTransaction + open={true} + onClose={closeModal} + onSubmit={amount => + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderVoid.mutate({ id })} /> - + onSubmit={amount => orderPaymentCapture.mutate({ - ...variables, + amount, id, }) } diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index c5fab507284..0340111c657 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -14,6 +14,7 @@ import { OrderTransactionRequestActionMutationVariables, OrderUpdateMutation, OrderUpdateMutationVariables, + TransactionActionEnum, useCustomerAddressesQuery, useWarehouseListQuery, } from "@dashboard/graphql"; @@ -33,7 +34,7 @@ import { OrderLineDiscountProvider } from "@dashboard/products/components/OrderD import { useOrderVariantSearch } from "@dashboard/searches/useOrderVariantSearch"; import { PartialMutationProviderOutput } from "@dashboard/types"; import { mapEdgesToItems } from "@dashboard/utils/maps"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { customerUrl } from "../../../../customers/urls"; @@ -46,11 +47,11 @@ import { import { productUrl } from "../../../../products/urls"; import OrderAddressFields from "../../../components/OrderAddressFields/OrderAddressFields"; import OrderCancelDialog from "../../../components/OrderCancelDialog"; +import { OrderCaptureDialog } from "../../../components/OrderCaptureDialog/OrderCaptureDialog"; import OrderDetailsPage from "../../../components/OrderDetailsPage/OrderDetailsPage"; import OrderFulfillmentCancelDialog from "../../../components/OrderFulfillmentCancelDialog"; import OrderFulfillmentTrackingDialog from "../../../components/OrderFulfillmentTrackingDialog"; import OrderMarkAsPaidDialog from "../../../components/OrderMarkAsPaidDialog/OrderMarkAsPaidDialog"; -import OrderPaymentDialog from "../../../components/OrderPaymentDialog"; import OrderPaymentVoidDialog from "../../../components/OrderPaymentVoidDialog"; import OrderProductAddDialog from "../../../components/OrderProductAddDialog"; import OrderShippingMethodEditDialog from "../../../components/OrderShippingMethodEditDialog"; @@ -168,6 +169,11 @@ export const OrderUnconfirmedDetails = ({ const intl = useIntl(); const [transactionReference, setTransactionReference] = useState(""); const errors = orderUpdate.opts.data?.orderUpdate.errors || []; + const defaultZeroMoney = { amount: 0, currency: "USD" }; + const selectedTransaction = useMemo( + () => order?.transactions?.find(t => t.id === params.id), + [order?.transactions, params.id], + ); const hasOrderFulfillmentsFulFilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, @@ -213,11 +219,19 @@ export const OrderUnconfirmedDetails = ({ order={order} shop={shop} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderLineAdd={() => openModal("add-order-line")} onOrderLineChange={(id, data) => @@ -366,6 +380,30 @@ export const OrderUnconfirmedDetails = ({ transactionReference={transactionReference} handleTransactionReference={({ target }) => setTransactionReference(target.value)} /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-charge-action" && ( + + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderVoid.mutate({ id })} /> - + onSubmit={amount => orderPaymentCapture.mutate({ - ...variables, + amount, id, }) } diff --git a/src/orders/views/OrderTransactionRefundCreate/handlers.ts b/src/orders/views/OrderTransactionRefundCreate/handlers.ts index d776ebf53af..0caef0888d8 100644 --- a/src/orders/views/OrderTransactionRefundCreate/handlers.ts +++ b/src/orders/views/OrderTransactionRefundCreate/handlers.ts @@ -1,4 +1,4 @@ -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { OrderDetailsGrantRefundQuery, OrderGrantRefundAddMutation, @@ -25,7 +25,7 @@ export const handleRefundCreateComplete = ({ orderId, }: { submitData: OrderGrantRefundAddMutation; - notify: (message: IMessage) => void; + notify: (notification: INotification) => void; setLinesErrors: (value: React.SetStateAction) => void; navigate: UseNavigatorResult; intl: IntlShape; diff --git a/src/orders/views/OrderTransactionRefundEdit/handlers.ts b/src/orders/views/OrderTransactionRefundEdit/handlers.ts index 611b3afa340..3de42687af5 100644 --- a/src/orders/views/OrderTransactionRefundEdit/handlers.ts +++ b/src/orders/views/OrderTransactionRefundEdit/handlers.ts @@ -1,4 +1,4 @@ -import { IMessage } from "@dashboard/components/messages"; +import { INotification } from "@dashboard/components/notifications"; import { OrderGrantRefundEditMutation, OrderGrantRefundUpdateErrorCode, @@ -16,7 +16,7 @@ export const handleRefundEditComplete = ({ intl, }: { submitData: OrderGrantRefundEditMutation; - notify: (message: IMessage) => void; + notify: (notification: INotification) => void; setLinesErrors: (value: React.SetStateAction) => void; intl: IntlShape; orderId: string;