@learncard/react 3.0.0 → 3.0.1

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 (97) hide show
  1. package/dist/cjs/{BoostGenericCard-8767d920.js → BoostGenericCard-87cd2091.js} +3 -3
  2. package/dist/cjs/{BoostGenericCard-8767d920.js.map → BoostGenericCard-87cd2091.js.map} +1 -1
  3. package/dist/cjs/{CertificateDisplayCard-1d4bca84.js → CertificateDisplayCard-d4300c7d.js} +16 -11
  4. package/dist/cjs/CertificateDisplayCard-d4300c7d.js.map +1 -0
  5. package/dist/cjs/{MeritBadgeDisplayCard-0b5241e4.js → MeritBadgeDisplayCard-30ef639d.js} +16 -10
  6. package/dist/cjs/MeritBadgeDisplayCard-30ef639d.js.map +1 -0
  7. package/dist/cjs/{MeritBadgeSkillsCount-0bd63bcd.js → MeritBadgeSkillsCount-a6127a40.js} +2 -2
  8. package/dist/cjs/{MeritBadgeSkillsCount-0bd63bcd.js.map → MeritBadgeSkillsCount-a6127a40.js.map} +1 -1
  9. package/dist/cjs/{VCCard-c4767894.js → VCCard-6298f49f.js} +3 -3
  10. package/dist/cjs/{VCCard-c4767894.js.map → VCCard-6298f49f.js.map} +1 -1
  11. package/dist/cjs/{VCDisplayBackFace-c73ce3f6.js → VCDisplayBackFace-65e36d2b.js} +3 -3
  12. package/dist/cjs/{VCDisplayBackFace-c73ce3f6.js.map → VCDisplayBackFace-65e36d2b.js.map} +1 -1
  13. package/dist/cjs/{VCDisplayCard-7832f605.js → VCDisplayCard-51e917ab.js} +3 -3
  14. package/dist/cjs/{VCDisplayCard-7832f605.js.map → VCDisplayCard-51e917ab.js.map} +1 -1
  15. package/dist/cjs/{VCDisplayCard2-7ae4e64b.js → VCDisplayCard2-156a6584.js} +8 -8
  16. package/dist/cjs/{VCDisplayCard2-7ae4e64b.js.map → VCDisplayCard2-156a6584.js.map} +1 -1
  17. package/dist/cjs/{VCVerificationPill-0f27e8e0.js → VCVerificationPill-d4ee15ca.js} +2 -2
  18. package/dist/cjs/{VCVerificationPill-0f27e8e0.js.map → VCVerificationPill-d4ee15ca.js.map} +1 -1
  19. package/dist/cjs/{VerifierStateBadgeAndText-6bc07df3.js → VerifierStateBadgeAndText-04c90074.js} +2 -2
  20. package/dist/cjs/{VerifierStateBadgeAndText-6bc07df3.js.map → VerifierStateBadgeAndText-04c90074.js.map} +1 -1
  21. package/dist/cjs/{credential.helpers-da94e829.js → credential.helpers-14544e48.js} +240 -6
  22. package/dist/cjs/credential.helpers-14544e48.js.map +1 -0
  23. package/dist/cjs/{i18n-ddbb18e7.js → i18n-0648eec1.js} +3 -1
  24. package/dist/cjs/i18n-0648eec1.js.map +1 -0
  25. package/dist/cjs/{index-da255c9a.js → index-a03dec43.js} +3 -1
  26. package/dist/{esm/index-cb3a593b.js.map → cjs/index-a03dec43.js.map} +1 -1
  27. package/dist/cjs/index.js +14 -14
  28. package/dist/cjs/index14.js +14 -14
  29. package/dist/cjs/index22.js +7 -7
  30. package/dist/cjs/index39.js +12 -12
  31. package/dist/cjs/index4.js +5 -5
  32. package/dist/cjs/index40.js +5 -5
  33. package/dist/cjs/index41.js +6 -6
  34. package/dist/cjs/index42.js +8 -8
  35. package/dist/cjs/index43.js +2 -2
  36. package/dist/cjs/index46.js +2 -2
  37. package/dist/cjs/index7.js +6 -6
  38. package/dist/cjs/{types.esm-ad9cf6de.js → types.esm-55f8d7fb.js} +3633 -1573
  39. package/dist/cjs/types.esm-55f8d7fb.js.map +1 -0
  40. package/dist/esm/{BoostGenericCard-fec5d413.js → BoostGenericCard-dd2b515a.js} +3 -3
  41. package/dist/esm/{BoostGenericCard-fec5d413.js.map → BoostGenericCard-dd2b515a.js.map} +1 -1
  42. package/dist/esm/{CertificateDisplayCard-c0748577.js → CertificateDisplayCard-46c13c49.js} +16 -11
  43. package/dist/esm/CertificateDisplayCard-46c13c49.js.map +1 -0
  44. package/dist/esm/{MeritBadgeDisplayCard-0433ad9d.js → MeritBadgeDisplayCard-9f95dd08.js} +16 -10
  45. package/dist/esm/MeritBadgeDisplayCard-9f95dd08.js.map +1 -0
  46. package/dist/esm/{MeritBadgeSkillsCount-434bc6ed.js → MeritBadgeSkillsCount-c20f9f60.js} +2 -2
  47. package/dist/esm/{MeritBadgeSkillsCount-434bc6ed.js.map → MeritBadgeSkillsCount-c20f9f60.js.map} +1 -1
  48. package/dist/esm/{VCCard-c8816edc.js → VCCard-1e6261ea.js} +3 -3
  49. package/dist/esm/{VCCard-c8816edc.js.map → VCCard-1e6261ea.js.map} +1 -1
  50. package/dist/esm/{VCDisplayBackFace-b6451634.js → VCDisplayBackFace-eb2cad59.js} +3 -3
  51. package/dist/esm/{VCDisplayBackFace-b6451634.js.map → VCDisplayBackFace-eb2cad59.js.map} +1 -1
  52. package/dist/esm/{VCDisplayCard-31a8f581.js → VCDisplayCard-1400714d.js} +3 -3
  53. package/dist/esm/{VCDisplayCard-31a8f581.js.map → VCDisplayCard-1400714d.js.map} +1 -1
  54. package/dist/esm/{VCDisplayCard2-8ef4a6b6.js → VCDisplayCard2-e4164d30.js} +8 -8
  55. package/dist/esm/{VCDisplayCard2-8ef4a6b6.js.map → VCDisplayCard2-e4164d30.js.map} +1 -1
  56. package/dist/esm/{VCVerificationPill-bd76c5bc.js → VCVerificationPill-65f7901a.js} +2 -2
  57. package/dist/esm/{VCVerificationPill-bd76c5bc.js.map → VCVerificationPill-65f7901a.js.map} +1 -1
  58. package/dist/esm/{VerifierStateBadgeAndText-12066b2a.js → VerifierStateBadgeAndText-1588afeb.js} +2 -2
  59. package/dist/esm/{VerifierStateBadgeAndText-12066b2a.js.map → VerifierStateBadgeAndText-1588afeb.js.map} +1 -1
  60. package/dist/esm/{credential.helpers-f55a4962.js → credential.helpers-4c72d304.js} +240 -6
  61. package/dist/esm/credential.helpers-4c72d304.js.map +1 -0
  62. package/dist/esm/{i18n-35209bfa.js → i18n-b142d48e.js} +3 -1
  63. package/dist/esm/i18n-b142d48e.js.map +1 -0
  64. package/dist/esm/{index-cb3a593b.js → index-75c5b01c.js} +2 -2
  65. package/dist/{cjs/index-da255c9a.js.map → esm/index-75c5b01c.js.map} +1 -1
  66. package/dist/esm/index.js +14 -14
  67. package/dist/esm/index14.js +14 -14
  68. package/dist/esm/index22.js +7 -7
  69. package/dist/esm/index39.js +12 -12
  70. package/dist/esm/index4.js +5 -5
  71. package/dist/esm/index40.js +5 -5
  72. package/dist/esm/index41.js +6 -6
  73. package/dist/esm/index42.js +8 -8
  74. package/dist/esm/index43.js +2 -2
  75. package/dist/esm/index46.js +2 -2
  76. package/dist/esm/index7.js +6 -6
  77. package/dist/esm/{types.esm-383b69a5.js → types.esm-a70402df.js} +3633 -1573
  78. package/dist/esm/types.esm-a70402df.js.map +1 -0
  79. package/package.json +9 -9
  80. package/src/components/CertificateDisplayCard/CertificateAwardDate.test.tsx +67 -0
  81. package/src/components/CertificateDisplayCard/CertificateBackFace.tsx +14 -7
  82. package/src/components/CertificateDisplayCard/CertificateFrontFace.tsx +11 -2
  83. package/src/components/MeritBadgeDisplayCard/MeritBadgeBackFace.tsx +14 -7
  84. package/src/components/MeritBadgeDisplayCard/MeritBadgeFrontFace.tsx +11 -6
  85. package/src/helpers/credential.helpers.test.ts +96 -0
  86. package/src/helpers/credential.helpers.tsx +23 -4
  87. package/src/i18n/index.tsx +2 -0
  88. package/dist/cjs/CertificateDisplayCard-1d4bca84.js.map +0 -1
  89. package/dist/cjs/MeritBadgeDisplayCard-0b5241e4.js.map +0 -1
  90. package/dist/cjs/credential.helpers-da94e829.js.map +0 -1
  91. package/dist/cjs/i18n-ddbb18e7.js.map +0 -1
  92. package/dist/cjs/types.esm-ad9cf6de.js.map +0 -1
  93. package/dist/esm/CertificateDisplayCard-c0748577.js.map +0 -1
  94. package/dist/esm/MeritBadgeDisplayCard-0433ad9d.js.map +0 -1
  95. package/dist/esm/credential.helpers-f55a4962.js.map +0 -1
  96. package/dist/esm/i18n-35209bfa.js.map +0 -1
  97. package/dist/esm/types.esm-383b69a5.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/react",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "",
5
5
  "main": "dist/cjs",
6
6
  "module": "dist/esm",
@@ -25,13 +25,13 @@
25
25
  "author": "Learning Economy Foundation (www.learningeconomy.io)",
26
26
  "license": "MIT",
27
27
  "devDependencies": {
28
- "@learncard/types": "5.23.2",
28
+ "@learncard/types": "5.24.0",
29
29
  "@rollup/plugin-commonjs": "^22.0.0",
30
30
  "@rollup/plugin-json": "^4.1.0",
31
31
  "@rollup/plugin-node-resolve": "^13.3.0",
32
- "@storybook/addon-essentials": "8.6.18",
33
- "@storybook/react-vite": "8.6.18",
34
- "@storybook/test": "8.6.18",
32
+ "@storybook/addon-essentials": "8.6.19",
33
+ "@storybook/react-vite": "8.6.19",
34
+ "@storybook/test": "8.6.19",
35
35
  "@svgr/rollup": "^6.2.1",
36
36
  "@testing-library/jest-dom": "^5.16.4",
37
37
  "@testing-library/react": "^13.0.0",
@@ -44,7 +44,7 @@
44
44
  "dts-bundle-generator": "^6.10.0",
45
45
  "esbuild": "^0.27.1",
46
46
  "glob": "^8.0.3",
47
- "happy-dom": "^20.8.9",
47
+ "happy-dom": "^21.0.4",
48
48
  "postcss": "^8.5.10",
49
49
  "postcss-loader": "^6.2.1",
50
50
  "rollup": "^2.71.1",
@@ -54,7 +54,7 @@
54
54
  "rollup-plugin-peer-deps-external": "^2.2.4",
55
55
  "rollup-plugin-smart-asset": "^2.1.2",
56
56
  "rollup-plugin-styles": "^4.0.0",
57
- "storybook": "8.6.18",
57
+ "storybook": "8.6.19",
58
58
  "style-loader": "^3.3.1",
59
59
  "tailwindcss": "^4.3.3",
60
60
  "vite": "^6.4.3",
@@ -62,8 +62,8 @@
62
62
  "@tailwindcss/node": "^4.3.3"
63
63
  },
64
64
  "dependencies": {
65
- "@learncard/helpers": "1.7.0",
66
- "@learncard/init": "2.5.5",
65
+ "@learncard/helpers": "1.7.1",
66
+ "@learncard/init": "2.5.6",
67
67
  "date-fns": "^2.28.0",
68
68
  "numeral": "^2.0.6",
69
69
  "pretty-bytes": "^6.0.0",
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { render, screen, cleanup } from '@testing-library/react';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import CertificateFrontFace from './CertificateFrontFace';
6
+ import CertificateBackFace from './CertificateBackFace';
7
+ import MeritBadgeFrontFace from '../MeritBadgeDisplayCard/MeritBadgeFrontFace';
8
+ import MeritBadgeBackFace from '../MeritBadgeDisplayCard/MeritBadgeBackFace';
9
+ import { I18nProvider } from '../../i18n';
10
+ import type { VC } from '@learncard/types';
11
+
12
+ const credential = {
13
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
14
+ type: ['VerifiableCredential', 'OpenBadgeCredential'],
15
+ issuer: { id: 'did:example:issuer', name: 'Example school' },
16
+ validFrom: '2026-10-26T12:00:00Z',
17
+ awardedDate: '2027-10-01T00:00:00Z',
18
+ credentialSubject: {
19
+ id: 'did:example:learner',
20
+ achievement: { name: 'Web Foundations', description: 'Completed the course.' },
21
+ },
22
+ } as unknown as VC;
23
+
24
+ afterEach(cleanup);
25
+
26
+ describe.each([
27
+ ['certificate front', (value: VC) => <CertificateFrontFace credential={value} />],
28
+ [
29
+ 'certificate back',
30
+ (value: VC) => <CertificateBackFace credential={value} verificationItems={[]} />,
31
+ ],
32
+ ['award front', (value: VC) => <MeritBadgeFrontFace credential={value} />],
33
+ ['award back', (value: VC) => <MeritBadgeBackFace credential={value} verificationItems={[]} />],
34
+ ] as const)('%s award date', (_, renderFace) => {
35
+ it('shows the mapped award day instead of the issuance day', () => {
36
+ render(renderFace(credential));
37
+ expect(screen.getByText(/^Awarded on Oct 01, 2027(?: by)?$/)).toBeTruthy();
38
+ expect(screen.queryByText(/Awarded on Oct 26, 2026/)).toBeNull();
39
+ });
40
+ it('retains the legacy issuance-date fallback', () => {
41
+ render(renderFace({ ...credential, awardedDate: undefined }));
42
+ expect(screen.getByText(/^Awarded on Oct 26, 2026(?: by)?$/)).toBeTruthy();
43
+ });
44
+ it('retains the issuance-date fallback when the award date is invalid', () => {
45
+ render(renderFace({ ...credential, awardedDate: 'not-a-date' }));
46
+ expect(screen.getByText(/^Awarded on Oct 26, 2026(?: by)?$/)).toBeTruthy();
47
+ });
48
+ it('omits the date for an undated draft', () => {
49
+ render(renderFace({ ...credential, awardedDate: undefined, validFrom: undefined }));
50
+ expect(screen.queryByText(/Awarded on/)).toBeNull();
51
+ });
52
+ it('resolves the award label through the host translation adapter', () => {
53
+ render(
54
+ <I18nProvider
55
+ locale="fr"
56
+ resolve={(key, params) =>
57
+ key.startsWith('credential.awardedOn')
58
+ ? `Décernée le ${params?.date}`
59
+ : undefined
60
+ }
61
+ >
62
+ {renderFace(credential)}
63
+ </I18nProvider>
64
+ );
65
+ expect(screen.getByText('Décernée le Oct 01, 2027')).toBeTruthy();
66
+ });
67
+ });
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { useT } from '../../i18n';
2
3
  import { getCategoryDarkColor, getInfoFromCredential } from '../../helpers/credential.helpers';
3
4
  import { VC, VerificationItem } from '@learncard/types';
4
5
  import {
@@ -44,8 +45,10 @@ export const CertificateBackFace: React.FC<CertificateBackFaceProps> = ({
44
45
  handleViewFrontFace,
45
46
  customLinkedCredentialsComponent,
46
47
  }) => {
48
+ const t = useT();
47
49
  const { createdAt, credentialSubject } = getInfoFromCredential(credential, 'MMM dd, yyyy', {
48
50
  uppercaseDate: false,
51
+ dateType: 'awarded',
49
52
  });
50
53
 
51
54
  const { description } = credentialSubject?.achievement ?? {};
@@ -83,13 +86,17 @@ export const CertificateBackFace: React.FC<CertificateBackFaceProps> = ({
83
86
  )}
84
87
 
85
88
  <TruncateTextBox headerText="About" text={description}>
86
- <span
87
- className={`text-grayscale-600 font-poppins text-[12px] font-[600] w-full ${
88
- description ? 'pt-[10px] border-t border-solid border-grayscale-200' : ''
89
- }`}
90
- >
91
- Awarded on {createdAt}
92
- </span>
89
+ {createdAt && (
90
+ <span
91
+ className={`text-grayscale-600 font-poppins text-[12px] font-[600] w-full ${
92
+ description
93
+ ? 'pt-[10px] border-t border-solid border-grayscale-200'
94
+ : ''
95
+ }`}
96
+ >
97
+ {t('credential.awardedOn', { date: createdAt })}
98
+ </span>
99
+ )}
93
100
  </TruncateTextBox>
94
101
 
95
102
  {criteria && <TruncateTextBox headerText="Criteria" text={criteria} />}
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { useT } from '../../i18n';
2
3
 
3
4
  import CertificateCornerIcon from './CertificateCornerIcon';
4
5
  import CertificateImageDisplay from './CertificateImageDisplay';
@@ -68,6 +69,7 @@ export const CertificateFrontFace: React.FC<CertificateFrontFaceProps> = ({
68
69
  hideFrontFaceDetails,
69
70
  onVerifierClick,
70
71
  }) => {
72
+ const t = useT();
71
73
  const {
72
74
  title = '',
73
75
  createdAt,
@@ -75,7 +77,10 @@ export const CertificateFrontFace: React.FC<CertificateFrontFaceProps> = ({
75
77
  issuee: _issuee = '',
76
78
  credentialSubject,
77
79
  imageUrl,
78
- } = getInfoFromCredential(credential, 'MMM dd, yyyy', { uppercaseDate: false });
80
+ } = getInfoFromCredential(credential, 'MMM dd, yyyy', {
81
+ uppercaseDate: false,
82
+ dateType: 'awarded',
83
+ });
79
84
 
80
85
  const issuee = issueeOverride || _issuee;
81
86
  const issuer = issuerOverride || _issuer;
@@ -218,7 +223,11 @@ export const CertificateFrontFace: React.FC<CertificateFrontFaceProps> = ({
218
223
  </>
219
224
  )}
220
225
  </span>
221
- {!hideIssueDate && <span className="font-jacques">Awarded on {createdAt}</span>}
226
+ {!hideIssueDate && createdAt && (
227
+ <span className="font-jacques">
228
+ {t('credential.awardedOn', { date: createdAt })}
229
+ </span>
230
+ )}
222
231
  </div>
223
232
 
224
233
  <div className="flex flex-col gap-[10px] items-center">
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { useT } from '../../i18n';
2
3
  import { getCategoryDarkColor, getInfoFromCredential } from '../../helpers/credential.helpers';
3
4
  import { VC, VerificationItem } from '@learncard/types';
4
5
  import {
@@ -44,8 +45,10 @@ export const MeritBadgeBackFace: React.FC<MeritBadgeBackFaceProps> = ({
44
45
  handleViewFrontFace,
45
46
  customLinkedCredentialsComponent,
46
47
  }) => {
48
+ const t = useT();
47
49
  const { createdAt, credentialSubject } = getInfoFromCredential(credential, 'MMM dd, yyyy', {
48
50
  uppercaseDate: false,
51
+ dateType: 'awarded',
49
52
  });
50
53
 
51
54
  const { description } = credentialSubject?.achievement ?? {};
@@ -83,13 +86,17 @@ export const MeritBadgeBackFace: React.FC<MeritBadgeBackFaceProps> = ({
83
86
  )}
84
87
 
85
88
  <TruncateTextBox headerText="About" text={description}>
86
- <span
87
- className={`text-grayscale-600 font-poppins text-[12px] font-[600] w-full ${
88
- description ? 'pt-[10px] border-t border-solid border-grayscale-200' : ''
89
- }`}
90
- >
91
- Awarded on {createdAt}
92
- </span>
89
+ {createdAt && (
90
+ <span
91
+ className={`text-grayscale-600 font-poppins text-[12px] font-[600] w-full ${
92
+ description
93
+ ? 'pt-[10px] border-t border-solid border-grayscale-200'
94
+ : ''
95
+ }`}
96
+ >
97
+ {t('credential.awardedOn', { date: createdAt })}
98
+ </span>
99
+ )}
93
100
  </TruncateTextBox>
94
101
 
95
102
  {criteria && <TruncateTextBox headerText="Criteria" text={criteria} />}
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { useT } from '../../i18n';
2
3
 
3
4
  import MeritBadgeCornerIcon from './MeritBadgeCornerIcon';
4
5
  import MeritBadgeImageDisplay from './MeritBadgeImageDisplay';
@@ -66,6 +67,7 @@ export const MeritBadgeFrontFace: React.FC<MeritBadgeFrontFaceProps> = ({
66
67
  hideFrontFaceDetails,
67
68
  onVerifierClick,
68
69
  }) => {
70
+ const t = useT();
69
71
  const {
70
72
  title = '',
71
73
  createdAt,
@@ -73,7 +75,10 @@ export const MeritBadgeFrontFace: React.FC<MeritBadgeFrontFaceProps> = ({
73
75
  issuee: _issuee = '',
74
76
  credentialSubject,
75
77
  imageUrl,
76
- } = getInfoFromCredential(credential, 'MMM dd, yyyy', { uppercaseDate: false });
78
+ } = getInfoFromCredential(credential, 'MMM dd, yyyy', {
79
+ uppercaseDate: false,
80
+ dateType: 'awarded',
81
+ });
77
82
 
78
83
  const issuee = issueeOverride || _issuee;
79
84
  const issuer = issuerOverride || _issuer;
@@ -205,11 +210,11 @@ export const MeritBadgeFrontFace: React.FC<MeritBadgeFrontFaceProps> = ({
205
210
  <div className="flex flex-col w-full">
206
211
  <div className="text-[14px] text-grayscale-900 flex flex-col items-center w-full mb-[10px]">
207
212
  <span className="font-jacques flex gap-[5px] items-center w-full text-ellipsis whitespace-nowrap overflow-hidden justify-center">
208
- {issueeName === '0 person' ? (
209
- 'Not yet awarded'
210
- ) : (
211
- <>Awarded on {createdAt} by</>
212
- )}
213
+ {issueeName === '0 person'
214
+ ? 'Not yet awarded'
215
+ : createdAt
216
+ ? t('credential.awardedOnBy', { date: createdAt })
217
+ : t('credential.by')}
213
218
  </span>
214
219
  </div>
215
220
 
@@ -106,3 +106,99 @@ describe('Credential Helpers', () => {
106
106
  });
107
107
  });
108
108
  });
109
+
110
+ describe('award-date formatting', () => {
111
+ it.each(['awarded', 'issuance'] as const)(
112
+ 'falls back safely for a large malformed award date in %s mode',
113
+ dateType => {
114
+ const options = { uppercaseDate: false, dateType } as const;
115
+ const malformed = ' '.repeat(100_000);
116
+ expect(
117
+ getInfoFromCredential(
118
+ { ...VC2CredentialWithValidFrom, awardedDate: malformed } as never,
119
+ 'MMM dd, yyyy',
120
+ options
121
+ ).createdAt
122
+ ).toBe(
123
+ getInfoFromCredential(VC2CredentialWithValidFrom as never, 'MMM dd, yyyy', options)
124
+ .createdAt
125
+ );
126
+ }
127
+ );
128
+ it.each(['2027-10-01T00:30+02', '2027-10-01T00:30+0200', '2027-10-01T00:30+02:00'])(
129
+ 'recognizes an explicit award timezone in %s',
130
+ awardedDate => {
131
+ expect(
132
+ getInfoFromCredential(
133
+ { ...VC2CredentialWithValidFrom, awardedDate } as never,
134
+ 'MMM dd, yyyy',
135
+ { uppercaseDate: false, dateType: 'awarded' }
136
+ ).createdAt
137
+ ).toBe('Sep 30, 2027');
138
+ }
139
+ );
140
+ it.each(['20271001', '2027-10-01T00:00', '2027-10-01T00:00:00', '2027-10-01T20:00:00'])(
141
+ 'shows a consistent calendar award day for %s',
142
+ awardedDate => {
143
+ expect(
144
+ getInfoFromCredential(
145
+ { ...VC2CredentialWithValidFrom, awardedDate } as never,
146
+ 'MMM dd, yyyy',
147
+ { uppercaseDate: false, dateType: 'awarded' }
148
+ ).createdAt
149
+ ).toBe('Oct 01, 2027');
150
+ }
151
+ );
152
+ it('uses the standard award date only when requested and retains issuance metadata', () => {
153
+ const credential = { ...VC2CredentialWithValidFrom, awardedDate: '2027-10-01T00:00:00Z' };
154
+ expect(
155
+ getInfoFromCredential(credential as never, 'MMM dd, yyyy', {
156
+ uppercaseDate: false,
157
+ dateType: 'awarded',
158
+ }).createdAt
159
+ ).toBe('Oct 01, 2027');
160
+ expect(getInfoFromCredential(credential as never).createdAt).toBe(
161
+ getInfoFromCredential(VC2CredentialWithValidFrom as never).createdAt
162
+ );
163
+ });
164
+ it('falls back for missing or invalid award dates without inventing a date for an undated draft', () => {
165
+ expect(
166
+ getInfoFromCredential(
167
+ { ...VC2CredentialWithValidFrom, awardedDate: 'not-a-date' } as never,
168
+ 'MMM dd, yyyy',
169
+ { uppercaseDate: false, dateType: 'awarded' }
170
+ ).createdAt
171
+ ).toBe(
172
+ getInfoFromCredential(VC2CredentialWithValidFrom as never, 'MMM dd, yyyy', {
173
+ uppercaseDate: false,
174
+ }).createdAt
175
+ );
176
+ expect(
177
+ getInfoFromCredential(VC2CredentialNoDate as never, 'MMM dd, yyyy', {
178
+ dateType: 'awarded',
179
+ }).createdAt
180
+ ).toBe('');
181
+ });
182
+ it('keeps the UTC award day stable at a timezone boundary', () => {
183
+ expect(
184
+ getInfoFromCredential(
185
+ {
186
+ ...VC2CredentialWithValidFrom,
187
+ awardedDate: '2027-10-01T23:30:00-02:00',
188
+ } as never,
189
+ 'MMM dd, yyyy',
190
+ { uppercaseDate: false, dateType: 'awarded' }
191
+ ).createdAt
192
+ ).toBe('Oct 02, 2027');
193
+ });
194
+ });
195
+
196
+ it('treats a date-only award as a UTC calendar day', () => {
197
+ expect(
198
+ getInfoFromCredential(
199
+ { ...VC2CredentialWithValidFrom, awardedDate: '2027-10-01' } as never,
200
+ 'MMM dd, yyyy',
201
+ { uppercaseDate: false, dateType: 'awarded' }
202
+ ).createdAt
203
+ ).toBe('Oct 01, 2027');
204
+ });
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { format } from 'date-fns';
2
+ import { format, isValid, parseISO } from 'date-fns';
3
3
 
4
4
  import {
5
5
  Image,
@@ -43,7 +43,9 @@ export const getImageFromProfile = (profile: Profile): string => {
43
43
  export const getInfoFromCredential = (
44
44
  credential: VC | AchievementCredential,
45
45
  dateFormat: string = 'dd MMM yyyy',
46
- options: { uppercaseDate?: boolean } = { uppercaseDate: true }
46
+ options: { uppercaseDate?: boolean; dateType?: 'issuance' | 'awarded' } = {
47
+ uppercaseDate: true,
48
+ }
47
49
  ): CredentialInfo => {
48
50
  const { issuer, issuanceDate, validFrom } = credential;
49
51
 
@@ -57,12 +59,29 @@ export const getInfoFromCredential = (
57
59
 
58
60
  // Support both VC 2.0 (validFrom) and VC 1.0 (issuanceDate)
59
61
  // Gracefully handle missing dates
60
- const dateValue = validFrom || issuanceDate;
62
+ const awardDateText = typeof credential.awardedDate === 'string' ? credential.awardedDate : '';
63
+ const awardedDate =
64
+ options.dateType === 'awarded' && awardDateText ? parseISO(awardDateText) : undefined;
65
+ const validAwardedDate = awardedDate && isValid(awardedDate) ? awardedDate : undefined;
66
+ const dateValue = validAwardedDate ?? (validFrom || issuanceDate);
61
67
  let createdAt = '';
62
68
 
63
69
  if (dateValue) {
64
70
  try {
65
- createdAt = format(new Date(dateValue), dateFormat);
71
+ const date = dateValue instanceof Date ? dateValue : new Date(dateValue);
72
+ // Offset-less ISO values describe a calendar day. Only explicit
73
+ // instants need UTC conversion, matching the Details date helper.
74
+ // Inspect at most six suffix characters; never scan arbitrary input
75
+ // with a backtracking wildcard when looking for a timezone.
76
+ const hasAwardOffset =
77
+ validAwardedDate &&
78
+ (awardDateText.includes('T') || awardDateText.includes(' ')) &&
79
+ /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(awardDateText.slice(-6));
80
+ const displayDate =
81
+ validAwardedDate && hasAwardOffset
82
+ ? new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
83
+ : date;
84
+ createdAt = format(displayDate, dateFormat);
66
85
  if (options.uppercaseDate) {
67
86
  createdAt = createdAt.toUpperCase();
68
87
  }
@@ -45,6 +45,8 @@ export const EN_DEFAULTS: Record<string, string> = {
45
45
  'credential.close': 'Close',
46
46
  'credential.by': 'By',
47
47
  'credential.verified': 'Verified Credential',
48
+ 'credential.awardedOn': 'Awarded on {date}',
49
+ 'credential.awardedOnBy': 'Awarded on {date} by',
48
50
  'verification.title': 'Credential Verifications',
49
51
  'verification.infoText':
50
52
  'Credential verifications check the cryptographic proof of digital credentials to ensure their authenticity and accuracy.',