+): JSX.Element {
+ return (
+
+ );
+}
+
+Stars.displayName = 'Stars';
+
+export default Stars;
diff --git a/client/src/client-only-routes/show-settings.test.tsx b/client/src/client-only-routes/show-settings.test.tsx
index 3db2f5195ad..ddb07f334bb 100644
--- a/client/src/client-only-routes/show-settings.test.tsx
+++ b/client/src/client-only-routes/show-settings.test.tsx
@@ -11,6 +11,9 @@ import { initialState } from '../redux';
const testUsername = 'testuser';
vi.mock('../utils/get-words');
+vi.mock('@growthbook/growthbook-react', () => ({
+ useFeature: () => ({ on: false })
+}));
const { apiLocation } = envData;
diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx
index cc564fde793..8da55580e5c 100644
--- a/client/src/client-only-routes/show-settings.tsx
+++ b/client/src/client-only-routes/show-settings.tsx
@@ -161,7 +161,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
isHonest,
sendQuincyEmail,
username,
- keyboardShortcuts
+ keyboardShortcuts,
+ socrates
} = user;
const sound = (store.get('fcc-sound') as boolean) ?? false;
@@ -199,6 +200,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
resetEditorLayout={resetEditorLayout}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
toggleSoundMode={toggleSoundMode}
+ socrates={socrates}
/>
diff --git a/client/src/components/settings/account.tsx b/client/src/components/settings/account.tsx
index 34cd6ca9a39..a5cce73d202 100644
--- a/client/src/components/settings/account.tsx
+++ b/client/src/components/settings/account.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
+import { useFeature } from '@growthbook/growthbook-react';
import { Button, Spacer } from '@freecodecamp/ui';
import { FullWidthRow } from '../helpers';
@@ -7,11 +8,13 @@ import SoundSettings from './sound';
import KeyboardShortcutsSettings from './keyboard-shortcuts';
import ScrollbarWidthSettings from './scrollbar-width';
import SectionHeader from './section-header';
+import SocratesSettings from './socrates';
type MiscSettingsProps = {
keyboardShortcuts: boolean;
sound: boolean;
editorLayout: boolean | null;
+ socrates: boolean;
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
toggleSoundMode: (sound: boolean) => void;
resetEditorLayout: () => void;
@@ -23,14 +26,17 @@ const MiscSettings = ({
editorLayout,
resetEditorLayout,
toggleKeyboardShortcuts,
- toggleSoundMode
+ toggleSoundMode,
+ socrates
}: MiscSettingsProps) => {
const { t } = useTranslation();
+ const showSocratesFlag = useFeature('show-socrates').on;
return (
{t('settings.headings.account')}
+ {showSocratesFlag && }
void;
+ toggleSoundMode: (sound: boolean) => void;
+ resetEditorLayout: () => void;
+};
+
+const MiscSettings = ({
+ keyboardShortcuts,
+ sound,
+ editorLayout,
+ resetEditorLayout,
+ toggleKeyboardShortcuts,
+ toggleSoundMode
+}: MiscSettingsProps) => {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default MiscSettings;
diff --git a/client/src/components/settings/socrates.tsx b/client/src/components/settings/socrates.tsx
new file mode 100644
index 00000000000..13fcc7880fb
--- /dev/null
+++ b/client/src/components/settings/socrates.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import type { TFunction } from 'i18next';
+import { withTranslation } from 'react-i18next';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import type { Dispatch } from 'redux';
+
+import { updateMySocrates as updateMySocratesAction } from '../../redux/settings/actions';
+import ToggleButtonSetting from './toggle-button-setting';
+
+const mapStateToProps = () => ({});
+const mapDispatchToProps = (dispatch: Dispatch) =>
+ bindActionCreators({ updateMySocrates: updateMySocratesAction }, dispatch);
+
+type SocratesProps = {
+ socrates: boolean;
+ t: TFunction;
+ updateMySocrates: (socrates: { socrates: boolean }) => void;
+};
+
+function SocratesSettings({
+ socrates,
+ t,
+ updateMySocrates
+}: SocratesProps): JSX.Element {
+ return (
+
+ updateMySocrates({ socrates: !socrates })}
+ />
+
+ );
+}
+
+SocratesSettings.displayName = 'SocratesSettings';
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(withTranslation()(SocratesSettings));
diff --git a/client/src/redux/index.js b/client/src/redux/index.js
index 6caae3a3419..c0e9358f2bd 100644
--- a/client/src/redux/index.js
+++ b/client/src/redux/index.js
@@ -487,6 +487,8 @@ export const reducer = handleActions(
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyQuincyEmailComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
+ [settingsTypes.updateMySocratesComplete]: (state, { payload }) =>
+ payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyPortfolioComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyExperienceComplete]: (state, { payload }) =>
diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts
index d6d88d785a8..9f3ae2891c7 100644
--- a/client/src/redux/prop-types.ts
+++ b/client/src/redux/prop-types.ts
@@ -451,6 +451,7 @@ export type User = {
sound: boolean;
theme: UserThemes;
keyboardShortcuts: boolean;
+ socrates: boolean;
twitter: string;
bluesky: string;
username: string;
@@ -516,6 +517,7 @@ export type ChallengeMeta = {
title?: string;
challengeType?: number;
helpCategory: string;
+ description?: string;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
saveSubmissionToDB?: boolean;
diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js
index 5b03e62d8ae..f6ad501353a 100644
--- a/client/src/redux/selectors.js
+++ b/client/src/redux/selectors.js
@@ -27,6 +27,7 @@ export const isDonatingSelector = state => userSelector(state)?.isDonating;
export const isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
export const isSignedInSelector = state => !!userSelector(state);
+export const isSocratesOnSelector = state => userSelector(state)?.socrates;
export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal;
export const isSignoutModalOpenSelector = state =>
diff --git a/client/src/redux/settings/action-types.js b/client/src/redux/settings/action-types.js
index bab4e535647..070550d8aaa 100644
--- a/client/src/redux/settings/action-types.js
+++ b/client/src/redux/settings/action-types.js
@@ -13,6 +13,7 @@ export const actionTypes = createTypes(
...createAsyncTypes('updateMyKeyboardShortcuts'),
...createAsyncTypes('updateMyHonesty'),
...createAsyncTypes('updateMyQuincyEmail'),
+ ...createAsyncTypes('updateMySocrates'),
...createAsyncTypes('updateMyPortfolio'),
...createAsyncTypes('updateMyExperience'),
...createAsyncTypes('submitProfileUI'),
diff --git a/client/src/redux/settings/actions.js b/client/src/redux/settings/actions.js
index fac529b414e..96555173d30 100644
--- a/client/src/redux/settings/actions.js
+++ b/client/src/redux/settings/actions.js
@@ -73,6 +73,13 @@ export const updateMyQuincyEmailError = createAction(
types.updateMyQuincyEmailError
);
+export const updateMySocrates = createAction(types.updateMySocrates);
+export const updateMySocratesComplete = createAction(
+ types.updateMySocratesComplete,
+ checkForSuccessPayload
+);
+export const updateMySocratesError = createAction(types.updateMySocratesError);
+
export const updateMyPortfolio = createAction(types.updateMyPortfolio);
export const updateMyPortfolioComplete = createAction(
types.updateMyPortfolioComplete,
diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js
index c8d466c275f..e76541db3ce 100644
--- a/client/src/redux/settings/settings-sagas.js
+++ b/client/src/redux/settings/settings-sagas.js
@@ -22,6 +22,7 @@ import {
putUpdateMyExperience,
putUpdateMyProfileUI,
putUpdateMyQuincyEmail,
+ putUpdateMySocrates,
putUpdateMySocials,
putUpdateMyUsername,
putVerifyCert
@@ -46,6 +47,8 @@ import {
updateMyExperienceError,
updateMyQuincyEmailComplete,
updateMyQuincyEmailError,
+ updateMySocratesComplete,
+ updateMySocratesError,
updateMySocialsComplete,
updateMySocialsError,
updateMySoundComplete,
@@ -162,6 +165,16 @@ function* updateMyQuincyEmailSaga({ payload: update }) {
}
}
+function* updateMySocratesSaga({ payload: update }) {
+ try {
+ const { data } = yield call(putUpdateMySocrates, update);
+ yield put(updateMySocratesComplete({ ...data, payload: update }));
+ yield put(createFlashMessage({ ...data }));
+ } catch {
+ yield put(updateMySocratesError);
+ }
+}
+
function* updateMyPortfolioSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMyPortfolio, update);
@@ -250,6 +263,7 @@ export function createSettingsSagas(types) {
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
+ takeEvery(types.updateMySocrates, updateMySocratesSaga),
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
takeEvery(types.updateMyExperience, updateMyExperienceSaga),
takeLatest(types.submitNewAbout, submitNewAboutSaga),
diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx
index 121f0659fb8..fec70917eee 100644
--- a/client/src/templates/Challenges/classic/show.tsx
+++ b/client/src/templates/Challenges/classic/show.tsx
@@ -400,6 +400,7 @@ function ShowClassic({
title,
challengeType,
helpCategory,
+ description,
...challengePaths
});
challengeMounted(challengeMeta.id);
diff --git a/client/src/templates/Challenges/codeally/show.tsx b/client/src/templates/Challenges/codeally/show.tsx
index a9343e2ead6..77c46c329bc 100644
--- a/client/src/templates/Challenges/codeally/show.tsx
+++ b/client/src/templates/Challenges/codeally/show.tsx
@@ -150,7 +150,6 @@ function ShowCodeAlly({
}
}
} = data;
-
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
@@ -174,6 +173,7 @@ function ShowCodeAlly({
title,
challengeType,
helpCategory,
+ description,
...challengePaths
});
challengeMounted(challengeMeta.id);
diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.css b/client/src/templates/Challenges/components/independent-lower-jaw.css
index 766203add7f..d8051175bb9 100644
--- a/client/src/templates/Challenges/components/independent-lower-jaw.css
+++ b/client/src/templates/Challenges/components/independent-lower-jaw.css
@@ -27,6 +27,7 @@
display: flex;
justify-content: space-between;
flex-direction: row;
+ margin-bottom: 10px;
}
.independent-lower-jaw .hint-container .hint-header svg {
@@ -67,9 +68,7 @@
}
.independent-lower-jaw .hint-container button {
- height: 30px;
font-size: 1.5rem;
- min-width: 30px;
display: flex;
justify-content: center;
align-items: center;
@@ -102,6 +101,7 @@
font-size: 1rem;
text-align: center;
position: absolute;
+ width: max-content;
top: -60px;
z-index: 1;
}
@@ -113,6 +113,8 @@
transition: opacity 0.5s ease 1s;
}
+/* .independent-lower-jaw */
+
.tooltiptext::after {
content: '';
position: absolute;
@@ -143,6 +145,52 @@
gap: 10px;
}
+.socrates-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 0;
+}
+
+.skeleton-line {
+ height: 16px;
+ border-radius: 2px;
+}
+
+.skeleton-line-1 {
+ background-color: var(--tertiary-background);
+ width: 100%;
+ animation: pulse-1 1.5s ease-in-out infinite;
+}
+
+.skeleton-line-2 {
+ background-color: var(--tertiary-background);
+ width: 85%;
+ animation: pulse-2 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-1 {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@keyframes pulse-2 {
+ 0% {
+ opacity: 0.5;
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.5;
+ }
+}
+
.share-button-wrapper a {
border: 1px solid var(--quaternary-color);
padding: 2px 6px;
diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx
index 027b362bc59..7cf4035184f 100644
--- a/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx
+++ b/client/src/templates/Challenges/components/independent-lower-jaw.test.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useStaticQuery } from 'gatsby';
@@ -11,6 +12,11 @@ import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data';
import { render } from '../../../../utils/test-utils';
vi.mock('../../../components/Progress');
+
+let showSocratesFlag = true;
+vi.mock('@growthbook/growthbook-react', () => ({
+ useFeature: () => ({ on: showSocratesFlag })
+}));
vi.mock('../utils/fetch-all-curriculum-data', () => ({
useSubmit: () => vi.fn()
}));
@@ -30,19 +36,30 @@ const baseProps = {
openHelpModal: vi.fn(),
openResetModal: vi.fn(),
executeChallenge: vi.fn(),
+ submitChallenge: vi.fn(),
+ askSocrates: vi.fn(),
saveChallenge: vi.fn(),
tests: passingTests,
isSignedIn: true,
challengeMeta: baseChallengeMeta,
completedPercent: 100,
completedChallengeIds: ['id-1', 'test-challenge-id'],
- currentBlockIds: ['id-1', 'test-challenge-id']
+ currentBlockIds: ['id-1', 'test-challenge-id'],
+ hasSocratesAccess: false,
+ socratesHintState: {
+ hint: null,
+ isLoading: false,
+ error: null,
+ attempts: null,
+ limit: null
+ }
};
vi.mock('../../../utils/get-words');
describe('', () => {
beforeEach(() => {
+ showSocratesFlag = true;
vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData);
});
@@ -83,4 +100,61 @@ describe('', () => {
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
});
+
+ it('shows socrates button when hasSocratesAccess is true and flag is on', () => {
+ render(
+ ,
+ createStore()
+ );
+
+ expect(screen.getByText('buttons.ask-socrates')).toBeInTheDocument();
+ });
+
+ it('hides socrates button when show-socrates flag is off', () => {
+ showSocratesFlag = false;
+
+ render(
+ ,
+ createStore()
+ );
+
+ expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
+ });
+
+ it('hides socrates button when hasSocratesAccess is false', () => {
+ render(
+ ,
+ createStore()
+ );
+
+ expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
+ });
+
+ it('displays usage counter when attempts and limit are set', async () => {
+ const failingTests: Test[] = [
+ { pass: false, err: 'fail', text: 'test', testString: 'test' }
+ ];
+
+ render(
+ ,
+ createStore()
+ );
+
+ // Click the socrates button to open the results panel
+ await userEvent.click(screen.getByRole('button', { name: /ask-socrates/ }));
+
+ expect(screen.getByText(/2\/3/)).toBeInTheDocument();
+ expect(screen.getByText(/learn\.hints-used-today/)).toBeInTheDocument();
+ });
});
diff --git a/client/src/templates/Challenges/components/independent-lower-jaw.tsx b/client/src/templates/Challenges/components/independent-lower-jaw.tsx
index ee447efb80c..bcc9655ea76 100644
--- a/client/src/templates/Challenges/components/independent-lower-jaw.tsx
+++ b/client/src/templates/Challenges/components/independent-lower-jaw.tsx
@@ -2,7 +2,9 @@ import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useTranslation } from 'react-i18next';
+import sanitizeHtml from 'sanitize-html';
import { Button, Spacer } from '@freecodecamp/ui';
+import { useFeature } from '@growthbook/growthbook-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faLightbulb,
@@ -15,17 +17,19 @@ import {
import Progress from '../../../components/Progress';
import {
completedChallengesIdsSelector,
- isSignedInSelector
+ isSignedInSelector,
+ isSocratesOnSelector
} from '../../../redux/selectors';
import { ChallengeMeta, Test } from '../../../redux/prop-types';
import {
challengeMetaSelector,
challengeTestsSelector,
completedPercentageSelector,
- currentBlockIdsSelector
+ currentBlockIdsSelector,
+ socratesHintStateSelector
} from '../redux/selectors';
import { apiLocation } from '../../../../config/env.json';
-import { openModal, executeChallenge } from '../redux/actions';
+import { openModal, executeChallenge, askSocrates } from '../redux/actions';
import { saveChallenge } from '../../../redux/actions';
import Help from '../../../assets/icons/help';
import callGA from '../../../analytics/call-ga';
@@ -33,6 +37,15 @@ import { Share } from '../../../components/share';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
import './independent-lower-jaw.css';
+import Stars from '../../../assets/icons/stars';
+
+type SocratesHintState = {
+ hint: null | string;
+ isLoading: boolean;
+ error: null | string;
+ attempts: null | number;
+ limit: null | number;
+};
const mapStateToProps = createSelector(
challengeTestsSelector,
@@ -41,26 +54,33 @@ const mapStateToProps = createSelector(
completedPercentageSelector,
completedChallengesIdsSelector,
currentBlockIdsSelector,
+ socratesHintStateSelector,
+ isSocratesOnSelector,
(
tests: Test[],
isSignedIn: boolean,
challengeMeta: ChallengeMeta,
completedPercent: number,
completedChallengeIds: string[],
- currentBlockIds: string[]
+ currentBlockIds: string[],
+ socratesHintState: SocratesHintState,
+ hasSocratesAccess: boolean
) => ({
tests,
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
- currentBlockIds
+ currentBlockIds,
+ socratesHintState,
+ hasSocratesAccess
})
);
const mapDispatchToProps = {
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset'),
+ askSocrates: () => askSocrates(),
executeChallenge,
saveChallenge
};
@@ -69,6 +89,7 @@ interface IndependentLowerJawProps {
openHelpModal: () => void;
openResetModal: () => void;
executeChallenge: () => void;
+ askSocrates: () => void;
saveChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
@@ -76,10 +97,13 @@ interface IndependentLowerJawProps {
completedPercent: number;
completedChallengeIds: string[];
currentBlockIds: string[];
+ socratesHintState: SocratesHintState;
+ hasSocratesAccess: boolean;
}
export function IndependentLowerJaw({
openHelpModal,
openResetModal,
+ askSocrates,
executeChallenge,
saveChallenge,
tests,
@@ -87,13 +111,17 @@ export function IndependentLowerJaw({
challengeMeta,
completedPercent,
completedChallengeIds,
- currentBlockIds
+ currentBlockIds,
+ socratesHintState,
+ hasSocratesAccess
}: IndependentLowerJawProps): JSX.Element {
const { t } = useTranslation();
+ const showSocratesFlag = useFeature('show-socrates').on;
const submitChallenge = useSubmit();
const firstFailedTest = tests.find(test => !!test.err);
const hint = firstFailedTest?.message;
const [showHint, setShowHint] = React.useState(false);
+ const [showSocratesResults, setShowSocratesResults] = React.useState(false);
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
const signInLinkRef = React.useRef(null);
const submitButtonRef = React.useRef(null);
@@ -132,6 +160,7 @@ export function IndependentLowerJaw({
const handleCheckButtonClick = () => {
setWasCheckButtonClicked(true);
+ setShowSocratesResults(false);
executeChallenge();
};
@@ -141,6 +170,14 @@ export function IndependentLowerJaw({
? t('buttons.command-enter')
: t('buttons.ctrl-enter');
+ const askSocratesAttempt = () => {
+ setShowSocratesResults(true);
+ setShowHint(false);
+ setShowSubmissionHint(false);
+ if (socratesHintState.isLoading) return;
+ askSocrates();
+ };
+
return (
{t('buttons.close')}
-
+
+
+ )}
+ {showSocratesResults && (
+
+
+
+
+
+ {socratesHintState.isLoading ? (
+
+ ) : (
+
+ )}
+ {socratesHintState.attempts !== null &&
+ socratesHintState.limit !== null && (
+
+ {socratesHintState.attempts}/{socratesHintState.limit}{' '}
+ {t('learn.hints-used-today')}
+
+ )}
)}
{isChallengeComplete && showSubmissionHint && (
@@ -254,6 +337,16 @@ export function IndependentLowerJaw({
)}
+ {hasSocratesAccess && showSocratesFlag && (
+
+ )}
{showRevertButton ? (
<>