richie-education 2.28.2-dev39 → 2.28.2-dev58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.eslintrc.json +11 -2
  2. package/i18n/locales/ar-SA.json +209 -125
  3. package/i18n/locales/es-ES.json +210 -126
  4. package/i18n/locales/fa-IR.json +209 -125
  5. package/i18n/locales/fr-CA.json +209 -125
  6. package/i18n/locales/fr-FR.json +209 -125
  7. package/i18n/locales/ko-KR.json +209 -125
  8. package/i18n/locales/pt-PT.json +212 -128
  9. package/i18n/locales/ru-RU.json +209 -125
  10. package/i18n/locales/vi-VN.json +209 -125
  11. package/js/api/joanie.ts +14 -17
  12. package/js/api/lms/dummy.ts +1 -12
  13. package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +16 -9
  14. package/js/components/ContractFrame/AbstractContractFrame.tsx +32 -25
  15. package/js/components/ContractFrame/LearnerContractFrame.tsx +2 -2
  16. package/js/components/ContractFrame/_styles.scss +6 -14
  17. package/js/components/CreditCardSelector/index.spec.tsx +7 -7
  18. package/js/components/CreditCardSelector/index.tsx +2 -2
  19. package/js/components/DownloadContractButton/index.spec.tsx +1 -1
  20. package/js/components/OpenEdxFullNameForm/index.spec.tsx +229 -0
  21. package/js/components/OpenEdxFullNameForm/index.tsx +7 -7
  22. package/js/components/PaymentInterfaces/LyraPopIn.tsx +2 -2
  23. package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
  24. package/js/components/PaymentInterfaces/__mocks__/index.tsx +1 -4
  25. package/js/components/PaymentInterfaces/types.ts +5 -2
  26. package/js/components/PurchaseButton/index.spec.tsx +69 -37
  27. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +2 -1
  28. package/js/components/SaleTunnel/CertificateSaleTunnel/index.tsx +2 -2
  29. package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +6 -10
  30. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +75 -41
  31. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +0 -30
  32. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/_styles.scss +12 -0
  33. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +160 -0
  34. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +15 -29
  35. package/js/components/SaleTunnel/Sponsors/SaleTunnelSponsors.tsx +5 -0
  36. package/js/components/SaleTunnel/SubscriptionButton/_styles.scss +7 -0
  37. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +202 -0
  38. package/js/components/SaleTunnel/_styles.scss +10 -1
  39. package/js/components/SaleTunnel/hooks/useTerms.tsx +0 -77
  40. package/js/components/SaleTunnel/index.credential.spec.tsx +12 -21
  41. package/js/components/SaleTunnel/index.full-process.spec.tsx +110 -48
  42. package/js/components/SaleTunnel/index.spec.tsx +330 -779
  43. package/js/components/SignContractButton/index.omniscientOrders.spec.tsx +16 -11
  44. package/js/components/SignContractButton/index.spec.tsx +16 -20
  45. package/js/components/SignContractButton/index.tsx +3 -1
  46. package/js/hooks/useCreditCards/index.spec.tsx +70 -6
  47. package/js/hooks/useCreditCards/index.ts +49 -11
  48. package/js/hooks/useOrders/index.spec.tsx +322 -0
  49. package/js/hooks/{useOrders.ts → useOrders/index.ts} +40 -14
  50. package/js/hooks/useProductOrder/index.spec.tsx +77 -60
  51. package/js/hooks/useProductOrder/index.tsx +2 -2
  52. package/js/hooks/useResources/useResourcesRoot.ts +1 -0
  53. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.spec.tsx +1 -1
  54. package/js/pages/DashboardCreditCardsManagement/CreditCardBrandLogo.tsx +4 -2
  55. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +8 -5
  56. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +8 -9
  57. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +1 -1
  58. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +1 -6
  59. package/js/settings/settings.test.ts +11 -2
  60. package/js/translations/ar-SA.json +1 -1
  61. package/js/translations/es-ES.json +1 -1
  62. package/js/translations/fa-IR.json +1 -1
  63. package/js/translations/fr-CA.json +1 -1
  64. package/js/translations/fr-FR.json +1 -1
  65. package/js/translations/ko-KR.json +1 -1
  66. package/js/translations/pt-PT.json +1 -1
  67. package/js/translations/ru-RU.json +1 -1
  68. package/js/translations/vi-VN.json +1 -1
  69. package/js/types/Joanie.ts +49 -34
  70. package/js/utils/OrderHelper/index.ts +38 -42
  71. package/js/utils/search/getSuggestionsSection/index.spec.ts +3 -2
  72. package/js/utils/test/factories/joanie.ts +36 -51
  73. package/js/widgets/Dashboard/components/DashboardItem/CourseEnrolling/index.tsx +8 -18
  74. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +26 -32
  75. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +11 -6
  76. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +7 -6
  77. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +9 -10
  78. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +3 -1
  79. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +6 -7
  80. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +28 -8
  81. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +4 -6
  82. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +18 -71
  83. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +34 -35
  84. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +27 -24
  85. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +18 -73
  86. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +32 -16
  87. package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +3 -11
  88. package/js/widgets/Dashboard/components/Signature/SignatureDummy.tsx +25 -3
  89. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/EnrollableCourseRunList.tsx +2 -6
  90. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +7 -14
  91. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.spec.tsx +7 -5
  92. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseRunItem/index.tsx +5 -7
  93. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +242 -332
  94. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +12 -13
  95. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +10 -21
  96. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +2 -2
  97. package/package.json +27 -27
  98. package/scss/components/_index.scss +2 -1
  99. package/js/components/PaymentButton/_styles.scss +0 -27
  100. package/js/components/SaleTunnel/GenericPaymentButton/index.tsx +0 -333
  101. package/js/components/SaleTunnel/SaleTunnelNotValidated/index.tsx +0 -70
  102. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader/index.tsx +0 -41
@@ -57,16 +57,7 @@ type Story = StoryObj<typeof CourseProductItem>;
57
57
 
58
58
  export const Default: Story = {};
59
59
 
60
- export const WithPendingOrder: Story = {
61
- args: {
62
- productId: 'AAA',
63
- course: PacedCourseFactory({ code: 'BBB' }).one(),
64
- },
65
- render: (args) =>
66
- render(args, { order: CredentialOrderFactory({ state: OrderState.PENDING }).one() }),
67
- };
68
-
69
- export const WithValidatedOrder: Story = {
60
+ export const WithCompletedOrder: Story = {
70
61
  args: {
71
62
  productId: 'AAA',
72
63
  course: PacedCourseFactory({ code: 'BBB' }).one(),
@@ -75,7 +66,7 @@ export const WithValidatedOrder: Story = {
75
66
  const courseRunWithEnrollment = CourseRunFactory().one();
76
67
  return render(args, {
77
68
  order: CredentialOrderFactory({
78
- state: OrderState.VALIDATED,
69
+ state: OrderState.COMPLETED,
79
70
  target_enrollments: EnrollmentFactory({
80
71
  is_active: true,
81
72
  course_run: courseRunWithEnrollment,
@@ -91,11 +82,19 @@ export const WithValidatedOrder: Story = {
91
82
  },
92
83
  };
93
84
 
94
- export const WithSubmittedOrder: Story = {
85
+ export const WithPendingOrder: Story = {
95
86
  args: {
96
87
  productId: 'AAA',
97
88
  course: PacedCourseFactory({ code: 'BBB' }).one(),
98
89
  },
99
90
  render: (args) =>
100
- render(args, { order: CredentialOrderFactory({ state: OrderState.SUBMITTED }).one() }),
91
+ render(args, { order: CredentialOrderFactory({ state: OrderState.PENDING }).one() }),
92
+ };
93
+
94
+ export const WithNoOrder: Story = {
95
+ args: {
96
+ productId: 'AAA',
97
+ course: PacedCourseFactory({ code: 'BBB' }).one(),
98
+ },
99
+ render: (args) => render(args),
101
100
  };
@@ -1,7 +1,7 @@
1
1
  import { Children, useEffect, useMemo } from 'react';
2
2
  import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
3
3
  import c from 'classnames';
4
- import { ProductType, OrderState, Product, CredentialOrder } from 'types/Joanie';
4
+ import { ProductType, Product, CredentialOrder, PURCHASABLE_ORDER_STATES } from 'types/Joanie';
5
5
  import { useCourseProduct } from 'hooks/useCourseProducts';
6
6
  import { Spinner } from 'components/Spinner';
7
7
  import { Icon, IconTypeEnum } from 'components/Icon';
@@ -11,7 +11,6 @@ import { ProductHelper } from 'utils/ProductHelper';
11
11
  import useProductOrder from 'hooks/useProductOrder';
12
12
  import { OrderHelper } from 'utils/OrderHelper';
13
13
  import { handle } from 'utils/errors/handle';
14
- import { ProductSignatureHeader } from 'widgets/SyllabusCourseRunsList/components/CourseProductItem/components/ProductSignatureHeader';
15
14
  import { PacedCourse } from 'types';
16
15
  import CertificateItem from './components/CourseProductCertificateItem';
17
16
  import CourseRunItem from './components/CourseRunItem';
@@ -23,12 +22,6 @@ const messages = defineMessages({
23
22
  description: 'Message displayed when authenticated user owned the product',
24
23
  id: 'components.CourseProductItem.purchased',
25
24
  },
26
- pending: {
27
- defaultMessage: 'Pending',
28
- description:
29
- 'Message displayed when authenticated user has purchased the product but order is still pending',
30
- id: 'components.CourseProductItem.pending',
31
- },
32
25
  loading: {
33
26
  defaultMessage: 'Loading product information...',
34
27
  description:
@@ -64,10 +57,10 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
64
57
  const intl = useIntl();
65
58
  const formatDate = useDateFormat();
66
59
 
67
- // compact mode is available for product until they got a VALIDATED order.
60
+ // compact mode is available for product until they got an active order.
68
61
  const canShowMetadata = useMemo(() => {
69
- return compact && (!order || [OrderState.SUBMITTED, OrderState.PENDING].includes(order!.state));
70
- }, [compact, hasPurchased]);
62
+ return compact && (!order || canPurchase);
63
+ }, [compact, hasPurchased, canPurchase]);
71
64
 
72
65
  const [minDate, maxDate] = useMemo(() => {
73
66
  if (!canShowMetadata) return [undefined, undefined];
@@ -84,8 +77,7 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
84
77
  <div className="product-widget__header-main">
85
78
  <h3 className="product-widget__title">{product.title}</h3>
86
79
  <strong className="product-widget__price h6">
87
- {order?.state === OrderState.VALIDATED && <FormattedMessage {...messages.purchased} />}
88
- {order?.state === OrderState.SUBMITTED && <FormattedMessage {...messages.pending} />}
80
+ {hasPurchased && <FormattedMessage {...messages.purchased} />}
89
81
  {canPurchase && (
90
82
  <FormattedNumber
91
83
  currency={product.price_currency}
@@ -123,9 +115,6 @@ const Header = ({ product, order, hasPurchased, canPurchase, compact }: HeaderPr
123
115
  );
124
116
  };
125
117
  const Content = ({ product, order }: { product: Product; order?: CredentialOrder }) => {
126
- const needsSignature = order
127
- ? OrderHelper.orderNeedsSignature(order, product.contract_definition)
128
- : false;
129
118
  const targetCourses = useMemo(() => {
130
119
  if (order) {
131
120
  return order.target_courses;
@@ -140,10 +129,9 @@ const Content = ({ product, order }: { product: Product; order?: CredentialOrder
140
129
 
141
130
  return (
142
131
  <ol className="product-widget__content">
143
- {needsSignature && <ProductSignatureHeader order={order} />}
144
132
  {Children.toArray(
145
133
  targetCourses.map((target_course) => (
146
- <CourseRunItem targetCourse={target_course} order={order} product={product} />
134
+ <CourseRunItem targetCourse={target_course} order={order} />
147
135
  )),
148
136
  )}
149
137
  {product.certificate_definition && (
@@ -168,12 +156,13 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
168
156
  });
169
157
 
170
158
  const order = productOrder as CredentialOrder;
171
- const canPurchase = !order || order.state === OrderState.PENDING;
172
- const hasPurchased = (order && order.state === OrderState.VALIDATED) ?? false;
159
+ const canPurchase = !order || PURCHASABLE_ORDER_STATES.includes(order.state);
160
+ const hasPurchased = OrderHelper.isActive(order);
161
+ const canEnroll = OrderHelper.allowEnrollment(order);
173
162
 
174
163
  const hasError = Boolean(productQueryStates.error);
175
164
  const isFetching = productQueryStates.fetching || orderQueryStates.fetching;
176
- const canShowContent = !compact || hasPurchased;
165
+ const canShowContent = !compact || canEnroll;
177
166
 
178
167
  useEffect(() => {
179
168
  if (product && product.type !== ProductType.CREDENTIAL) {
@@ -2,12 +2,12 @@ import { act, fireEvent, screen } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
 
4
4
  import { faker } from '@faker-js/faker';
5
- import { Deferred } from 'utils/test/deferred';
6
- import { EnrollmentFactory as JoanieEnrollment } from 'utils/test/factories/joanie';
7
5
  import {
8
6
  CourseRunFactory,
9
7
  RichieContextFactory as mockRichieContextFactory,
10
8
  } from 'utils/test/factories/richie';
9
+ import { Deferred } from 'utils/test/deferred';
10
+ import { EnrollmentFactory as JoanieEnrollment } from 'utils/test/factories/joanie';
11
11
  import { HttpStatusCode } from 'utils/errors/HttpError';
12
12
  import { Priority } from 'types';
13
13
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.28.2-dev39",
3
+ "version": "2.28.2-dev58",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -38,10 +38,10 @@
38
38
  "not dead"
39
39
  ],
40
40
  "dependencies": {
41
- "@babel/core": "7.24.9",
41
+ "@babel/core": "7.25.2",
42
42
  "@babel/plugin-syntax-dynamic-import": "7.8.3",
43
43
  "@babel/plugin-transform-modules-commonjs": "7.24.8",
44
- "@babel/preset-env": "7.25.0",
44
+ "@babel/preset-env": "7.25.3",
45
45
  "@babel/preset-react": "7.24.7",
46
46
  "@babel/preset-typescript": "7.24.7",
47
47
  "@faker-js/faker": "8.4.1",
@@ -51,20 +51,20 @@
51
51
  "@lyracom/embedded-form-glue": "1.4.2",
52
52
  "@openfun/cunningham-react": "2.9.3",
53
53
  "@openfun/cunningham-tokens": "2.1.1",
54
- "@sentry/browser": "8.20.0",
55
- "@sentry/types": "8.20.0",
56
- "@storybook/addon-actions": "8.2.6",
57
- "@storybook/addon-essentials": "8.2.6",
58
- "@storybook/addon-interactions": "8.2.6",
59
- "@storybook/addon-links": "8.2.6",
60
- "@storybook/react": "8.2.6",
61
- "@storybook/react-webpack5": "8.2.6",
62
- "@storybook/test": "8.2.6",
63
- "@tanstack/query-core": "5.51.15",
64
- "@tanstack/query-sync-storage-persister": "5.51.15",
65
- "@tanstack/react-query": "5.51.15",
66
- "@tanstack/react-query-devtools": "5.51.5",
67
- "@tanstack/react-query-persist-client": "5.51.15",
54
+ "@sentry/browser": "8.25.0",
55
+ "@sentry/types": "8.25.0",
56
+ "@storybook/addon-actions": "8.2.8",
57
+ "@storybook/addon-essentials": "8.2.8",
58
+ "@storybook/addon-interactions": "8.2.8",
59
+ "@storybook/addon-links": "8.2.8",
60
+ "@storybook/react": "8.2.8",
61
+ "@storybook/react-webpack5": "8.2.8",
62
+ "@storybook/test": "8.2.8",
63
+ "@tanstack/query-core": "5.51.21",
64
+ "@tanstack/query-sync-storage-persister": "5.51.21",
65
+ "@tanstack/react-query": "5.51.23",
66
+ "@tanstack/react-query-devtools": "5.51.23",
67
+ "@tanstack/react-query-persist-client": "5.51.23",
68
68
  "@testing-library/dom": "10.4.0",
69
69
  "@testing-library/jest-dom": "6.4.8",
70
70
  "@testing-library/react": "16.0.0",
@@ -81,16 +81,16 @@
81
81
  "@types/react-dom": "18.3.0",
82
82
  "@types/react-modal": "3.16.3",
83
83
  "@types/uuid": "10.0.0",
84
- "@typescript-eslint/eslint-plugin": "7.17.0",
85
- "@typescript-eslint/parser": "7.17.0",
84
+ "@typescript-eslint/eslint-plugin": "8.0.1",
85
+ "@typescript-eslint/parser": "8.0.1",
86
86
  "babel-jest": "29.7.0",
87
87
  "babel-loader": "9.1.3",
88
88
  "babel-plugin-react-intl": "8.2.25",
89
89
  "bootstrap": ">=4.6.0 <5",
90
90
  "classnames": "2.5.1",
91
91
  "cljs-merge": "1.1.1",
92
- "core-js": "3.37.1",
93
- "downshift": "9.0.7",
92
+ "core-js": "3.38.0",
93
+ "downshift": "9.0.8",
94
94
  "eslint": ">=8.57.0 <9",
95
95
  "eslint-config-airbnb": "19.0.4",
96
96
  "eslint-config-airbnb-typescript": "18.0.0",
@@ -108,14 +108,14 @@
108
108
  "file-loader": "6.2.0",
109
109
  "glob": "11.0.0",
110
110
  "i18n-iso-countries": "7.11.3",
111
- "iframe-resizer": "4.4.5",
111
+ "iframe-resizer": "<5",
112
112
  "intl-pluralrules": "2.0.1",
113
113
  "jest": "29.7.0",
114
114
  "jest-environment-jsdom": "29.7.0",
115
115
  "js-cookie": "3.0.5",
116
116
  "lodash-es": "4.17.21",
117
117
  "mdn-polyfills": "5.20.0",
118
- "msw": "2.3.4",
118
+ "msw": "2.3.5",
119
119
  "node-fetch": ">2.6.6 <3",
120
120
  "nodemon": "3.1.4",
121
121
  "prettier": "3.3.3",
@@ -123,13 +123,13 @@
123
123
  "react": "18.3.1",
124
124
  "react-autosuggest": "10.1.0",
125
125
  "react-dom": "18.3.1",
126
- "react-hook-form": "7.52.1",
126
+ "react-hook-form": "7.52.2",
127
127
  "react-intl": "6.6.8",
128
128
  "react-modal": "3.16.1",
129
- "react-router-dom": "6.25.1",
129
+ "react-router-dom": "6.26.0",
130
130
  "sass": "1.77.8",
131
131
  "source-map-loader": "5.0.0",
132
- "storybook": "8.2.6",
132
+ "storybook": "8.2.8",
133
133
  "tsconfig-paths-webpack-plugin": "4.1.0",
134
134
  "typescript": "5.5.4",
135
135
  "uuid": "10.0.0",
@@ -152,7 +152,7 @@
152
152
  "node": "20.11.0"
153
153
  },
154
154
  "devDependencies": {
155
- "@storybook/addon-mdx-gfm": "8.2.6",
155
+ "@storybook/addon-mdx-gfm": "8.2.8",
156
156
  "@storybook/addon-webpack5-compiler-babel": "3.0.3"
157
157
  }
158
158
  }
@@ -4,13 +4,14 @@
4
4
  @import '../../js/components/Tabs/styles';
5
5
  @import '../../js/components/TeacherDashboardCourseList/styles';
6
6
  @import '../../js/components/Modal/styles';
7
- @import '../../js/components/PaymentButton/styles';
7
+ @import '../../js/components/SaleTunnel/SubscriptionButton/styles';
8
8
  @import '../../js/components/PaymentScheduleGrid/styles';
9
9
  @import '../../js/components/PurchaseButton/styles';
10
10
  @import '../../js/components/SaleTunnel/styles';
11
11
  @import '../../js/components/SaleTunnel/AddressSelector/styles';
12
12
  @import '../../js/components/SaleTunnel/ProductPath/styles';
13
13
  @import '../../js/components/SaleTunnel/Sponsors/SaleTunnelSponsors';
14
+ @import '../../js/components/SaleTunnel/SaleTunnelSavePaymentMethod/styles';
14
15
  @import '../../js/components/SuccessIcon/styles';
15
16
  @import '../../js/components/WarningIcon/styles';
16
17
  @import '../../js/components/RegisteredAddress/styles';
@@ -1,27 +0,0 @@
1
- .payment-button {
2
- text-align: center;
3
- display: flex;
4
- flex-direction: column;
5
- justify-content: center;
6
- align-items: center;
7
-
8
- &__error {
9
- color: r-color('firebrick6');
10
- margin-top: 0.5rem;
11
- margin-bottom: 0;
12
- }
13
-
14
- &__terms {
15
- button {
16
- color: $link-color;
17
- text-decoration: underline;
18
- background-color: transparent;
19
- border: none;
20
- padding: 0;
21
-
22
- &:hover {
23
- color: $link-hover-color;
24
- }
25
- }
26
- }
27
- }
@@ -1,333 +0,0 @@
1
- import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
- import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { Button } from '@openfun/cunningham-react';
4
- import { useJoanieApi } from 'contexts/JoanieApiContext';
5
- import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
6
- import { useOrders } from 'hooks/useOrders';
7
- import { OrderCreationPayload, OrderState } from 'types/Joanie';
8
- import type { Maybe } from 'types/utils';
9
- import { useTerms } from 'components/SaleTunnel/hooks/useTerms';
10
- import WebAnalyticsAPIHandler from 'api/web-analytics';
11
- import { CourseProductEvent } from 'types/web-analytics';
12
- import { ObjectHelper } from 'utils/ObjectHelper';
13
- import { HttpError } from 'utils/errors/HttpError';
14
- import { PAYMENT_SETTINGS } from 'settings';
15
- import { Spinner } from 'components/Spinner';
16
- import PaymentInterface from 'components/PaymentInterfaces';
17
- import { useMatchMediaLg } from 'hooks/useMatchMedia';
18
- import { PaymentErrorMessageId, Payment, PaymentWithId } from 'components/PaymentInterfaces/types';
19
-
20
- const messages = defineMessages({
21
- errorAbort: {
22
- defaultMessage: 'You have aborted the payment.',
23
- description: 'Error message shown when user aborts the payment.',
24
- id: 'components.PaymentButton.errorAbort',
25
- },
26
- errorAborting: {
27
- defaultMessage: 'Aborting the payment...',
28
- description: 'Error message shown when user asks to abort the payment.',
29
- id: 'components.PaymentButton.errorAborting',
30
- },
31
- errorDefault: {
32
- defaultMessage: 'An error occurred during payment. Please retry later.',
33
- description: 'Error message shown when payment creation request failed.',
34
- id: 'components.PaymentButton.errorDefault',
35
- },
36
- errorFullProduct: {
37
- defaultMessage: 'There are no more places available for this product.',
38
- description:
39
- 'Error message shown when payment creation request failed because there is no remaining available seat for the product.',
40
- id: 'components.PaymentButton.errorFullProduct',
41
- },
42
- errorAddress: {
43
- defaultMessage: 'You must have a billing address.',
44
- description: "Error message shown when the user didn't select a billing address.",
45
- id: 'components.PaymentButton.errorAddress',
46
- },
47
- errorTerms: {
48
- defaultMessage: 'You must accept the terms.',
49
- description: "Error message shown when the user didn't check the terms checkbox.",
50
- id: 'components.PaymentButton.errorTerms',
51
- },
52
- pay: {
53
- defaultMessage: 'Subscribe',
54
- description: 'CTA label to proceed to the payment of the product',
55
- id: 'components.PaymentButton.pay',
56
- },
57
- paymentInProgress: {
58
- defaultMessage: 'Payment in progress',
59
- description: 'Label for screen reader when a payment is in progress.',
60
- id: 'components.PaymentButton.paymentInProgress',
61
- },
62
- });
63
-
64
- type PaymentInfo = Payment & { order_id: string };
65
-
66
- enum ComponentStates {
67
- IDLE = 'idle',
68
- LOADING = 'loading',
69
- ERROR = 'error',
70
- }
71
-
72
- interface Props {
73
- buildOrderPayload: (
74
- payload: Pick<OrderCreationPayload, 'product_id' | 'has_consent_to_terms'>,
75
- ) => OrderCreationPayload;
76
- }
77
-
78
- export const GenericPaymentButton = ({ buildOrderPayload }: Props) => {
79
- const intl = useIntl();
80
- const API = useJoanieApi();
81
- const timeoutRef = useRef<NodeJS.Timeout>();
82
- const {
83
- webAnalyticsEventKey,
84
- order,
85
- billingAddress,
86
- creditCard,
87
- product,
88
- onPaymentSuccess,
89
- props: saleTunnelProps,
90
- runSubmitCallbacks,
91
- } = useSaleTunnelContext();
92
- const { methods: orderMethods } = useOrders(undefined, { enabled: false });
93
- const [payment, setPayment] = useState<PaymentInfo>();
94
- const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
95
- const [error, setError] = useState<PaymentErrorMessageId | string>();
96
- const hasPaymentId = (p: Maybe<Payment>): p is Extract<Payment, PaymentWithId> => {
97
- return Boolean(p?.hasOwnProperty('payment_id'));
98
- };
99
- const paymentId = hasPaymentId(payment) ? payment.payment_id : undefined;
100
- const isMobile = useMatchMediaLg();
101
-
102
- // This pattern is ugly but I couldn't find a better way to achieve it in a nicer way.
103
- // Without this, when we call onPaymentSuccess() directly from the after polling function,
104
- // it is not the latest version of the function, so the value of `order` inside the context function (defined in GenericSaleTunnel.tsx)
105
- // will be undefined, then calling onFinish(order) with an undefined order.
106
- const onPaymentSuccessRef = useRef(onPaymentSuccess);
107
- onPaymentSuccessRef.current = onPaymentSuccess;
108
-
109
- const { validateTerms, termsAccepted, renderTermsCheckbox } = useTerms({
110
- product,
111
- error,
112
- onError: (e) => {
113
- handleError(e);
114
- },
115
- });
116
-
117
- const isReadyToPay = useMemo(() => {
118
- return (
119
- (saleTunnelProps.course || saleTunnelProps.enrollment) &&
120
- product &&
121
- billingAddress &&
122
- termsAccepted
123
- );
124
- }, [product, saleTunnelProps.course, saleTunnelProps.enrollment, billingAddress, termsAccepted]);
125
-
126
- const isBusy = useMemo(() => {
127
- return (
128
- state === ComponentStates.LOADING ||
129
- (state === ComponentStates.ERROR && error === PaymentErrorMessageId.ERROR_ABORTING)
130
- );
131
- }, [state, error]);
132
-
133
- /**
134
- * Use Joanie API to retrieve an order and check if it's state is validated
135
- *
136
- * @param {string} id - Order id
137
- * @returns {Promise<boolean>} - Promise resolving to true if order is validated
138
- */
139
- const isOrderValidated = async (id: string): Promise<Boolean> => {
140
- const orderToCheck = await API.user.orders.get({ id });
141
- return orderToCheck?.state === OrderState.VALIDATED;
142
- };
143
-
144
- const createPayment = async (orderId: string) => {
145
- WebAnalyticsAPIHandler()?.sendCourseProductEvent(
146
- CourseProductEvent.PAYMENT_CREATION,
147
- webAnalyticsEventKey,
148
- );
149
-
150
- if (!billingAddress) {
151
- handleError(PaymentErrorMessageId.ERROR_ADDRESS);
152
- }
153
-
154
- validateTerms();
155
-
156
- if (isReadyToPay) {
157
- setState(ComponentStates.LOADING);
158
- setError(undefined);
159
-
160
- if (!payment) {
161
- const billingAddressPayload = ObjectHelper.omit(billingAddress!, 'id', 'is_main');
162
-
163
- orderMethods.submit(
164
- {
165
- id: orderId,
166
- billing_address: billingAddressPayload,
167
- ...(creditCard && { credit_card_id: creditCard.id }),
168
- },
169
- {
170
- onSuccess: (orderPayment) => {
171
- const paymentInfos = {
172
- ...orderPayment.payment_info,
173
- order_id: orderId,
174
- };
175
- setPayment(paymentInfos);
176
- },
177
- onError: async (createPaymentError: HttpError) => {
178
- if (createPaymentError.responseBody) {
179
- const responseErrors = await createPaymentError.responseBody;
180
- if ('max_validated_orders' in responseErrors) {
181
- handleError(PaymentErrorMessageId.ERROR_FULL_PRODUCT);
182
- }
183
- }
184
- handleError();
185
- },
186
- },
187
- );
188
- }
189
- }
190
- };
191
-
192
- const createOrder = async () => {
193
- setState(ComponentStates.LOADING);
194
-
195
- try {
196
- await runSubmitCallbacks();
197
- } catch (e) {
198
- // Example: full name failed saving to OpenEDX.
199
- setState(ComponentStates.IDLE);
200
- return;
201
- }
202
-
203
- if (!billingAddress) {
204
- handleError(PaymentErrorMessageId.ERROR_ADDRESS);
205
- }
206
-
207
- validateTerms();
208
-
209
- if (!isReadyToPay) {
210
- return;
211
- }
212
-
213
- if (order) {
214
- createPayment(order.id);
215
- } else {
216
- const payload = buildOrderPayload({
217
- product_id: product.id,
218
- has_consent_to_terms: termsAccepted,
219
- });
220
- orderMethods.create(payload, {
221
- onSuccess: (newOrder) => {
222
- createPayment(newOrder.id);
223
- },
224
- onError: async () => {
225
- handleError();
226
- },
227
- });
228
- }
229
- };
230
-
231
- const handleSuccess = () => {
232
- let round = 0;
233
-
234
- const checkOrderValidity = async () => {
235
- if (round >= PAYMENT_SETTINGS.pollLimit) {
236
- timeoutRef.current = undefined;
237
- onPaymentSuccessRef.current(false);
238
- } else {
239
- const isValidated = await isOrderValidated(payment!.order_id);
240
- if (isValidated) {
241
- setState(ComponentStates.IDLE);
242
- timeoutRef.current = undefined;
243
- onPaymentSuccessRef.current();
244
- } else {
245
- round++;
246
- timeoutRef.current = setTimeout(checkOrderValidity, PAYMENT_SETTINGS.pollInterval);
247
- }
248
- }
249
- };
250
-
251
- checkOrderValidity();
252
- };
253
-
254
- const handleError = (
255
- messageId: PaymentErrorMessageId | string = PaymentErrorMessageId.ERROR_DEFAULT,
256
- ) => {
257
- setState(ComponentStates.ERROR);
258
- setError(messageId);
259
- };
260
-
261
- useEffect(() => {
262
- if (error === PaymentErrorMessageId.ERROR_ABORTING) {
263
- orderMethods
264
- .abort({
265
- id: payment!.order_id,
266
- payment_id: paymentId,
267
- })
268
- .then(() => {
269
- handleError(PaymentErrorMessageId.ERROR_ABORT);
270
- })
271
- .catch(() => {
272
- handleError();
273
- });
274
- } else if (error && !messages.hasOwnProperty(error)) {
275
- orderMethods.invalidate();
276
- } else if (state === ComponentStates.ERROR) {
277
- setPayment(undefined);
278
- }
279
- }, [error]);
280
-
281
- useEffect(() => {
282
- if (state === ComponentStates.ERROR) {
283
- document.querySelector<HTMLElement>('#sale-tunnel-payment-error')?.focus();
284
- }
285
- }, [state]);
286
-
287
- return (
288
- <>
289
- {renderTermsCheckbox()}
290
- <Button
291
- disabled={isBusy}
292
- onClick={createOrder}
293
- data-testid={order && 'payment-button-order-loaded'}
294
- fullWidth={isMobile}
295
- {...(state === ComponentStates.ERROR && {
296
- 'aria-describedby': 'sale-tunnel-payment-error',
297
- })}
298
- >
299
- {isBusy ? (
300
- <Spinner theme="light" aria-labelledby="payment-in-progress">
301
- <span id="payment-in-progress">
302
- <FormattedMessage {...messages.paymentInProgress} />
303
- </span>
304
- </Spinner>
305
- ) : (
306
- <FormattedMessage
307
- {...messages.pay}
308
- values={{
309
- price: intl.formatNumber(product.price, {
310
- style: 'currency',
311
- currency: product.price_currency,
312
- }),
313
- }}
314
- />
315
- )}
316
- </Button>
317
- {state === ComponentStates.LOADING && payment && (
318
- <PaymentInterface {...payment} onError={handleError} onSuccess={handleSuccess} />
319
- )}
320
- {state === ComponentStates.ERROR && (
321
- <p className="payment-button__error" id="sale-tunnel-payment-error" tabIndex={-1}>
322
- {!error || messages.hasOwnProperty(error) ? (
323
- <FormattedMessage
324
- {...messages[(error as PaymentErrorMessageId) || PaymentErrorMessageId.ERROR_DEFAULT]}
325
- />
326
- ) : (
327
- error
328
- )}
329
- </p>
330
- )}
331
- </>
332
- );
333
- };