From 12b903d4c574a94f7c7b0b774e0e00089343e9db Mon Sep 17 00:00:00 2001
From: Yana
Date: Thu, 18 Jun 2026 01:40:11 +0300
Subject: [PATCH 1/3] Solution
---
package-lock.json | 9 +-
package.json | 2 +-
src/App.tsx | 201 +++++++++------------------
src/UserWarning.tsx | 15 ++
src/components/ErrorNotification.tsx | 23 +++
src/components/Footer.tsx | 52 +++++++
src/components/TodoContext.tsx | 78 +++++++++++
src/components/TodoItem.tsx | 97 +++++++++++++
src/components/TodoList.tsx | 14 ++
src/index.tsx | 20 ++-
src/styles/filter.scss | 22 +++
src/styles/todo.scss | 149 ++++++++++++++++++++
src/types/Errors.ts | 7 +
src/types/Todo.ts | 6 +
src/utils/fetchClient.ts | 41 ++++++
15 files changed, 590 insertions(+), 146 deletions(-)
create mode 100644 src/UserWarning.tsx
create mode 100644 src/components/ErrorNotification.tsx
create mode 100644 src/components/Footer.tsx
create mode 100644 src/components/TodoContext.tsx
create mode 100644 src/components/TodoItem.tsx
create mode 100644 src/components/TodoList.tsx
create mode 100644 src/styles/filter.scss
create mode 100644 src/styles/todo.scss
create mode 100644 src/types/Errors.ts
create mode 100644 src/types/Todo.ts
create mode 100644 src/utils/fetchClient.ts
diff --git a/package-lock.json b/package-lock.json
index 1f19b4743..a7f06fc95 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.9.12",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
@@ -1170,10 +1170,11 @@
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.9.12",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz",
- "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz",
+ "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@octokit/rest": "^17.11.2",
"@types/get-port": "^4.2.0",
diff --git a/package.json b/package.json
index 91d7489b9..446974833 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.9.12",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..ead313cd4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,87 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import React, { useState, useContext, useRef, useEffect } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from './components/TodoContext';
+import { TodoList } from './components/TodoList';
+import { Footer } from './components/Footer';
+import { Todo } from './types/Todo';
export const App: React.FC = () => {
+ const { todos, addTodo, toggleAll } = useContext(TodoContext);
+ const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All');
+ const [title, setTitle] = useState('');
+
+ const newTodoField = useRef(null);
+
+ useEffect(() => {
+ newTodoField.current?.focus();
+ }, [todos.length]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ return;
+ }
+
+ addTodo(trimmedTitle);
+ setTitle('');
+ };
+
+ const visibleTodos = todos.filter((todo: Todo) => {
+ if (filter === 'Active') {
+ return !todo.completed;
+ }
+
+ if (filter === 'Completed') {
+ return todo.completed;
+ }
+
+ return true;
+ });
+
+ const isAllCompleted =
+ todos.length > 0 && todos.every((todo: Todo) => todo.completed);
+
return (
todos
- {/* this button should have `active` class only if all todos are completed */}
-
+ {todos.length > 0 && (
+
+ )}
- {/* Add a todo on form submit */}
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
-
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+ {todos.length > 0 && (
+ <>
+
+
+ >
+ )}
);
diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx
new file mode 100644
index 000000000..fa25838e6
--- /dev/null
+++ b/src/UserWarning.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+export const UserWarning: React.FC = () => (
+
+
+ Please get your userId {' '}
+
+ here
+ {' '}
+ and save it in the app
const USER_ID = ...
+ All requests to the API must be sent with this
+ userId.
+
+
+);
diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx
new file mode 100644
index 000000000..7ed40de02
--- /dev/null
+++ b/src/components/ErrorNotification.tsx
@@ -0,0 +1,23 @@
+type Prop = {
+ errorMessage: string;
+ setErrorMessage: (errorMessage: string) => void;
+};
+
+export const ErrorNotification = ({ errorMessage, setErrorMessage }: Prop) => {
+ return (
+
+ setErrorMessage('')}
+ />
+ {errorMessage}
+
+ );
+};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 000000000..c616490b4
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,52 @@
+import { useContext } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from './TodoContext';
+
+type FilterType = 'All' | 'Active' | 'Completed';
+
+export const Footer: React.FC<{
+ filter: FilterType;
+ setFilter: Dispatch>;
+}> = ({ filter, setFilter }) => {
+ const { todos, clearCompleted } = useContext(TodoContext);
+
+ const activeCount = todos.filter(todo => !todo.completed).length;
+ const hasCompleted = todos.some(todo => todo.completed);
+
+ const filters: FilterType[] = ['All', 'Active', 'Completed'];
+
+ return (
+
+ );
+};
diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx
new file mode 100644
index 000000000..2193d36af
--- /dev/null
+++ b/src/components/TodoContext.tsx
@@ -0,0 +1,78 @@
+import React, { createContext, useState, useEffect } from 'react';
+import { Todo } from '../types/Todo';
+
+type TodoContextType = {
+ todos: Todo[];
+ addTodo: (title: string) => void;
+ deleteTodo: (id: number) => void;
+ updateTodo: (updatedTodo: Todo) => void;
+ toggleAll: () => void;
+ clearCompleted: () => void;
+};
+
+export const TodoContext = createContext(
+ {} as TodoContextType,
+);
+
+export const TodoProvider = ({ children }: { children?: React.ReactNode }) => {
+ const [todos, setTodos] = useState(() => {
+ const saved = localStorage.getItem('todos');
+
+ return saved ? JSON.parse(saved) : [];
+ });
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(todos));
+ }, [todos]);
+
+ const addTodo = (title: string) => {
+ const newTodo: Todo = {
+ id: +new Date(),
+ userId: 1,
+ title,
+ completed: false,
+ };
+
+ setTodos((prev: Todo[]) => [...prev, newTodo]);
+ };
+
+ const deleteTodo = (id: number) => {
+ setTodos((prev: Todo[]) => prev.filter((todo: Todo) => todo.id !== id));
+ };
+
+ const updateTodo = (updatedTodo: Todo) => {
+ setTodos((prev: Todo[]) =>
+ prev.map((todo: Todo) =>
+ todo.id === updatedTodo.id ? updatedTodo : todo,
+ ),
+ );
+ };
+
+ const toggleAll = () => {
+ const isAllCompleted =
+ todos.length > 0 && todos.every((todo: Todo) => todo.completed);
+
+ setTodos((prev: Todo[]) =>
+ prev.map((todo: Todo) => ({ ...todo, completed: !isAllCompleted })),
+ );
+ };
+
+ const clearCompleted = () => {
+ setTodos((prev: Todo[]) => prev.filter((todo: Todo) => !todo.completed));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
new file mode 100644
index 000000000..f8221d438
--- /dev/null
+++ b/src/components/TodoItem.tsx
@@ -0,0 +1,97 @@
+import React, { useState, useRef, useEffect, useContext } from 'react';
+import classNames from 'classnames';
+import { Todo } from '../types/Todo';
+import { TodoContext } from './TodoContext';
+
+export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
+ const { deleteTodo, updateTodo } = useContext(TodoContext);
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [newTitle, setNewTitle] = useState(todo.title);
+ const editField = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ editField.current?.focus();
+ }
+ }, [isEditing]);
+
+ const saveTitle = () => {
+ const trimmedTitle = newTitle.trim();
+
+ if (!trimmedTitle) {
+ deleteTodo(todo.id);
+ } else if (trimmedTitle !== todo.title) {
+ updateTodo({ ...todo, title: trimmedTitle });
+ }
+
+ setIsEditing(false);
+ setNewTitle(trimmedTitle || todo.title);
+ };
+
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsEditing(false);
+ setNewTitle(todo.title);
+ }
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ saveTitle();
+ };
+
+ return (
+
+
+ updateTodo({ ...todo, completed: !todo.completed })}
+ />
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
setIsEditing(true)}
+ >
+ {todo.title}
+
+
deleteTodo(todo.id)}
+ >
+ ×
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx
new file mode 100644
index 000000000..63ae0d06f
--- /dev/null
+++ b/src/components/TodoList.tsx
@@ -0,0 +1,14 @@
+import { TodoItem } from './TodoItem';
+import { Todo } from '../types/Todo';
+
+export const TodoList: React.FC<{
+ visibleTodos: Todo[];
+}> = ({ visibleTodos }) => {
+ return (
+
+ {visibleTodos.map((todo: Todo) => (
+
+ ))}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..2289baa5b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,9 +1,17 @@
-import { createRoot } from 'react-dom/client';
-
-import './styles/index.scss';
-
+import React from 'react';
+import ReactDOM from 'react-dom/client';
import { App } from './App';
+import { TodoProvider } from './components/TodoContext';
+import './styles/index.scss';
-const container = document.getElementById('root') as HTMLDivElement;
+const root = ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement,
+);
-createRoot(container).render( );
+root.render(
+
+
+
+
+ ,
+);
diff --git a/src/styles/filter.scss b/src/styles/filter.scss
new file mode 100644
index 000000000..75b5804e5
--- /dev/null
+++ b/src/styles/filter.scss
@@ -0,0 +1,22 @@
+.filter {
+ display: flex;
+
+ &__link {
+ margin: 3px;
+ padding: 3px 7px;
+
+ color: inherit;
+ text-decoration: none;
+
+ border: 1px solid transparent;
+ border-radius: 3px;
+
+ &:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+ }
+
+ &.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+ }
+ }
+}
diff --git a/src/styles/todo.scss b/src/styles/todo.scss
new file mode 100644
index 000000000..c7f93ff6b
--- /dev/null
+++ b/src/styles/todo.scss
@@ -0,0 +1,149 @@
+.todo {
+ position: relative;
+
+ display: grid;
+ grid-template-columns: 45px 1fr;
+ justify-items: stretch;
+
+ font-size: 24px;
+ line-height: 1.4em;
+ border-bottom: 1px solid #ededed;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &__status-label {
+ cursor: pointer;
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: center left;
+ }
+
+ &.completed &__status-label {
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E");
+ }
+
+ &__status {
+ opacity: 0;
+ }
+
+ &__title {
+ padding: 12px 15px;
+
+ word-break: break-all;
+ transition: color 0.4s;
+ }
+
+ &.completed &__title {
+ color: #d9d9d9;
+ text-decoration: line-through;
+ }
+
+ &__remove {
+ position: absolute;
+ right: 12px;
+ top: 0;
+ bottom: 0;
+
+ font-size: 120%;
+ line-height: 1;
+ font-family: inherit;
+ font-weight: inherit;
+ color: #cc9a9a;
+
+ float: right;
+ border: 0;
+ background: none;
+ cursor: pointer;
+
+ transform: translateY(-2px);
+ opacity: 0;
+ transition: color 0.2s ease-out;
+
+ &:hover {
+ color: #af5b5e;
+ }
+ }
+
+ &:hover &__remove {
+ opacity: 1;
+ }
+
+ &__title-field {
+ width: 100%;
+ padding: 11px 14px;
+
+ font-size: inherit;
+ line-height: inherit;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+
+ &::placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+ }
+ }
+
+ .overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 58px;
+
+ opacity: 0.5;
+ }
+}
+
+.item-enter {
+ max-height: 0;
+}
+
+.item-enter-active {
+ overflow: hidden;
+ max-height: 58px;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.item-exit {
+ max-height: 58px;
+}
+
+.item-exit-active {
+ overflow: hidden;
+ max-height: 0;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.temp-item-enter {
+ max-height: 0;
+}
+
+.temp-item-enter-active {
+ overflow: hidden;
+ max-height: 58px;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.temp-item-exit {
+ max-height: 58px;
+}
+
+.temp-item-exit-active {
+ transform: translateY(-58px);
+ max-height: 0;
+ opacity: 0;
+ transition: 0.3s ease-in-out;
+ transition-property: opacity, max-height, transform;
+}
+
+.has-error .temp-item-exit-active {
+ transform: translateY(0);
+ overflow: hidden;
+}
diff --git a/src/types/Errors.ts b/src/types/Errors.ts
new file mode 100644
index 000000000..956245312
--- /dev/null
+++ b/src/types/Errors.ts
@@ -0,0 +1,7 @@
+export enum Errors {
+ Load = 'Unable to load todos',
+ TitleEmpty = 'Title should not be empty',
+ Add = 'Unable to add a todo',
+ Delete = 'Unable to delete a todo',
+ Update = 'Unable to update a todo',
+}
diff --git a/src/types/Todo.ts b/src/types/Todo.ts
new file mode 100644
index 000000000..3f52a5fdd
--- /dev/null
+++ b/src/types/Todo.ts
@@ -0,0 +1,6 @@
+export interface Todo {
+ id: number;
+ userId: number;
+ title: string;
+ completed: boolean;
+}
diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts
new file mode 100644
index 000000000..b96cf3d50
--- /dev/null
+++ b/src/utils/fetchClient.ts
@@ -0,0 +1,41 @@
+const BASE_URL = 'https://mate.academy/students-api';
+
+function wait(delay: number) {
+ return new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+}
+
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
+
+function request(
+ url: string,
+ method: RequestMethod = 'GET',
+ data?: D,
+): Promise {
+ const options: RequestInit = { method };
+
+ if (data !== undefined) {
+ options.body = JSON.stringify(data);
+ options.headers = {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ };
+ }
+
+ return wait(100)
+ .then(() => fetch(BASE_URL + url, options))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error();
+ }
+
+ return response.json();
+ });
+}
+
+export const client = {
+ get: (url: string) => request(url),
+ post: (url: string, data?: D) => request(url, 'POST', data),
+ patch: (url: string, data?: D) => request(url, 'PATCH', data),
+ delete: (url: string) => request(url, 'DELETE'),
+};
From cef4d051c519f566c02a4577192b3e8b261e78d1 Mon Sep 17 00:00:00 2001
From: Yana
Date: Thu, 18 Jun 2026 01:55:13 +0300
Subject: [PATCH 2/3] Solution
---
cypress/tsconfig.json | 2 +-
src/utils/fetchClient.ts | 8 +++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json
index 0211a3074..f6a2ae7aa 100644
--- a/cypress/tsconfig.json
+++ b/cypress/tsconfig.json
@@ -3,4 +3,4 @@
"compilerOptions": {
"sourceMap": false
}
-}
+}
\ No newline at end of file
diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts
index b96cf3d50..acd865416 100644
--- a/src/utils/fetchClient.ts
+++ b/src/utils/fetchClient.ts
@@ -35,7 +35,9 @@ function request(
export const client = {
get: (url: string) => request(url),
- post: (url: string, data?: D) => request(url, 'POST', data),
- patch: (url: string, data?: D) => request(url, 'PATCH', data),
- delete: (url: string) => request(url, 'DELETE'),
+ post: (url: string, data?: D) =>
+ request(url, 'POST', data),
+ patch: (url: string, data?: D) =>
+ request(url, 'PATCH', data),
+ delete: (url: string) => request(url, 'DELETE'),
};
From 36ba2d13721b71cc6e99e8621fc12d374ff2ee72 Mon Sep 17 00:00:00 2001
From: Yana
Date: Thu, 18 Jun 2026 13:30:17 +0300
Subject: [PATCH 3/3] Solution
---
src/components/TodoItem.tsx | 4 ++--
src/styles/index.scss | 6 ++++++
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
index f8221d438..b30ac7463 100644
--- a/src/components/TodoItem.tsx
+++ b/src/components/TodoItem.tsx
@@ -49,7 +49,7 @@ export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
editing: isEditing,
})}
>
-
+
= ({ todo }) => {
checked={todo.completed}
onChange={() => updateTodo({ ...todo, completed: !todo.completed })}
/>
-
+
{isEditing ? (