From 9c70aa5939c32226d9f078ccaadd2320364f2774 Mon Sep 17 00:00:00 2001 From: JacquesLarique <134954692+JacquesLarique@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:26:17 +0200 Subject: [PATCH] feat(hub): created payment status component (#13293) ref: MANAGER-15058 Signed-off-by: Jacques Larique --- packages/manager/apps/hub/package.json | 16 +- .../billing/actions/Messages_fr_FR.json | 24 ++ .../billing/status/Messages_fr_FR.json | 12 + .../hub/error/Messages_fr_FR.json | 1 + .../hub/payment-status/Messages_fr_FR.json | 9 + .../apps/hub/src/_mock_/billingServices.ts | 150 ++++++++ .../manager/apps/hub/src/billing.constants.ts | 41 +++ .../BillingStatus.component.tsx | 72 ++++ .../billing-status/BillingStatus.constants.ts | 15 + .../ServicesActions.component.tsx | 107 ++++++ .../src/billing/hooks/useServiceActions.ts | 223 ++++++++++++ .../hub/src/billing/hooks/useServiceLinks.tsx | 187 ++++++++++ .../src/billing/types/billingServices.type.ts | 332 ++++++++++++++++++ .../src/billing/types/service-links.type.ts | 18 + .../apps/hub/src/data/api/billingServices.ts | 29 ++ .../useBillingServices.spec.tsx | 34 ++ .../billingServices/useBillingServices.tsx | 11 + packages/manager/apps/hub/src/index.tsx | 3 + .../pages/layout/PaymentStatus.component.tsx | 302 ++++++++++++++++ .../hub/src/pages/layout/layout.constants.ts | 16 +- .../apps/hub/src/pages/layout/layout.test.tsx | 290 ++++++++++++++- .../apps/hub/src/pages/layout/layout.tsx | 69 ++-- packages/manager/apps/hub/tailwind.config.js | 2 +- packages/manager/apps/hub/tsconfig.json | 2 +- packages/manager/apps/hub_old/package.json | 2 +- 25 files changed, 1898 insertions(+), 69 deletions(-) create mode 100644 packages/manager/apps/hub/public/translations/billing/actions/Messages_fr_FR.json create mode 100644 packages/manager/apps/hub/public/translations/billing/status/Messages_fr_FR.json create mode 100644 packages/manager/apps/hub/public/translations/hub/payment-status/Messages_fr_FR.json create mode 100644 packages/manager/apps/hub/src/_mock_/billingServices.ts create mode 100644 packages/manager/apps/hub/src/billing.constants.ts create mode 100644 packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.component.tsx create mode 100644 packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.constants.ts create mode 100644 packages/manager/apps/hub/src/billing/components/services-actions/ServicesActions.component.tsx create mode 100644 packages/manager/apps/hub/src/billing/hooks/useServiceActions.ts create mode 100644 packages/manager/apps/hub/src/billing/hooks/useServiceLinks.tsx create mode 100644 packages/manager/apps/hub/src/billing/types/billingServices.type.ts create mode 100644 packages/manager/apps/hub/src/billing/types/service-links.type.ts create mode 100644 packages/manager/apps/hub/src/data/api/billingServices.ts create mode 100644 packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.spec.tsx create mode 100644 packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.tsx create mode 100644 packages/manager/apps/hub/src/pages/layout/PaymentStatus.component.tsx diff --git a/packages/manager/apps/hub/package.json b/packages/manager/apps/hub/package.json index b0ec42ceee18..25b5d6c718d1 100644 --- a/packages/manager/apps/hub/package.json +++ b/packages/manager/apps/hub/package.json @@ -22,13 +22,13 @@ "test:watch": "vitest watch" }, "dependencies": { - "@ovh-ux/manager-config": "*", - "@ovh-ux/manager-core-api": "*", - "@ovh-ux/manager-core-utils": "*", - "@ovh-ux/manager-react-shell-client": "*", - "@ovh-ux/manager-tailwind-config": "*", - "@ovh-ux/request-tagger": "*", - "@ovhcloud/manager-components": "*", + "@ovh-ux/manager-config": "^7.3.3", + "@ovh-ux/manager-core-api": "^0.8.0", + "@ovh-ux/manager-models": "^1.14.13", + "@ovh-ux/manager-react-components": "^1.31.0", + "@ovh-ux/manager-react-shell-client": "^0.7.0", + "@ovh-ux/manager-tailwind-config": "^0.2.0", + "@ovh-ux/request-tagger": "^0.3.0", "@ovhcloud/ods-common-core": "17.2.1", "@ovhcloud/ods-common-theming": "17.2.1", "@ovhcloud/ods-components": "17.2.1", @@ -48,7 +48,7 @@ }, "devDependencies": { "@cucumber/cucumber": "^10.3.1", - "@ovh-ux/manager-vite-config": "*", + "@ovh-ux/manager-vite-config": "^0.8.0", "@playwright/test": "^1.41.2", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^14.1.2", diff --git a/packages/manager/apps/hub/public/translations/billing/actions/Messages_fr_FR.json b/packages/manager/apps/hub/public/translations/billing/actions/Messages_fr_FR.json new file mode 100644 index 000000000000..219001192e19 --- /dev/null +++ b/packages/manager/apps/hub/public/translations/billing/actions/Messages_fr_FR.json @@ -0,0 +1,24 @@ +{ + "billing_services_actions_menu_label": "Plus d'actions sur ce service", + "billing_autorenew_service_enable_autorenew": "Activer le paiement automatique", + "billing_services_actions_menu_pay_bill": "Régler ma facture", + "billing_services_actions_menu_manage_renew": "Configurer le renouvellement", + "billing_services_actions_menu_exchange_update_accounts": "Configurer le renouvellement des comptes", + "billing_services_actions_menu_anticipate_renew": "Anticiper le paiement", + "billing_services_actions_menu_resiliate": "Résilier", + "billing_services_actions_menu_resiliate_my_engagement": "Résilier mon engagement", + "billing_services_actions_menu_renew_label": "Renouveler le service : {{ serviceName }} (Nouvelle fenêtre)", + "billing_services_actions_menu_renew": "Renouveler le service", + "billing_services_actions_menu_exchange_update": "Modifier la facturation", + "billing_services_actions_menu_resiliate_EMAIL_DOMAIN": "Supprimer immédiatement le MX Plan", + "billing_services_actions_menu_resiliate_ENTERPRISE_CLOUD_DATABASE": "Supprimer immédiatement l'enterprise cloud databases", + "billing_services_actions_menu_resiliate_HOSTING_WEB": "Supprimer immédiatement l'hébergement", + "billing_services_actions_menu_resiliate_HOSTING_PRIVATE_DATABASE": "Supprimer mon hébergement SQL privé", + "billing_services_actions_menu_resiliate_WEBCOACH": "Supprimer mon WebCoach", + "billing_services_actions_menu_sms_credit": "Ajouter des crédits", + "billing_services_actions_menu_sms_renew": "Configurer la recharge automatique", + "billing_services_actions_menu_resiliate_cancel": "Annuler la résiliation du service", + "billing_services_actions_menu_see_dashboard": "Voir le détail du service", + "billing_services_actions_menu_commit": "Gérer mon engagement", + "billing_services_actions_menu_commit_cancel": "Annuler la demande d'engagement" +} diff --git a/packages/manager/apps/hub/public/translations/billing/status/Messages_fr_FR.json b/packages/manager/apps/hub/public/translations/billing/status/Messages_fr_FR.json new file mode 100644 index 000000000000..9a411d802751 --- /dev/null +++ b/packages/manager/apps/hub/public/translations/billing/status/Messages_fr_FR.json @@ -0,0 +1,12 @@ +{ + "manager_billing_service_status": "Statut", + "manager_billing_service_status_auto": "Renouvellement automatique", + "manager_billing_service_status_automatic": "Renouvellement automatique", + "manager_billing_service_status_manual": "Renouvellement manuel", + "manager_billing_service_status_manualPayment": "Renouvellement manuel", + "manager_billing_service_status_pending_debt": "Facture à régler", + "manager_billing_service_status_delete_at_expiration": "Résiliation demandée", + "manager_billing_service_status_expired": "Résilié", + "manager_billing_service_status_billing_suspended": "Facturation reportée", + "manager_billing_service_status_forced_manual": "Renouvellement manuel forcé" +} diff --git a/packages/manager/apps/hub/public/translations/hub/error/Messages_fr_FR.json b/packages/manager/apps/hub/public/translations/hub/error/Messages_fr_FR.json index ba8736c53323..050261db3d39 100644 --- a/packages/manager/apps/hub/public/translations/hub/error/Messages_fr_FR.json +++ b/packages/manager/apps/hub/public/translations/hub/error/Messages_fr_FR.json @@ -5,6 +5,7 @@ "manager_error_page_action_reload_label": "Réessayer", "manager_error_page_action_home_label": "Retour à la page d'accueil", "manager_error_page_default": "Une erreur est survenue lors du chargement de la page.", + "ovh_manager_hub_payment_status_tile_error": "Impossible de récupérer les services", "manager_error_tile_title": "Oops …!", "manager_error_tile_action_reload_label": "Recharger" } diff --git a/packages/manager/apps/hub/public/translations/hub/payment-status/Messages_fr_FR.json b/packages/manager/apps/hub/public/translations/hub/payment-status/Messages_fr_FR.json new file mode 100644 index 000000000000..5636d8ff972e --- /dev/null +++ b/packages/manager/apps/hub/public/translations/hub/payment-status/Messages_fr_FR.json @@ -0,0 +1,9 @@ +{ + "ovh_manager_hub_payment_status_tile_title": "Derniers statuts de paiement des services", + "ovh_manager_hub_payment_status_tile_see_all": "Voir tout", + "ovh_manager_hub_payment_status_tile_now": "Immédiatement", + "ovh_manager_hub_payment_status_tile_before": "avant le {{ date }}", + "ovh_manager_hub_payment_status_tile_renew": "depuis le {{ date }}", + "ovh_manager_hub_payment_status_tile_error": "Impossible de récupérer les services", + "ovh_manager_hub_payment_status_tile_no_services": "Aucun service" +} diff --git a/packages/manager/apps/hub/src/_mock_/billingServices.ts b/packages/manager/apps/hub/src/_mock_/billingServices.ts new file mode 100644 index 000000000000..ea37e8ff18b4 --- /dev/null +++ b/packages/manager/apps/hub/src/_mock_/billingServices.ts @@ -0,0 +1,150 @@ +import { + HubBillingServices, + BillingService, +} from '@/billing/types/billingServices.type'; + +const serviceResiliated = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic1', + contactBilling: 'billingNic1', + domain: 'serviceResiliated', + expiration: '2024-10-01T07:37:24Z', + id: 333333, + renew: { + automatic: true, + deleteAtExpiration: false, + forced: false, + manualPayment: false, + period: 12, + }, + renewalType: 'automaticV2016', + serviceId: 'serviceResiliated', + serviceType: 'HOSTING_WEB', + status: 'TERMINATED', + url: + 'https://www.ovh.com/manager/#/web/configuration/hosting/serviceResiliated', +}); +const serviceWithManualRenewNotResiliatedWithoutDebt = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic2', + contactBilling: 'billingNic2', + domain: 'serviceWithManualRenewNotResiliatedWithoutDebt', + expiration: '2024-10-06T16:38:41Z', + id: 444444, + renew: { + automatic: false, + deleteAtExpiration: false, + forced: false, + manualPayment: true, + period: null, + }, + renewalType: 'manual', + serviceId: 'serviceWithManualRenewNotResiliatedWithoutDebt', + serviceType: 'DOMAIN', + status: 'ACTIVE', + url: + 'https://www.ovh.com/manager/#/web/configuration/domain/serviceWithManualRenewNotResiliatedWithoutDebt/information', +}); +const serviceOneShotWithoutResiliation = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic3', + contactBilling: 'billingNic3', + domain: 'serviceOneShotWithoutResiliation', + expiration: '2024-11-19T04:28:17Z', + id: 555555, + renew: { + automatic: false, + deleteAtExpiration: false, + forced: false, + manualPayment: false, + period: 12, + }, + renewalType: 'oneShot', + serviceId: 'serviceOneShotWithoutResiliation', + serviceType: 'DEDICATED_SERVER', + status: 'ACTIVE', + url: + 'https://www.ovh.com/manager/#/dedicated/server/serviceOneShotWithoutResiliation', +}); +const serviceWithoutUrlAndSuspendedBilling = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic4', + contactBilling: 'billingNic4', + domain: 'serviceWithoutUrlAndSuspendedBilling', + expiration: '2024-11-19T14:49:20Z', + id: 666666, + renew: { + automatic: false, + deleteAtExpiration: false, + forced: false, + manualPayment: true, + period: 12, + }, + renewalType: 'automaticV2016', + serviceId: 'serviceWithoutUrlAndSuspendedBilling', + serviceType: 'DEDICATED_CLOUD', + status: 'BILLING_SUSPENDED', + url: null, +}); +const serviceInDebt = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic3', + contactBilling: 'billingNic3', + domain: 'serviceOneShotWithoutResiliation', + expiration: '2024-11-19T04:28:17Z', + id: 777777, + renew: { + automatic: false, + deleteAtExpiration: false, + forced: false, + manualPayment: false, + period: 12, + }, + renewalType: 'oneShot', + serviceId: 'serviceOneShotWithoutResiliation', + serviceType: 'DEDICATED_SERVER', + status: 'PENDING_DEBT', + url: + 'https://www.ovh.com/manager/#/dedicated/server/serviceOneShotWithoutResiliation', +}); +const serviceWithAutomaticRenewNotResiliated = new BillingService({ + canDeleteAtExpiration: false, + contactAdmin: 'adminNic1', + contactBilling: 'billingNic1', + domain: 'serviceWithAutomaticRenewNotResiliated', + expiration: '2024-10-01T07:37:24Z', + id: 888888, + renew: { + automatic: true, + deleteAtExpiration: false, + forced: false, + manualPayment: false, + period: 12, + }, + renewalType: 'automaticV2016', + serviceId: 'serviceWithAutomaticRenewNotResiliated', + serviceType: 'HOSTING_WEB', + status: 'ACTIVE', + url: + 'https://www.ovh.com/manager/#/web/configuration/hosting/serviceWithAutomaticRenewNotResiliated', +}); + +export const NoServices: HubBillingServices = { + services: [], + count: 0, +}; + +export const TwoServices: HubBillingServices = { + services: [serviceInDebt, serviceWithAutomaticRenewNotResiliated], + count: 2, +}; + +export const FourServices: HubBillingServices = { + services: [ + serviceResiliated, + serviceWithManualRenewNotResiliatedWithoutDebt, + serviceOneShotWithoutResiliation, + serviceWithoutUrlAndSuspendedBilling, + ], + count: 4, +}; diff --git a/packages/manager/apps/hub/src/billing.constants.ts b/packages/manager/apps/hub/src/billing.constants.ts new file mode 100644 index 000000000000..a7928fd84544 --- /dev/null +++ b/packages/manager/apps/hub/src/billing.constants.ts @@ -0,0 +1,41 @@ +export const SERVICE_TYPE = { + EMAIL_DOMAIN: 'EMAIL_DOMAIN', + ENTERPRISE_CLOUD_DATABASE: 'ENTERPRISE_CLOUD_DATABASE', + EXCHANGE: 'EMAIL_EXCHANGE', + HOSTING_PRIVATE_DATABASE: 'HOSTING_PRIVATE_DATABASE', + HOSTING_WEB: 'HOSTING_WEB', + OVH_CLOUD_CONNECT: 'OVH_CLOUD_CONNECT', + PACK_XDSL: 'PACK_XDSL', + SMS: 'SMS', + TELEPHONY: 'TELEPHONY', + WEBCOACH: 'WEBCOACH', + ALL_DOM: 'ALL_DOM', + OKMS: 'OKMS_RESOURCE', + VRACK_SERVICES: 'VRACK_SERVICES_RESOURCE', +}; + +export const RENEW_URL: Record = { + default: '/cgi-bin/order/renew.cgi?domainChooser=', + AU: 'https://ca.ovh.com/au/cgi-bin/order/renew.cgi?domainChooser=', + CA: 'https://ca.ovh.com/fr/cgi-bin/order/renew.cgi?domainChooser=', + CZ: 'https://www.ovh.cz/cgi-bin/order/renew.cgi?domainChooser=', + DE: 'https://www.ovh.de/cgi-bin/order/renew.cgi?domainChooser=', + EN: 'https://www.ovh.co.uk/cgi-bin/order/renew.cgi?domainChooser=', + ES: 'https://www.ovh.es/cgi-bin/order/renew.cgi?domainChooser=', + FI: 'https://www.ovh-hosting.fi/cgi-bin/order/renew.cgi?domainChooser=', + FR: 'https://eu.ovh.com/fr/cgi-bin/order/renew.cgi?domainChooser=', + GB: 'https://www.ovh.co.uk/cgi-bin/order/renew.cgi?domainChooser=', + IE: 'https://www.ovh.ie/cgi-bin/order/renew.cgi?domainChooser=', + IT: 'https://www.ovh.it/cgi-bin/order/renew.cgi?domainChooser=', + LT: 'https://www.ovh.lt/cgi-bin/order/renew.cgi?domainChooser=', + MA: 'https://www.ovh.com/ma/cgi-bin/order/renew.cgi?domainChooser=', + NL: 'https://www.ovh.nl/cgi-bin/order/renew.cgi?domainChooser=', + PL: 'https://www.ovh.pl/cgi-bin/order/renew.cgi?domainChooser=', + PT: 'https://www.ovh.pt/cgi-bin/order/renew.cgi?domainChooser=', + QC: 'https://ca.ovh.com/fr/cgi-bin/order/renew.cgi?domainChooser=', + RU: 'https://www.ovh.co.uk/cgi-bin/order/renew.cgi?domainChooser=', + SG: 'https://ca.ovh.com/sg/cgi-bin/order/renew.cgi?domainChooser=', + SN: 'https://www.ovh.sn/cgi-bin/order/renew.cgi?domainChooser=', + TN: 'https://www.ovh.com/tn/cgi-bin/order/renew.cgi?domainChooser=', + WE: 'https://ca.ovh.com/fr/cgi-bin/order/renew.cgi?domainChooser=', +}; diff --git a/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.component.tsx b/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.component.tsx new file mode 100644 index 000000000000..065449484fa9 --- /dev/null +++ b/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.component.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next'; +import { ODS_CHIP_SIZE } from '@ovhcloud/ods-components'; +import { OsdsChip, OsdsText } from '@ovhcloud/ods-components/react'; +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_LEVEL, + ODS_THEME_TYPOGRAPHY_SIZE, +} from '@ovhcloud/ods-common-theming'; +import { BADGE_INTENT_BY_STATUS } from '@/billing/components/billing-status/BillingStatus.constants'; +import { BillingService } from '@/billing/types/billingServices.type'; + +type BillingStatusProps = { + service: BillingService; +}; + +export default function BillingStatus({ service }: BillingStatusProps) { + const { t } = useTranslation('billing/status'); + const shouldHideAutoRenewStatus = + service.isOneShot() || ['SMS'].includes(service.serviceType); + return ( +
+ {service.hasDebt() && ( + + + {t('manager_billing_service_status_pending_debt')} + + + )} + {shouldHideAutoRenewStatus && !service.isResiliated() && -} + {shouldHideAutoRenewStatus && service.isResiliated() && ( + + + {t('manager_billing_service_status_expired')} + + + )} + {!service.hasDebt() && !shouldHideAutoRenewStatus && ( + + + {t(`manager_billing_service_status_${service.getRenew()}`)} + + + )} +
+ ); +} diff --git a/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.constants.ts b/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.constants.ts new file mode 100644 index 000000000000..b28fcfd3c7b8 --- /dev/null +++ b/packages/manager/apps/hub/src/billing/components/billing-status/BillingStatus.constants.ts @@ -0,0 +1,15 @@ +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; + +export const BADGE_INTENT_BY_STATUS: Record< + string, + keyof typeof ODS_THEME_COLOR_INTENT +> = { + auto: 'success', + automatic: 'success', + billing_suspended: 'info', + delete_at_expiration: 'error', + expired: 'error', + forced_manual: 'info', + manual: 'warning', + manualPayment: 'warning', +}; diff --git a/packages/manager/apps/hub/src/billing/components/services-actions/ServicesActions.component.tsx b/packages/manager/apps/hub/src/billing/components/services-actions/ServicesActions.component.tsx new file mode 100644 index 000000000000..d6c880d93d10 --- /dev/null +++ b/packages/manager/apps/hub/src/billing/components/services-actions/ServicesActions.component.tsx @@ -0,0 +1,107 @@ +import { + ODS_BUTTON_SIZE, + ODS_BUTTON_TYPE, + ODS_BUTTON_VARIANT, + ODS_DIVIDER_SIZE, + ODS_ICON_NAME, + ODS_ICON_SIZE, +} from '@ovhcloud/ods-components'; +import { + OsdsButton, + OsdsDivider, + OsdsIcon, + OsdsLink, + OsdsPopover, + OsdsPopoverContent, + OsdsSkeleton, +} from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import React, { Suspense } from 'react'; +import { + ServiceAction, + useServiceActions, +} from '@/billing/hooks/useServiceActions'; +import { BillingService } from '@/billing/types/billingServices.type'; +import { useServiceLinks } from '@/billing/hooks/useServiceLinks'; + +type ServicesActionsProps = { + service: BillingService; + autoRenewLink: string; + trackingPrefix: string[]; +}; + +export default function ServicesActions({ + service, + autoRenewLink, + trackingPrefix, +}: ServicesActionsProps) { + const links = useServiceLinks(service, autoRenewLink); + const items: ServiceAction[] = useServiceActions( + service, + links, + trackingPrefix, + ); + const shouldBeDisplayed = + Boolean(autoRenewLink) || + service.canBeEngaged || + service.hasPendingEngagement; + + // When we'll migrate to ODS 18, we should try to have the popover "rounded" & "withArrow" and add a direction to it + return shouldBeDisplayed ? ( + + + + + + + + + + + + } + > + {items.map((item, index) => { + const { disabled, external, ...link } = item; + return ( +
+ {index > 0 && } + + {item.label} + {external && ( + + )} + +
+ ); + })} +
+
+
+ ) : null; +} diff --git a/packages/manager/apps/hub/src/billing/hooks/useServiceActions.ts b/packages/manager/apps/hub/src/billing/hooks/useServiceActions.ts new file mode 100644 index 000000000000..72568edf0d99 --- /dev/null +++ b/packages/manager/apps/hub/src/billing/hooks/useServiceActions.ts @@ -0,0 +1,223 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + OdsHTMLAnchorElementRel, + OdsHTMLAnchorElementTarget, +} from '@ovhcloud/ods-common-core'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { useOvhTracking } from '@ovh-ux/manager-react-shell-client'; +import { ServiceLinks } from '@/billing/types/service-links.type'; +import { BillingService } from '@/billing/types/billingServices.type'; + +export type ServiceAction = { + color?: ODS_THEME_COLOR_INTENT; + disabled?: boolean; + external?: boolean; + href: string; + label: string; + onClick?: () => void; + target?: OdsHTMLAnchorElementTarget; + rel?: OdsHTMLAnchorElementRel; +}; + +export const useServiceActions = ( + service: BillingService, + getLinksPromise: Promise, + trackingPrefix?: string[], +) => { + const { t } = useTranslation('billing/actions'); + const { trackClick } = useOvhTracking(); + + const trackAction = (hit: string, hasActionInEvent = true): void => { + if (trackingPrefix) { + trackClick({ + actionType: 'action', + actions: [...trackingPrefix, ...[hasActionInEvent && 'action', hit]], + }); + } + }; + const [actions, setActions] = useState([]); + useEffect(() => { + getLinksPromise.then((links: ServiceLinks) => { + const items: ServiceAction[] = []; + + if (links.warnBillingNic) { + items.push({ + label: t('billing_services_actions_menu_pay_bill'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-pay-bill'); + }, + href: links.warnBillingNic, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.payBill) { + items.push({ + label: t('billing_services_actions_menu_pay_bill'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-pay-bill'); + }, + href: links.payBill, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.configureRenewal) { + items.push({ + label: t('billing_services_actions_menu_manage_renew'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-configure-renew'); + }, + href: links.configureRenewal, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.anticipatePayment) { + items.push({ + label: t('billing_services_actions_menu_anticipate_renew'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-anticipate-payment'); + }, + href: links.anticipatePayment, + external: true, + }); + } + if (links.renewManually) { + items.push({ + disabled: service.hasForcedRenew(), + label: t('billing_services_actions_menu_renew'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-renew-manually'); + }, + href: links.renewManually, + external: true, + target: OdsHTMLAnchorElementTarget._blank, + rel: OdsHTMLAnchorElementRel.noopener, + }); + } + if (links.manageCommitment) { + items.push({ + label: t('billing_services_actions_menu_commit'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-manage-commitment', false); + }, + href: links.manageCommitment, + }); + } + if (links.cancelCommitment) { + items.push({ + label: t('billing_services_actions_menu_commit_cancel'), + color: ODS_THEME_COLOR_INTENT.primary, + href: links.cancelCommitment, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.modifyExchangeBilling) { + items.push({ + label: t('billing_services_actions_menu_exchange_update'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-modify-billing-Exchange', false); + }, + href: links.modifyExchangeBilling, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.configureExchangeAccountsRenewal) { + items.push({ + label: t('billing_services_actions_menu_exchange_update_accounts'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-modify-billing-ExchangeAccounts', false); + }, + href: links.configureExchangeAccountsRenewal, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.resiliate) { + items.push({ + label: t( + `billing_services_actions_menu_resiliate${ + service.hasEngagement() ? 'my_engagement' : '' + }`, + ), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-resiliate'); + }, + href: links.resiliate, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.resiliateByDeletion) { + items.push({ + label: t( + `billing_services_actions_menu_resiliate_${service.serviceType}`, + ), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-resiliate'); + }, + href: links.resiliateByDeletion, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.buySMSCredits) { + items.push({ + label: t('billing_services_actions_menu_sms_credit'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-buy-SMScredits'); + }, + href: links.buySMSCredits, + target: OdsHTMLAnchorElementTarget._blank, + rel: OdsHTMLAnchorElementRel.noopener, + external: true, + }); + } + if (links.configureSMSAutoReload) { + items.push({ + label: t('billing_services_actions_menu_sms_renew'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-configure-SMSautoreload'); + }, + href: links.configureSMSAutoReload, + target: OdsHTMLAnchorElementTarget._blank, + rel: OdsHTMLAnchorElementRel.noopener, + external: true, + }); + } + if (links.cancelResiliation) { + items.push({ + label: t('billing_services_actions_menu_resiliate_cancel'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-cancel-resiliation'); + }, + href: links.cancelResiliation, + target: OdsHTMLAnchorElementTarget._top, + }); + } + if (links.seeService) { + items.push({ + label: t('billing_services_actions_menu_see_dashboard'), + color: ODS_THEME_COLOR_INTENT.primary, + onClick: () => { + trackAction('go-to-service'); + }, + href: links.seeService, + target: OdsHTMLAnchorElementTarget._top, + }); + } + setActions(items); + }); + }, [getLinksPromise]); + + return actions; +}; diff --git a/packages/manager/apps/hub/src/billing/hooks/useServiceLinks.tsx b/packages/manager/apps/hub/src/billing/hooks/useServiceLinks.tsx new file mode 100644 index 000000000000..43634a3a2cee --- /dev/null +++ b/packages/manager/apps/hub/src/billing/hooks/useServiceLinks.tsx @@ -0,0 +1,187 @@ +import { useContext } from 'react'; +import { + ShellContext, + ShellContextType, +} from '@ovh-ux/manager-react-shell-client'; +import { BillingService } from '@/billing/types/billingServices.type'; +import { ServiceLinks } from '@/billing/types/service-links.type'; +import { RENEW_URL, SERVICE_TYPE } from '@/billing.constants'; + +export const useServiceLinks = async ( + service: BillingService, + autoRenewLink?: string, +) => { + const { + shell: { navigation }, + environment: { user }, + } = useContext(ShellContext); + const links: ServiceLinks = {}; + + const serviceTypeParam = service.serviceType + ? `&serviceType=${service.serviceType}` + : ''; + const renewUrl = `${RENEW_URL[user.ovhSubsidiary] || RENEW_URL.default}${ + service.serviceId + }`; + const [organization, exchangeName] = service.serviceId.split('/service/'); + // When we will fully migrate billing in React, we should add a possibility to give + // the cancelResiliation link (with an additional parameter in useServiceLinks) + const cancelResiliationLink = + service.serviceType !== SERVICE_TYPE.EMAIL_DOMAIN + ? `${autoRenewLink}/cancel-resiliation?serviceId=${service.serviceId}${serviceTypeParam}` + : null; + // When we will fully migrate billing in React, we should add a possibility to give + // the resiliationByEndRule link (with an additional parameter in useServiceLinks) + const resiliationByEndRuleLink = `${autoRenewLink}/resiliation?serviceId=${service.id}&serviceName=${service.serviceId}${serviceTypeParam}`; + + let resiliateLink: string; + switch (service.serviceType) { + case SERVICE_TYPE.EXCHANGE: + resiliateLink = `${service.url}?action=resiliate`; + break; + case SERVICE_TYPE.EMAIL_DOMAIN: + resiliateLink = `${autoRenewLink}/delete-email?serviceId=${service.serviceId}&name=${service.domain}`; + break; + case SERVICE_TYPE.TELEPHONY: + resiliateLink = (await navigation.getURL( + 'telecom', + '#/telephony/:serviceName/administration/deleteGroup', + { serviceName: service.serviceId }, + )) as string; + break; + case SERVICE_TYPE.PACK_XDSL: + resiliateLink = (await navigation.getURL( + 'telecom', + '#/pack/:serviceName', + { serviceName: service.serviceId }, + )) as string; + break; + case SERVICE_TYPE.ALL_DOM: + resiliateLink = service.canResiliateByEndRule() + ? resiliationByEndRuleLink + : `${autoRenewLink}/delete-all-dom?serviceId=${service.serviceId}&serviceType=${service.serviceType}`; + break; + case SERVICE_TYPE.OKMS: + case SERVICE_TYPE.VRACK_SERVICES: + resiliateLink = `${autoRenewLink}/terminate-service?id=${service.id}${serviceTypeParam}`; + break; + default: + resiliateLink = service.canResiliateByEndRule() + ? resiliationByEndRuleLink + : autoRenewLink && + `${autoRenewLink}/delete?serviceId=${service.serviceId}${serviceTypeParam}`; + break; + } + + if ( + autoRenewLink && + service.hasDebt() && + !service.hasBillingRights(user.nichandle) + ) { + links.warnBillingNic = `${autoRenewLink}/warn-nic?nic=${service.contactBilling}`; + } + if (service.hasDebt() && service.hasBillingRights(user.nichandle)) { + links.payBill = (await navigation.getURL( + 'dedicated', + '#/billing/history', + {}, + )) as string; + } + if ( + autoRenewLink && + !service.hasParticularRenew() && + !service.hasPendingResiliation() && + !service.hasDebt() + ) { + if (!service.isOneShot() && service.canHandleRenew()) { + if ( + !service.isResiliated() && + !service.hasForcedRenew() && + !service.hasEngagement() + ) { + links.configureRenewal = `${autoRenewLink}/update?serviceId=${service.serviceId}${serviceTypeParam}`; + } + if ( + !service.hasManualRenew() && + !service.canBeEngaged && + !service.hasPendingEngagement + ) { + links.anticipatePayment = renewUrl; + } + } + if (service.hasManualRenew() && service.canHandleRenew()) { + links.renewManually = renewUrl; + } + } + if (service.hasPendingEngagement) { + // When we will fully migrate billing in React, we should add a possibility to give + // the cancelCommitment link (with an additional parameter in useServiceLinks) + links.cancelCommitment = `${autoRenewLink}/${service.id}/cancel-commitment`; + } else if (service.canBeEngaged && !service.isSuspended()) { + // When we will fully migrate billing in React, we should add a possibility to give + // the manageCommitment link (with an additional parameter in useServiceLinks) + links.manageCommitment = `${autoRenewLink}/${service.id}/commitment`; + } + if (service.serviceType === SERVICE_TYPE.EXCHANGE) { + const exchangeBillingLink = `${autoRenewLink}/exchange?organization=${organization}&exchangeName=${exchangeName || + service.serviceId}`; + if (service.menuItems?.manageEmailAccountsInBilling) { + links.modifyExchangeBilling = exchangeBillingLink; + } else if (service.menuItems?.manageEmailAccountsInExchange) { + links.configureExchangeAccountsRenewal = exchangeBillingLink; + } + } + if (service.serviceType === SERVICE_TYPE.PACK_XDSL) { + if ( + (service.shouldDeleteAtExpiration() || !service.isResiliated()) && + !service.hasDebt() && + !service.hasPendingResiliation() && + resiliateLink && + service.hasAdminRights(user.auth.account) + ) { + links.resiliate = resiliateLink; + } + } else if ( + (service.shouldDeleteAtExpiration() || !service.isResiliated()) && + !service.hasDebt() && + !service.hasPendingResiliation() + ) { + if ( + resiliateLink && + (service.hasAdminRights(user.auth.account) || + service.hasAdminRights(user.nichandle)) + ) { + links.resiliate = resiliateLink; + } + if (autoRenewLink && service.canBeDeleted()) { + links.resiliateByDeletion = + service.serviceType && + `${autoRenewLink}/delete-${service.serviceType + .replace(/_/g, '-') + .toLowerCase()}?serviceId=${service.serviceId}`; + } + } + if (service.serviceType === SERVICE_TYPE.SMS) { + links.buySMSCredits = (await navigation.getURL( + 'telecom', + '#/sms/:serviceName/order', + { serviceName: service.serviceId }, + )) as string; + links.configureSMSAutoReload = (await navigation.getURL( + 'telecom', + '#/sms/:serviceName/options/recredit', + { serviceName: service.serviceId }, + )) as string; + } + if ( + cancelResiliationLink && + (service.canBeUnresiliated(user.nichandle) || + service.canCancelResiliationByEndRule()) + ) { + links.cancelResiliation = cancelResiliationLink; + } + if (service.url && !service.isByoipService()) { + links.seeService = service.url; + } + return links; +}; diff --git a/packages/manager/apps/hub/src/billing/types/billingServices.type.ts b/packages/manager/apps/hub/src/billing/types/billingServices.type.ts new file mode 100644 index 000000000000..4b8899ffa6ce --- /dev/null +++ b/packages/manager/apps/hub/src/billing/types/billingServices.type.ts @@ -0,0 +1,332 @@ +import { ApiAggregateEnvelope, ApiEnvelope } from '@/types/apiEnvelope.type'; + +export const DEBT_STATUS = ['PENDING_DEBT', 'UN_PAID', 'UNPAID']; + +export const BYOIP_SERVICE_PREFIX = 'byoip-failover-'; + +type ServiceState = + // Agora API statuses + | 'ACTIVE' + | 'ERROR' + | 'RUPTURE' + | 'TERMINATED' + | 'TO_RENEW' + | 'UNPAID' + | 'UNRENEWED' + // Rebound statuses + | 'EXPIRED' + | 'PENDING_DEBT' + | 'DELETE_AT_EXPIRATION' + | 'AUTO' + | 'MANUAL'; + +type ServiceMenuItems = { + manageEmailAccountsInBilling: boolean; + manageEmailAccountsInExchange: boolean; + resiliate: boolean; +}; + +type EngagementStrategy = + | 'CANCEL_SERVICE' + | 'REACTIVATE_ENGAGEMENT' + | 'STOP_ENGAGEMENT_FALLBACK_DEFAULT_PRICE' + | 'STOP_ENGAGEMENT_KEEP_PRICE'; + +type EngagementDetails = { + endDate: Date; + endRule: { + possibleStrategies: EngagementStrategy[]; + strategy: EngagementStrategy; + }; +}; + +export type BillingServiceData = { + canBeEngaged?: boolean; + canDeleteAtExpiration: boolean; + contactAdmin: string; + contactBilling: string; + creation?: string; + domain: string; + engagedUpTo?: string | Date; + engagementDetails?: EngagementDetails; + expiration: string; + hasPendingEngagement?: boolean; + id: number | string; + menuItems?: ServiceMenuItems; + renew: { + automatic: boolean; + deleteAtExpiration: boolean; + forced: boolean; + manualPayment: boolean; + period: number; + }; + renewalType: string; + serviceId: string; + serviceType: string; + // FIXME: this should be `status: ServiceState;`but not sure this is the reality + status: string; + url: string; +}; + +export type BillingServicesData = { + billingServices: ApiEnvelope>; +}; + +export type HubBillingServices = { + services: BillingService[]; + count: number; +}; + +export class BillingService implements BillingServiceData { + // Not sent by /hub/billingServices + canBeEngaged?: boolean; + + canDeleteAtExpiration: boolean; + + contactAdmin: string; + + contactBilling: string; + + domain: string; + + expiration: string; + + engagedUpTo?: Date; + + engagementDetails?: EngagementDetails; + + // Not sent by /hub/billingServices + hasPendingEngagement?: boolean; + + id: number | string; + + menuItems?: ServiceMenuItems; + + renew: { + automatic: boolean; + deleteAtExpiration: boolean; + forced: boolean; + manualPayment: boolean; + period: number; + }; + + renewalType: string; + + serviceId: string; + + serviceType: string; + + status: string; + + url: string; + + expirationDate: Date; + + formattedExpiration: Date; + + creationDate: Date; + + constructor({ + canBeEngaged, + canDeleteAtExpiration, + contactAdmin, + contactBilling, + creation, + domain, + engagedUpTo, + engagementDetails, + expiration, + hasPendingEngagement, + id, + menuItems, + renew, + renewalType, + serviceId, + serviceType, + status, + url, + }: BillingServiceData) { + this.canBeEngaged = canBeEngaged; + this.canDeleteAtExpiration = canDeleteAtExpiration; + this.contactAdmin = contactAdmin; + this.contactBilling = contactBilling; + this.domain = domain; + this.engagedUpTo = new Date(engagedUpTo); + this.engagementDetails = engagementDetails; + this.expiration = expiration; + this.hasPendingEngagement = hasPendingEngagement; + this.id = id; + this.menuItems = menuItems; + this.renew = renew; + this.renewalType = renewalType; + this.serviceId = serviceId; + this.serviceType = serviceType; + this.status = status; + this.url = url; + + this.id = id || serviceId; + this.expirationDate = new Date(this.expiration); + this.creationDate = new Date(creation); + this.formattedExpiration = new Date(this.expiration); + } + + isBillingSuspended(): boolean { + return this.status === 'BILLING_SUSPENDED'; + } + + getRenew(): string { + if (this.isResiliated()) { + return 'expired'; + } + + if (this.isManualForced()) { + return this.status.toLowerCase(); + } + + if (this.hasManualRenew()) { + return 'manualPayment'; + } + + if (this.shouldDeleteAtExpiration() && !this.isResiliated()) { + return 'delete_at_expiration'; + } + + if (this.hasAutomaticRenew() || this.hasForcedRenew()) { + return 'automatic'; + } + + return 'manualPayment'; + } + + isResiliated(): boolean { + return ( + this.isExpired() || ['TERMINATED'].includes(this.status.toUpperCase()) + ); + } + + isExpired(): boolean { + return ['expired', 'unrenewed'].includes(this.status.toLowerCase()); + } + + isManualForced(): boolean { + return this.status === 'FORCED_MANUAL'; + } + + hasManualRenew(): boolean { + // From the API code, this.renew.manualPayment is true if this.renewalType === 'manual' + // So this code could be simplified + return this.renew.manualPayment || this.renewalType === 'manual'; + } + + shouldDeleteAtExpiration(): boolean { + return Boolean(this.renew.deleteAtExpiration); + } + + hasAutomaticRenew(): boolean { + return this.renew.automatic; + } + + hasAutomaticRenewal(): boolean { + return this.hasForcedRenew() || this.hasAutomaticRenew(); + } + + hasForcedRenew(): boolean { + return ( + this.renew.forced && !this.shouldDeleteAtExpiration() && !this.isExpired() + ); + } + + hasDebt(): boolean { + return DEBT_STATUS.includes(this.status); + } + + isOneShot(): boolean { + return this.renewalType === 'oneShot'; + } + + hasPendingResiliation(): boolean { + return ( + this.shouldDeleteAtExpiration() && + !this.hasManualRenew() && + !this.isResiliated() + ); + } + + hasResiliationRights(nichandle: string) { + return this.hasBillingRights(nichandle) || nichandle === this.contactAdmin; + } + + hasBillingRights(nichandle: string): boolean { + return nichandle === this.contactBilling; + } + + hasAdminRights(nichandle: string): boolean { + return nichandle === this.contactAdmin; + } + + isSuspended() { + return DEBT_STATUS.includes(this.status) || this.isResiliated(); + } + + canHandleRenew() { + return ![ + 'VIP', + 'OVH_CLOUD_CONNECT', + 'PACK_XDSL', + 'XDSL', + 'OKMS_RESOURCE', + 'VRACK_SERVICES_RESOURCE', + ].includes(this.serviceType); + } + + canBeDeleted() { + return ( + [ + 'EMAIL_DOMAIN', + 'ENTERPRISE_CLOUD_DATABASE', + 'HOSTING_WEB', + 'HOSTING_PRIVATE_DATABASE', + 'WEBCOACH', + ].includes(this.serviceType) && !this.isResiliated() + ); + } + + hasParticularRenew() { + return [ + 'EXCHANGE', + 'EMAIL_EXCHANGE', + 'SMS', + 'EMAIL_DOMAIN', + 'VEEAM_ENTERPRISE', + ].includes(this.serviceType); + } + + hasEngagement() { + return Boolean(this.engagedUpTo) && Date.now() < this.engagedUpTo.getTime(); + } + + canBeUnresiliated(nichandle: string) { + return ( + this.shouldDeleteAtExpiration() && + !this.hasManualRenew() && + this.hasResiliationRights(nichandle) + ); + } + + canCancelResiliationByEndRule() { + return this.engagementDetails?.endRule?.possibleStrategies.includes( + 'REACTIVATE_ENGAGEMENT', + ); + } + + canResiliateByEndRule() { + return ( + this.engagementDetails?.endRule?.strategy === 'REACTIVATE_ENGAGEMENT' && + this.engagementDetails?.endRule?.possibleStrategies?.length > 0 + ); + } + + isByoipService() { + return this.domain?.startsWith(BYOIP_SERVICE_PREFIX); + } +} diff --git a/packages/manager/apps/hub/src/billing/types/service-links.type.ts b/packages/manager/apps/hub/src/billing/types/service-links.type.ts new file mode 100644 index 000000000000..d324c37d0578 --- /dev/null +++ b/packages/manager/apps/hub/src/billing/types/service-links.type.ts @@ -0,0 +1,18 @@ +export type ServiceLinkName = + | 'anticipatePayment' + | 'buySMSCredits' + | 'cancelCommitment' + | 'cancelResiliation' + | 'configureExchangeAccountsRenewal' + | 'configureRenewal' + | 'configureSMSAutoReload' + | 'manageCommitment' + | 'modifyExchangeBilling' + | 'payBill' + | 'renewManually' + | 'resiliate' + | 'resiliateByDeletion' + | 'seeService' + | 'warnBillingNic'; + +export type ServiceLinks = Partial>; diff --git a/packages/manager/apps/hub/src/data/api/billingServices.ts b/packages/manager/apps/hub/src/data/api/billingServices.ts new file mode 100644 index 000000000000..bf132ca55be5 --- /dev/null +++ b/packages/manager/apps/hub/src/data/api/billingServices.ts @@ -0,0 +1,29 @@ +import { aapi } from '@ovh-ux/manager-core-api'; +import { AxiosResponse } from 'axios'; +import { + BillingService, + BillingServicesData, + HubBillingServices, +} from '@/billing/types/billingServices.type'; + +export const getBillingServices: () => Promise< + HubBillingServices +> = async () => { + const { data } = await aapi.get>( + `/hub/billingServices`, + ); + const services = data.data?.billingServices; + // The returned value when status is 'ERROR' has changed in order to keep a standard return type + // for this hook, also this give the same result as previous code without the confusing part + return services.status === 'ERROR' + ? { + count: 0, + services: [], + } + : { + count: services?.data?.count, + services: + services?.data?.data?.map((service) => new BillingService(service)) || + [], + }; +}; diff --git a/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.spec.tsx b/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.spec.tsx new file mode 100644 index 000000000000..81820f2d639b --- /dev/null +++ b/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.spec.tsx @@ -0,0 +1,34 @@ +import React, { PropsWithChildren } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, it, vi } from 'vitest'; +import { useFetchHubBillingServices } from '@/data/hooks/billingServices/useBillingServices'; +import * as BillingServicesApi from '@/data/api/billingServices'; +import { HubBillingServices } from '@/billing/types/billingServices.type'; + +const queryClient = new QueryClient(); + +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + +describe('useFetchHubBillingServices', () => { + it('returns capsule even if api returned no services', async () => { + const services: HubBillingServices = { + services: [], + count: 0, + }; + const getServices = vi + .spyOn(BillingServicesApi, 'getBillingServices') + .mockReturnValue(new Promise((resolve) => resolve(services))); + + const { result } = renderHook(() => useFetchHubBillingServices(), { + wrapper, + }); + + await waitFor(() => { + expect(getServices).toHaveBeenCalled(); + expect(result.current.data).toEqual(services); + }); + }); +}); diff --git a/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.tsx b/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.tsx new file mode 100644 index 000000000000..5d054e51bdaa --- /dev/null +++ b/packages/manager/apps/hub/src/data/hooks/billingServices/useBillingServices.tsx @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { getBillingServices } from '@/data/api/billingServices'; +import { HubBillingServices } from '@/billing/types/billingServices.type'; + +export const useFetchHubBillingServices = () => + useQuery({ + queryKey: ['getHubBillingServices'], + queryFn: getBillingServices, + retry: 0, + }); diff --git a/packages/manager/apps/hub/src/index.tsx b/packages/manager/apps/hub/src/index.tsx index fcbe7c6384c7..7331788ef837 100644 --- a/packages/manager/apps/hub/src/index.tsx +++ b/packages/manager/apps/hub/src/index.tsx @@ -45,6 +45,9 @@ const init = async (appName: string) => { `${appName}/order`, `${appName}/billing`, `${appName}/error`, + `${appName}/payment-status`, + `billing/actions`, + `billing/status`, ], }); diff --git a/packages/manager/apps/hub/src/pages/layout/PaymentStatus.component.tsx b/packages/manager/apps/hub/src/pages/layout/PaymentStatus.component.tsx new file mode 100644 index 000000000000..64f290c4e887 --- /dev/null +++ b/packages/manager/apps/hub/src/pages/layout/PaymentStatus.component.tsx @@ -0,0 +1,302 @@ +import { lazy, Suspense, useContext } from 'react'; +import { + OsdsChip, + OsdsIcon, + OsdsLink, + OsdsSkeleton, + OsdsTable, + OsdsText, + OsdsTile, +} from '@ovhcloud/ods-components/react'; +import { + ODS_CHIP_SIZE, + ODS_ICON_NAME, + ODS_ICON_SIZE, + ODS_SKELETON_SIZE, + ODS_TEXT_LEVEL, + ODS_TEXT_SIZE, +} from '@ovhcloud/ods-components'; +import { + ODS_THEME_COLOR_INTENT, + ODS_THEME_TYPOGRAPHY_LEVEL, +} from '@ovhcloud/ods-common-theming'; +import { OdsHTMLAnchorElementTarget } from '@ovhcloud/ods-common-core'; +import { + ShellContext, + useOvhTracking, +} from '@ovh-ux/manager-react-shell-client'; +import { useTranslation } from 'react-i18next'; +import { Await } from 'react-router-dom'; +import { useFetchHubBillingServices } from '@/data/hooks/billingServices/useBillingServices'; +import { BillingService } from '@/billing/types/billingServices.type'; +import useDateFormat from '@/hooks/dateFormat/useDateFormat'; + +const TileError = lazy(() => + import('@/components/tile-error/TileError.component'), +); +const BillingStatus = lazy(() => + import('@/billing/components/billing-status/BillingStatus.component'), +); +const ServicesActions = lazy(() => + import('@/billing/components/services-actions/ServicesActions.component'), +); + +type PaymentStatusProps = { + canManageBilling: boolean; +}; + +export default function PaymentStatus({ + canManageBilling, +}: PaymentStatusProps) { + const { t } = useTranslation('hub/payment-status'); + const { t: tProducts } = useTranslation('hub/products'); + const { t: tCommon } = useTranslation('hub'); + const { data, isLoading, refetch } = useFetchHubBillingServices(); + const { + shell: { navigation }, + } = useContext(ShellContext); + const { trackClick } = useOvhTracking(); + const { format } = useDateFormat({ + options: { + year: 'numeric', + month: 'long', + day: 'numeric', + }, + }); + + const autorenewLink = canManageBilling + ? navigation.getURL('dedicated', '#/billing/autorenew', {}) + : null; + + const trackServiceAccess = () => { + trackClick({ + actionType: 'action', + actions: ['activity', 'payment-status', 'go-to-service'], + }); + }; + + const services = data?.services; + const count = data?.count || 0; + + return ( + +
+ + {t('ovh_manager_hub_payment_status_tile_title')} + + {count} + + + {autorenewLink && ( + } + > + ( + + {tCommon('manager_hub_see_all')} + + + + + )} + /> + + )} +
+ {!isLoading && !services && ( + } + > + + + )} + {!isLoading && services?.length === 0 && ( + + {t('ovh_manager_hub_payment_status_tile_no_services')} + + )} + {(isLoading || services) && ( + + + + {!isLoading && + services.map((service: BillingService) => ( + + + + {autorenewLink && ( + + )} + + ))} + {isLoading && + [1, 2, 3, 4].map((index) => ( + + + + ))} + +
+ {service.url ? ( + + {service.domain} + + ) : ( + + {service.domain} + + )} + + {tProducts( + `manager_hub_products_${service.serviceType}`, + )} + + +
+ + } + > + + +
+ {!service.isBillingSuspended() && ( +
+ {service.isOneShot() && + !service.isResiliated() && + !service.hasPendingResiliation() && ( + + - + + )} + {service.hasManualRenew() && + !service.isResiliated() && + !service.hasDebt() && ( + + {t( + 'ovh_manager_hub_payment_status_tile_before', + { + date: format(service.formattedExpiration), + }, + )} + + )} + {(service.isResiliated() || + service.hasPendingResiliation()) && ( + + {t('ovh_manager_hub_payment_status_tile_renew', { + date: format(service.formattedExpiration), + })} + + )} + {service.hasAutomaticRenewal() && + !service.isOneShot() && + !service.hasDebt() && + !service.isResiliated() && + !service.hasPendingResiliation() && ( + + {format(service.formattedExpiration)} + + )} + {service.hasDebt() && ( + + {t('ovh_manager_hub_payment_status_tile_now')} + + )} +
+ )} +
+ + } + > + ( + + )} + /> + +
+ +
+
+ )} +
+ ); +} diff --git a/packages/manager/apps/hub/src/pages/layout/layout.constants.ts b/packages/manager/apps/hub/src/pages/layout/layout.constants.ts index a424575154fc..66265c40fecd 100644 --- a/packages/manager/apps/hub/src/pages/layout/layout.constants.ts +++ b/packages/manager/apps/hub/src/pages/layout/layout.constants.ts @@ -1,9 +1,15 @@ +export const BILLING_FEATURE = 'billing:management'; +export const SIRET_BANNER_FEATURE = 'hub:banner-hub-invite-customer-siret'; +export const SIRET_MODAL_FEATURE = 'hub:popup-hub-invite-customer-siret'; +export const KYC_INDIA_FEATURE = 'identity-documents'; +export const KYC_FRAUD_FEATURE = 'procedures:fraud'; + export const features = [ - 'billing:management', - 'hub:banner-hub-invite-customer-siret', - 'hub:popup-hub-invite-customer-siret', - 'identity-documents', - 'procedures:fraud', + BILLING_FEATURE, + SIRET_BANNER_FEATURE, + SIRET_MODAL_FEATURE, + KYC_INDIA_FEATURE, + KYC_FRAUD_FEATURE, ]; export const BILLING_SUMMARY_PERIODS_IN_MONTHS = [1, 3, 6]; diff --git a/packages/manager/apps/hub/src/pages/layout/layout.test.tsx b/packages/manager/apps/hub/src/pages/layout/layout.test.tsx index 319ed4fb4ca5..c32d4257ecd2 100644 --- a/packages/manager/apps/hub/src/pages/layout/layout.test.tsx +++ b/packages/manager/apps/hub/src/pages/layout/layout.test.tsx @@ -1,6 +1,12 @@ -import React, { ReactNode } from 'react'; +import React, { LazyExoticComponent, ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as reactShellClientModule from '@ovh-ux/manager-react-shell-client'; import { @@ -17,7 +23,14 @@ import { ProductList } from '@/types/services.type'; import { LastOrder } from '@/types/lastOrder.type'; import BillingSummary from '@/pages/layout/BillingSummary.component'; import * as UseBillsHook from '@/data/hooks/bills/useBills'; +import * as UseBillingServicesHook from '@/data/hooks/billingServices/useBillingServices'; import EnterpriseBillingSummary from '@/pages/layout/EnterpriseBillingSummary.component'; +import PaymentStatus from '@/pages/layout/PaymentStatus.component'; +import { + FourServices, + NoServices, + TwoServices, +} from '@/_mock_/billingServices'; const queryClient = new QueryClient(); @@ -26,6 +39,9 @@ const services: ApiEnvelope = { status: 'OK', }; const lastOrder: LastOrder = { data: null, status: 'OK' }; +const featuresAvailability = { + 'billing:management': true, +}; const trackClickMock = vi.fn(); let isLastOrderLoading = true; let isAccountSidebarVisible = false; @@ -50,7 +66,12 @@ const shellContext = { isAccountSidebarVisible: () => isAccountSidebarVisible, }, navigation: { - getURL: vi.fn(), + getURL: vi.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve('https://fake-link.com'), 50), + ), + ), }, }, }; @@ -75,6 +96,10 @@ vi.mock('react-router-dom', async (importOriginal) => { }; }); +vi.mock('@ovh-ux/request-tagger', () => ({ + defineCurrentPage: () => ({}), +})); + vi.mock('@/components/welcome/Welcome.component', () => ({ default: () =>
Welcome
, })); @@ -95,6 +120,29 @@ vi.mock('@/components/hub-order-tracking/HubOrderTracking.component', () => ({ default: () =>
Order Tracking
, })); +vi.mock('@/pages/layout/BillingSummary.component', () => ({ + default: () =>
Billing Summary
, +})); + +vi.mock('@/pages/layout/EnterpriseBillingSummary.component', () => ({ + default: () =>
Enterprise Billing Summary
, +})); + +vi.mock('@/billing/components/billing-status/BillingStatus.component', () => ({ + default: () =>
Billing Status
, +})); + +vi.mock( + '@/billing/components/services-actions/ServicesActions.component', + () => ({ + default: () =>
Service Actions
, + }), +); + +vi.mock('@/pages/layout/PaymentStatus.component', () => ({ + default: () =>
Payment Status
, +})); + vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => { const original: typeof reactShellClientModule = await importOriginal(); return { @@ -109,6 +157,15 @@ vi.mock('@ovh-ux/manager-react-shell-client', async (importOriginal) => { }; }); +vi.mock('@ovh-ux/manager-react-components', () => ({ + useFeatureAvailability: ( + features: string[], + ): { + data: typeof featuresAvailability; + isPending: boolean; + } => ({ data: featuresAvailability, isPending: false }), +})); + vi.mock('@/data/hooks/services/useServices', () => ({ useFetchHubServices: (): { data: ApiEnvelope; @@ -172,14 +229,23 @@ vi.mock('@/data/hooks/debt/useDebt', () => ({ useFetchHubDebt: vi.fn(() => useDebtMockValue), })); +const useBillingServicesMockValue: any = { + data: null, + isLoading: true, + error: null, +}; +vi.spyOn(UseBillingServicesHook, 'useFetchHubBillingServices').mockReturnValue( + useBillingServicesMockValue, +); + const intlSpy = vi.spyOn(Intl, 'NumberFormat'); describe('Layout.page', () => { it('should render skeletons while loading', async () => { const { getByTestId, findByTestId } = renderComponent(); - expect(getByTestId('welcome_skeleton')).not.toBeNull(); expect(getByTestId('banners_skeleton')).not.toBeNull(); + const tileGridTitleSkeleton = await findByTestId( 'tile_grid_title_skeleton', ); @@ -203,7 +269,7 @@ describe('Layout.page', () => { expect(queryByText('oui-modal.siret')).not.toBeInTheDocument(); expect(getByText('oui-message.kycIndia')).not.toBeNull(); expect(getByText('oui-message.kycFraud')).not.toBeNull(); - expect(getByText('hub-payment-status')).not.toBeNull(); + expect(queryByText('Payment Status')).not.toBeInTheDocument(); expect(queryByText('Support')).not.toBeInTheDocument(); expect(queryByText('Order Tracking')).not.toBeInTheDocument(); expect(queryByText('Products')).not.toBeInTheDocument(); @@ -257,14 +323,15 @@ describe('Layout.page', () => { expect(getByText('oui-modal.siret')).not.toBeNull(); expect(getByText('oui-message.kycIndia')).not.toBeNull(); expect(getByText('oui-message.kycFraud')).not.toBeNull(); - expect(getByText('hub-payment-status')).not.toBeNull(); expect(getByText('Support')).not.toBeNull(); expect(getByText('Order Tracking')).not.toBeNull(); expect(getByText('Products')).not.toBeNull(); expect(queryByText('hub-catalog-items')).not.toBeInTheDocument(); const billingSummary = await findByTestId('billing_summary'); + const paymentStatus = await findByTestId('payment_status'); expect(billingSummary).not.toBeNull(); + expect(paymentStatus).not.toBeNull(); }); it('should have correct css class if account sidebard is closed', async () => { @@ -310,6 +377,8 @@ describe('Layout.page', () => { }); describe('BillingSummary component', () => { + vi.unmock('@/pages/layout/BillingSummary.component'); + it('should render skeletons while loading', async () => { const { getByText, getByTestId } = renderComponent(); @@ -321,10 +390,13 @@ describe('Layout.page', () => { it('should display correct wording when customer has no bills', async () => { useBillsMockValue.isPending = false; - const { getByText, queryByTestId } = renderComponent(); + const { findByTestId, getByTestId, getByText } = renderComponent( + , + ); expect(getByText('hub_billing_summary_debt_no_bills')).not.toBeNull(); - const link = await queryByTestId('bills_link'); + expect(getByTestId('bills_link_skeleton')).not.toBeNull(); + const link = await findByTestId('bills_link'); expect(link).not.toBeNull(); }); @@ -367,9 +439,10 @@ describe('Layout.page', () => { }); it('should track click on bills link', async () => { - const { getByTestId } = renderComponent(); + const { findByTestId, getByTestId } = renderComponent(); - const link = getByTestId('bills_link'); + expect(getByTestId('bills_link_skeleton')).not.toBeNull(); + const link = await findByTestId('bills_link'); await act(() => fireEvent.click(link)); expect(trackClickMock).toHaveBeenCalledWith({ @@ -396,6 +469,8 @@ describe('Layout.page', () => { }); describe('EnterpriseBillingSummary component', () => { + vi.unmock('@/pages/layout/EnterpriseBillingSummary.component'); + it('should render title, description and tracked link', async () => { const { getByTestId } = renderComponent(); @@ -415,4 +490,199 @@ describe('Layout.page', () => { }); }); }); + + describe('PaymentStatus component', () => { + vi.unmock('@/pages/layout/PaymentStatus.component'); + it('should render title and badge', async () => { + const { findByTestId, getByTestId } = renderComponent( + , + ); + + expect(getByTestId('payment_status_title')).not.toBeNull(); + expect(getByTestId('payment_status_badge')).not.toBeNull(); + expect(getByTestId('my_services_link_skeleton')).not.toBeNull(); + + const myServiceLink = await findByTestId('my_services_link'); + expect(myServiceLink).not.toBeNull(); + }); + + it('should render table with skeletons while loading', async () => { + const { getAllByTestId, getByTestId } = renderComponent( + , + ); + + expect(getByTestId('payment_status_table')).not.toBeNull(); + expect(getAllByTestId('payment_status_skeleton_line').length).toBe(4); + }); + + it('should render error if loading is done and no data has been retrieved', async () => { + useBillingServicesMockValue.isLoading = false; + const { findByText } = renderComponent( + , + ); + + const tileError = await findByText('manager_error_tile_title'); + expect(tileError).not.toBeNull(); + }); + + it('should render a message if loading is done and user has no services', async () => { + useBillingServicesMockValue.data = NoServices; + const { getByText } = renderComponent( + , + ); + + expect( + getByText('ovh_manager_hub_payment_status_tile_no_services'), + ).not.toBeNull(); + }); + + it('should render the correct number of services', async () => { + useBillingServicesMockValue.data = TwoServices; + const { findAllByText, getAllByTestId, getByTestId } = renderComponent( + , + ); + + expect(getByTestId('payment_status_badge').innerHTML.includes('2')).toBe( + true, + ); + const servicesLine = getAllByTestId('billing_service'); + expect(servicesLine.length).toBe(2); + expect(getAllByTestId('billing_status_skeleton').length).toBe(2); + const servicesStatuses = await findAllByText('Billing Status'); + expect(servicesStatuses.length).toBe(2); + expect(getAllByTestId('service_expiration_date_message').length).toBe(2); + }); + + it('should display the correct message for service in debt', async () => { + const { getByTestId } = renderComponent( + , + ); + + expect(getByTestId('service_with_debt')).not.toBeNull(); + }); + + it('should display the correct message for service in automatic renew without debt and not resiliated', async () => { + const { getByTestId } = renderComponent( + , + ); + + expect(getByTestId('service_with_expiration_date')).not.toBeNull(); + }); + + it('should display service type for each service', async () => { + useBillingServicesMockValue.data = FourServices; + const { getByText } = renderComponent( + , + ); + + expect(getByText('manager_hub_products_HOSTING_WEB')).not.toBeNull(); + expect(getByText('manager_hub_products_DOMAIN')).not.toBeNull(); + expect(getByText('manager_hub_products_DEDICATED_SERVER')).not.toBeNull(); + expect(getByText('manager_hub_products_DEDICATED_CLOUD')).not.toBeNull(); + }); + + it('should display the correct information for resiliated service', async () => { + const { getByTestId, getByText } = renderComponent( + , + ); + const serviceLink = getByText('serviceResiliated'); + expect(serviceLink).not.toBeNull(); + expect(serviceLink).toHaveAttribute( + 'href', + 'https://www.ovh.com/manager/#/web/configuration/hosting/serviceResiliated', + ); + + expect(getByTestId('service_with_termination_date')).not.toBeNull(); + }); + + it('should display the correct information for service in manual renew without debt and not resiliated', async () => { + const { getByTestId, getByText } = renderComponent( + , + ); + const serviceLink = getByText( + 'serviceWithManualRenewNotResiliatedWithoutDebt', + ); + expect(serviceLink).not.toBeNull(); + expect(serviceLink).toHaveAttribute( + 'href', + 'https://www.ovh.com/manager/#/web/configuration/domain/serviceWithManualRenewNotResiliatedWithoutDebt/information', + ); + + expect(getByTestId('service_valid_until_date')).not.toBeNull(); + }); + + it('should display the correct information for one shot service not resiliated', async () => { + const { getByTestId, getByText } = renderComponent( + , + ); + const serviceLink = getByText('serviceOneShotWithoutResiliation'); + expect(serviceLink).not.toBeNull(); + expect(serviceLink).toHaveAttribute( + 'href', + 'https://www.ovh.com/manager/#/dedicated/server/serviceOneShotWithoutResiliation', + ); + + expect(getByTestId('service_without_expiration_date')).not.toBeNull(); + }); + + it('should display the correct information for service without url and billing suspended', async () => { + const { getByText } = renderComponent( + , + ); + const serviceWithoutUrlAndSuspendedBillingLink = getByText( + 'serviceWithoutUrlAndSuspendedBilling', + ); + expect(serviceWithoutUrlAndSuspendedBillingLink).not.toBeNull(); + expect(serviceWithoutUrlAndSuspendedBillingLink).not.toHaveAttribute( + 'href', + ); + }); + + it('should track service access', async () => { + const { getByText } = renderComponent( + , + ); + const service = getByText( + 'serviceWithManualRenewNotResiliatedWithoutDebt', + ); + expect(service).not.toBeNull(); + await act(() => fireEvent.click(service)); + + expect(trackClickMock).toHaveBeenCalledWith({ + actionType: 'action', + actions: ['activity', 'payment-status', 'go-to-service'], + }); + }); + + describe('With billing management', () => { + it('should render "see all" link', async () => { + const { + getAllByTestId, + findAllByText, + findByTestId, + getByTestId, + } = renderComponent(); + expect(getByTestId('my_services_link_skeleton')).not.toBeNull(); + expect(getAllByTestId('services_actions_skeleton').length).toBe(4); + + const myServiceLink = await findByTestId('my_services_link'); + expect(myServiceLink).not.toBeNull(); + + const serviceActionsComponents = await findAllByText('Service Actions'); + expect(serviceActionsComponents).not.toBeNull(); + }); + }); + + describe('Without billing management', () => { + it('should not render "see all" link', async () => { + const { queryAllByTestId, queryByTestId } = renderComponent( + , + ); + expect( + queryByTestId('my_services_link_skeleton'), + ).not.toBeInTheDocument(); + expect(queryAllByTestId('services_actions_skeleton').length).toBe(0); + }); + }); + }); }); diff --git a/packages/manager/apps/hub/src/pages/layout/layout.tsx b/packages/manager/apps/hub/src/pages/layout/layout.tsx index 719ef125ff64..ca78da254572 100644 --- a/packages/manager/apps/hub/src/pages/layout/layout.tsx +++ b/packages/manager/apps/hub/src/pages/layout/layout.tsx @@ -17,11 +17,15 @@ import { ODS_TEXT_LEVEL, ODS_TEXT_SIZE, } from '@ovhcloud/ods-components'; -// import { useFeatureAvailability } from '@ovhcloud/manager-components'; +import { defineCurrentPage } from '@ovh-ux/request-tagger'; +import { useFeatureAvailability } from '@ovh-ux/manager-react-components'; import { useTranslation } from 'react-i18next'; -// import { features } from '@/pages/layout/layout.constants'; +import { features, BILLING_FEATURE } from '@/pages/layout/layout.constants'; import { useFetchHubServices } from '@/data/hooks/services/useServices'; import { useFetchHubLastOrder } from '@/data/hooks/lastOrder/useLastOrder'; +// Components used in Suspense's fallback cannot be lazy loaded (break testing) +import TileGridSkeleton from '@/components/tile-grid-skeleton/TileGridSkeleton.component'; +import TileSkeleton from '@/components/tile-grid-skeleton/tile-skeleton/TileSkeleton.component'; const Welcome = lazy(() => import('@/components/welcome/Welcome.component')); const Banner = lazy(() => import('@/components/banner/Banner.component')); @@ -29,9 +33,6 @@ const Products = lazy(() => import('@/components/products/Products.component')); const OrderTracking = lazy(() => import('@/components/hub-order-tracking/HubOrderTracking.component'), ); -const TileGridSkeleton = lazy(() => - import('@/components/tile-grid-skeleton/TileGridSkeleton.component'), -); const HubSupport = lazy(() => import('@/components/hub-support/HubSupport.component'), ); @@ -41,6 +42,9 @@ const BillingSummary = lazy(() => const EnterpriseBillingSummary = lazy(() => import('@/pages/layout/EnterpriseBillingSummary.component'), ); +const PaymentStatus = lazy(() => + import('@/pages/layout/PaymentStatus.component'), +); export default function Layout() { const location = useLocation(); @@ -58,11 +62,15 @@ export default function Layout() { }, [location]); useEffect(() => { + defineCurrentPage(`app.dashboard`); shell.ux.hidePreloader(); shell.ux.stopProgress(); }, []); - // const { data: availability, isPending: isAvailabilityLoading } = useFeatureAvailability(features); + const { + data: availability, + isPending: isAvailabilityLoading, + } = useFeatureAvailability(features); const { data: services, isPending: areServicesLoading, @@ -176,28 +184,20 @@ export default function Layout() { {t('manager_hub_dashboard_overview')}
- {isLoading && ( - <> - - - - - - )} - {!isLoading && ( -
- hub-payment-status -
- )} + {isLoading && } {!isLoading && !isFreshCustomer && ( <> +
+ }> + + +
+ } > {user.enterprise ? ( @@ -210,10 +210,7 @@ export default function Layout() {
+ } > @@ -222,10 +219,7 @@ export default function Layout() {
+ } > @@ -235,18 +229,7 @@ export default function Layout() { )}
- {isLoading && ( - - } - > - - - )} + {isLoading && } {!isLoading && !isFreshCustomer && ( }> diff --git a/packages/manager/apps/hub/tailwind.config.js b/packages/manager/apps/hub/tailwind.config.js index 83594593e220..657ab11bb87d 100644 --- a/packages/manager/apps/hub/tailwind.config.js +++ b/packages/manager/apps/hub/tailwind.config.js @@ -7,7 +7,7 @@ module.exports = { content: [ './src/**/*.{js,jsx,ts,tsx}', path.join( - path.dirname(require.resolve('@ovhcloud/manager-components')), + path.dirname(require.resolve('@ovh-ux/manager-react-components')), '**/*.{js,jsx,ts,tsx}', ), ], diff --git a/packages/manager/apps/hub/tsconfig.json b/packages/manager/apps/hub/tsconfig.json index e2104f471575..8ff43fa18040 100644 --- a/packages/manager/apps/hub/tsconfig.json +++ b/packages/manager/apps/hub/tsconfig.json @@ -16,7 +16,7 @@ "declaration": true, "resolveJsonModule": true, "allowJs": true, - "jsx": "react", + "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["./src/*"] diff --git a/packages/manager/apps/hub_old/package.json b/packages/manager/apps/hub_old/package.json index 3d70c9e14092..198eab377941 100644 --- a/packages/manager/apps/hub_old/package.json +++ b/packages/manager/apps/hub_old/package.json @@ -1,5 +1,5 @@ { - "name": "@ovh-ux/manager-hub-app", + "name": "@ovh-ux/manager-hub-old-app", "version": "5.10.14", "private": true, "description": "OVHcloud Dashboard control panel.",