diff --git a/packages/manager/apps/pci-project/package.json b/packages/manager/apps/pci-project/package.json index 15d793cf9fa2..cd301c8c4698 100644 --- a/packages/manager/apps/pci-project/package.json +++ b/packages/manager/apps/pci-project/package.json @@ -56,6 +56,7 @@ "validator": "^13.15.15" }, "devDependencies": { + "@originjs/vite-plugin-federation": "^1.3.9", "@testing-library/react": "*", "@types/validator": "^13.15.2", "@vitest/coverage-v8": "^2.1.9", diff --git a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.spec.tsx index cc9e5c339815..9e5410c9c100 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.spec.tsx @@ -78,9 +78,28 @@ vi.mock('./hooks/useConfigForm', () => ({ const mockUseStepper = vi.fn(); vi.mock('./hooks/useStepper', () => ({ + Step: { + Config: 0, + Payment: 1, + CreditConfirmation: 2, + }, useStepper: () => mockUseStepper(), })); +// Mock useProjectCreation hook +const mockUseProjectCreation = vi.fn(); + +vi.mock('./hooks/useProjectCreation', () => ({ + useProjectCreation: () => mockUseProjectCreation(), +})); + +// Mock useWillPayment hook +const mockUseWillPayment = vi.fn(); + +vi.mock('./hooks/useWillPayment', () => ({ + useWillPayment: () => mockUseWillPayment(), +})); + // Mock useAntiFraud hook const mockUseAntiFraud = vi.fn(); @@ -129,10 +148,6 @@ vi.mock('@/components/FullPageSpinner', () => ({ })); // Mock step components -vi.mock('./steps/ConfigurationStep', () => ({ - default: () =>
Configuration Step
, -})); - vi.mock('./steps/ConfigStep', () => ({ default: () =>
Config Step
, })); @@ -141,6 +156,12 @@ vi.mock('./steps/PaymentStep', () => ({ default: () =>
Payment Step
, })); +vi.mock('./components/credit/CreditConfirmation.component', () => ({ + default: () => ( +
Credit Confirmation
+ ), +})); + const renderWithProviders = (component: ReactElement) => { const Wrapper = createWrapper(mockShellContext); @@ -155,7 +176,7 @@ describe('Creation Page', () => { mockUseParams.mockReturnValue({ projectId: 'test-project-id' }); mockUseGetCart.mockReturnValue({ - cart: { + data: { cartId: 'test-cart-id', description: 'Test Cart', items: [], @@ -193,7 +214,11 @@ describe('Creation Page', () => { }); mockUseGetOrderProjectId.mockReturnValue({ - orderId: null, + data: { + itemId: 'test-project-item-id', + cartId: 'test-cart-id', + type: 'publicCloudProject', + }, isLoading: false, error: null, }); @@ -217,18 +242,37 @@ describe('Creation Page', () => { }); mockUseStepper.mockReturnValue({ - currentStep: 'generalInformations', - setCurrentStep: vi.fn(), + goToConfig: vi.fn(), + goToPayment: vi.fn(), + goToCreditConfirmation: vi.fn(), isConfigChecked: false, isConfigLocked: false, isPaymentOpen: false, isPaymentChecked: false, - isPaymentLocked: false, - goToStep: vi.fn(), - nextStep: vi.fn(), - previousStep: vi.fn(), - isFirstStep: true, - isLastStep: false, + isPaymentLocked: true, + isCreditConfirmationOpen: false, + isCreditConfirmationChecked: false, + isCreditConfirmationLocked: false, + }); + + mockUseProjectCreation.mockReturnValue({ + isSubmitting: false, + needToCheckCustomerInfo: false, + billingHref: '', + handleProjectCreation: vi.fn(), + handleCreditAndPay: vi.fn(), + }); + + mockUseWillPayment.mockReturnValue({ + isCreditPayment: false, + creditAmount: null, + needsSave: false, + isSaved: false, + canSubmit: true, + hasDefaultPaymentMethod: false, + savePaymentMethod: vi.fn(), + handlePaymentStatusChange: vi.fn(), + handleRegisteredPaymentMethodSelected: vi.fn(), }); mockUseAttachConfigurationToCartItem.mockReturnValue({ @@ -261,33 +305,73 @@ describe('Creation Page', () => { expect(screen.getByTestId('payment-step')).toBeInTheDocument(); }); - it('should show proper content when cart is loading', () => { + it('should show spinner when cart or projectItem is null', () => { + // Override the default mocks to return null data + mockUseCreateAndAssignCart.mockReturnValue({ + mutate: vi.fn(), + data: null, // No created cart + isLoading: false, + error: null, + }); + mockUseGetCart.mockReturnValue({ - cart: null, - isLoading: true, + data: null, // No existing cart + isLoading: false, + error: null, + }); + + mockUseOrderProjectItem.mockReturnValue({ + mutate: vi.fn(), + data: null, // No created project item + isLoading: false, + error: null, + }); + + mockUseGetOrderProjectId.mockReturnValue({ + data: null, // No existing project item + isLoading: false, error: null, }); renderWithProviders(); - // Component should still render the basic structure - expect(screen.getByTestId('config-step')).toBeInTheDocument(); - expect(screen.getByTestId('payment-step')).toBeInTheDocument(); + expect(screen.getByTestId('full-page-spinner')).toBeInTheDocument(); }); it('should handle cart loading error', () => { const error = new Error('Failed to load cart'); + + // Override all cart/project related mocks to return null + mockUseCreateAndAssignCart.mockReturnValue({ + mutate: vi.fn(), + data: null, + isLoading: false, + error, + }); + mockUseGetCart.mockReturnValue({ - cart: null, + data: null, + isLoading: false, + error, + }); + + mockUseOrderProjectItem.mockReturnValue({ + mutate: vi.fn(), + data: null, + isLoading: false, + error, + }); + + mockUseGetOrderProjectId.mockReturnValue({ + data: null, isLoading: false, error, }); renderWithProviders(); - // Component should still render even with error - expect(screen.getByTestId('config-step')).toBeInTheDocument(); - expect(screen.getByTestId('payment-step')).toBeInTheDocument(); + // Should show spinner when cart/projectItem is null + expect(screen.getByTestId('full-page-spinner')).toBeInTheDocument(); }); it('should render config step by default', () => { @@ -315,35 +399,57 @@ describe('Creation Page', () => { expect(screen.getByTestId('payment-step')).toBeInTheDocument(); }); - // Verify cart hook is called + // Verify hooks are called expect(mockUseGetCart).toHaveBeenCalled(); - expect(mockUseAntiFraud).toHaveBeenCalled(); + expect(mockUseProjectCreation).toHaveBeenCalled(); + expect(mockUseWillPayment).toHaveBeenCalled(); }); - it('should handle anti-fraud polling', () => { - mockUseAntiFraud.mockReturnValue({ - checkAntiFraud: vi.fn(), - isPolling: true, - error: null, + it('should handle project creation submission', () => { + const mockHandleProjectCreation = vi.fn(); + + mockUseProjectCreation.mockReturnValue({ + isSubmitting: false, + needToCheckCustomerInfo: false, + billingHref: '', + handleProjectCreation: mockHandleProjectCreation, + handleCreditAndPay: vi.fn(), + }); + + mockUseWillPayment.mockReturnValue({ + isCreditPayment: false, + creditAmount: null, + needsSave: false, + isSaved: false, + canSubmit: true, + hasDefaultPaymentMethod: true, // Has default payment method + savePaymentMethod: vi.fn(), + handlePaymentStatusChange: vi.fn(), + handleRegisteredPaymentMethodSelected: vi.fn(), }); renderWithProviders(); - // Component should handle polling state appropriately - expect(mockUseAntiFraud).toHaveBeenCalled(); + expect(mockUseProjectCreation).toHaveBeenCalled(); + expect(mockUseWillPayment).toHaveBeenCalled(); }); - it('should handle anti-fraud error', () => { - const error = new Error('Anti-fraud check failed'); - mockUseAntiFraud.mockReturnValue({ - checkAntiFraud: vi.fn(), - isPolling: false, - error, + it('should handle credit payment flow', () => { + mockUseWillPayment.mockReturnValue({ + isCreditPayment: true, + creditAmount: { value: 100, currency: 'EUR' }, + needsSave: false, + isSaved: false, + canSubmit: true, + hasDefaultPaymentMethod: false, + savePaymentMethod: vi.fn(), + handlePaymentStatusChange: vi.fn(), + handleRegisteredPaymentMethodSelected: vi.fn(), }); renderWithProviders(); - expect(mockUseAntiFraud).toHaveBeenCalled(); + expect(mockUseWillPayment).toHaveBeenCalled(); }); it('should handle order polling state', () => { @@ -364,7 +470,7 @@ describe('Creation Page', () => { it('should handle cart with items', () => { mockUseGetCart.mockReturnValue({ - cart: { + data: { cartId: 'test-cart-id', description: 'Cart with items', items: [ @@ -386,7 +492,7 @@ describe('Creation Page', () => { it('should handle readonly cart', () => { mockUseGetCart.mockReturnValue({ - cart: { + data: { cartId: 'readonly-cart', description: 'Readonly Cart', items: [], @@ -400,4 +506,54 @@ describe('Creation Page', () => { expect(mockUseGetCart).toHaveBeenCalled(); }); + + it('should render credit confirmation when isCreditConfirmationOpen is true', () => { + mockUseStepper.mockReturnValue({ + goToConfig: vi.fn(), + goToPayment: vi.fn(), + goToCreditConfirmation: vi.fn(), + isConfigChecked: true, + isConfigLocked: true, + isPaymentOpen: true, + isPaymentChecked: true, + isPaymentLocked: true, + isCreditConfirmationOpen: true, + isCreditConfirmationChecked: false, + isCreditConfirmationLocked: false, + }); + + renderWithProviders(); + + expect(screen.getByTestId('credit-confirmation')).toBeInTheDocument(); + }); + + it('should call handleProjectCreation when isSaved becomes true', async () => { + const mockHandleProjectCreation = vi.fn(); + + mockUseProjectCreation.mockReturnValue({ + isSubmitting: false, + needToCheckCustomerInfo: false, + billingHref: '', + handleProjectCreation: mockHandleProjectCreation, + handleCreditAndPay: vi.fn(), + }); + + mockUseWillPayment.mockReturnValue({ + isCreditPayment: false, + creditAmount: null, + needsSave: false, + isSaved: true, // This should trigger handleProjectCreation + canSubmit: true, + hasDefaultPaymentMethod: false, + savePaymentMethod: vi.fn(), + handlePaymentStatusChange: vi.fn(), + handleRegisteredPaymentMethodSelected: vi.fn(), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockHandleProjectCreation).toHaveBeenCalledWith({}); + }); + }); }); diff --git a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx index 49f6a59a6577..da45dfa3264e 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/Creation.page.tsx @@ -3,12 +3,11 @@ import { Notifications, OvhSubsidiary, StepComponent, - useNotifications, } from '@ovh-ux/manager-react-components'; -import { OdsLink, OdsText } from '@ovhcloud/ods-components/react'; -import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; import { ShellContext } from '@ovh-ux/manager-react-shell-client'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsLink, OdsText } from '@ovhcloud/ods-components/react'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { @@ -20,61 +19,28 @@ import { } from '@/data/hooks/useCart'; import FullPageSpinner from '@/components/FullPageSpinner'; import { useConfigForm } from './hooks/useConfigForm'; +import { useProjectCreation } from './hooks/useProjectCreation'; +import { Step, useStepper } from './hooks/useStepper'; import ConfigStep from './steps/ConfigStep'; import PaymentStep from './steps/PaymentStep'; -import { useStepper } from './hooks/useStepper'; -import { TPaymentMethodRef } from '@/components/payment/PaymentMethods'; -import { - checkoutCart, - getCartCheckout, - attachConfigurationToCartItem as postAttachConfigurationToCartItem, -} from '@/data/api/cart'; -import { CartSummary } from '@/data/types/cart.type'; -import { usePaymentRedirect } from '@/hooks/payment/usePaymentRedirect'; -import { AntiFraudError, PCI_PROJECT_ORDER_CART } from '@/constants'; -import useAntiFraud from './hooks/useAntiFraud'; +import { useWillPayment } from './hooks/useWillPayment'; +import CreditConfirmation from './components/credit/CreditConfirmation.component'; export default function ProjectCreation() { const { t } = useTranslation([ 'new/config', 'new/payment', + 'payment/integrations/credit/confirmation', NAMESPACES.ACTIONS, ]); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const { - environment, - shell: { navigation }, - } = useContext(ShellContext); + const { environment } = useContext(ShellContext); const user = environment.getUser(); const ovhSubsidiary = user.ovhSubsidiary as OvhSubsidiary; const [hasInitialCart] = useState(!!searchParams.get('cartId')); - const [isPaymentMethodValid, setIsPaymentMethodValid] = useState( - false, - ); - const [isSubmitting, setIsSubmitting] = useState(false); - const [customSubmitButton, setCustomSubmitButton] = useState< - string | JSX.Element | null - >(null); - const [needToCheckCustomerInfo, setNeedToCheckCustomerInfo] = useState< - boolean - >(false); - const [billingHref, setBillingHref] = React.useState(''); - const [orderId, setOrderId] = React.useState(null); - const { checkAntiFraud } = useAntiFraud(); - - useEffect(() => { - if (orderId) { - navigation - .getURL('dedicated', '#/billing/orders/:orderId', { orderId }) - .then((url) => setBillingHref(`${url}`)); - } - }, [navigation, orderId]); - - const { addError, addWarning } = useNotifications(); - const { mutate: createAndAssignCart, data: createdCart, @@ -110,15 +76,39 @@ export default function ProjectCreation() { } = useAttachConfigurationToCartItem(); const { - currentStep, - setCurrentStep, + goToConfig, + goToPayment, + goToCreditConfirmation, isConfigChecked, isConfigLocked, isPaymentOpen, isPaymentChecked, isPaymentLocked, + isCreditConfirmationOpen, + isCreditConfirmationChecked, + isCreditConfirmationLocked, } = useStepper(); + const { + isSubmitting, + needToCheckCustomerInfo, + billingHref, + handleProjectCreation, + handleCreditAndPay, + } = useProjectCreation({ t, cart, projectItem, goToCreditConfirmation }); + + const { + isCreditPayment, + creditAmount, + needsSave, + isSaved, + canSubmit, + hasDefaultPaymentMethod, + savePaymentMethod, + handlePaymentStatusChange, + handleRegisteredPaymentMethodSelected, + } = useWillPayment(); + useEffect(() => { if (isLoadingConfigForm) { return; @@ -129,7 +119,7 @@ export default function ProjectCreation() { } // Proceed to the next step - setCurrentStep(1); + goToPayment(); }, [isLoadingConfigForm]); useEffect(() => { @@ -164,240 +154,46 @@ export default function ProjectCreation() { }, }, { - onSuccess: () => setCurrentStep(1), + onSuccess: () => goToPayment(), }, ); }; - const paymentHandlerRef = React.useRef(null); - const handleCancel = useCallback(() => navigate('..'), [navigate]); - const handlePaymentSubmit = useCallback( - async ({ - paymentMethodId, - skipRegistration, - }: { - paymentMethodId?: number; - skipRegistration?: boolean; - }) => { - if (!cart || !paymentHandlerRef.current) { - return false; - } - - setIsSubmitting(true); - - try { - let currentPaymentMethodId: number | undefined = paymentMethodId; - - // Step 1 : Register payment method - if (!skipRegistration) { - const resultRegister = await paymentHandlerRef.current.submitPaymentMethod( - cart, - ); - - if (resultRegister.paymentMethod?.paymentMethodId) { - currentPaymentMethodId = - resultRegister.paymentMethod?.paymentMethodId; - } - - if (!resultRegister.continueProcessing) { - setIsSubmitting(false); - return resultRegister.dataToReturn; - } - } - - // Step 2: Check payment method - if (paymentHandlerRef.current.checkPaymentMethod) { - const resultCheck = await paymentHandlerRef.current.checkPaymentMethod( - cart, - currentPaymentMethodId, - ); - - if (!resultCheck.continueProcessing) { - setIsSubmitting(false); - return resultCheck.dataToReturn; - } - } - - if (!projectItem) { - throw new Error('Project item not found'); - } - - // Step 3: Attach configuration to cart item - await postAttachConfigurationToCartItem( - cart.cartId, - projectItem.itemId, - { - label: 'infrastructure', - value: PCI_PROJECT_ORDER_CART.infraConfigValue, - }, - ); - - // Step 4: Get checkout info - const cartCheckoutInfo = await getCartCheckout(cart.cartId); - - // Step 5: Callback after checkout received - if (paymentHandlerRef.current.onCheckoutRetrieved) { - const resultCheckout = await paymentHandlerRef.current.onCheckoutRetrieved( - { - ...cartCheckoutInfo, - cartId: cart.cartId, - }, - currentPaymentMethodId, - ); - - if (!resultCheckout.continueProcessing) { - setIsSubmitting(false); - return resultCheckout.dataToReturn; - } - } - - // Step 6: Finalize cart - const cartFinalized: CartSummary = await checkoutCart(cart.cartId); - - setOrderId(cartFinalized.orderId); - - // Step 7: Callback after cart is finalized - if ( - paymentHandlerRef.current && - paymentHandlerRef.current.onCartFinalized - ) { - const resultFinalize = await paymentHandlerRef.current.onCartFinalized( - { - ...cartFinalized, - cartId: cart.cartId, - }, - paymentMethodId, - ); - - if (!resultFinalize.continueProcessing) { - setIsSubmitting(false); - return resultFinalize.dataToReturn; - } - } - - // Step 8: Anti-fraud check - try { - await checkAntiFraud(cartFinalized); - } catch (err) { - const antiFraudError = err as AntiFraudError; - - setIsSubmitting(false); - - switch (antiFraudError) { - case AntiFraudError.CASE_FRAUD_REFUSED: - addError( - t( - 'pci_project_new_payment_check_anti_fraud_case_fraud_refused', - { - ns: 'new/payment', - }, - ), - ); - break; - case AntiFraudError.NEED_CUSTOMER_INFO_CHECK: - setNeedToCheckCustomerInfo(true); - addWarning( - t('pci_project_new_payment_create_error_fraud_suspect', { - ns: 'new/payment', - }), - ); - break; - default: - addError( - t('pci_project_new_payment_create_error', { - ns: 'new/payment', - }), - ); - break; - } - - return false; - } - - // Step 9: Redirect to the project creation finalization page - setIsSubmitting(false); - - if (cartFinalized.orderId) { - const voucherCode = searchParams.get('voucher'); - - if (voucherCode) { - navigation.navigateTo( - 'public-cloud', - '#/pci/projects/creating/:orderId/:voucherCode', - { orderId: cartFinalized.orderId, voucherCode }, - ); - } else { - navigation.navigateTo( - 'public-cloud', - '#/pci/projects/creating/:orderId', - { orderId: cartFinalized.orderId }, - ); - } - } - - return true; - } catch (error) { - setIsSubmitting(false); - addError( - t('pci_project_new_payment_create_error', { ns: 'new/payment' }), - ); - return false; - } - }, - [ - paymentHandlerRef, - cart, - isSubmitting, - projectItem, - needToCheckCustomerInfo, - searchParams, - ], - ); - - const onPaymentSuccess = useCallback( - (paymentMethodId: number) => { - handlePaymentSubmit({ paymentMethodId, skipRegistration: true }); - }, - [cart, paymentHandlerRef, isSubmitting], - ); - - const onPaymentError = useCallback(() => { - addError(t('pci_project_new_payment_create_error', { ns: 'new/payment' })); - }, []); - - const isPageReady = !!cart && !!paymentHandlerRef.current; + const handlePaymentSubmit = useCallback(() => { + if (needsSave && !isCreditPayment) { + // Need to save the payment method first + savePaymentMethod(); + } else if (hasDefaultPaymentMethod || isCreditPayment) { + handleProjectCreation({ + isCreditPayment, + creditAmount: creditAmount?.value ?? 0, + }); + } + }, [ + needsSave, + hasDefaultPaymentMethod, + savePaymentMethod, + handleProjectCreation, + isCreditPayment, + creditAmount, + ]); - usePaymentRedirect(isPageReady, { - onPaymentError, - onPaymentSuccess, - }); + /** + * Auto-proceed with project creation when payment method is saved + */ + useEffect(() => { + if (isSaved) { + handleProjectCreation({}); + } + }, [isSaved]); if (!cart || !projectItem) { return ; } - const isPaymentStepLoading = isSubmitting; - const isPaymentStepDisabled = - !isPaymentMethodValid || isSubmitting || needToCheckCustomerInfo; - const paymentStepNextCustomButton: string | JSX.Element = - customSubmitButton || - t('pci_project_new_payment_btn_continue_default', { - ns: 'new/payment', - }); - - const paymentStepNextCustomButtonWithProps: string | JSX.Element = - typeof paymentStepNextCustomButton === 'string' - ? paymentStepNextCustomButton - : React.cloneElement(paymentStepNextCustomButton, { - isDisabled: isPaymentStepDisabled, - isLoading: isPaymentStepLoading, - }); - - const paymentStepNextButton: - | string - | JSX.Element = needToCheckCustomerInfo ? ( + const paymentStepNextButton = needToCheckCustomerInfo ? ( ) : ( - paymentStepNextCustomButtonWithProps + t('pci_project_new_payment_btn_continue_default', { + ns: 'new/payment', + }) ); return ( @@ -425,15 +223,15 @@ export default function ProjectCreation() { 0 + isConfigLocked ? { - action: () => setCurrentStep(0), + action: () => goToConfig(), label: t('modify', { ns: NAMESPACES.ACTIONS }), isDisabled: false, } @@ -459,18 +257,27 @@ export default function ProjectCreation() { goToPayment(), + label: t('modify', { ns: NAMESPACES.ACTIONS }), + isDisabled: false, + } + : undefined + } next={{ - action: () => handlePaymentSubmit({ skipRegistration: false }), + action: handlePaymentSubmit, label: paymentStepNextButton, - isDisabled: isPaymentStepDisabled, - isLoading: isPaymentStepLoading, + isDisabled: !canSubmit, + isLoading: isSubmitting, }} skip={{ action: handleCancel, @@ -480,15 +287,38 @@ export default function ProjectCreation() { { - setIsPaymentMethodValid(isValid); - }} - handleCustomSubmitButton={(btn) => setCustomSubmitButton(btn)} - paymentHandler={paymentHandlerRef} - onPaymentSubmit={handlePaymentSubmit} - onPaymentError={onPaymentError} + onPaymentStatusChange={handlePaymentStatusChange} + onRegisteredPaymentMethodSelected={ + handleRegisteredPaymentMethodSelected + } /> + + {isCreditConfirmationOpen && ( + + + + )} ); diff --git a/packages/manager/apps/pci-project/src/pages/creation/components/credit/CreditConfirmation.component.tsx b/packages/manager/apps/pci-project/src/pages/creation/components/credit/CreditConfirmation.component.tsx new file mode 100644 index 000000000000..ea94d8713332 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/components/credit/CreditConfirmation.component.tsx @@ -0,0 +1,43 @@ +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsText } from '@ovhcloud/ods-components/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { TCreditData } from '@/types/WillPayment.type'; + +interface CreditConfirmationProps { + creditAmount: TCreditData['creditAmount']; +} + +const CreditConfirmation: React.FC = ({ + creditAmount, +}) => { + const { t } = useTranslation([ + 'payment/integrations/credit/confirmation', + 'new/payment', + ]); + + return ( +
+ + {t('pci_project_new_payment_credit_thanks', { + ns: 'payment/integrations/credit/confirmation', + })} + + + + {t('pci_project_new_payment_credit_explain', { + ns: 'payment/integrations/credit/confirmation', + amount: creditAmount?.text, + })} + + + + {t('pci_project_new_payment_credit_info', { + ns: 'payment/integrations/credit/confirmation', + })} + +
+ ); +}; + +export default CreditConfirmation; diff --git a/packages/manager/apps/pci-project/src/pages/creation/components/payment/WillPayment.component.tsx b/packages/manager/apps/pci-project/src/pages/creation/components/payment/WillPayment.component.tsx new file mode 100644 index 000000000000..35be6b5235f7 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/components/payment/WillPayment.component.tsx @@ -0,0 +1,75 @@ +import React, { lazy, Suspense, useEffect, useRef } from 'react'; +import { TWillPaymentConfig } from '@/types/WillPayment.type'; +import { setupRegisteredPaymentMethodListener } from '../../utils/paymentEvents'; + +type WillPaymentModuleProps = { + slotRef: React.RefObject; + config: TWillPaymentConfig; +}; + +type WillPaymentComponentProps = { + config: TWillPaymentConfig; + onRegisteredPaymentMethodSelected: (event: CustomEvent) => void; +}; + +/** + * Lazy-loads and mounts the Will Payment federated module. + * Cleans up on unmount. + */ +const WillPaymentModule = lazy(() => + import('willPayment/WillPayment').then((module) => ({ + default: (props: WillPaymentModuleProps) => { + const setUpWillPayment = module.default; + + useEffect(() => { + const { slotRef, config } = props; + + if (!slotRef.current) { + return undefined; + } + + setUpWillPayment((slotRef.current as unknown) as HTMLSlotElement, { + configuration: config, + }); + + // Cleanup function to prevent memory leaks + return () => { + if (slotRef.current) { + slotRef.current.innerHTML = ''; + } + }; + }, [props.slotRef]); + + return null; + }, + })), +); + +/** + * WillPaymentComponent + * @param config - Configuration for the Will Payment module + */ +function WillPaymentComponent({ + config, + onRegisteredPaymentMethodSelected, +}: Readonly) { + const slotRef = useRef(null); + + useEffect(() => { + const cleanup = setupRegisteredPaymentMethodListener( + onRegisteredPaymentMethodSelected, + ); + + return cleanup || undefined; + }, [slotRef, onRegisteredPaymentMethodSelected]); + + return ( +
+ + + +
+ ); +} + +export default React.memo(WillPaymentComponent); diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useProjectCreation.tsx b/packages/manager/apps/pci-project/src/pages/creation/hooks/useProjectCreation.tsx new file mode 100644 index 000000000000..de41bfad00d4 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useProjectCreation.tsx @@ -0,0 +1,231 @@ +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { useMutation } from '@tanstack/react-query'; +import { TFunction } from 'i18next'; +import { useContext, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { AntiFraudError, PCI_PROJECT_ORDER_CART } from '@/constants'; +import { + checkoutCart, + getCartCheckout, + attachConfigurationToCartItem as postAttachConfigurationToCartItem, +} from '@/data/api/cart'; +import { payWithRegisteredPaymentMean } from '@/data/api/payment'; +import { addCartCreditOption } from '@/data/api/payment/cart'; +import { useGetCreditAddonOption } from '@/data/hooks/payment/useCart'; +import { + Cart, + CartSummary, + OrderedProduct, + PaymentMean, +} from '@/data/types/cart.type'; +import { CREDIT_ORDER_CART } from '@/payment/constants'; +import useAntiFraud from './useAntiFraud'; + +export type UseProjectCreationProps = { + t: TFunction; + cart?: Cart; + projectItem?: OrderedProduct; + goToCreditConfirmation: () => void; +}; + +export const useProjectCreation = ({ + t, + cart, + projectItem, + goToCreditConfirmation, +}: Readonly) => { + const [needToCheckCustomerInfo, setNeedToCheckCustomerInfo] = useState(false); + const [billingHref, setBillingHref] = useState(''); + const [orderId, setOrderId] = useState(null); + + const [searchParams] = useSearchParams(); + const voucherCode = searchParams.get('voucher'); + + const navigate = useNavigate(); + const { addError, addWarning } = useNotifications(); + const { + shell: { navigation }, + } = useContext(ShellContext); + + const { checkAntiFraud } = useAntiFraud(); + const { data: creditAddonOption } = useGetCreditAddonOption(cart?.cartId); + + const handleError = (error: unknown) => { + const antiFraudError = error as AntiFraudError; + if (antiFraudError === AntiFraudError.CASE_FRAUD_REFUSED) { + addError( + t('pci_project_new_payment_check_anti_fraud_case_fraud_refused', { + ns: 'new/payment', + }), + ); + } else if (antiFraudError === AntiFraudError.NEED_CUSTOMER_INFO_CHECK) { + setNeedToCheckCustomerInfo(true); + if (orderId) { + navigation + .getURL('dedicated', '#/billing/orders/:orderId', { + orderId, + }) + .then((url) => setBillingHref(String(url))); + } + addWarning( + t('pci_project_new_payment_create_error_fraud_suspect', { + ns: 'new/payment', + }), + ); + } else { + addError( + t('pci_project_new_payment_checkout_error', { + ns: 'new/payment', + }), + ); + } + }; + + // Infrastructure configuration mutation + const infraConfigMutation = useMutation({ + mutationFn: async () => { + /** + * TODO: should be trigger while some conditions are met + */ + return postAttachConfigurationToCartItem( + cart!.cartId, + projectItem!.itemId, + { + label: 'infrastructure', + value: PCI_PROJECT_ORDER_CART.infraConfigValue, + }, + ); + }, + onError: handleError, + }); + + // Credit configuration mutation (separate for better control) + const creditConfigMutation = useMutation({ + mutationFn: async ({ creditAmount }: { creditAmount: number }) => { + if (!creditAddonOption?.prices?.length) { + return null; + } + + return addCartCreditOption(cart!.cartId, { + planCode: CREDIT_ORDER_CART.planCode, + quantity: Math.floor( + creditAmount / creditAddonOption.prices[0].price.value, + ), + duration: creditAddonOption.prices[0].duration, + pricingMode: creditAddonOption.prices[0].pricingMode, + itemId: projectItem!.itemId, + }); + }, + onError: handleError, + }); + + // Check if payment is required + const checkPaymentRequiredMutation = useMutation({ + mutationFn: async () => { + const cartSummary = await getCartCheckout(cart!.cartId); + return { + paymentRequired: cartSummary.prices.withTax.value !== 0, + cartSummary, + }; + }, + onSuccess: ({ paymentRequired }) => { + if (paymentRequired) { + goToCreditConfirmation(); + } + }, + onError: handleError, + }); + + const checkoutMutation = useMutation({ + mutationFn: async () => { + return checkoutCart(cart!.cartId); + }, + onSuccess: async (cartSummary) => { + // Auto-pay with fidelity account if free + if (cartSummary.prices.withTax.value === 0 && cartSummary.orderId) { + await payWithRegisteredPaymentMean(cartSummary.orderId, { + paymentMean: PaymentMean.FIDELITY_ACCOUNT, + }); + } + }, + onError: handleError, + }); + + const antiFraudCheckMutation = useMutation({ + mutationFn: async (cartSummary: CartSummary) => { + if (!cartSummary?.orderId) { + throw new Error('Order ID missing'); + } + + setOrderId(cartSummary.orderId); + + await checkAntiFraud(cartSummary); + + return cartSummary; + }, + onSuccess: (cartSummary) => { + if (cartSummary.orderId) { + // Navigate to Creating page + const redirectPath = voucherCode + ? `../creating/${cartSummary.orderId}/${voucherCode}` + : `../creating/${cartSummary.orderId}`; + navigate(redirectPath); + } + }, + onError: handleError, + }); + + const handleProjectCreation = async ({ + isCreditPayment = false, + creditAmount = 0, + }: { + isCreditPayment?: boolean; + creditAmount?: number; + }) => { + // Step 1: Setup infrastructure config + await infraConfigMutation.mutateAsync(); + + // Step 2: Setup credit config if needed + if (isCreditPayment) { + await creditConfigMutation.mutateAsync({ creditAmount }); + } + + // Step 3: Check if payment required + const { + paymentRequired, + } = await checkPaymentRequiredMutation.mutateAsync(); + + if (!paymentRequired) { + // Step 4: If no payment required, proceed with checkout + const cartSummary = await checkoutMutation.mutateAsync(); + + // Step 5: Run anti-fraud check + await antiFraudCheckMutation.mutateAsync(cartSummary); + } + }; + + const handleCreditAndPay = async () => { + const cartSummary = await checkoutMutation.mutateAsync(); + + if (cartSummary.url && window.top) { + window.top.location.href = cartSummary.url; + } + }; + + const isSubmitting = [ + infraConfigMutation, + creditConfigMutation, + checkPaymentRequiredMutation, + checkoutMutation, + antiFraudCheckMutation, + ].some((mutation) => mutation.isPending); + + return { + handleProjectCreation, + handleCreditAndPay, + isSubmitting, + needToCheckCustomerInfo, + billingHref, + }; +}; diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.spec.tsx index 434fe6a78b60..0055bd40f3c2 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.spec.tsx @@ -3,89 +3,126 @@ import { describe, it, expect } from 'vitest'; import { useStepper } from './useStepper'; describe('useStepper', () => { - it('should initialize with step 0 and correct initial state', () => { + it('should initialize with Config step and correct initial state', () => { const { result } = renderHook(() => useStepper()); - expect(result.current.currentStep).toBe(0); expect(result.current.isConfigChecked).toBe(false); expect(result.current.isConfigLocked).toBe(false); expect(result.current.isPaymentOpen).toBe(false); expect(result.current.isPaymentChecked).toBe(false); expect(result.current.isPaymentLocked).toBe(true); + expect(result.current.isCreditConfirmationOpen).toBe(false); + expect(result.current.isCreditConfirmationChecked).toBe(false); + expect(result.current.isCreditConfirmationLocked).toBe(false); }); - it('should update step state when currentStep changes to 1', () => { + it('should update step state when moving to Payment step', () => { const { result } = renderHook(() => useStepper()); act(() => { - result.current.setCurrentStep(1); + result.current.goToPayment(); }); - expect(result.current.currentStep).toBe(1); expect(result.current.isConfigChecked).toBe(true); expect(result.current.isConfigLocked).toBe(true); expect(result.current.isPaymentOpen).toBe(true); expect(result.current.isPaymentChecked).toBe(false); expect(result.current.isPaymentLocked).toBe(false); + expect(result.current.isCreditConfirmationOpen).toBe(false); }); it('should handle step transitions correctly', () => { const { result } = renderHook(() => useStepper()); - // Start at step 0 - expect(result.current.currentStep).toBe(0); + // Start at Config step expect(result.current.isPaymentLocked).toBe(true); expect(result.current.isConfigChecked).toBe(false); - // Move to step 1 + // Move to Payment step act(() => { - result.current.setCurrentStep(1); + result.current.goToPayment(); }); - expect(result.current.currentStep).toBe(1); expect(result.current.isConfigChecked).toBe(true); expect(result.current.isConfigLocked).toBe(true); expect(result.current.isPaymentOpen).toBe(true); expect(result.current.isPaymentLocked).toBe(false); - // Move back to step 0 + // Move back to Config step act(() => { - result.current.setCurrentStep(0); + result.current.goToConfig(); }); - expect(result.current.currentStep).toBe(0); expect(result.current.isConfigChecked).toBe(false); expect(result.current.isConfigLocked).toBe(false); expect(result.current.isPaymentOpen).toBe(false); expect(result.current.isPaymentLocked).toBe(true); }); - it('should handle steps beyond the current flow', () => { + it('should handle CreditConfirmation step correctly', () => { const { result } = renderHook(() => useStepper()); act(() => { - result.current.setCurrentStep(2); + result.current.goToCreditConfirmation(); }); - expect(result.current.currentStep).toBe(2); expect(result.current.isConfigChecked).toBe(true); expect(result.current.isConfigLocked).toBe(true); - expect(result.current.isPaymentOpen).toBe(false); // Only true when step === 1 - expect(result.current.isPaymentChecked).toBe(false); // Always false per comment - expect(result.current.isPaymentLocked).toBe(false); + expect(result.current.isPaymentOpen).toBe(true); + expect(result.current.isPaymentChecked).toBe(true); + expect(result.current.isPaymentLocked).toBe(true); // Not Payment step + expect(result.current.isCreditConfirmationOpen).toBe(true); + expect(result.current.isCreditConfirmationChecked).toBe(false); + expect(result.current.isCreditConfirmationLocked).toBe(false); }); it('should provide all expected return values', () => { const { result } = renderHook(() => useStepper()); expect(result.current).toEqual({ - currentStep: expect.any(Number), - setCurrentStep: expect.any(Function), + // Step states isConfigChecked: expect.any(Boolean), isConfigLocked: expect.any(Boolean), isPaymentOpen: expect.any(Boolean), isPaymentChecked: expect.any(Boolean), isPaymentLocked: expect.any(Boolean), + isCreditConfirmationOpen: expect.any(Boolean), + isCreditConfirmationChecked: expect.any(Boolean), + isCreditConfirmationLocked: expect.any(Boolean), + // Navigation functions + goToConfig: expect.any(Function), + goToPayment: expect.any(Function), + goToCreditConfirmation: expect.any(Function), + }); + }); + + it('should test all navigation functions', () => { + const { result } = renderHook(() => useStepper()); + + // Test goToConfig + act(() => { + result.current.goToPayment(); // First go to payment + }); + expect(result.current.isPaymentOpen).toBe(true); + + act(() => { + result.current.goToConfig(); // Then back to config + }); + expect(result.current.isPaymentOpen).toBe(false); + expect(result.current.isConfigChecked).toBe(false); + + // Test goToPayment + act(() => { + result.current.goToPayment(); + }); + expect(result.current.isPaymentOpen).toBe(true); + expect(result.current.isConfigChecked).toBe(true); + + // Test goToCreditConfirmation + act(() => { + result.current.goToCreditConfirmation(); }); + expect(result.current.isCreditConfirmationOpen).toBe(true); + expect(result.current.isPaymentChecked).toBe(true); }); }); diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.tsx b/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.tsx index 9d97d2e4c45c..f5f82a9d7ca1 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useStepper.tsx @@ -1,28 +1,40 @@ import { useState } from 'react'; -/** - * Custom hook to manage stepper state for the project creation flow. - * Returns the current step, a setter, and booleans for each step's state. - */ +export enum Step { + Config = 0, + Payment = 1, + CreditConfirmation = 2, +} + +const getStepStates = (currentStep: Step) => ({ + // Config step (Step 0) + isConfigChecked: currentStep > Step.Config, + isConfigLocked: currentStep > Step.Config, + + // Payment step (Step 1) + isPaymentOpen: currentStep > Step.Config, + isPaymentChecked: currentStep > Step.Payment, + isPaymentLocked: currentStep !== Step.Payment, + + // Credit confirmation step (Step 2) + isCreditConfirmationOpen: currentStep === Step.CreditConfirmation, + isCreditConfirmationChecked: false, + isCreditConfirmationLocked: false, +}); + export const useStepper = () => { - const [currentStep, setCurrentStep] = useState(0); + const [currentStep, setCurrentStep] = useState(Step.Config); - // Step 1: Config - const isConfigChecked = currentStep > 0; - const isConfigLocked = currentStep > 0; + const stepStates = getStepStates(currentStep); - // Step 2: Payment - const isPaymentOpen = currentStep === 1; - const isPaymentChecked = false; // (update if you add more steps) - const isPaymentLocked = currentStep < 1; + const goToConfig = () => setCurrentStep(Step.Config); + const goToPayment = () => setCurrentStep(Step.Payment); + const goToCreditConfirmation = () => setCurrentStep(Step.CreditConfirmation); return { - currentStep, - setCurrentStep, - isConfigChecked, - isConfigLocked, - isPaymentOpen, - isPaymentChecked, - isPaymentLocked, + ...stepStates, + goToConfig, + goToPayment, + goToCreditConfirmation, }; }; diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.spec.tsx new file mode 100644 index 000000000000..3197182215ec --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.spec.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useWillPayment } from './useWillPayment'; +import { + ComponentStatus, + GlobalStateStatus, + PaymentMethodStatus, +} from '@/types/WillPayment.type'; + +describe('useWillPayment', () => { + beforeEach(() => { + vi.clearAllMocks(); + const mockEventBus = document.createElement('div'); + mockEventBus.id = 'will-payment-event-bus'; + mockEventBus.dispatchEvent = vi.fn(); + document.getElementById = vi.fn().mockReturnValue(mockEventBus); + }); + + it('should initialize with correct default state', () => { + const { result } = renderHook(() => useWillPayment()); + + expect(result.current.hasDefaultPaymentMethod).toBe(false); + expect(result.current.needsSave).toBe(false); + expect(result.current.isSaved).toBe(false); + expect(result.current.canSubmit).toBe(false); + expect(result.current.isCreditPayment).toBeUndefined(); + expect(result.current.creditAmount).toBeUndefined(); + expect(typeof result.current.savePaymentMethod).toBe('function'); + expect(typeof result.current.handlePaymentStatusChange).toBe('function'); + expect(typeof result.current.handleRegisteredPaymentMethodSelected).toBe( + 'function', + ); + }); + + it('should handle ERROR state correctly', () => { + const { result } = renderHook(() => useWillPayment()); + + const errorStatus: GlobalStateStatus = { + componentStatus: ComponentStatus.ERROR, + paymentMethodStatus: PaymentMethodStatus.PENDING, + }; + + act(() => { + result.current.handlePaymentStatusChange(errorStatus); + }); + + expect(result.current.hasDefaultPaymentMethod).toBe(false); + expect(result.current.needsSave).toBe(false); + expect(result.current.isSaved).toBe(false); + expect(result.current.canSubmit).toBe(false); + }); + + it('should handle PAYMENT_METHOD_SAVED state correctly', () => { + const { result } = renderHook(() => useWillPayment()); + + const completedStatus: GlobalStateStatus = { + componentStatus: ComponentStatus.PAYMENT_METHOD_SAVED, + paymentMethodStatus: PaymentMethodStatus.PENDING, + }; + + act(() => { + result.current.handlePaymentStatusChange(completedStatus); + }); + + expect(result.current.isSaved).toBe(true); + expect(result.current.needsSave).toBe(false); + expect(result.current.canSubmit).toBe(false); // No default payment method and no save required + }); + + it('should handle READY_TO_GO_FORWARD state correctly', () => { + const { result } = renderHook(() => useWillPayment()); + + const readyStatus: GlobalStateStatus = { + componentStatus: ComponentStatus.READY_TO_GO_FORWARD, + paymentMethodStatus: PaymentMethodStatus.PENDING, + }; + + act(() => { + result.current.handlePaymentStatusChange(readyStatus); + }); + + expect(result.current.needsSave).toBe(true); + expect(result.current.isSaved).toBe(false); + expect(result.current.canSubmit).toBe(true); // Save required, so button enabled + }); + + it('should handle LOADING state correctly', () => { + const { result } = renderHook(() => useWillPayment()); + + const loadingStatus: GlobalStateStatus = { + componentStatus: ComponentStatus.LOADING, + paymentMethodStatus: PaymentMethodStatus.PROCESSING, + }; + + act(() => { + result.current.handlePaymentStatusChange(loadingStatus); + }); + + expect(result.current.needsSave).toBe(false); + expect(result.current.isSaved).toBe(false); + expect(result.current.canSubmit).toBe(false); // Neither condition met, so button disabled + }); + + it('should trigger save payment method event', () => { + const mockEventBus = document.createElement('div'); + const dispatchEventSpy = vi.fn(); + mockEventBus.dispatchEvent = dispatchEventSpy; + document.getElementById = vi.fn().mockReturnValue(mockEventBus); + + const { result } = renderHook(() => useWillPayment()); + + act(() => { + result.current.savePaymentMethod(); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'GO_SAVE_PAYMENT_METHOD', + }), + ); + }); + + it('should handle missing event bus element gracefully', () => { + document.getElementById = vi.fn().mockReturnValue(null); + + const { result } = renderHook(() => useWillPayment()); + + expect(() => { + act(() => { + result.current.savePaymentMethod(); + }); + }).not.toThrow(); + }); + + it('should handle credit payment data correctly', () => { + const { result } = renderHook(() => useWillPayment()); + + const creditStatus: GlobalStateStatus = { + componentStatus: ComponentStatus.READY_TO_GO_FORWARD, + paymentMethodStatus: PaymentMethodStatus.PENDING, + data: { + isCredit: true, + creditAmount: { value: 100, currency: 'EUR' }, + }, + }; + + act(() => { + result.current.handlePaymentStatusChange(creditStatus); + }); + + expect(result.current.isCreditPayment).toBe(true); + expect(result.current.creditAmount).toEqual({ + value: 100, + currency: 'EUR', + }); + }); + + it('should enable submit when has default payment method', () => { + const { result } = renderHook(() => useWillPayment()); + + // First set default payment method + const mockEvent = new CustomEvent('test', { + detail: { paymentMethodId: 'pm_123' }, + }); + + act(() => { + result.current.handleRegisteredPaymentMethodSelected(mockEvent); + }); + + expect(result.current.canSubmit).toBe(true); + }); +}); diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.tsx b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.tsx new file mode 100644 index 000000000000..d3b6882fd017 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPayment.tsx @@ -0,0 +1,62 @@ +import { useCallback, useState } from 'react'; +import { GlobalStateStatus, TCreditData } from '@/types/WillPayment.type'; +import { triggerSavePaymentMethodEvent } from '../utils/paymentEvents'; +import { + isPaymentMethodSaveRequired as checkIsPaymentMethodSaveRequired, + isPaymentMethodSaved as checkIsPaymentMethodSaved, + isSubmittingEnabled as checkIsSubmittingEnabled, +} from '../utils/paymentLogic'; + +/** + * Simplified hook for WillPayment integration + * Manages communication with the WillPayment federated module + */ +export const useWillPayment = () => { + const [ + globalStateStatus, + setGlobalStateStatus, + ] = useState(null); + + const [hasDefaultPaymentMethod, setHasDefaultPaymentMethod] = useState(false); + + const handleRegisteredPaymentMethodSelected = (event: CustomEvent) => { + if (event && event.detail) { + setHasDefaultPaymentMethod(true); + } + }; + + /** + * Handles payment status changes from the WillPayment module + */ + const handlePaymentStatusChange = (willPaymentStatus: GlobalStateStatus) => { + setGlobalStateStatus(willPaymentStatus); + }; + + /** + * Triggers payment method saving via DOM event + */ + const savePaymentMethod = () => { + triggerSavePaymentMethodEvent(); + }; + + const needsSave = checkIsPaymentMethodSaveRequired(globalStateStatus); + const isSaved = checkIsPaymentMethodSaved(globalStateStatus); + const canSubmit = checkIsSubmittingEnabled( + hasDefaultPaymentMethod, + needsSave, + ); + + const creditData = globalStateStatus?.data as TCreditData | undefined; + + return { + isCreditPayment: creditData?.isCredit, + creditAmount: creditData?.creditAmount, + hasDefaultPaymentMethod, + needsSave, + isSaved, + canSubmit, + savePaymentMethod, + handlePaymentStatusChange, + handleRegisteredPaymentMethodSelected, + }; +}; diff --git a/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPaymentConfig.ts b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPaymentConfig.ts new file mode 100644 index 000000000000..3b5a1e8c0cd5 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/hooks/useWillPaymentConfig.ts @@ -0,0 +1,29 @@ +import { useContext } from 'react'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { + TWillPaymentConfig, + GlobalStateStatus, +} from '@/types/WillPayment.type'; + +export type WillPaymentConfigOptions = { + onPaymentStatusChange?: (status: GlobalStateStatus) => void; +}; + +/** + * Hook that generates WillPayment configuration automatically + * Centralizes configuration logic and reduces boilerplate + */ +export const useWillPaymentConfig = ({ + onPaymentStatusChange, +}: WillPaymentConfigOptions = {}): TWillPaymentConfig => { + const { environment } = useContext(ShellContext); + const user = environment.getUser(); + + return { + baseUrl: window.location.origin, + onChange: (state: GlobalStateStatus) => onPaymentStatusChange?.(state), + subsidiary: user.ovhSubsidiary, + language: `${user.language}`, + hostApp: 'pci', + }; +}; diff --git a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx index b060ce0515dd..55e12ebbcec7 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.spec.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { UseQueryResult } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -22,9 +23,14 @@ vi.mock('@/data/hooks/useCredit', () => ({ useStartupProgramAmountText: vi.fn(), })); -vi.mock('@/components/payment/PaymentMethods', () => ({ +// Mock WillPayment federated module +vi.mock('willPayment/WillPayment', () => ({ + default: vi.fn(), +})); + +vi.mock('../components/payment/WillPayment.component', () => ({ default: vi.fn(() => ( -
Payment Methods Component
+
Will Payment Component
)), })); @@ -73,11 +79,8 @@ describe('PaymentStep', () => { quantity: 1, }, }, - handleIsPaymentMethodValid: vi.fn(), - paymentHandler: { current: null }, - handleCustomSubmitButton: vi.fn(), - onPaymentSubmit: vi.fn(), - onPaymentError: vi.fn(), + onPaymentStatusChange: vi.fn(), + onRegisteredPaymentMethodSelected: vi.fn(), }; const mockStartupProgramAmountText = '100.00 €'; @@ -114,7 +117,7 @@ describe('PaymentStep', () => { it('should render the component without crashing', () => { renderComponent(); expect(screen.getByTestId('voucher')).toBeVisible(); - expect(screen.getByTestId('payment-methods')).toBeVisible(); + expect(screen.getByTestId('will-payment')).toBeVisible(); }); it('should render Voucher component with correct props', () => { @@ -124,11 +127,11 @@ describe('PaymentStep', () => { expect(voucherComponent).toBeVisible(); }); - it('should render PaymentMethods component with correct props', () => { + it('should render WillPayment component with correct props', () => { renderComponent(); - const paymentMethodsComponent = screen.getByTestId('payment-methods'); - expect(paymentMethodsComponent).toBeVisible(); + const willPaymentComponent = screen.getByTestId('will-payment'); + expect(willPaymentComponent).toBeVisible(); }); it('should not render StartupProgram when not available', () => { diff --git a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx index c324d6118033..de8d74a60b8b 100644 --- a/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx +++ b/packages/manager/apps/pci-project/src/pages/creation/steps/PaymentStep.tsx @@ -1,9 +1,4 @@ -import { useCallback, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import PaymentMethods, { - TPaymentMethodRef, -} from '@/components/payment/PaymentMethods'; - +import { useState } from 'react'; import { useIsStartupProgramAvailable, useStartupProgramAmountText, @@ -14,23 +9,17 @@ import { OrderedProduct, } from '@/data/types/cart.type'; import { TPaymentMethod } from '@/data/types/payment/payment-method.type'; +import { GlobalStateStatus } from '@/types/WillPayment.type'; +import WillPaymentComponent from '../components/payment/WillPayment.component'; import StartupProgram from '../components/startup-program/StartupProgram'; import Voucher from '../components/voucher/Voucher'; +import { useWillPaymentConfig } from '../hooks/useWillPaymentConfig'; export type PaymentStepProps = { cart: Cart; cartProjectItem: OrderedProduct; - handleIsPaymentMethodValid: (isValid: boolean) => void; - paymentHandler: React.Ref; - handleCustomSubmitButton: (btn: string | JSX.Element) => void; - onPaymentSubmit: ({ - paymentMethodId, - skipRegistration, - }: { - paymentMethodId?: number; - skipRegistration?: boolean; - }) => Promise; - onPaymentError: (err: string | undefined) => void; + onPaymentStatusChange?: (willPaymentStatus: GlobalStateStatus) => void; + onRegisteredPaymentMethodSelected: (event: CustomEvent) => void; }; type PaymentForm = { @@ -42,19 +31,19 @@ type PaymentForm = { export default function PaymentStep({ cart, cartProjectItem, - handleIsPaymentMethodValid, - paymentHandler, - handleCustomSubmitButton, - onPaymentSubmit, - onPaymentError, -}: PaymentStepProps) { - const [searchParams] = useSearchParams(); + onPaymentStatusChange, + onRegisteredPaymentMethodSelected, +}: Readonly) { const [paymentForm, setPaymentForm] = useState({ voucherConfiguration: undefined, paymentMethod: undefined, isAsDefault: false, }); + const willPaymentConfig = useWillPaymentConfig({ + onPaymentStatusChange, + }); + const handleVoucherConfigurationChange = ( voucherConfiguration: CartConfiguration | undefined, ) => { @@ -69,20 +58,6 @@ export default function PaymentStep({ isStartupProgramAvailable ?? false, ); - const onPaymentMethodChange = useCallback((method: TPaymentMethod) => { - setPaymentForm((prev) => ({ - ...prev, - paymentMethod: method, - })); - }, []); - - const onSetAsDefaultChange = useCallback((value: boolean) => { - setPaymentForm((prev) => ({ - ...prev, - isAsDefault: value, - })); - }, []); - return (
- {isStartupProgramAvailable && startupProgramAmountText && ( diff --git a/packages/manager/apps/pci-project/src/pages/creation/utils/paymentEvents.ts b/packages/manager/apps/pci-project/src/pages/creation/utils/paymentEvents.ts new file mode 100644 index 000000000000..d53ffbc03c25 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/utils/paymentEvents.ts @@ -0,0 +1,46 @@ +/** + * Utility functions for WillPayment DOM events + * Centralizes DOM manipulation for better testability + */ + +const EVENT_BUS_ID = 'will-payment-event-bus'; +const SAVE_PAYMENT_METHOD_EVENT = 'GO_SAVE_PAYMENT_METHOD'; +const REGISTERED_PM_EVENT = 'WP::USER_ACTION::REGISTERED_PM_SELECTED'; + +/** + * Triggers the save payment method event via DOM + * @returns boolean indicating if the event was dispatched successfully + */ +export const triggerSavePaymentMethodEvent = (): boolean => { + const eventBus = document.getElementById(EVENT_BUS_ID); + if (eventBus) { + eventBus.dispatchEvent(new CustomEvent(SAVE_PAYMENT_METHOD_EVENT)); + return true; + } + return false; +}; + +/** + * Sets up listener for registered payment method selection + * @param handler - Function to handle the event + * @returns cleanup function to remove the listener + */ +export const setupRegisteredPaymentMethodListener = ( + handler: (event: CustomEvent) => void, +): (() => void) | null => { + const eventBus = document.getElementById(EVENT_BUS_ID); + if (!eventBus) { + return null; + } + + const eventHandler = (event: Event) => { + handler(event as CustomEvent); + }; + + eventBus.addEventListener(REGISTERED_PM_EVENT, eventHandler); + + // Return cleanup function + return () => { + eventBus.removeEventListener(REGISTERED_PM_EVENT, eventHandler); + }; +}; diff --git a/packages/manager/apps/pci-project/src/pages/creation/utils/paymentLogic.ts b/packages/manager/apps/pci-project/src/pages/creation/utils/paymentLogic.ts new file mode 100644 index 000000000000..231b610d42c7 --- /dev/null +++ b/packages/manager/apps/pci-project/src/pages/creation/utils/paymentLogic.ts @@ -0,0 +1,45 @@ +import { + ComponentStatus, + GlobalStateStatus, + PaymentMethodStatus, +} from '@/types/WillPayment.type'; + +/** + * Pure business logic functions for payment state validation + * These functions are easily testable and reusable + */ + +/** + * Determines if payment method save is required based on component status + */ +export const isPaymentMethodSaveRequired = ( + globalStateStatus: GlobalStateStatus | null, +): boolean => { + return ( + globalStateStatus?.componentStatus === ComponentStatus.READY_TO_GO_FORWARD + ); +}; + +/** + * Determines if payment method has been saved successfully + */ +export const isPaymentMethodSaved = ( + globalStateStatus: GlobalStateStatus | null, +): boolean => { + return ( + globalStateStatus?.componentStatus === + ComponentStatus.PAYMENT_METHOD_SAVED || + globalStateStatus?.paymentMethodStatus === + PaymentMethodStatus.PAYMENT_METHOD_SAVED + ); +}; + +/** + * Determines if submission should be enabled + */ +export const isSubmittingEnabled = ( + hasDefaultPaymentMethod: boolean, + saveRequired: boolean, +): boolean => { + return hasDefaultPaymentMethod || saveRequired; +}; diff --git a/packages/manager/apps/pci-project/src/setupTests.tsx b/packages/manager/apps/pci-project/src/setupTests.tsx index b9e1d518960b..80a16afd8be9 100644 --- a/packages/manager/apps/pci-project/src/setupTests.tsx +++ b/packages/manager/apps/pci-project/src/setupTests.tsx @@ -621,6 +621,12 @@ vi.mock('@ovh-ux/manager-react-shell-client', async () => { getURL: mockGetURL, }, }, + environment: { + getUser: () => ({ + ovhSubsidiary: 'FR', + language: 'fr_FR', + }), + }, }), useOvhTracking: () => ({ trackClick: vi.fn(), diff --git a/packages/manager/apps/pci-project/src/types/WillPayment.type.ts b/packages/manager/apps/pci-project/src/types/WillPayment.type.ts new file mode 100644 index 000000000000..0f169e140ed6 --- /dev/null +++ b/packages/manager/apps/pci-project/src/types/WillPayment.type.ts @@ -0,0 +1,48 @@ +export enum PaymentMethodStatus { + PROCESSING = 'PROCESSING', // Waiting a backend return + REGISTERED = 'REGISTERED', // Done, the PM is ready to rumble + ERROR = 'ERROR', // Something when't wrong with the PM + PENDING = 'PENDING', // Waiting something, generally a user action + CHALLENGE_WAITING = 'CHALLENGE_WAITING', // Waiting for user action required by the PSP + CHALLENGE_OK = 'CHALLENGE_OK', // When challenge required by the PSP is ok + CHALLENGE_ERROR = 'CHALLENGE_ERROR', // When challenge required by the PSP and return an Error + CHALLENGE_CANCELED = 'CHALLENGE_CANCELED', // When challenge required by the PSP is canceled by the user + CHALLENGE_REFUSED = 'CHALLENGE_REFUSED', // When challenge required by the PSP and payment was refused + PAYMENT_METHOD_SAVED = 'PAYMENT_METHOD_SAVED', // When a payment is a success +} + +export enum ComponentStatus { + PENDING = 'PENDING', // Waiting something, generally a user action + LOADING = 'LOADING', // When loading external resource + ERROR = 'ERROR', // Something when't wrong + WAITING_USER_ACTION = 'WAITING_USER_ACTION', // Waiting a user action + PROCESSING = 'PROCESSING', // Work in progress… + READY_TO_GO_FORWARD = 'READY_TO_GO_FORWARD', // PM and PSP are ready, the UI can go forward or wait user action + PAYMENT_SUCCESS = 'PAYMENT_SUCCESS', // When a payment is a success + PAYMENT_METHOD_SAVED = 'PAYMENT_METHOD_SAVED', // When a payment method is registered or updated +} + +export type TCreditData = { + isCredit?: boolean; + creditAmount?: { value: number; text: string; currencyCode: string }; +}; + +export type GlobalStateStatus = { + componentStatus: ComponentStatus; + paymentMethodStatus: PaymentMethodStatus; + error?: string | null; + data?: TCreditData | unknown; +}; + +export type TWillPaymentConfig = { + baseUrl: string; + onChange: (param: GlobalStateStatus) => void; + subsidiary?: string; + language?: string; + eventBus?: Element; + hostApp?: 'manager' | 'pci'; + logging?: { + environment?: string; + userId?: string; + }; +}; diff --git a/packages/manager/apps/pci-project/src/types/module-federation.d.ts b/packages/manager/apps/pci-project/src/types/module-federation.d.ts new file mode 100644 index 000000000000..c9415ca5c555 --- /dev/null +++ b/packages/manager/apps/pci-project/src/types/module-federation.d.ts @@ -0,0 +1,10 @@ +import { TWillPaymentConfig } from './WillPayment.type'; + +declare module 'willPayment/WillPayment' { + export default function setupWillPayment( + slot: HTMLSlotElement, + config: { + configuration: TWillPaymentConfig; + }, + ): void; +} diff --git a/packages/manager/apps/pci-project/tsconfig.json b/packages/manager/apps/pci-project/tsconfig.json index 0bf657fc7a31..5a8ebe288a94 100644 --- a/packages/manager/apps/pci-project/tsconfig.json +++ b/packages/manager/apps/pci-project/tsconfig.json @@ -25,7 +25,8 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "react": ["./node_modules/@types/react"] + "react": ["./node_modules/@types/react"], + "willPayment/WillPayment": ["./src/types/module-federation.d.ts"] } }, "include": ["src"], diff --git a/packages/manager/apps/pci-project/vite.config.mjs b/packages/manager/apps/pci-project/vite.config.mjs index f33ab6dc98cd..08e548c2b08c 100644 --- a/packages/manager/apps/pci-project/vite.config.mjs +++ b/packages/manager/apps/pci-project/vite.config.mjs @@ -1,8 +1,28 @@ import { defineConfig } from 'vite'; import { getBaseConfig } from '@ovh-ux/manager-vite-config'; import { resolve } from 'path'; +import federation from '@originjs/vite-plugin-federation'; + +const getWillPaymentEntryUrl = () => { + if (process.env.LABEU || process.env.NODE_ENV === 'development') { + return 'https://www.build-ovh.com/order/payment/assets/remoteEntry.js'; + } + + return '/order/payment/assets/remoteEntry.js'; +}; export default defineConfig({ ...getBaseConfig(), root: resolve(process.cwd()), + plugins: [ + ...getBaseConfig().plugins, + federation({ + name: 'host-app', + remotes: { + willPayment: getWillPaymentEntryUrl(), + }, + }), + ], }); + + diff --git a/yarn.lock b/yarn.lock index b65a648c4d6c..1783ca95d77d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7075,6 +7075,14 @@ resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e" integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA== +"@originjs/vite-plugin-federation@^1.3.9": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2" + integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ== + dependencies: + estree-walker "^3.0.2" + magic-string "^0.27.0" + "@ovh-ux/manager-common-translations@^0.10.0": version "0.10.0" resolved "https://registry.yarnpkg.com/@ovh-ux/manager-common-translations/-/manager-common-translations-0.10.0.tgz#b135fbf4c9fd64a6c2045ad54091a1982fbad2d0" @@ -7228,7 +7236,7 @@ i18next "^23.8.2" i18next-http-backend "^2.4.2" -"@ovh-ux/manager-react-shell-client@^0.9.3": +"@ovh-ux/manager-react-shell-client@^0.9.1", "@ovh-ux/manager-react-shell-client@^0.9.3": version "0.9.3" resolved "https://registry.yarnpkg.com/@ovh-ux/manager-react-shell-client/-/manager-react-shell-client-0.9.3.tgz#4f6f40ae85076390ad1c54b5c3a24207d1fab158" integrity sha512-i86lAEyYPY2iCoGCpm8DVF0IuyIxYO+Iccav0/cw0ItUEexsvNHbDWRu9DLlIIS3Vknrx/ualDg0cEbPEA5lZw== @@ -19351,7 +19359,7 @@ estree-walker@^2.0.1, estree-walker@^2.0.2: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -estree-walker@^3.0.3: +estree-walker@^3.0.2, estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==