richie-education 3.5.1-dev2 → 3.5.2-dev6

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 (44) hide show
  1. package/i18n/locales/ar-SA.json +4 -0
  2. package/i18n/locales/es-ES.json +4 -0
  3. package/i18n/locales/fa-IR.json +4 -0
  4. package/i18n/locales/fr-CA.json +4 -0
  5. package/i18n/locales/fr-FR.json +5 -1
  6. package/i18n/locales/ko-KR.json +4 -0
  7. package/i18n/locales/pt-PT.json +4 -0
  8. package/i18n/locales/ru-RU.json +4 -0
  9. package/i18n/locales/vi-VN.json +4 -0
  10. package/js/api/joanie.ts +5 -0
  11. package/js/components/SaleTunnel/index.spec.tsx +1 -1
  12. package/js/hooks/useLearnerCoursesSearch/index.tsx +17 -4
  13. package/js/hooks/useProductOrder/index.spec.tsx +1 -0
  14. package/js/pages/DashboardCourses/index.spec.tsx +40 -8
  15. package/js/translations/ar-SA.json +1 -1
  16. package/js/translations/es-ES.json +1 -1
  17. package/js/translations/fa-IR.json +1 -1
  18. package/js/translations/fr-CA.json +1 -1
  19. package/js/translations/fr-FR.json +1 -1
  20. package/js/translations/ko-KR.json +1 -1
  21. package/js/translations/pt-PT.json +1 -1
  22. package/js/translations/ru-RU.json +1 -1
  23. package/js/translations/vi-VN.json +1 -1
  24. package/js/types/Joanie.ts +18 -1
  25. package/js/utils/OrderHelper/index.spec.ts +75 -0
  26. package/js/utils/OrderHelper/index.ts +14 -2
  27. package/js/utils/test/factories/joanie.ts +10 -0
  28. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +213 -1
  29. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +133 -31
  30. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +22 -0
  31. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +29 -13
  32. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +0 -2
  33. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +12 -0
  34. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +11 -0
  35. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +5 -1
  36. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +12 -0
  37. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +11 -0
  38. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.spec.tsx +139 -0
  39. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.tsx +259 -0
  40. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.spec.tsx +102 -0
  41. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +84 -2
  42. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +9 -1
  43. package/js/widgets/Dashboard/index.spec.tsx +0 -1
  44. package/package.json +1 -1
@@ -23,6 +23,7 @@ import {
23
23
  PaymentInstallmentFactory,
24
24
  } from 'utils/test/factories/joanie';
25
25
  import { Priority } from 'types';
26
+ import { DATETIME_FORMAT } from 'hooks/useDateFormat';
26
27
  import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
27
28
  import { expectNoSpinner } from 'utils/test/expectSpinner';
28
29
  import { PER_PAGE } from 'settings';
@@ -70,6 +71,33 @@ jest.mock('widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryMod
70
71
  );
71
72
  },
72
73
  }));
74
+ jest.mock('widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal', () => ({
75
+ __esModule: true,
76
+ OrderWithdrawalModal: ({
77
+ isOpen,
78
+ order,
79
+ productTitle,
80
+ reference,
81
+ onSuccess,
82
+ }: {
83
+ isOpen: boolean;
84
+ order: { id: string; state: string };
85
+ productTitle: string;
86
+ reference: string;
87
+ onSuccess?: (order: any) => void;
88
+ }) => {
89
+ if (!isOpen) return null;
90
+
91
+ return (
92
+ <div data-testid="OrderWithdrawalModalMock">
93
+ {order.id} - {productTitle} - {reference}
94
+ <button onClick={() => onSuccess?.({ ...order, state: 'canceled' })}>
95
+ Trigger Success
96
+ </button>
97
+ </div>
98
+ );
99
+ },
100
+ }));
73
101
 
74
102
  describe('<ProductCertificateFooter/>', () => {
75
103
  let product: CertificateProduct;
@@ -230,7 +258,6 @@ describe('<ProductCertificateFooter/>', () => {
230
258
  fetchMock.get(
231
259
  'https://joanie.endpoint/api/v1.0/orders/' +
232
260
  '?product_type=credential' +
233
- '&state_exclude=canceled' +
234
261
  '&state_exclude=refunding' +
235
262
  '&state_exclude=refunded' +
236
263
  '&page=1' +
@@ -384,4 +411,189 @@ describe('<ProductCertificateFooter/>', () => {
384
411
  expect(fetchMock.lastUrl()).toBe(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`);
385
412
  },
386
413
  );
414
+
415
+ it('should display the withdrawal manager and open the withdrawal modal when the order is eligible', async () => {
416
+ const order = OrderEnrollmentFactory({
417
+ state: OrderState.COMPLETED,
418
+ certificate_id: undefined,
419
+ product_id: product.id,
420
+ eligible_to_withdraw: true,
421
+ withdrawal_date_limit: '2026-08-30T10:00:00.000Z',
422
+ }).one();
423
+ const enrollment = EnrollmentFactory({
424
+ orders: [order],
425
+ course_run: CourseRunFactory({ course }).one(),
426
+ }).one();
427
+
428
+ render(
429
+ <ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
430
+ );
431
+
432
+ const datetimeFormatter = new Intl.DateTimeFormat('en', DATETIME_FORMAT);
433
+ screen.getByText(`until ${datetimeFormatter.format(new Date(order.withdrawal_date_limit!))}.`, {
434
+ exact: false,
435
+ });
436
+ expect(screen.queryByTestId('OrderWithdrawalModalMock')).not.toBeInTheDocument();
437
+
438
+ const user = userEvent.setup();
439
+ await user.click(screen.getByRole('button', { name: 'I wish to withdraw' }));
440
+
441
+ const modal = screen.getByTestId('OrderWithdrawalModalMock');
442
+ within(modal).getByText(`${order.id} - ${product.title} - ${course.code}`);
443
+
444
+ // A successful withdrawal request updates the order held by the footer, hiding the
445
+ // withdrawal manager since the order is no longer active.
446
+ await user.click(within(modal).getByRole('button', { name: 'Trigger Success' }));
447
+ expect(screen.queryByRole('button', { name: 'I wish to withdraw' })).not.toBeInTheDocument();
448
+ });
449
+
450
+ it('should not display the withdrawal manager when the order is not eligible', () => {
451
+ const order = OrderEnrollmentFactory({
452
+ state: OrderState.COMPLETED,
453
+ certificate_id: undefined,
454
+ product_id: product.id,
455
+ eligible_to_withdraw: false,
456
+ }).one();
457
+ const enrollment = EnrollmentFactory({ orders: [order] }).one();
458
+
459
+ render(
460
+ <ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
461
+ );
462
+
463
+ expect(screen.queryByRole('button', { name: 'I wish to withdraw' })).not.toBeInTheDocument();
464
+ });
465
+
466
+ it('should display a pending withdrawal message while the withdrawal request is being processed', () => {
467
+ const requestedAt = '2026-08-20T09:30:00.000Z';
468
+ const order = OrderEnrollmentFactory({
469
+ state: OrderState.PENDING_WITHDRAW,
470
+ certificate_id: undefined,
471
+ product_id: product.id,
472
+ eligible_to_withdraw: false,
473
+ withdrawn_requested_at: requestedAt,
474
+ }).one();
475
+ const enrollment = EnrollmentFactory({ orders: [order] }).one();
476
+
477
+ render(
478
+ <ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
479
+ );
480
+
481
+ screen.getByText(
482
+ `Your withdrawal request has been recorded on ${dateFormatter.format(
483
+ new Date(requestedAt),
484
+ )} and is being processed.`,
485
+ );
486
+ expect(
487
+ screen.queryByText(product.certificate_definition.title, { exact: false }),
488
+ ).not.toBeInTheDocument();
489
+ });
490
+
491
+ it('should display a withdrawn message and hide the exam access status once the order has been withdrawn', () => {
492
+ const confirmedAt = '2026-08-25T14:00:00.000Z';
493
+ const order = OrderEnrollmentFactory({
494
+ state: OrderState.CANCELED,
495
+ certificate_id: undefined,
496
+ product_id: product.id,
497
+ eligible_to_withdraw: false,
498
+ withdrawn_confirmation_at: confirmedAt,
499
+ }).one();
500
+ const enrollment = EnrollmentFactory({ orders: [order] }).one();
501
+
502
+ render(
503
+ <ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
504
+ );
505
+
506
+ screen.getByText(
507
+ `You withdrew from this order on ${dateFormatter.format(new Date(confirmedAt))}.`,
508
+ );
509
+ expect(
510
+ screen.queryByText(product.certificate_definition.title, { exact: false }),
511
+ ).not.toBeInTheDocument();
512
+ });
513
+
514
+ /**
515
+ * The footer holds the active order in local state, initialized once from the
516
+ * `enrollment` prop: it isn't kept in sync with prop changes, so the only way the
517
+ * learner sees an admin's decision on a pending withdrawal request is by reloading
518
+ * the dashboard, which remounts the footer with fresh data. These two tests simulate
519
+ * that reload via unmount + render, rather than rerender.
520
+ */
521
+ it('reflects a confirmed withdrawal after the page is refreshed', () => {
522
+ const pendingOrder = OrderEnrollmentFactory({
523
+ state: OrderState.PENDING_WITHDRAW,
524
+ certificate_id: undefined,
525
+ product_id: product.id,
526
+ eligible_to_withdraw: false,
527
+ withdrawn_requested_at: '2026-08-20T09:30:00.000Z',
528
+ }).one();
529
+ const { unmount } = render(
530
+ <ProductCertificateFooter
531
+ product={product}
532
+ enrollment={EnrollmentFactory({ orders: [pendingOrder] }).one()}
533
+ isWithdrawable={true}
534
+ />,
535
+ );
536
+ screen.getByText('and is being processed.', { exact: false });
537
+ unmount();
538
+
539
+ const confirmedOrder = {
540
+ ...pendingOrder,
541
+ state: OrderState.CANCELED,
542
+ withdrawn_confirmation_at: '2026-08-21T10:00:00.000Z',
543
+ };
544
+ render(
545
+ <ProductCertificateFooter
546
+ product={product}
547
+ enrollment={EnrollmentFactory({ orders: [confirmedOrder] }).one()}
548
+ isWithdrawable={true}
549
+ />,
550
+ );
551
+
552
+ screen.getByText(
553
+ `You withdrew from this order on ${dateFormatter.format(
554
+ new Date(confirmedOrder.withdrawn_confirmation_at),
555
+ )}.`,
556
+ );
557
+ expect(screen.queryByText('and is being processed.', { exact: false })).not.toBeInTheDocument();
558
+ });
559
+
560
+ it('reflects a rejected withdrawal after the page is refreshed', () => {
561
+ const pendingOrder = OrderEnrollmentFactory({
562
+ state: OrderState.PENDING_WITHDRAW,
563
+ certificate_id: undefined,
564
+ product_id: product.id,
565
+ eligible_to_withdraw: false,
566
+ withdrawn_requested_at: '2026-08-20T09:30:00.000Z',
567
+ }).one();
568
+ const { unmount } = render(
569
+ <ProductCertificateFooter
570
+ product={product}
571
+ enrollment={EnrollmentFactory({ orders: [pendingOrder] }).one()}
572
+ isWithdrawable={true}
573
+ />,
574
+ );
575
+ screen.getByText('and is being processed.', { exact: false });
576
+ unmount();
577
+
578
+ // A rejected request resumes its normal course: the order goes back to completed
579
+ // and, since no certificate has been issued yet, remains eligible to a new request.
580
+ const rejectedOrder = {
581
+ ...pendingOrder,
582
+ state: OrderState.COMPLETED,
583
+ eligible_to_withdraw: true,
584
+ };
585
+ render(
586
+ <ProductCertificateFooter
587
+ product={product}
588
+ enrollment={EnrollmentFactory({ orders: [rejectedOrder] }).one()}
589
+ isWithdrawable={true}
590
+ />,
591
+ );
592
+
593
+ expect(screen.queryByText('and is being processed.', { exact: false })).not.toBeInTheDocument();
594
+ expect(
595
+ screen.queryByText('You withdrew from this order', { exact: false }),
596
+ ).not.toBeInTheDocument();
597
+ screen.getByRole('button', { name: 'I wish to withdraw' });
598
+ });
387
599
  });
@@ -16,9 +16,10 @@ import { useCertificate } from 'hooks/useCertificates';
16
16
  import { isOpenedCourseRunCertificate } from 'utils/CourseRuns';
17
17
  import { OrderHelper } from 'utils/OrderHelper';
18
18
  import { OrderPaymentRetryModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal';
19
+ import { OrderWithdrawalModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal';
19
20
  import PaymentScheduleHelper from 'utils/PaymentScheduleHelper';
20
21
  import { useOrder } from 'hooks/useOrders';
21
- import useDateFormat from 'hooks/useDateFormat';
22
+ import useDateFormat, { DATETIME_FORMAT } from 'hooks/useDateFormat';
22
23
  import CertificateStatus from '../../CertificateStatus';
23
24
 
24
25
  const messages = defineMessages({
@@ -58,6 +59,27 @@ const messages = defineMessages({
58
59
  description: 'Button label for the payment needed message',
59
60
  defaultMessage: 'Pay {amount}',
60
61
  },
62
+ withdrawalDescription: {
63
+ id: 'components.ProductCertificateFooter.withdrawalDescription',
64
+ description: 'Message displayed to offer the withdrawal of the certificate order',
65
+ defaultMessage:
66
+ "If you haven't accessed the exam yet, you can exercise your right of withdrawal until {date}.",
67
+ },
68
+ withdrawalButton: {
69
+ id: 'components.ProductCertificateFooter.withdrawalButton',
70
+ description: 'Button label to open the withdrawal request modal',
71
+ defaultMessage: 'I wish to withdraw',
72
+ },
73
+ pendingWithdrawalMessage: {
74
+ id: 'components.ProductCertificateFooter.pendingWithdrawalMessage',
75
+ description: 'Message displayed when a withdrawal request is being processed',
76
+ defaultMessage: 'Your withdrawal request has been recorded on {date} and is being processed.',
77
+ },
78
+ withdrawnMessage: {
79
+ id: 'components.ProductCertificateFooter.withdrawnMessage',
80
+ description: 'Message displayed once a withdrawal request has been confirmed',
81
+ defaultMessage: 'You withdrew from this order on {date}.',
82
+ },
61
83
  });
62
84
 
63
85
  export interface ProductCertificateFooterProps {
@@ -75,6 +97,8 @@ const ProductCertificateFooter = ({
75
97
  OrderHelper.getActiveEnrollmentOrder(enrollment.orders || [], product.id),
76
98
  );
77
99
  const isOrderActive = OrderHelper.isActive(order);
100
+ const isOrderWithdrawn = OrderHelper.isWithdrawn(order);
101
+ const isOrderVisible = isOrderActive || isOrderWithdrawn;
78
102
  const isPurchasable =
79
103
  OrderHelper.isPurchasable(order) && isOpenedCourseRunCertificate(enrollment.course_run.state);
80
104
  const isCertificateIssued = isOrderActive && Boolean(order!.certificate_id);
@@ -92,36 +116,46 @@ const ProductCertificateFooter = ({
92
116
  }
93
117
 
94
118
  return (
95
- <div className="dashboard-item__course-enrolling__infos">
96
- {!isOrderActive ? (
97
- <ProductCertificateStatus />
98
- ) : (
99
- <OrderCertificateStatus order={order!} product={product} hasError={hasError} />
100
- )}
101
- {isCertificateIssued && (
102
- <DownloadCertificateButton
119
+ <>
120
+ <div className="dashboard-item__course-enrolling__infos">
121
+ {!isOrderVisible ? (
122
+ <ProductCertificateStatus />
123
+ ) : (
124
+ <OrderCertificateStatus order={order!} product={product} hasError={hasError} />
125
+ )}
126
+ {isCertificateIssued && (
127
+ <DownloadCertificateButton
128
+ className="dashboard-item__button"
129
+ certificateId={order!.certificate_id!}
130
+ />
131
+ )}
132
+ <PurchaseButton
103
133
  className="dashboard-item__button"
104
- certificateId={order!.certificate_id!}
134
+ product={product}
135
+ enrollment={enrollment}
136
+ isWithdrawable={isWithdrawable}
137
+ buttonProps={{ size: 'small' }}
138
+ disabled={!isPurchasable}
139
+ onFinish={(o) => {
140
+ /**
141
+ * As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
142
+ * scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
143
+ * update the active order in the enrollment in order to hide the buy button and display the download button.
144
+ */
145
+ setOrder(o);
146
+ }}
147
+ />
148
+ {hasError && <FailedInstallmentManager order={order!} updateOrder={setOrder} />}
149
+ </div>
150
+ {isOrderActive && order!.eligible_to_withdraw && (
151
+ <WithdrawalManager
152
+ order={order!}
153
+ product={product}
154
+ enrollment={enrollment}
155
+ updateOrder={setOrder}
105
156
  />
106
157
  )}
107
- <PurchaseButton
108
- className="dashboard-item__button"
109
- product={product}
110
- enrollment={enrollment}
111
- isWithdrawable={isWithdrawable}
112
- buttonProps={{ size: 'small' }}
113
- disabled={!isPurchasable}
114
- onFinish={(o) => {
115
- /**
116
- * As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
117
- * scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
118
- * update the active order in the enrollment in order to hide the buy button and display the download button.
119
- */
120
- setOrder(o);
121
- }}
122
- />
123
- {hasError && <FailedInstallmentManager order={order!} updateOrder={setOrder} />}
124
- </div>
158
+ </>
125
159
  );
126
160
  };
127
161
 
@@ -143,10 +177,20 @@ const OrderCertificateStatus = ({ order, product, hasError }: OrderCertificateSt
143
177
  const formatDate = useDateFormat();
144
178
  const nextInstallment = PaymentScheduleHelper.getPendingInstallment(order?.payment_schedule);
145
179
  const canAccessToExam = ![OrderState.PENDING, OrderState.NO_PAYMENT].includes(order.state);
180
+ const isPendingWithdrawal = order.state === OrderState.PENDING_WITHDRAW;
181
+ const isWithdrawn = OrderHelper.isWithdrawn(order);
182
+ let statusIcon = IconTypeEnum.CERTIFICATE;
183
+ if (hasError) {
184
+ statusIcon = IconTypeEnum.WARNING;
185
+ } else if (isPendingWithdrawal) {
186
+ statusIcon = IconTypeEnum.CLOCK;
187
+ } else if (isWithdrawn) {
188
+ statusIcon = IconTypeEnum.ROUND_CLOSE;
189
+ }
146
190
 
147
191
  return (
148
192
  <div className="dashboard-item__block__status">
149
- <Icon name={hasError ? IconTypeEnum.WARNING : IconTypeEnum.CERTIFICATE} />
193
+ <Icon name={statusIcon} />
150
194
  <p>
151
195
  {hasError && (
152
196
  <>
@@ -156,12 +200,30 @@ const OrderCertificateStatus = ({ order, product, hasError }: OrderCertificateSt
156
200
  <br />
157
201
  </>
158
202
  )}
159
- {canAccessToExam ? (
203
+ {isPendingWithdrawal && (
204
+ <>
205
+ <strong>
206
+ <FormattedMessage
207
+ {...messages.pendingWithdrawalMessage}
208
+ values={{ date: formatDate(order.withdrawn_requested_at) }}
209
+ />
210
+ </strong>
211
+ <br />
212
+ </>
213
+ )}
214
+ {isWithdrawn && (
215
+ <FormattedMessage
216
+ {...messages.withdrawnMessage}
217
+ values={{ date: formatDate(order.withdrawn_confirmation_at) }}
218
+ />
219
+ )}
220
+ {!isWithdrawn && !isPendingWithdrawal && canAccessToExam && (
160
221
  <>
161
222
  {product.certificate_definition.title + '. '}
162
223
  <CertificateStatus certificate={certificate} productType={product.type} />
163
224
  </>
164
- ) : (
225
+ )}
226
+ {!isWithdrawn && !isPendingWithdrawal && !canAccessToExam && (
165
227
  <FormattedMessage {...messages.examAccessBlocked} />
166
228
  )}
167
229
  {!hasError && nextInstallment && (
@@ -232,4 +294,44 @@ const FailedInstallmentManager = ({ order, updateOrder }: FailedInstallmentManag
232
294
  );
233
295
  };
234
296
 
297
+ type WithdrawalManagerProps = {
298
+ order: OrderEnrollment;
299
+ product: CertificateProduct;
300
+ enrollment: Enrollment;
301
+ updateOrder: (order: Order) => void;
302
+ };
303
+ const WithdrawalManager = ({ order, product, enrollment, updateOrder }: WithdrawalManagerProps) => {
304
+ const intl = useIntl();
305
+ const formatDate = useDateFormat(DATETIME_FORMAT);
306
+ const modal = useModal();
307
+
308
+ return (
309
+ <div className="dashboard-item__course-enrolling__infos">
310
+ <div className="dashboard-item__block__status">
311
+ <Icon name={IconTypeEnum.MONEY} />
312
+ <FormattedMessage
313
+ {...messages.withdrawalDescription}
314
+ values={{ date: formatDate(order.withdrawal_date_limit) }}
315
+ />
316
+ </div>
317
+ <Button
318
+ className="dashboard-item__course-enrolling__infos__withdrawal__button"
319
+ size="small"
320
+ color="brand"
321
+ variant="tertiary"
322
+ onClick={modal.open}
323
+ >
324
+ {intl.formatMessage(messages.withdrawalButton)}
325
+ </Button>
326
+ <OrderWithdrawalModal
327
+ {...modal}
328
+ order={order}
329
+ productTitle={product.title}
330
+ reference={enrollment.course_run.course.code}
331
+ onSuccess={updateOrder}
332
+ />
333
+ </div>
334
+ );
335
+ };
336
+
235
337
  export default ProductCertificateFooter;
@@ -195,6 +195,28 @@ describe('<DashboardItemOrder/>', () => {
195
195
  });
196
196
  });
197
197
 
198
+ it('renders a withdrawn order without its active-course content', async () => {
199
+ const order: CredentialOrder = CredentialOrderFactory({
200
+ state: OrderState.CANCELED,
201
+ withdrawn_confirmation_at: '2026-01-15T10:00:00.000Z',
202
+ }).one();
203
+ const { product } = mockCourseProductWithOrder(order);
204
+
205
+ render(<DashboardItemOrder order={order} />);
206
+
207
+ await screen.findByRole('heading', { level: 5, name: product.title });
208
+ const withdrawnOnDate = new Intl.DateTimeFormat('en', DEFAULT_DATE_FORMAT).format(
209
+ new Date(order.withdrawn_confirmation_at!),
210
+ );
211
+ await screen.findByText(`Withdrawn On ${withdrawnOnDate}.`, { exact: false });
212
+ expect(screen.queryByRole('link', { name: 'View details' })).not.toBeInTheDocument();
213
+ order.target_courses.forEach((course) => {
214
+ expect(
215
+ screen.queryByRole('heading', { level: 6, name: course.title }),
216
+ ).not.toBeInTheDocument();
217
+ });
218
+ });
219
+
198
220
  it('renders a non-writable order with enrolled target course ', async () => {
199
221
  const order: CredentialOrder = CredentialOrderFactory({
200
222
  target_courses: TargetCourseFactory().many(1),
@@ -7,6 +7,7 @@ import { DashboardSubItem } from 'widgets/Dashboard/components/DashboardItem/Das
7
7
  import { RouterButton } from 'widgets/Dashboard/components/RouterButton';
8
8
  import { useCourseProduct } from 'hooks/useCourseProducts';
9
9
  import { OrderHelper } from 'utils/OrderHelper';
10
+ import useDateFormat from 'hooks/useDateFormat';
10
11
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
11
12
  import OrganizationBlock from 'widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock';
12
13
  import CertificateItem from 'widgets/Dashboard/components/DashboardItem/Order/CertificateItem';
@@ -50,6 +51,11 @@ const messages = defineMessages({
50
51
  defaultMessage:
51
52
  'The subscription process cannot be resumed. The related training is no more purchasable.',
52
53
  },
54
+ withdrawnOn: {
55
+ id: 'components.DashboardItemOrder.withdrawnOn',
56
+ description: 'Date at which the withdrawal was confirmed',
57
+ defaultMessage: 'On {date}.',
58
+ },
53
59
  });
54
60
 
55
61
  interface DashboardItemOrderProps {
@@ -67,6 +73,7 @@ export const DashboardItemOrder = ({
67
73
  }: DashboardItemOrderProps) => {
68
74
  const { course } = order;
69
75
  const intl = useIntl();
76
+ const formatDate = useDateFormat();
70
77
  const { item: offering } = useCourseProduct({
71
78
  product_id: order.product_id,
72
79
  course_id: course.code,
@@ -75,8 +82,10 @@ export const DashboardItemOrder = ({
75
82
  const needsSignature = OrderHelper.orderNeedsSignature(order);
76
83
  const needsPaymentMethod = order.state === OrderState.TO_SAVE_PAYMENT_METHOD;
77
84
  const isActive = OrderHelper.isActive(order);
85
+ const isWithdrawn = OrderHelper.isWithdrawn(order);
78
86
  const isProductPurchasable = ProductHelper.isPurchasable(offering?.product);
79
87
  const isNotResumable = !isActive && !isProductPurchasable;
88
+ const hideResumableContent = isNotResumable || isWithdrawn;
80
89
  const canEnroll = OrderHelper.allowEnrollment(order);
81
90
  const isFreeFromBatchOrder = OrderHelper.isFreeFromBatchOrder(order);
82
91
 
@@ -107,12 +116,23 @@ export const DashboardItemOrder = ({
107
116
  </>
108
117
  ) : (
109
118
  <>
110
- <Icon name={IconTypeEnum.SCHOOL} />
111
- <OrderStateLearnerMessage order={order} />
119
+ <Icon name={isWithdrawn ? IconTypeEnum.ROUND_CLOSE : IconTypeEnum.SCHOOL} />
120
+ <span>
121
+ <OrderStateLearnerMessage order={order} />
122
+ {isWithdrawn && order.withdrawn_confirmation_at && (
123
+ <>
124
+ {' '}
125
+ <FormattedMessage
126
+ {...messages.withdrawnOn}
127
+ values={{ date: formatDate(order.withdrawn_confirmation_at) }}
128
+ />
129
+ </>
130
+ )}
131
+ </span>
112
132
  </>
113
133
  )}
114
134
  </div>
115
- {!isNotResumable && showDetailsButton && !isFreeFromBatchOrder && (
135
+ {!hideResumableContent && showDetailsButton && !isFreeFromBatchOrder && (
116
136
  <RouterButton
117
137
  size="small"
118
138
  className="dashboard-item__button"
@@ -170,7 +190,7 @@ export const DashboardItemOrder = ({
170
190
  </>
171
191
  }
172
192
  >
173
- {!isNotResumable && (
193
+ {!hideResumableContent && (
174
194
  <DashboardSubItemsList
175
195
  subItems={order.target_courses?.map((targetCourse) => (
176
196
  <DashboardSubItem
@@ -195,15 +215,11 @@ export const DashboardItemOrder = ({
195
215
  />
196
216
  )}
197
217
  </DashboardItem>
198
- {!isNotResumable && (
199
- <>
200
- {showCertificate && !!product?.certificate_definition && (
201
- <CertificateItem order={order} product={product} />
202
- )}
203
- {writable && !isFreeFromBatchOrder && (
204
- <OrganizationBlock order={order} product={product} />
205
- )}
206
- </>
218
+ {!hideResumableContent && showCertificate && !!product?.certificate_definition && (
219
+ <CertificateItem order={order} product={product} />
220
+ )}
221
+ {!isNotResumable && writable && !isFreeFromBatchOrder && (
222
+ <OrganizationBlock order={order} product={product} />
207
223
  )}
208
224
  </div>
209
225
  );
@@ -88,7 +88,6 @@ describe('<DashboardItemOrder/> Contract', () => {
88
88
  fetchMock.get(
89
89
  'https://joanie.endpoint/api/v1.0/orders/' +
90
90
  '?product_type=credential' +
91
- '&state_exclude=canceled' +
92
91
  '&state_exclude=refunding' +
93
92
  '&state_exclude=refunded' +
94
93
  '&page=1' +
@@ -267,7 +266,6 @@ describe('<DashboardItemOrder/> Contract', () => {
267
266
  fetchMock.get(
268
267
  'https://joanie.endpoint/api/v1.0/orders/' +
269
268
  '?product_type=credential' +
270
- '&state_exclude=canceled' +
271
269
  '&state_exclude=refunding' +
272
270
  '&state_exclude=refunded' +
273
271
  '&page=1' +
@@ -19,6 +19,7 @@ describe('<OrderStateLearnerMessage/>', () => {
19
19
  [OrderState.NO_PAYMENT, 'First direct debit has failed'],
20
20
  [OrderState.PENDING, 'Pending for the first direct debit'],
21
21
  [OrderState.PENDING_PAYMENT, 'On going'],
22
+ [OrderState.PENDING_WITHDRAW, 'Withdrawal in progress'],
22
23
  [OrderState.SIGNING, 'Signature required'],
23
24
  [OrderState.TO_SAVE_PAYMENT_METHOD, 'Payment method is missing'],
24
25
  [OrderState.TO_SIGN, 'Signature required'],
@@ -30,6 +31,17 @@ describe('<OrderStateLearnerMessage/>', () => {
30
31
  expect(screen.getByText(expectedMessage)).toBeInTheDocument();
31
32
  });
32
33
 
34
+ it('should display message for a withdrawn order', () => {
35
+ const order = CredentialOrderFactory({
36
+ state: OrderState.CANCELED,
37
+ withdrawn_confirmation_at: new Date().toISOString(),
38
+ }).one();
39
+ render(<OrderStateLearnerMessage order={order} />, {
40
+ wrapper: ({ children }: PropsWithChildren) => <IntlWrapper>{children}</IntlWrapper>,
41
+ });
42
+ expect(screen.getByText('Withdrawn')).toBeInTheDocument();
43
+ });
44
+
33
45
  it('should display message for completed order that have a generated certificate', () => {
34
46
  const order = CredentialOrderFactory({
35
47
  state: OrderState.COMPLETED,
@@ -71,6 +71,17 @@ export const messages = defineMessages<MessageKeys>({
71
71
  description: 'Status shown on the dashboard order item when order is refunded',
72
72
  defaultMessage: 'Refunded',
73
73
  },
74
+ statusPendingWithdrawal: {
75
+ id: 'components.DashboardItem.Order.OrderStateLearnerMessage.statusPendingWithdrawal',
76
+ description:
77
+ 'Status shown on the dashboard order item when a withdrawal request is being processed',
78
+ defaultMessage: 'Withdrawal in progress',
79
+ },
80
+ statusWithdrawn: {
81
+ id: 'components.DashboardItem.Order.OrderStateLearnerMessage.statusWithdrawn',
82
+ description: 'Status shown on the dashboard order item when order has been withdrawn',
83
+ defaultMessage: 'Withdrawn',
84
+ },
74
85
  });
75
86
 
76
87
  const OrderStateLearnerMessage = (props: OrderStateMessageBaseProps) => {
@@ -19,10 +19,12 @@ export type MessageKeys =
19
19
  | 'statusPassed'
20
20
  | 'statusPending'
21
21
  | 'statusPendingPayment'
22
+ | 'statusPendingWithdrawal'
22
23
  | 'statusRefunded'
23
24
  | 'statusWaitingCounterSignature'
24
25
  | 'statusWaitingPaymentMethod'
25
- | 'statusWaitingSignature';
26
+ | 'statusWaitingSignature'
27
+ | 'statusWithdrawn';
26
28
 
27
29
  interface OrderStateMessageProps extends OrderStateMessageBaseProps {
28
30
  messages: Record<MessageKeys, MessageDescriptor>;
@@ -45,10 +47,12 @@ const OrderStateMessage = ({ order, messages }: OrderStateMessageProps) => {
45
47
  [OrderStatus.PASSED]: messages.statusPassed,
46
48
  [OrderStatus.PENDING]: messages.statusPending,
47
49
  [OrderStatus.PENDING_PAYMENT]: messages.statusPendingPayment,
50
+ [OrderStatus.PENDING_WITHDRAWAL]: messages.statusPendingWithdrawal,
48
51
  [OrderStatus.REFUNDED]: messages.statusRefunded,
49
52
  [OrderStatus.WAITING_COUNTER_SIGNATURE]: messages.statusWaitingCounterSignature,
50
53
  [OrderStatus.WAITING_PAYMENT_METHOD]: messages.statusWaitingPaymentMethod,
51
54
  [OrderStatus.WAITING_SIGNATURE]: messages.statusWaitingSignature,
55
+ [OrderStatus.WITHDRAWN]: messages.statusWithdrawn,
52
56
  };
53
57
  const status = OrderHelper.getState(order);
54
58