diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 019cbf2ab6f..fa2e591c33e 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -4770,6 +4770,14 @@ } } }, + "daily-coding-challenge": { + "title": "Daily Coding Challenge", + "blocks": { + "daily-coding-challenge": { + "title": "Daily Coding Challenge" + } + } + }, "misc-text": { "browse-other": "Browse our other free certifications", "courses": "Courses", diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index d707713faca..9fd2bb1000b 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -117,7 +117,38 @@ "share-on-bluesky": "Share on BlueSky", "share-on-threads": "Share on Threads", "play-scene": "Press Play", - "download-latest-version": "Download the Latest Version" + "download-latest-version": "Download the Latest Version", + "start": "Start", + "go-to-today": "Go to Today's Challenge", + "go-to-today-long": "Go to Today's Coding Challenge", + "go-to-archive": "Go to Archive", + "go-to-archive-long": "Go to Daily Coding Challenge Archive" + }, + "daily-coding-challenges": { + "title": "Daily Coding Challenges", + "map-title": "Try the coding challenge of the day:", + "not-found": "Daily Coding Challenge Not Found.", + "release-note": "New challenges are released at midnight US Central time." + }, + "weekdays": { + "short": { + "sunday": "S", + "monday": "M", + "tuesday": "T", + "wednesday": "W", + "thursday": "T", + "friday": "F", + "saturday": "S" + }, + "long": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + } }, "landing": { "big-heading-1": "Learn to code — for free.", @@ -851,6 +882,8 @@ "github": "Link to {{username}}'s GitHub", "website": "Link to {{username}}'s website", "twitter": "Link to {{username}}'s Twitter", + "next-month": "Go to next month", + "previous-month": "Go to previous month", "first-page": "Go to first page", "previous-page": "Go to previous page", "next-page": "Go to next page", @@ -880,7 +913,8 @@ "editor-a11y-off-non-macos": "{{editorName}} editor content. Press Alt+F1 for accessibility options.", "editor-a11y-on-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Command+E to disable or press Option+F1 for more options.", "editor-a11y-on-non-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Ctrl+E to disable or press Alt+F1 for more options.", - "terminal-output": "Terminal output" + "terminal-output": "Terminal output", + "not-available": "Not available" }, "flash": { "no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.", diff --git a/client/src/assets/icons/calendar.tsx b/client/src/assets/icons/calendar.tsx new file mode 100644 index 00000000000..0d896a0190b --- /dev/null +++ b/client/src/assets/icons/calendar.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +function CalendarIcon( + props: JSX.IntrinsicAttributes & React.SVGProps +): JSX.Element { + return ( + + ); +} + +CalendarIcon.displayName = 'CalendarIcon'; + +export default CalendarIcon; diff --git a/client/src/assets/icons/daily-coding-challenge.tsx b/client/src/assets/icons/daily-coding-challenge.tsx new file mode 100644 index 00000000000..ddfdb93108a --- /dev/null +++ b/client/src/assets/icons/daily-coding-challenge.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +function DailyCodingChallengeIcon( + props: JSX.IntrinsicAttributes & React.SVGProps +): JSX.Element { + return ( + + ); +} + +DailyCodingChallengeIcon.displayName = 'DailyCodingChallengeIcon'; + +export default DailyCodingChallengeIcon; diff --git a/client/src/components/Map/index.tsx b/client/src/components/Map/index.tsx index 729af347432..76a2e377ac5 100644 --- a/client/src/components/Map/index.tsx +++ b/client/src/components/Map/index.tsx @@ -12,7 +12,11 @@ import { import { SuperBlockIcon } from '../../assets/superblock-icon'; import LinkButton from '../../assets/icons/link-button'; import { ButtonLink } from '../helpers'; -import { showUpcomingChanges } from '../../../config/env.json'; +import { + showUpcomingChanges, + showDailyCodingChallenges +} from '../../../config/env.json'; +import DailyCodingChallengeWidget from '../daily-coding-challenge/widget'; import './map.css'; interface MapProps { @@ -82,6 +86,16 @@ function Map({ forLanding = false }: MapProps) { return ( + { + /* Show the daily coding challenge before the "English" curriculum */ + showDailyCodingChallenges && + stage === SuperBlockStage.English && ( + <> + + + + ) + }

{t(superBlockHeadings[stage])}

diff --git a/client/src/components/Progress/progress-inner.tsx b/client/src/components/Progress/progress-inner.tsx index 6b4250c168c..dfac6282bf0 100644 --- a/client/src/components/Progress/progress-inner.tsx +++ b/client/src/components/Progress/progress-inner.tsx @@ -41,9 +41,16 @@ function ProgressInner({ const [shownPercent, setShownPercent] = useState(0); const [lastShownPercent, setLastShownPercent] = useState(0); const progressInnerWrap = useRef(null); + const intervalRef = useRef(null); const isProgressInViewport = useIsInViewport(progressInnerWrap); const animateProgressInner = (completedPercent: number) => { + // Clear any existing interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (completedPercent > 100) completedPercent = 100; if (completedPercent < 0) completedPercent = 0; @@ -51,7 +58,7 @@ function ProgressInner({ const intervalsToFinish = transitionLength / intervalLength; const amountPerInterval = completedPercent / intervalsToFinish; - const myInterval = window.setInterval(() => { + intervalRef.current = window.setInterval(() => { percent += amountPerInterval; if (percent > completedPercent) percent = completedPercent; @@ -61,10 +68,14 @@ function ProgressInner({ ); if (percent >= completedPercent) { percent = 0; - clearInterval(myInterval); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } } }, intervalLength); }; + useEffect(() => { if (lastShownPercent !== completedPercent && isProgressInViewport) { setLastShownPercent(completedPercent); @@ -73,6 +84,16 @@ function ProgressInner({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isProgressInViewport]); + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, []); + return ( <>
{title}
diff --git a/client/src/components/Progress/progress.tsx b/client/src/components/Progress/progress.tsx index 23935003f8c..a05980918c9 100644 --- a/client/src/components/Progress/progress.tsx +++ b/client/src/components/Progress/progress.tsx @@ -14,6 +14,11 @@ import { import { liveCerts } from '../../../config/cert-and-project-map'; import { updateAllChallengesInfo } from '../../redux/actions'; import { CertificateNode, ChallengeNode } from '../../redux/prop-types'; +import { getIsDailyCodingChallenge } from '../../../../shared/config/challenge-types'; +import { + isValidDateParam, + formatDisplayDate +} from '../daily-coding-challenge/helpers'; import ProgressInner from './progress-inner'; const mapStateToProps = createSelector( @@ -24,10 +29,12 @@ const mapStateToProps = createSelector( ( currentBlockIds: string[], { + challengeType, id, block, superBlock }: { + challengeType: number; id: string; block: string; superBlock: string; @@ -36,6 +43,7 @@ const mapStateToProps = createSelector( completedPercent: number ) => ({ currentBlockIds, + challengeType, id, block, superBlock, @@ -56,17 +64,28 @@ function Progress({ block, id, superBlock, + challengeType, completedChallengesInBlock, completedPercent, t, updateAllChallengesInfo }: ProgressProps): JSX.Element { - const blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); + let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); // Always false for legacy full stack, since it has no projects. const isCertificationProject = liveCerts.some(cert => cert.projects?.some((project: { id: string }) => project.id === id) ); + // Display the date of the challenge in the completion modal for daily challenges + if (getIsDailyCodingChallenge(challengeType)) { + const dateParam = + new URLSearchParams(window.location.search).get('date') || ''; + + if (isValidDateParam(dateParam)) { + blockTitle += `: ${formatDisplayDate(dateParam)}`; + } + } + const { challengeNodes, certificateNodes } = useGetAllBlockIds(); useEffect(() => { updateAllChallengesInfo({ challengeNodes, certificateNodes }); diff --git a/client/src/components/daily-coding-challenge/calendar-day.tsx b/client/src/components/daily-coding-challenge/calendar-day.tsx new file mode 100644 index 00000000000..5c8391dccd0 --- /dev/null +++ b/client/src/components/daily-coding-challenge/calendar-day.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from '../helpers'; +import GreenPass from '../../assets/icons/green-pass'; +import GreenNotCompleted from '../../assets/icons/green-not-completed'; +import { formatDisplayDate } from './helpers'; + +interface CalendarDayProps { + dayNumber: number; + date?: string; + isCompleted?: boolean; + isAvailable?: boolean; +} + +// Todo: Change this to render checkmarks for JS and Python + +function DailyCodingChallengeCalendarDay({ + dayNumber, + date, + isCompleted = false, + isAvailable = false +}: CalendarDayProps): JSX.Element { + const { t } = useTranslation(); + // dayNumber = 0 -> render nothing + if (dayNumber === 0) return
; + + if (!isAvailable) + return ( + + ); + + // isAvailable -> render link to challenge + return ( + + + + {isCompleted ? ( + + + + ) : ( + + + + )} + + ); +} + +DailyCodingChallengeCalendarDay.displayName = 'DailyCodingChallengeCalendarDay'; + +export default DailyCodingChallengeCalendarDay; diff --git a/client/src/components/daily-coding-challenge/calendar.css b/client/src/components/daily-coding-challenge/calendar.css new file mode 100644 index 00000000000..e78dc3795da --- /dev/null +++ b/client/src/components/daily-coding-challenge/calendar.css @@ -0,0 +1,102 @@ +.calendar-weekday-labels { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; +} + +.calendar-head { + display: flex; + justify-content: center; + align-items: center; +} + +.calendar-head h2 { + min-width: 200px; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); /* 7 columns for days of the week */ + gap: 4px; +} + +.calendar-day { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 10px; + min-height: 100px; + border: 1px solid var(--tertiary-color); + text-align: center; + background-color: var(--primary-background); +} + +.not-available:hover, +.not-available:active { + cursor: not-allowed; + background-color: var(--primary-background); +} + +.not-available:hover .calendar-day-number, +.not-available:active .calendar-day-number { + color: var(--primary-color); +} + +.available:hover, +.available:active { + cursor: pointer; + background-color: var(--primary-color); +} + +.available:hover .calendar-day-number, +.available:active .calendar-day-number { + color: var(--primary-background); +} + +.available:hover .completed svg circle, +.available:active .completed svg circle { + fill: var(--primary-background); + stroke: var(--primary-background); +} + +.available:hover .not-completed svg circle, +.available:active .not-completed svg circle { + fill: var(--primary-color); + stroke: var(--primary-background); +} + +.available:hover svg rect, +.available:active svg rect { + fill: var(--primary-color); + stroke: var(--primary-color); +} + +.calendar-day-number { + position: absolute; + top: 0; + left: 5px; +} + +.calendar-day svg, +.empty-cirle { + width: calc(10px + 2vw); + height: calc(10px + 2vw); + max-width: 40px; + max-height: 40px; +} + +.empty-cirle { + width: calc(10px + 2vw); + height: calc(10px + 2vw); + max-width: 40px; + max-height: 40px; + border-radius: 50%; + border: 2px solid var(--primary-color); +} + +@media (max-width: 500px) { + .calendar-day { + min-height: 75px; + } +} diff --git a/client/src/components/daily-coding-challenge/calendar.tsx b/client/src/components/daily-coding-challenge/calendar.tsx new file mode 100644 index 00000000000..41824b47809 --- /dev/null +++ b/client/src/components/daily-coding-challenge/calendar.tsx @@ -0,0 +1,314 @@ +import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { Button, Callout, Col, Spacer } from '@freecodecamp/ui'; +import { + completedDailyCodingChallengesSelector, + isSignedInSelector +} from '../../redux/selectors'; +import { CompletedDailyCodingChallenge } from '../../redux/prop-types'; +import { Loader } from '../helpers'; +import envData from '../../../config/env.json'; +import Login from '../Header/components/login'; +import CalendarDay from './calendar-day'; +import { getTodayUsCentral, formatDate } from './helpers'; + +import './calendar.css'; +import DailyCodingChallengeNotFound from './not-found'; + +const { apiLocation } = envData; + +const mapStateToProps = (state: unknown) => ({ + completedDailyCodingChallenges: completedDailyCodingChallengesSelector( + state + ) as CompletedDailyCodingChallenge[], + isSignedIn: isSignedInSelector(state) +}); + +interface DailyCodingChallengeCalendarProps { + completedDailyCodingChallenges: CompletedDailyCodingChallenge[]; + isSignedIn: boolean; +} + +interface AllDailyChallengeFromDb { + id: string; + date: string; + challengeNumber: number; + title: string; +} + +interface DailyChallengeMap { + id: string; + date: string; + isCompleted: boolean; + challengeNumber: number; + title: string; +} + +type DailyChallengesMap = Map; + +interface MonthInfo { + days: JSX.Element[]; + index: number; + name: string; + year: number; +} + +const getMonthInfo = ( + year: number, + monthIndex: number, + dailyChallengesMap: DailyChallengesMap +) => { + // Create date for first of the month (handles rollover automatically) + const firstOfMonth = new Date(Date.UTC(year, monthIndex, 1)); + const firstOfMonthWeekdayIndex = firstOfMonth.getUTCDay(); + const utcYear = firstOfMonth.getUTCFullYear(); + const utcMonthIndex = firstOfMonth.getUTCMonth(); + + // Get number of days in the month (day 0 of next month = last day of current month) + const numberOfDays = new Date( + Date.UTC(utcYear, utcMonthIndex + 1, 0) + ).getUTCDate(); + + const days: JSX.Element[] = []; + + // push empty days to before the 1st of the month + for (let i = 0; i < firstOfMonthWeekdayIndex; i++) { + days.push(); + } + + for (let day = 1; day <= numberOfDays; day++) { + const formattedDate = formatDate({ + month: utcMonthIndex + 1, // Convert back to 1-indexed + day, + year: utcYear + }); + + const challengeData = dailyChallengesMap.get(formattedDate); + const isCompleted = challengeData?.isCompleted || false; + const isAvailable = challengeData !== undefined; + + days.push( + + ); + } + + return { + days, + index: utcMonthIndex, + name: firstOfMonth.toLocaleString('en-US', { + timeZone: 'UTC', + month: 'long' + }), + year: utcYear + }; +}; + +function DailyCodingChallengeCalendar({ + completedDailyCodingChallenges, + isSignedIn +}: DailyCodingChallengeCalendarProps): JSX.Element { + const { t } = useTranslation(); + + const todayUsCentral = getTodayUsCentral(); + + const completedDailyCodingChallengeIds = completedDailyCodingChallenges.map( + c => c.id + ); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + const [monthInfo, setMonthInfo] = useState(null); + const [dailyChallengesMap, setDailyChallengesMap] = useState( + () => new Map() + ); + + const fetchChallenges = async () => { + try { + const response = await fetch(`${apiLocation}/daily-coding-challenge/all`); + const challenges = (await response.json()) as AllDailyChallengeFromDb[]; + + if (Array.isArray(challenges)) { + // Todo: validate shape of challenges + + const newDailyChallengesMap = new Map() as DailyChallengesMap; + + challenges.forEach(c => { + const date = c.date.split('T')[0]; + + newDailyChallengesMap.set(date, { + ...c, + date, + isCompleted: completedDailyCodingChallengeIds.includes(c.id) + }); + }); + + setDailyChallengesMap(newDailyChallengesMap); + + // After getting the challenges and creating the map, set the initial month info - + // Display the month of the current US Central day because challenges are released + // at midnight US Central - so don't show the local month, show the US Central month + const [year, month] = todayUsCentral.split('-').map(Number); + const initialMonthInfo = getMonthInfo( + year, + month - 1, // Convert to 0-indexed month + newDailyChallengesMap + ); + + setMonthInfo(initialMonthInfo); + } else { + setError(true); + } + } catch (error) { + console.error('Error fetching challenges:', error); + setError(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void fetchChallenges(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // we just need to change the month, the year can stay the same + // because it just rolls over, e.g. (index) 12, 2024 will be Jan, 2025 + const nextMonth = () => { + setMonthInfo( + m => m && getMonthInfo(m.year, m.index + 1, dailyChallengesMap) + ); + }; + + const prevMonth = () => { + setMonthInfo( + m => m && getMonthInfo(m.year, m.index - 1, dailyChallengesMap) + ); + }; + + const hasOlderChallenges = ( + map: DailyChallengesMap, + monthInfo: MonthInfo + ): boolean => { + return Array.from(map.keys()).some(dateStr => { + const [year, month] = dateStr.split('-').map(Number); + return ( + year < monthInfo.year || + (year === monthInfo.year && month - 1 < monthInfo.index) + ); + }); + }; + + const hasNewerChallenges = ( + map: DailyChallengesMap, + monthInfo: MonthInfo + ): boolean => { + return Array.from(map.keys()).some(dateStr => { + const [year, month] = dateStr.split('-').map(Number); + return ( + year > monthInfo.year || + (year === monthInfo.year && month - 1 > monthInfo.index) + ); + }); + }; + + const showPrevButton = monthInfo + ? hasOlderChallenges(dailyChallengesMap, monthInfo) + : false; + + const showNextButton = monthInfo + ? hasNewerChallenges(dailyChallengesMap, monthInfo) + : false; + + if (isLoading) return ; + if (error || !monthInfo) return ; + + return ( + <> + + + {t('daily-coding-challenges.release-note')} + + + + + + + +
+ + +

+ {monthInfo.name} {monthInfo.year} +

+ +
+ + + +
+
+ {t('weekdays.short.sunday')} +
+
+ {t('weekdays.short.monday')} +
+
+ {t('weekdays.short.tuesday')} +
+
+ {t('weekdays.short.wednesday')} +
+
+ {t('weekdays.short.thursday')} +
+
+ {t('weekdays.short.friday')} +
+
+ {t('weekdays.short.saturday')} +
+
+ + +
{monthInfo.days}
+ + + {!isSignedIn && ( + + +
+ {t('buttons.logged-out-cta-btn')} +
+ + )} + + ); +} + +DailyCodingChallengeCalendar.displayName = 'DailyCodingChallengeCalendar'; + +export default connect(mapStateToProps)(DailyCodingChallengeCalendar); diff --git a/client/src/components/daily-coding-challenge/helpers.ts b/client/src/components/daily-coding-challenge/helpers.ts new file mode 100644 index 00000000000..7ee7c6a5c3a --- /dev/null +++ b/client/src/components/daily-coding-challenge/helpers.ts @@ -0,0 +1,35 @@ +import { parse, isValid, format } from 'date-fns'; +import { toZonedTime } from 'date-fns-tz'; + +interface formatDateProps { + month: number; + day: number; + year: number; +} + +// Format month, day, and year as "YYYY-MM-DD" with leading zeros +export function formatDate({ year, month, day }: formatDateProps) { + return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; +} + +// Returns the current US Central date in yyyy-MM-dd +export function getTodayUsCentral(dateObj: Date = new Date()) { + const zonedDate = toZonedTime(dateObj, 'America/Chicago'); + return format(zonedDate, 'yyyy-MM-dd'); +} + +// Validate that dateString is in the format yyyy-MM-dd +// Leading zero's are accepted for single digit month/day +export function isValidDateParam(dateString: string) { + const parsedDate = parse(dateString, 'yyyy-MM-dd', new Date()); + return isValid(parsedDate); +} + +// Convert yyyy-MM-dd to display format (e.g: "January 1, 2025") +export function formatDisplayDate(dateString: string) { + const parsedDate = parse(dateString, 'yyyy-MM-dd', new Date()); + if (!isValid(parsedDate)) { + return 'Invalid date'; + } + return format(parsedDate, 'MMMM d, yyyy'); +} diff --git a/client/src/components/daily-coding-challenge/not-found.css b/client/src/components/daily-coding-challenge/not-found.css new file mode 100644 index 00000000000..64b45b9b777 --- /dev/null +++ b/client/src/components/daily-coding-challenge/not-found.css @@ -0,0 +1,45 @@ +.not-found-wrapper { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px; + text-align: center; +} + +.not-found-wrapper img { + width: 380px; +} + +.quote-wrapper { + background-color: var(--tertiary-background); + padding-inline: 42px 20px; + padding-block: 20px; + border-width: 0; + position: relative; + max-width: 980px; +} + +.quote-wrapper .quote { + font-style: italic; + font-size: 20px; + margin-bottom: 0.6em; + text-align: left; +} + +.quote-wrapper .quote::before { + content: open-quote; + font-size: 25px; + font-weight: 700; +} + +.quote-wrapper .author { + text-align: right; + margin: 0; +} + +.button-wrapper { + width: 100%; + max-width: 635px; +} diff --git a/client/src/components/daily-coding-challenge/not-found.tsx b/client/src/components/daily-coding-challenge/not-found.tsx new file mode 100644 index 00000000000..4a76cca1256 --- /dev/null +++ b/client/src/components/daily-coding-challenge/not-found.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import Helmet from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { Button, Container, Col, Row, Spacer } from '@freecodecamp/ui'; +import { randomQuote } from '../../utils/get-words'; +import { Link } from '../helpers'; +import notFoundLogo from '../../assets/images/freeCodeCamp-404.svg'; +import { getTodayUsCentral } from './helpers'; + +import './not-found.css'; + +function DailyCodingChallengeNotFound(): JSX.Element { + const { t } = useTranslation(); + const quote = randomQuote(); + + return ( + + + + + {t('404.not-found')} + +

{t('daily-coding-challenges.not-found')}

+ +
+

{t('404.heres-a-quote')}

+ +
+

{quote.quote}

+

- {quote.author}

+
+
+ +
+ + + +
+ + + {t('buttons.view-curriculum')} + + +
+
+ ); +} + +DailyCodingChallengeNotFound.displayName = 'DailyCodingChallengeNotFound'; + +export default DailyCodingChallengeNotFound; diff --git a/client/src/components/daily-coding-challenge/widget.css b/client/src/components/daily-coding-challenge/widget.css new file mode 100644 index 00000000000..5fbb13b5233 --- /dev/null +++ b/client/src/components/daily-coding-challenge/widget.css @@ -0,0 +1,20 @@ +.daily-coding-challenge-info { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.daily-coding-challenge-date { + font-size: 1.2rem; +} + +.release-note { + font-size: 0.8rem; +} + +.daily-coding-challenge-button { + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; +} diff --git a/client/src/components/daily-coding-challenge/widget.tsx b/client/src/components/daily-coding-challenge/widget.tsx new file mode 100644 index 00000000000..f0f3d048e6e --- /dev/null +++ b/client/src/components/daily-coding-challenge/widget.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Spacer } from '@freecodecamp/ui'; + +import { ButtonLink } from '../helpers'; +import DailyCodingChallengeIcon from '../../assets/icons/daily-coding-challenge'; +import LinkButton from '../../assets/icons/link-button'; +import CalendarIcon from '../../assets/icons/calendar'; +import { getTodayUsCentral } from './helpers'; + +import './widget.css'; + +interface DailyCodingChallengeWidgetProps + extends React.AnchorHTMLAttributes { + forLanding: boolean; +} + +function DailyCodingChallengeWidget({ + forLanding +}: DailyCodingChallengeWidgetProps): JSX.Element { + const { t } = useTranslation(); + + return ( + <> +

+ {t('daily-coding-challenges.map-title')} +

+
+ +
+ + {t(`buttons.start`)} +
+ {forLanding && } +
+ + {!forLanding && ( + <> + + + +
+ + {t(`buttons.go-to-archive`)} +
+
+ + )} +
+ + ); +} + +DailyCodingChallengeWidget.displayName = 'DailyCodingChallengeWidget'; + +export default DailyCodingChallengeWidget; diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx index 8058dfbfa21..a135a4f5bb9 100644 --- a/client/src/components/layouts/default.tsx +++ b/client/src/components/layouts/default.tsx @@ -59,6 +59,7 @@ import './global.css'; import './variables.css'; import './rtl-layout.css'; import { LocalStorageThemes } from '../../redux/types'; +import DailyChallengeBreadCrumb from '../../templates/Challenges/components/daily-challenge-bread-crumb'; const mapStateToProps = createSelector( isSignedInSelector, @@ -112,6 +113,7 @@ interface DefaultLayoutProps extends StateProps, DispatchProps { pathname: string; showFooter?: boolean; isChallenge?: boolean; + isDailyChallenge?: boolean; usesMultifileEditor?: boolean; block?: string; examInProgress: boolean; @@ -130,6 +132,7 @@ function DefaultLayout({ removeFlashMessage, showFooter = true, isChallenge = false, + isDailyChallenge = false, usesMultifileEditor, block, superBlock, @@ -287,7 +290,13 @@ function DefaultLayout({ /> ) : null} - {isChallenge && + {isDailyChallenge ? ( +
+ +
+ ) : ( + isChallenge && + !isDailyChallenge && !examInProgress && (isRenderBreadcrumb ? (
@@ -298,7 +307,8 @@ function DefaultLayout({
) : ( - ))} + )) + )} {fetchState.complete && children} {showFooter &&