diff --git a/src/entities/course/index.ts b/src/entities/course/index.ts index 8d29b9b1c..c60e795cf 100644 --- a/src/entities/course/index.ts +++ b/src/entities/course/index.ts @@ -1,2 +1,3 @@ -export type { Course, CourseStatus } from './types'; +export type { Course, CourseItemData, CourseStatus } from './types'; export { CourseCard } from './ui/course-card/course-card'; +export { CourseItem } from './ui/course-item/course-item'; diff --git a/src/entities/course/types.ts b/src/entities/course/types.ts index 2284d5347..e235fa013 100644 --- a/src/entities/course/types.ts +++ b/src/entities/course/types.ts @@ -17,3 +17,8 @@ export type Course = { }; export type CourseStatus = 'planned' | 'available' | 'upcoming'; + +export type CourseItemData = Pick & { + buttonText: string; + iconSrc: string; +}; diff --git a/src/entities/course/ui/course-card/course-card.tsx b/src/entities/course/ui/course-card/course-card.tsx index 4a10fdba3..30cab17df 100644 --- a/src/entities/course/ui/course-card/course-card.tsx +++ b/src/entities/course/ui/course-card/course-card.tsx @@ -42,6 +42,7 @@ export const CourseCard = ({ href={detailsUrl} variant="rounded" aria-label="View course details" + data-testid="course-link" > View details diff --git a/src/entities/course/ui/course-item/course-item.module.scss b/src/entities/course/ui/course-item/course-item.module.scss new file mode 100644 index 000000000..50149d681 --- /dev/null +++ b/src/entities/course/ui/course-item/course-item.module.scss @@ -0,0 +1,44 @@ +.course-item { + @extend %transition-all; + + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + .icon-container { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + + .course-icon { + width: 48px; + height: 36px; + object-fit: contain; + } + } + + .course-info { + width: 100%; + + .date { + margin-top: 8px; + margin-bottom: 0; + + font-size: 14px; + line-height: 20px; + color: $color-gray-600; + } + } + + .details-link { + margin: 16px; + } + + @include media-hover { + &:hover { + box-shadow: 0 4px 16px 0 rgb(0 0 0 / 12%); + } + } +} diff --git a/src/entities/course/ui/course-item/course-item.test.tsx b/src/entities/course/ui/course-item/course-item.test.tsx new file mode 100644 index 000000000..2850f3bd7 --- /dev/null +++ b/src/entities/course/ui/course-item/course-item.test.tsx @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/react'; +import dayjs from 'dayjs'; +import { CourseItem, CourseItemData } from '@/entities/course'; +import { renderWithRouter } from '@/shared/__tests__/utils'; + +const mockedProps: CourseItemData = { + title: 'Introduction to React', + language: ['en'], + startDate: '2024-05-01', + detailsUrl: '/courses/react-intro', + buttonText: 'View Details', + iconSrc: '/images/react-icon.png', +}; + +const expectedDate = dayjs(mockedProps.startDate).toISOString(); + +describe('CourseItem Component', () => { + beforeEach(() => { + renderWithRouter(); + }); + + it('renders the component data as expected', () => { + const titleElement = screen.getByText(mockedProps.title); + const languageInitial = mockedProps.language[0].toUpperCase(); + const dateElement = screen.getByTestId('course-language'); + const courseDate = screen.getByTestId('course-date'); + + expect(titleElement).toBeInTheDocument(); + expect(dateElement).toBeInTheDocument(); + expect(dateElement).toHaveTextContent(`• ${languageInitial}`); + expect(courseDate).toHaveAttribute('datetime', expectedDate); + }); + + it('renders the LinkCustom component with correct href and text', () => { + const link = screen.getByTestId('course-link'); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', mockedProps.detailsUrl); + expect(link).toHaveTextContent(mockedProps.buttonText); + }); +}); diff --git a/src/entities/course/ui/course-item/course-item.tsx b/src/entities/course/ui/course-item/course-item.tsx new file mode 100644 index 000000000..f0c755f9b --- /dev/null +++ b/src/entities/course/ui/course-item/course-item.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames/bind'; +import dayjs from 'dayjs'; +import { CourseItemData } from '@/entities/course'; +import { Image } from '@/shared/ui/image'; +import { LinkCustom } from '@/shared/ui/link-custom'; +import { Subtitle } from '@/shared/ui/subtitle'; + +import styles from './course-item.module.scss'; + +const cx = classNames.bind(styles); + +export const CourseItem = ({ + title, + language, + startDate, + detailsUrl, + buttonText, + iconSrc, +}: CourseItemData) => { + const dateTime = dayjs(startDate).toISOString(); + + return ( +
+
+ +
+
+ + {title} + +

+ + {` • ${language[0].toUpperCase()}`} +

+
+ + {buttonText} + +
+ ); +}; diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 48ec534d0..094fb9cc0 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -4,13 +4,13 @@ import { useTitle } from '@/shared/hooks/use-title'; import { AboutSchool } from '@/widgets/about-school'; import { Alumni } from '@/widgets/alumni'; import { Breadcrumbs } from '@/widgets/breadcrumbs'; -import { Courses } from '@/widgets/courses-school'; import { HeroPage } from '@/widgets/hero-page'; import { Mentoring } from '@/widgets/mentoring'; import { Mentors } from '@/widgets/mentors'; import { Principles } from '@/widgets/principles'; import { Requirements } from '@/widgets/requirements'; import { StudyWithUs } from '@/widgets/study-with-us'; +import { UpcomingCourses } from '@/widgets/upcoming-courses'; export const Home: FC = () => { useTitle('Home · The Rolling Scopes School'); @@ -22,7 +22,7 @@ export const Home: FC = () => { - + diff --git a/src/shared/icons/index.tsx b/src/shared/icons/index.tsx index 3e75ed684..c895d920f 100644 --- a/src/shared/icons/index.tsx +++ b/src/shared/icons/index.tsx @@ -14,7 +14,6 @@ export { NodeJsIcon } from './nodejs-icon'; export { OpenSourcePhilosophyIcon } from './open-source-philosophy-icon'; export { OpenToEveryoneIcon } from './open-to-everyone-icon'; export { ReactIcon } from './react-icon'; -export { RsBanner } from './rs-banner'; export { TeachItForwardIcon } from './teach-It-forward-icon'; export { TelegramIcon } from './telegram'; export { TextLinkIcon } from './text-link-icon'; diff --git a/src/shared/icons/rs-banner.tsx b/src/shared/icons/rs-banner.tsx deleted file mode 100644 index 67f1ba25e..000000000 --- a/src/shared/icons/rs-banner.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import banner from '@/shared/assets/svg/RsBanner.svg'; -import { Image } from '@/shared/ui/image'; - -export const RsBanner = () => { - return RsBanner Icon; -}; diff --git a/src/widgets/courses-school/index.ts b/src/widgets/courses-school/index.ts deleted file mode 100644 index 8baffcdc2..000000000 --- a/src/widgets/courses-school/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Courses } from './ui/courses'; diff --git a/src/widgets/courses-school/model/constants.ts b/src/widgets/courses-school/model/constants.ts deleted file mode 100644 index 99523d0fe..000000000 --- a/src/widgets/courses-school/model/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const MAX_COURSE_COUNT = 5; diff --git a/src/widgets/courses-school/ui/CourseCard.tsx b/src/widgets/courses-school/ui/CourseCard.tsx deleted file mode 100644 index 354dfd54a..000000000 --- a/src/widgets/courses-school/ui/CourseCard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import classNames from 'classnames/bind'; -import type { Course } from '@/entities/course'; -import { Image } from '@/shared/ui/image'; -import { LinkCustom } from '@/shared/ui/link-custom'; - -import styles from './courses.module.scss'; - -const cx = classNames.bind(styles); - -type addFields = { - buttonText: string; - iconSrc: string; -}; - -type PropsType = Pick & addFields; - -export const CourseCard = ({ - title, - language, - startDate, - detailsUrl, - buttonText, - iconSrc, -}: PropsType) => { - return ( -
-
- {title} -
-
-

{title}

-

{`${startDate} • ${language[0].toUpperCase()}`}

-
-
- - {buttonText} - -
-
- ); -}; - -export default CourseCard; diff --git a/src/widgets/courses-school/ui/courses.module.scss b/src/widgets/courses-school/ui/courses.module.scss deleted file mode 100644 index 91898aefa..000000000 --- a/src/widgets/courses-school/ui/courses.module.scss +++ /dev/null @@ -1,84 +0,0 @@ -.course-title { - margin-top: 24px; -} - -.image { - img { - width: 299px; - height: 193px; - } - - @include media-laptop { - margin-top: 24px; - } -} - -.course-list { - display: flex; - flex-direction: column; - gap: 24px; - align-items: flex-start; - justify-content: space-between; - - width: 800px; - - .course-card { - @extend %transition-all; - - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - - .icon-container { - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - - img { - width: 48px; - height: 36px; - object-fit: contain; - } - } - - .course-info { - width: 100%; - text-align: left; - - .name { - margin-top: 0; - margin-bottom: 0; - - font-size: 18px; - font-weight: $font-weight-medium; - line-height: 24px; - color: $color-black; - } - - .date { - margin-top: 8px; - margin-bottom: 0; - - font-size: 14px; - line-height: 20px; - color: $color-gray-600; - } - } - - .details-container { - padding: 16px; - } - - @include media-hover { - &:hover { - box-shadow: 0 4px 16px 0 rgb(0 0 0 / 12%); - } - } - } - - @include media-laptop { - width: 100%; - } -} diff --git a/src/widgets/upcoming-courses/constants.ts b/src/widgets/upcoming-courses/constants.ts new file mode 100644 index 000000000..ae7246757 --- /dev/null +++ b/src/widgets/upcoming-courses/constants.ts @@ -0,0 +1,2 @@ +export const tabletScreenBreakPoint = 810; +export const maxUpcomingCoursesQuantity = 5; diff --git a/src/widgets/upcoming-courses/index.ts b/src/widgets/upcoming-courses/index.ts new file mode 100644 index 000000000..d43695aa3 --- /dev/null +++ b/src/widgets/upcoming-courses/index.ts @@ -0,0 +1 @@ +export { UpcomingCourses } from './ui/upcoming-courses'; diff --git a/src/widgets/upcoming-courses/ui/upcoming-courses.module.scss b/src/widgets/upcoming-courses/ui/upcoming-courses.module.scss new file mode 100644 index 000000000..195f10917 --- /dev/null +++ b/src/widgets/upcoming-courses/ui/upcoming-courses.module.scss @@ -0,0 +1,22 @@ +.rs-banner { + width: 300px; + height: 200px; + + @include media-laptop { + margin-top: 24px; + } +} + +.course-list { + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-start; + justify-content: space-between; + + width: 800px; + + @include media-laptop { + width: 100%; + } +} diff --git a/src/widgets/courses-school/courses.test.tsx b/src/widgets/upcoming-courses/ui/upcoming-courses.test.tsx similarity index 68% rename from src/widgets/courses-school/courses.test.tsx rename to src/widgets/upcoming-courses/ui/upcoming-courses.test.tsx index 6c674b844..3f5f8cf9a 100644 --- a/src/widgets/courses-school/courses.test.tsx +++ b/src/widgets/upcoming-courses/ui/upcoming-courses.test.tsx @@ -7,11 +7,11 @@ import { it, vi, } from 'vitest'; -import { Courses } from './ui/courses'; import { ROUTES } from '@/app/const'; - import { renderWithRouter } from '@/shared/__tests__/utils'; import { useWindowSize } from '@/shared/hooks/use-window-size'; +import { UpcomingCourses } from '@/widgets/upcoming-courses'; +import { tabletScreenBreakPoint } from '@/widgets/upcoming-courses/constants'; const mockedData = [ { @@ -58,6 +58,10 @@ const mockedData = [ }, ]; +const height = 900; +const widthMobile = tabletScreenBreakPoint; +const widthDesktop = widthMobile + 100; + vi.mock('@/app/hooks/use-data-by-name', () => { return { useDataByName: vi.fn().mockImplementation(() => ({ @@ -79,7 +83,7 @@ vi.mock('@/shared/hooks/use-window-size', () => { describe('Courses', () => { beforeEach(() => { - renderWithRouter(); + renderWithRouter(); }); it('renders the title correctly', () => { @@ -88,33 +92,10 @@ describe('Courses', () => { expect(titleElement).toBeInTheDocument(); }); - it.skip('renders no more than 5 course cards', () => { - // TODO remove 'skip' after 'data-testid' will be added to CourseCard - const courseCards = screen.getAllByRole('link'); - - expect(courseCards.length).toBeLessThan(5); - }); - - it.skip('renders link with "More" on window size 810px', () => { - (useWindowSize as Mock).mockReturnValue({ - width: 810, - height: 900, - }); - renderWithRouter(); - const courseCards = screen.getAllByRole('link', { name: 'More' }); - - expect(courseCards.length).toBeLessThanOrEqual(5); - }); - - it.skip('renders link with "More details" on window size more than 810px', () => { - (useWindowSize as Mock).mockReturnValue({ - width: 811, - height: 900, - }); - renderWithRouter(); - const courseCards = screen.getAllByText('More details'); + it('renders no more than 5 course cards', () => { + const courseCards = screen.getByTestId('courses-list'); - expect(courseCards.length).toBeLessThanOrEqual(5); + expect(courseCards.children.length).toEqual(5); }); it('renders the Go to RS School button', () => { @@ -129,3 +110,29 @@ describe('Courses', () => { expect(rsBanner).toBeInTheDocument(); }); }); + +describe('School Courses on different screen sizes', () => { + it('renders link with icon only on window size 810px', () => { + (useWindowSize as Mock).mockReturnValue({ + width: widthMobile, + height, + }); + + renderWithRouter(); + const courseCards = screen.getAllByTestId('course-link').at(0); + + expect(courseCards).toHaveTextContent(''); + }); + + it('renders link with "More details" on window size more than 810px', () => { + (useWindowSize as Mock).mockReturnValue({ + width: widthDesktop, + height, + }); + + renderWithRouter(); + const courseCards = screen.getAllByTestId('course-link').at(0); + + expect(courseCards).toHaveTextContent('More details'); + }); +}); diff --git a/src/widgets/courses-school/ui/courses.tsx b/src/widgets/upcoming-courses/ui/upcoming-courses.tsx similarity index 68% rename from src/widgets/courses-school/ui/courses.tsx rename to src/widgets/upcoming-courses/ui/upcoming-courses.tsx index 1fd389b5e..f9a8f395d 100644 --- a/src/widgets/courses-school/ui/courses.tsx +++ b/src/widgets/upcoming-courses/ui/upcoming-courses.tsx @@ -1,22 +1,25 @@ import classNames from 'classnames/bind'; -import { CourseCard } from './CourseCard'; -import { MAX_COURSE_COUNT } from '../model/constants'; import { COURSE_STALE_AFTER_DAYS, ROUTES } from '@/app/const'; import type { Course } from '@/entities/course'; +import { CourseItem } from '@/entities/course'; +import RSBanner from '@/shared/assets/svg/RsBanner.svg'; import { getActualData } from '@/shared/helpers/getActualData'; import { useWindowSize } from '@/shared/hooks/use-window-size'; -import { RsBanner } from '@/shared/icons'; +import { Image } from '@/shared/ui/image'; import { LinkCustom } from '@/shared/ui/link-custom'; import { WidgetTitle } from '@/shared/ui/widget-title'; +import { + maxUpcomingCoursesQuantity, + tabletScreenBreakPoint, +} from '@/widgets/upcoming-courses/constants.ts'; import { courses } from 'data'; -import styles from './courses.module.scss'; +import styles from './upcoming-courses.module.scss'; const cx = classNames.bind(styles); -export const Courses = () => { +export const UpcomingCourses = () => { const size = useWindowSize(); - const tabletScreenBreakPoint = 810; const coursesData: Course[] = getActualData({ data: courses, staleAfter: COURSE_STALE_AFTER_DAYS, @@ -30,10 +33,10 @@ export const Courses = () => { } const coursesContent = coursesData - .slice(0, Math.min(coursesData.length, MAX_COURSE_COUNT)) + .slice(0, Math.min(coursesData.length, maxUpcomingCoursesQuantity)) .map(({ title, language, startDate, detailsUrl, iconSrc }) => { return ( - { return (
- - Upcoming courses - + Upcoming courses
{coursesContent} @@ -58,9 +59,12 @@ export const Courses = () => { Go to courses
-
- -
+ The Rolling Scopes organization logo