richie-education 2.28.2-dev58 → 2.28.2-dev65

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.
@@ -78,7 +78,7 @@ export const CreditCardSelector = ({
78
78
  const isMobile = useMatchMediaLg();
79
79
 
80
80
  const {
81
- states: { fetching },
81
+ states: { fetching, isFetched },
82
82
  items: creditCards,
83
83
  } = useCreditCardsManagement();
84
84
 
@@ -101,7 +101,7 @@ export const CreditCardSelector = ({
101
101
 
102
102
  return (
103
103
  <div className="credit-card-selector">
104
- {fetching ? (
104
+ {!isFetched && fetching ? (
105
105
  <Spinner />
106
106
  ) : (
107
107
  <>
@@ -58,7 +58,6 @@ describe('AddressSelector', () => {
58
58
  billingAddress,
59
59
  setBillingAddress,
60
60
  setCreditCard: jest.fn(),
61
- onPaymentSuccess: jest.fn(),
62
61
  step: SaleTunnelStep.IDLE,
63
62
  registerSubmitCallback: jest.fn(),
64
63
  unregisterSubmitCallback: jest.fn(),
@@ -28,7 +28,6 @@ export interface SaleTunnelContextType {
28
28
  webAnalyticsEventKey: string;
29
29
 
30
30
  // internal
31
- onPaymentSuccess: () => void;
32
31
  step: SaleTunnelStep;
33
32
 
34
33
  // meta
@@ -75,16 +74,6 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
75
74
  enrollmentId: props.enrollment?.id,
76
75
  productId: props.product.id,
77
76
  });
78
-
79
- const {
80
- methods: { refetch: refetchOmniscientOrders },
81
- } = useOmniscientOrders();
82
- const {
83
- methods: { invalidate: invalidateOrders },
84
- } = useOrders(undefined, { enabled: false });
85
- const {
86
- methods: { invalidate: invalidateEnrollments },
87
- } = useEnrollments(undefined, { enabled: false });
88
77
  const [billingAddress, setBillingAddress] = useState<Address>();
89
78
  const [creditCard, setCreditCard] = useState<CreditCard>();
90
79
  const [step, setStep] = useState<SaleTunnelStep>(SaleTunnelStep.IDLE);
@@ -127,19 +116,6 @@ export const GenericSaleTunnel = (props: GenericSaleTunnelProps) => {
127
116
  creditCard,
128
117
  setCreditCard,
129
118
  nextStep,
130
- onPaymentSuccess: () => {
131
- nextStep();
132
- WebAnalyticsAPIHandler()?.sendCourseProductEvent(
133
- CourseProductEvent.PAYMENT_SUCCEED,
134
- props.eventKey,
135
- );
136
- // Once the user has completed the purchase, we need to refetch the orders
137
- // to update the ordersQuery cache
138
- invalidateOrders();
139
- refetchOmniscientOrders();
140
- invalidateEnrollments();
141
- props.onFinish?.(order!);
142
- },
143
119
  step,
144
120
  registerSubmitCallback: (key, callback) => {
145
121
  setSubmitCallbacks((prev) => new Map(prev).set(key, callback));
@@ -230,6 +206,34 @@ export const GenericSaleTunnelInitialStep = (props: GenericSaleTunnelProps) => {
230
206
  };
231
207
 
232
208
  export const GenericSaleTunnelSuccessStep = (props: SaleTunnelProps) => {
209
+ const {
210
+ webAnalyticsEventKey,
211
+ props: { onFinish },
212
+ order,
213
+ } = useSaleTunnelContext();
214
+ const {
215
+ methods: { refetch: refetchOmniscientOrders },
216
+ } = useOmniscientOrders();
217
+ const {
218
+ methods: { invalidate: invalidateOrders },
219
+ } = useOrders(undefined, { enabled: false });
220
+ const {
221
+ methods: { invalidate: invalidateEnrollments },
222
+ } = useEnrollments(undefined, { enabled: false });
223
+
224
+ useEffect(() => {
225
+ WebAnalyticsAPIHandler()?.sendCourseProductEvent(
226
+ CourseProductEvent.PAYMENT_SUCCEED,
227
+ webAnalyticsEventKey,
228
+ );
229
+ // Once the user has completed the purchase, we need to refetch the orders
230
+ // to update the ordersQuery cache
231
+ invalidateOrders();
232
+ refetchOmniscientOrders();
233
+ invalidateEnrollments();
234
+ onFinish?.(order!);
235
+ }, []);
236
+
233
237
  return (
234
238
  <Modal {...props} size={ModalSize.MEDIUM}>
235
239
  <SaleTunnelSuccess closeModal={props.onClose} />
@@ -10,7 +10,7 @@ import { Spinner } from 'components/Spinner';
10
10
  import PaymentInterfaces from 'components/PaymentInterfaces';
11
11
  import { useOrders } from 'hooks/useOrders';
12
12
  import { CreditCardSelector } from 'components/CreditCardSelector';
13
- import { useJoanieApi } from 'contexts/JoanieApiContext';
13
+ import { PAYMENT_SETTINGS } from 'settings';
14
14
 
15
15
  const messages = defineMessages({
16
16
  title: {
@@ -48,11 +48,12 @@ const messages = defineMessages({
48
48
 
49
49
  const SaleTunnelSavePaymentMethod = () => {
50
50
  const initialCreditCards = useRef<CreditCard[]>();
51
- const pollingTimeoutRef = useRef<NodeJS.Timeout>();
52
- const JoanieApi = useJoanieApi();
51
+ const [shouldPoll, setShouldPoll] = useState(false);
53
52
  const [payment, setPayment] = useState<Payment>();
54
53
  const [error, setError] = useState<string>();
55
- const creditCards = useCreditCards();
54
+ const creditCardsQuery = useCreditCards(undefined, {
55
+ refetchInterval: shouldPoll && PAYMENT_SETTINGS.pollInterval,
56
+ });
56
57
  const orders = useOrders(undefined, { enabled: false });
57
58
  const { order, nextStep, creditCard, setCreditCard } = useSaleTunnelContext();
58
59
 
@@ -64,23 +65,20 @@ const SaleTunnelSavePaymentMethod = () => {
64
65
  };
65
66
 
66
67
  const tokenizePaymentMethod = async () => {
67
- const data = await creditCards.methods.tokenize();
68
+ const data = await creditCardsQuery.methods.tokenize();
68
69
  setPayment(data);
69
70
  setError(undefined);
70
71
  };
71
72
 
72
- const waitForNewCreditCard = async () => {
73
- const { results } = await JoanieApi.user.creditCards.get();
73
+ const waitForNewCreditCard = () => {
74
74
  const initialIds = initialCreditCards.current!.map((cc) => cc.id);
75
- const newCard = results.find((cc) => !initialIds.includes(cc.id));
75
+ const newCard = creditCardsQuery.items.find((cc) => !initialIds.includes(cc.id));
76
76
 
77
- if (!newCard) {
78
- pollingTimeoutRef.current = setTimeout(waitForNewCreditCard, 1000);
79
- return;
80
- }
77
+ if (!newCard) return;
81
78
 
82
79
  setCreditCard(newCard);
83
- await setPaymentMethod(newCard.id);
80
+ setShouldPoll(false);
81
+ setPaymentMethod(newCard.id);
84
82
  };
85
83
 
86
84
  const handleError = (message: string = PaymentErrorMessageId.ERROR_DEFAULT) => {
@@ -90,9 +88,11 @@ const SaleTunnelSavePaymentMethod = () => {
90
88
 
91
89
  useEffect(() => {
92
90
  if (!payment) {
93
- initialCreditCards.current = creditCards.items;
91
+ initialCreditCards.current = creditCardsQuery.items;
92
+ } else {
93
+ waitForNewCreditCard();
94
94
  }
95
- }, [creditCards]);
95
+ }, [creditCardsQuery.items]);
96
96
 
97
97
  useEffect(() => {
98
98
  if (order?.state !== OrderState.TO_SAVE_PAYMENT_METHOD) {
@@ -100,13 +100,6 @@ const SaleTunnelSavePaymentMethod = () => {
100
100
  }
101
101
  }, [order]);
102
102
 
103
- useEffect(
104
- () => () => {
105
- clearTimeout(pollingTimeoutRef.current);
106
- },
107
- [],
108
- );
109
-
110
103
  return (
111
104
  <section
112
105
  className="sale-tunnel-step sale-tunnel-step--save-payment-method"
@@ -150,7 +143,11 @@ const SaleTunnelSavePaymentMethod = () => {
150
143
  </p>
151
144
  )}
152
145
  {payment && (
153
- <PaymentInterfaces {...payment} onSuccess={waitForNewCreditCard} onError={handleError} />
146
+ <PaymentInterfaces
147
+ {...payment}
148
+ onSuccess={() => setShouldPoll(true)}
149
+ onError={handleError}
150
+ />
154
151
  )}
155
152
  </footer>
156
153
  </section>
@@ -328,11 +328,9 @@ describe('SaleTunnel', () => {
328
328
  fetchMock
329
329
  .post('https://joanie.endpoint/api/v1.0/credit-cards/tokenize-card/', PaymentFactory().one())
330
330
  .post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/payment-method/`, 200)
331
- .get(
332
- 'https://joanie.endpoint/api/v1.0/credit-cards/',
333
- { results: [paymentMethod] },
334
- { overwriteRoutes: true },
335
- )
331
+ .get('https://joanie.endpoint/api/v1.0/credit-cards/', [paymentMethod], {
332
+ overwriteRoutes: true,
333
+ })
336
334
  .get(
337
335
  `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(orderQueryParameters)}`,
338
336
  [order],
@@ -217,7 +217,6 @@ describe.each([
217
217
 
218
218
  // - Route to create order should have been called
219
219
  nbApiCalls += 1; // order create
220
- nbApiCalls += 1; // order get (invalidate queries)
221
220
  nbApiCalls += 1; // useProductOrder call (invalidate from create)
222
221
 
223
222
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
@@ -31,8 +31,6 @@ export const REACT_QUERY_SETTINGS = {
31
31
  export const PAYMENT_SETTINGS = {
32
32
  // Interval in ms to poll the related order when a payment has succeeded.
33
33
  pollInterval: 1000,
34
- // Number of retries
35
- pollLimit: 30,
36
34
  };
37
35
 
38
36
  export const CONTRACT_SETTINGS = {
@@ -23,3 +23,8 @@ export const CONTRACT_SETTINGS = {
23
23
  // Simulated sign request delay
24
24
  dummySignatureSignTimeout: 100,
25
25
  };
26
+
27
+ export const PAYMENT_SETTINGS = {
28
+ // Interval in ms to poll the related order when a payment has succeeded.
29
+ pollInterval: 150,
30
+ };
@@ -6,6 +6,7 @@ import {
6
6
  OrderEnrollment,
7
7
  OrderState,
8
8
  PaymentScheduleState,
9
+ PURCHASABLE_ORDER_STATES,
9
10
  } from 'types/Joanie';
10
11
 
11
12
  export enum OrderStatus {
@@ -96,4 +97,9 @@ export class OrderHelper {
96
97
  if (!order) return false;
97
98
  return ACTIVE_ORDER_STATES.includes(order.state);
98
99
  }
100
+
101
+ static isPurchasable(order?: Order | NestedCourseOrder | OrderEnrollment) {
102
+ if (!order) return true;
103
+ return PURCHASABLE_ORDER_STATES.includes(order.state);
104
+ }
99
105
  }
@@ -484,7 +484,6 @@ export const SaleTunnelContextFactory = factory(
484
484
  billingAddress: undefined,
485
485
  setBillingAddress: noop,
486
486
  setCreditCard: noop,
487
- onPaymentSuccess: noop,
488
487
  step: SaleTunnelStep.IDLE,
489
488
  registerSubmitCallback: noop,
490
489
  unregisterSubmitCallback: noop,
@@ -2,12 +2,7 @@ import { FormattedMessage, defineMessages } from 'react-intl';
2
2
  import { useState } from 'react';
3
3
  import PurchaseButton from 'components/PurchaseButton';
4
4
  import { Icon, IconTypeEnum } from 'components/Icon';
5
- import {
6
- CertificateProduct,
7
- Enrollment,
8
- ProductType,
9
- PURCHASABLE_ORDER_STATES,
10
- } from 'types/Joanie';
5
+ import { CertificateProduct, Enrollment, ProductType } from 'types/Joanie';
11
6
  import DownloadCertificateButton from 'components/DownloadCertificateButton';
12
7
  import { useCertificate } from 'hooks/useCertificates';
13
8
  import { isOpenedCourseRunCertificate } from 'utils/CourseRuns';
@@ -65,30 +60,27 @@ const ProductCertificateFooter = ({ product, enrollment }: ProductCertificateFoo
65
60
  <FormattedMessage {...messages.buyProductCertificateLabel} />
66
61
  )}
67
62
  </div>
68
- {OrderHelper.isActive(order) ? (
69
- order!.certificate_id && (
70
- <DownloadCertificateButton
71
- className="dashboard-item__button"
72
- certificateId={order!.certificate_id}
73
- />
74
- )
75
- ) : (
76
- <PurchaseButton
63
+ {OrderHelper.isActive(order) && order!.certificate_id && (
64
+ <DownloadCertificateButton
77
65
  className="dashboard-item__button"
78
- product={product}
79
- enrollment={enrollment}
80
- buttonProps={{ size: 'small' }}
81
- disabled={order && !PURCHASABLE_ORDER_STATES.includes(order.state)}
82
- onFinish={(o) => {
83
- /**
84
- * As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
85
- * scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
86
- * update the active order in the enrollment in order to hide the buy button and display the download button.
87
- */
88
- setOrder(o);
89
- }}
66
+ certificateId={order!.certificate_id}
90
67
  />
91
68
  )}
69
+ <PurchaseButton
70
+ className="dashboard-item__button"
71
+ product={product}
72
+ enrollment={enrollment}
73
+ buttonProps={{ size: 'small' }}
74
+ disabled={!OrderHelper.isPurchasable(order)}
75
+ onFinish={(o) => {
76
+ /**
77
+ * As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
78
+ * scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
79
+ * update the active order in the enrollment in order to hide the buy button and display the download button.
80
+ */
81
+ setOrder(o);
82
+ }}
83
+ />
92
84
  </div>
93
85
  );
94
86
  };
@@ -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, Product, CredentialOrder, PURCHASABLE_ORDER_STATES } from 'types/Joanie';
4
+ import { ProductType, Product, CredentialOrder } 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';
@@ -156,7 +156,7 @@ const CourseProductItem = ({ productId, course, compact = false }: CourseProduct
156
156
  });
157
157
 
158
158
  const order = productOrder as CredentialOrder;
159
- const canPurchase = !order || PURCHASABLE_ORDER_STATES.includes(order.state);
159
+ const canPurchase = OrderHelper.isPurchasable(order);
160
160
  const hasPurchased = OrderHelper.isActive(order);
161
161
  const canEnroll = OrderHelper.allowEnrollment(order);
162
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.28.2-dev58",
3
+ "version": "2.28.2-dev65",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -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.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",
54
+ "@sentry/browser": "8.26.0",
55
+ "@sentry/types": "8.26.0",
56
+ "@storybook/addon-actions": "8.2.9",
57
+ "@storybook/addon-essentials": "8.2.9",
58
+ "@storybook/addon-interactions": "8.2.9",
59
+ "@storybook/addon-links": "8.2.9",
60
+ "@storybook/react": "8.2.9",
61
+ "@storybook/react-webpack5": "8.2.9",
62
+ "@storybook/test": "8.2.9",
63
+ "@tanstack/query-core": "5.51.24",
64
+ "@tanstack/query-sync-storage-persister": "5.51.24",
65
+ "@tanstack/react-query": "5.51.24",
66
+ "@tanstack/react-query-devtools": "5.51.24",
67
+ "@tanstack/react-query-persist-client": "5.51.24",
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,8 +81,8 @@
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": "8.0.1",
85
- "@typescript-eslint/parser": "8.0.1",
84
+ "@typescript-eslint/eslint-plugin": "8.1.0",
85
+ "@typescript-eslint/parser": "8.1.0",
86
86
  "babel-jest": "29.7.0",
87
87
  "babel-loader": "9.1.3",
88
88
  "babel-plugin-react-intl": "8.2.25",
@@ -126,10 +126,10 @@
126
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.26.0",
129
+ "react-router-dom": "6.26.1",
130
130
  "sass": "1.77.8",
131
131
  "source-map-loader": "5.0.0",
132
- "storybook": "8.2.8",
132
+ "storybook": "8.2.9",
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.8",
155
+ "@storybook/addon-mdx-gfm": "8.2.9",
156
156
  "@storybook/addon-webpack5-compiler-babel": "3.0.3"
157
157
  }
158
158
  }