feat(client): add exam attempts to exam-download page (#61361)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
Shaun Hamilton
2025-09-25 21:27:31 +02:00
committed by GitHub
co-authored by Tom Oliver Eyton-Williams Mrugesh Mohapatra
parent 6c3bc14f52
commit 35d05d2c05
12 changed files with 397 additions and 81 deletions
@@ -1232,34 +1232,77 @@ describe('/exam-environment/', () => {
});
});
describe('GET /exam-environment/challenges/:challengeId/exam-mappings', () => {
describe('GET /exam-environment/exam-challenge', () => {
afterAll(async () => {
await fastifyTestInstance.prisma.examEnvironmentChallenge.deleteMany(
{}
);
});
it('should return 200 and an empty array if no exams are mapped to the challenge', async () => {
it('should return 200 and an empty array if no mapping exists', async () => {
const challengeId = mock.oid();
const res = await superGet(
`/exam-environment/challenges/${challengeId}/exam-mappings`
const examId = mock.oid();
const res1 = await superGet(
`/exam-environment/exam-challenge?challengeId=${challengeId}`
);
expect(res.body).toStrictEqual([]);
expect(res.status).toBe(200);
expect(res1.body).toStrictEqual([]);
expect(res1.status).toBe(200);
const res2 = await superGet(
`/exam-environment/exam-challenge?examId=${examId}`
);
expect(res2.body).toStrictEqual([]);
expect(res2.status).toBe(200);
const res3 = await superGet(
`/exam-environment/exam-challenge?challengeId=${challengeId}&examId=${examId}`
);
expect(res3.body).toStrictEqual([]);
expect(res3.status).toBe(200);
});
it('should return 200 and a list of exams mapped to the challenge', async () => {
it('should return 200 and a list of challenge-exam mappings if one exists', async () => {
await fastifyTestInstance.prisma.examEnvironmentChallenge.create({
data: mock.examEnvironmentChallenge
});
const res = await superGet(
`/exam-environment/challenges/${mock.examEnvironmentChallenge.challengeId}/exam-mappings`
const res1 = await superGet(
`/exam-environment/exam-challenge?challengeId=${mock.examEnvironmentChallenge.challengeId}`
);
expect(res.body).toEqual(
expect(res1.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ examId: mock.examId })
expect.objectContaining({
examId: mock.examId,
challengeId: mock.examEnvironmentChallenge.challengeId
})
])
);
expect(res.status).toBe(200);
expect(res1.status).toBe(200);
const res2 = await superGet(
`/exam-environment/exam-challenge?examId=${mock.examId}`
);
expect(res2.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
examId: mock.examId,
challengeId: mock.examEnvironmentChallenge.challengeId
})
])
);
expect(res2.status).toBe(200);
const res3 = await superGet(
`/exam-environment/exam-challenge?challengeId=${mock.examEnvironmentChallenge.challengeId}&examId=${mock.examId}`
);
expect(res3.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
examId: mock.examId,
challengeId: mock.examEnvironmentChallenge.challengeId
})
])
);
expect(res3.status).toBe(200);
});
});
});
@@ -89,11 +89,11 @@ export const examEnvironmentOpenRoutes: FastifyPluginCallbackTypebox = (
tokenMetaHandler
);
fastify.get(
'/exam-environment/challenges/:challengeId/exam-mappings',
'/exam-environment/exam-challenge',
{
schema: schemas.examEnvironmentGetExamMappingsByChallengeId
schema: schemas.examEnvironmentGetExamChallenge
},
getExamMappingsByChallengeId
getExamChallenge
);
done();
};
@@ -1000,22 +1000,21 @@ export async function getExamAttemptsByExamIdHandler(
/**
* Gets all the relations for a given challenge and exam(s).
*/
export async function getExamMappingsByChallengeId(
export async function getExamChallenge(
this: FastifyInstance,
req: UpdateReqType<
typeof schemas.examEnvironmentGetExamMappingsByChallengeId
>,
req: UpdateReqType<typeof schemas.examEnvironmentGetExamChallenge>,
reply: FastifyReply
) {
const logger = this.log.child({ req });
const { challengeId } = req.params;
const { challengeId, examId } = req.query;
logger.info({ challengeId });
logger.info({ challengeId, examId });
const maybeData = await mapErr(
this.prisma.examEnvironmentChallenge.findMany({
where: {
challengeId
challengeId: challengeId ?? undefined,
examId: examId ?? undefined
}
})
);
@@ -1,9 +1,10 @@
import { Type } from '@fastify/type-provider-typebox';
// import { STANDARD_ERROR } from '../utils/errors';
export const examEnvironmentGetExamMappingsByChallengeId = {
params: Type.Object({
challengeId: Type.String({ format: 'objectid' })
export const examEnvironmentGetExamChallenge = {
querystring: Type.Object({
challengeId: Type.Optional(Type.String({ format: 'objectid' })),
examId: Type.Optional(Type.String({ format: 'objectid' }))
})
// response: {
// 200: examEnvAttempt,
+1 -1
View File
@@ -7,4 +7,4 @@ export {
export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam.js';
export { examEnvironmentTokenMeta } from './token-meta.js';
export { examEnvironmentExams } from './exam-environment-exams.js';
export { examEnvironmentGetExamMappingsByChallengeId } from './challenges.js';
export { examEnvironmentGetExamChallenge } from './challenges.js';
@@ -380,6 +380,14 @@
}
},
"exam": {
"attempts": "Attempts",
"no-attempts-yet": "No attempts yet",
"date-taken": "Date Taken",
"score": "Score",
"status": "Status",
"pending": "Pending",
"passed": "Passed",
"failed": "Failed",
"download-header": "Download the freeCodeCamp Exam Environment App",
"explanation": "To earn a certification, you must take an exam to test your understanding of the material you have learned. Taking the exam is absolutely free of charge.",
"version": "The latest version of our app is: {{version}}.",
+1
View File
@@ -57,6 +57,7 @@
"@loadable/component": "5.16.3",
"@redux-devtools/extension": "3.3.0",
"@redux-saga/core": "^1.2.2",
"@reduxjs/toolkit": "2.8.2",
"@stripe/react-stripe-js": "1.16.5",
"@stripe/stripe-js": "1.54.2",
"algoliasearch": "4.22.1",
+28 -10
View File
@@ -1,10 +1,12 @@
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/extension';
// import { composeWithDevTools } from '@redux-devtools/extension';
import { createEpicMiddleware } from 'redux-observable';
import createSagaMiddleware from 'redux-saga';
import { configureStore } from '@reduxjs/toolkit';
import envData from '../../config/env.json';
import { isBrowser } from '../../utils';
import { examAttempts } from '../utils/ajax';
import rootEpic from './root-epic';
import rootReducer from './root-reducer';
import rootSaga from './root-saga';
@@ -32,9 +34,9 @@ const epicMiddleware = createEpicMiddleware({
}
});
const composeEnhancers = composeWithDevTools({
// options like actionSanitizer, stateSanitizer
});
// const composeEnhancers = composeWithDevTools({
// // options like actionSanitizer, stateSanitizer
// });
export const createStore = (preloadedState = {}) => {
let store;
@@ -42,14 +44,30 @@ export const createStore = (preloadedState = {}) => {
store = reduxCreateStore(
rootReducer,
preloadedState,
applyMiddleware(sagaMiddleware, epicMiddleware)
// @ts-expect-error RTK uses unknown, Redux uses any
applyMiddleware(sagaMiddleware, epicMiddleware, examAttempts.middleware)
);
} else {
store = reduxCreateStore(
rootReducer,
preloadedState,
composeEnhancers(applyMiddleware(sagaMiddleware, epicMiddleware))
);
// store = reduxCreateStore(
// rootReducer,
// preloadedState,
// composeEnhancers(
// // @ts-expect-error RTK uses unknown, Redux uses any
// applyMiddleware(sagaMiddleware, epicMiddleware, examAttempts.middleware)
// )
// );
store = configureStore({
// @ts-expect-error RTK uses unknown, Redux uses any
reducer: rootReducer,
// @ts-expect-error RTK uses unknown, Redux uses any
middleware: getDefaultMiddleware => {
return getDefaultMiddleware()
.concat(examAttempts.middleware)
.concat(sagaMiddleware)
.concat(epicMiddleware);
},
preloadedState
});
}
sagaMiddleware.run(rootSaga);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+3 -1
View File
@@ -13,6 +13,7 @@ import {
ns as curriculumMapNameSpace,
reducer as curriculumMap
} from '../templates/Introduction/redux';
import { examAttempts } from '../utils/ajax';
import { ns as appNameSpace } from './action-types';
import { ns as settingsNameSpace, reducer as settings } from './settings';
import { FlashApp as flashNameSpace } from './types';
@@ -24,5 +25,6 @@ export default combineReducers({
[curriculumMapNameSpace]: curriculumMap,
[flashNameSpace]: flash,
[searchNameSpace]: search,
[settingsNameSpace]: settings
[settingsNameSpace]: settings,
[examAttempts.reducerPath]: examAttempts.reducer
});
@@ -0,0 +1,81 @@
import React, { useEffect } from 'react';
import { Table } from '@freecodecamp/ui';
import { useTranslation } from 'react-i18next';
import { Loader } from '../../../components/helpers';
import { examAttempts } from '../../../utils/ajax';
interface AttemptsProps {
id: string;
}
export function Attempts({ id }: AttemptsProps) {
const { t } = useTranslation();
const examIdsQuery = examAttempts.useGetExamIdsByChallengeIdQuery(id);
const [getAttempts, attemptsMutation] =
examAttempts.useGetExamAttemptsByExamIdMutation();
useEffect(() => {
if (!examIdsQuery.data) {
return;
}
const examId = examIdsQuery.data.at(0)!.examId;
void getAttempts(examId);
}, [examIdsQuery.data, getAttempts]);
if (examIdsQuery.isLoading || attemptsMutation.isLoading) {
return <Loader />;
}
if (examIdsQuery.error || !examIdsQuery.data) {
console.error(examIdsQuery.error);
return <p>{t('flash.generic-error')}</p>;
}
if (attemptsMutation.error) {
console.error(attemptsMutation.error);
return <p>{t('flash.generic-error')}</p>;
}
const attempts = attemptsMutation.data;
if (attempts === undefined) {
return <Loader />;
}
if (attempts.length === 0) {
return <p>{t('exam.no-attempts-yet')}</p>;
}
return (
<Table striped>
<thead>
<tr>
<th>{t('exam.date-taken')}</th>
<th>{t('exam.score')} [%]</th>
<th>{t('exam.status')}</th>
</tr>
</thead>
<tbody>
{attempts.map(attempt => (
<tr key={attempt.startTimeInMS}>
<td>{new Date(attempt.startTimeInMS).toTimeString()}</td>
<td>
{attempt.result
? `${attempt.result.percent}%`
: t('exam.pending')}
</td>
<td>
{attempt.result
? attempt.result.passed
? t('exam.passed')
: t('exam.failed')
: t('exam.pending')}
</td>
</tr>
))}
</tbody>
</Table>
);
}
@@ -1,9 +1,16 @@
import React, { useEffect, useState } from 'react';
import { Button, Spacer } from '@freecodecamp/ui';
import { graphql } from 'gatsby';
import { Button, Dropdown, MenuItem, Spacer } from '@freecodecamp/ui';
import { isEmpty } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useTranslation, withTranslation } from 'react-i18next';
import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import { FullWidthRow } from '../../../components/helpers';
import useDetectOS from '../utils/use-detect-os';
import { ChallengeNode } from '../../../redux/prop-types';
import { isSignedInSelector } from '../../../redux/selectors';
import { Attempts } from './attempts';
interface GitProps {
tag_name: string;
@@ -12,7 +19,26 @@ interface GitProps {
}[];
}
function ShowExamDownload(): JSX.Element {
const mapStateToProps = createSelector(
isSignedInSelector,
(isSignedIn: boolean) => ({
isSignedIn
})
);
interface ShowExamDownloadProps {
data: { challengeNode: ChallengeNode };
isSignedIn: boolean;
}
function ShowExamDownload({
data: {
challengeNode: {
challenge: { id }
}
},
isSignedIn
}: ShowExamDownloadProps): JSX.Element {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [downloadLink, setDownloadLink] = useState<string | undefined>('');
@@ -22,8 +48,8 @@ function ShowExamDownload(): JSX.Element {
const { t } = useTranslation();
const handleDownloadLink = (downloadLinks: string[]) => {
const win = downloadLinks.find(link => link.match(/\.msi/));
function handleDownloadLink(downloadLinks: string[]) {
const win = downloadLinks.find(link => link.match(/\.exe/));
const macARM = downloadLinks.find(
link => link.match(/aarch64/) && link.match(/\.dmg/)
);
@@ -70,41 +96,44 @@ function ShowExamDownload(): JSX.Element {
}
return '';
};
}
useEffect(() => {
const checkLatestVersion = async () => {
return await fetch(
'https://api.github.com/repos/freeCodeCamp/exam-env/releases/latest'
);
};
checkLatestVersion()
.then(response => {
async function checkLatestVersion() {
try {
const response = await fetch(
'https://api.github.com/repos/freeCodeCamp/exam-env/releases/latest'
);
if (response.ok) {
void response.json().then(data => {
const { tag_name, assets } = data as GitProps;
setLatestVersion(tag_name);
setDownloadLink(
handleDownloadLink(
assets.map(links => links.browser_download_url)
)
);
setDownloadLinks(assets.map(links => links.browser_download_url));
});
const data = (await response.json()) as GitProps;
const { tag_name, assets } = data;
setLatestVersion(tag_name);
const urls = assets.map(link => link.browser_download_url);
setDownloadLink(handleDownloadLink(urls));
setDownloadLinks(urls);
}
})
.catch(() => {
} catch {
setLatestVersion('...');
});
});
}
}
void checkLatestVersion();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [os]);
return (
<FullWidthRow>
<Spacer size='l' />
<h2>{t('exam.download-header')}</h2>
<p>{t('exam.explanation')}</p>
<Spacer size='l' />
{isSignedIn && (
<>
<h2>{t('exam.attempts')}</h2>
<Attempts id={id} />
<Spacer size='l' />
</>
)}
<p>
{t('exam.version', {
version: latestVersion || '...'
@@ -118,29 +147,48 @@ function ShowExamDownload(): JSX.Element {
>
{t('buttons.download-latest-version')}
</Button>
{!downloadLink && <strong>{t('exam.unable-to-detect-os')}</strong>}
<Spacer size='l' />
<details>
<summary>{t('exam.download-details')}</summary>
<ul>
{!downloadLink && (
<>
<Spacer size='m' />
<strong>{t('exam.unable-to-detect-os')}</strong>
</>
)}
<Spacer size='m' />
<Dropdown>
<Dropdown.Toggle>{t('exam.download-details')}</Dropdown.Toggle>
<Dropdown.Menu>
{downloadLinks
.filter(link => !link.match(/\.sig|\.json/))
.map((link, index) => {
return (
<li key={index} style={{ listStyle: 'none' }}>
<a href={link} download={link}>
{link}
</a>
</li>
<MenuItem
href={link}
download={link}
key={index}
variant='primary'
>
{link}
</MenuItem>
);
})}
</ul>
</details>
</Dropdown.Menu>
</Dropdown>
<Spacer size='l' />
<strong>{t('exam.download-trouble')}</strong>
<strong>{t('exam.download-trouble')}</strong>{' '}
<a href='mailto: support@freecodecamp.org'>support@freecodecamp.org</a>
</FullWidthRow>
);
}
export default ShowExamDownload;
export default connect(mapStateToProps)(withTranslation()(ShowExamDownload));
// GraphQL
export const query = graphql`
query ExamEnvironmentExam($id: String!) {
challengeNode(id: { eq: $id }) {
challenge {
id
}
}
}
`;
+56 -1
View File
@@ -1,6 +1,7 @@
import cookies from 'browser-cookies';
import envData from '../../config/env.json';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import envData from '../../config/env.json';
import type {
ChallengeFile,
ChallengeFiles,
@@ -212,6 +213,32 @@ export function getGenerateExam(
return get(`/exam/${challengeId}`);
}
export interface Exam {
id: string;
config: {
name: string;
};
}
export interface Attempt {
id: string;
examId: string;
startTimeInMS: number;
questionSets: unknown[];
result?: {
passed: boolean;
percent: number;
};
}
export function getExams(): Promise<ResponseWithData<{ exams: Exam[] }>> {
return get('/user/exam-environment/exams');
}
export function getExamAttempts(): Promise<ResponseWithData<Attempt[]>> {
return get('/user/exam-environment/exam/attempts');
}
/** POST **/
interface Donation {
@@ -393,3 +420,31 @@ export function deleteUserToken(): Promise<ResponseWithData<void>> {
export function deleteMsUsername(): Promise<ResponseWithData<void>> {
return deleteRequest('/user/ms-username', {});
}
/** RTK */
export interface ExamEnvironmentChallenge {
id: string;
examId: string;
challengeId: string;
}
export const examAttempts = createApi({
reducerPath: 'exam-attempts',
baseQuery: fetchBaseQuery({
baseUrl: apiLocation,
headers: {
'CSRF-Token': getCSRFToken()
},
credentials: 'include'
}),
endpoints: build => ({
getExamAttemptsByExamId: build.mutation<Attempt[], string>({
query: examId => `/user/exam-environment/exams/${examId}/attempts`
}),
getExamIdsByChallengeId: build.query<ExamEnvironmentChallenge[], string>({
query: challengeId =>
`/exam-environment/exam-challenge?challengeId=${challengeId}`
})
})
});
+60
View File
@@ -348,6 +348,9 @@ importers:
'@redux-saga/core':
specifier: ^1.2.2
version: 1.3.0
'@reduxjs/toolkit':
specifier: 2.8.2
version: 2.8.2(react-redux@7.2.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(react@17.0.2)
'@stripe/react-stripe-js':
specifier: 1.16.5
version: 1.16.5(@stripe/stripe-js@1.54.2)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)
@@ -3764,6 +3767,17 @@ packages:
'@redux-saga/types@1.2.1':
resolution: {integrity: sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==}
'@reduxjs/toolkit@2.8.2':
resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@remix-run/router@1.11.0':
resolution: {integrity: sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ==}
engines: {node: '>=14.0.0'}
@@ -4234,6 +4248,12 @@ packages:
resolution: {integrity: sha512-yxLC57GBDmbDmrnH+vJxsrbV4/aYUucBONkSRLZyJIVFAl/QJH+O/h+phITHDaxVZCYZAcudYJw4ERE32BJM7g==}
engines: {node: '>=14.0.0'}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/react-stripe-js@1.16.5':
resolution: {integrity: sha512-lVPW3IfwdacyS22pP+nBB6/GNFRRhT/4jfgAK6T2guQmtzPwJV1DogiGGaBNhiKtSY18+yS8KlHSu+PvZNclvQ==}
peerDependencies:
@@ -8589,6 +8609,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
immer@8.0.1:
resolution: {integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==}
@@ -11681,9 +11704,17 @@ packages:
peerDependencies:
redux: ^4
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@4.2.1:
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -11825,6 +11856,9 @@ packages:
reselect@4.1.8:
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -17891,6 +17925,18 @@ snapshots:
'@redux-saga/types@1.2.1': {}
'@reduxjs/toolkit@2.8.2(react-redux@7.2.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(react@17.0.2)':
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/utils': 0.3.0
immer: 10.1.1
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 17.0.2
react-redux: 7.2.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2)
'@remix-run/router@1.11.0': {}
'@rollup/rollup-android-arm-eabi@4.46.2':
@@ -18581,6 +18627,10 @@ snapshots:
'@smithy/types': 2.10.0
tslib: 2.6.2
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@stripe/react-stripe-js@1.16.5(@stripe/stripe-js@1.54.2)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)':
dependencies:
'@stripe/stripe-js': 1.54.2
@@ -24568,6 +24618,8 @@ snapshots:
ignore@7.0.5: {}
immer@10.1.1: {}
immer@8.0.1: {}
immutable@4.3.5: {}
@@ -28257,10 +28309,16 @@ snapshots:
dependencies:
redux: 4.2.1
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@4.2.1:
dependencies:
'@babel/runtime': 7.23.9
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -28487,6 +28545,8 @@ snapshots:
reselect@4.1.8: {}
reselect@5.1.1: {}
resize-observer-polyfill@1.5.1: {}
resolve-alpn@1.2.1: {}