Этап 5: Избранное и финальная полировка

Free Preview
Продолжительность:

Финальный этап. Добавляем избранное, убеждаемся что приложение работает на любом экране, и полируем детали.

Требования

Избранное

  • На каждой карточке — кнопка с иконкой сердечка для добавления в избранное.
  • Клик по сердечку добавляет или убирает место из избранного. Кнопка меняет внешний вид в зависимости от состояния.
  • Список избранных id сохраняется в localStorage и восстанавливается при перезагрузке страницы.
  • В шапке — кнопка «Избранное», которая переключает режим: показывает только избранные карточки или возвращает полный список.
  • Когда режим избранного включён и список пуст, показывайте соответствующее сообщение с призывом добавить понравившиеся места.
  • В модальном окне тоже должна быть кнопка избранного — и она должна быть синхронизирована с кнопкой на карточке (добавил в модалке → иконка на карточке тоже изменилась).

Как хранить избранное. Держите массив id в state.favourites и зеркалируйте его в localStorage. Удобно вынести в отдельные функции:

function toggleFavourite(id) {
  const index = state.favourites.indexOf(id);
  if (index === -1) {
    state.favourites.push(id);
  } else {
    state.favourites.splice(index, 1);
  }
  localStorage.setItem('favourites', JSON.stringify(state.favourites));
  updateFavouriteButtons(id);
}

function isFavourite(id) {
  return state.favourites.includes(id);
}
js

При загрузке страницы восстанавливайте из localStorage:

const saved = localStorage.getItem('favourites');
state.favourites = saved ? JSON.parse(saved) : [];
js

Синхронизация кнопок на карточке и в модалке. После каждого изменения избранного нужно обновить все кнопки, связанные с этим id. Напишите функцию updateFavouriteButtons(id), которая находит все элементы с нужным data-id и обновляет их внешний вид:

function updateFavouriteButtons(id) {
  const isFav = isFavourite(id);

  // Кнопка на карточке
  const card = document.querySelector(`.card[data-id="${id}"]`);
  if (card) {
    card.querySelector('.card__fav-btn').classList.toggle('active', isFav);
  }

  // Кнопка в модалке (если открыта для этого же места)
  if (state.currentAttractionId === id) {
    modalFavBtn.classList.toggle('active', isFav);
  }
}
js

Чтобы клик по сердечку не открывал модалку. Обработчик делегирования на сетке откликается на любой клик внутри карточки. Если не остановить всплытие при клике на сердечко, сработают оба обработчика. Добавьте проверку:

grid.addEventListener('click', (e) => {
  if (e.target.closest('.card__fav-btn')) {
    const card = e.target.closest('.card');
    toggleFavourite(Number(card.dataset.id));
    return; // не открываем модалку
  }

  const card = e.target.closest('.card');
  if (card) openModal(Number(card.dataset.id));
});
js

Адаптивность

Проверьте приложение на следующих размерах экрана и исправьте всё, что выглядит плохо:

  • 320px — самый узкий мобильный
  • 375px — стандартный iPhone
  • 768px — планшет
  • 1280px и шире — десктоп

Типичные проблемы, на которые стоит обратить внимание: кнопки фильтров выходят за экран, модалка не помещается по высоте, поисковая строка слишком узкая, отступы слишком большие на мобильном.

Модалка на мобильном. На маленьких экранах модальное окно часто не помещается в высоту. Чтобы решить: сделайте .modal__content с max-height: 90vh; overflow-y: auto — тогда при необходимости внутри появится прокрутка. На совсем узких экранах можно отображать модалку на весь экран: width: 100%; max-width: 100%; border-radius: 0.

Анимации

  • Карточки появляются с анимацией при первой загрузке — через @keyframes и animation-delay на основе порядкового номера карточки.
  • Переход «добавить в избранное» — анимирован (хотя бы небольшой scale или смена цвета).

Последовательная анимация карточек. При создании карточки через JavaScript задайте задержку через style:

function createCard(attraction, index) {
  const card = document.createElement('article');
  // ...
  card.style.animationDelay = `${index * 0.08}s`;
  return card;
}
js

В CSS пропишите анимацию появления:

.card {
  animation: fadeUp 0.4s ease-out both;
}

@keyframes fadeUp {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}
css

Финальная проверка

Пройдитесь по всему приложению и убедитесь:

  • в консоли браузера нет ошибок
  • все интерактивные элементы работают как ожидается
  • при быстром вводе в поиск ничего не ломается
  • при медленном интернете (вкладка Network → дросселинг → Slow 3G) виден индикатор загрузки
  • при отключённом интернете видно сообщение об ошибке

Продвинутые задания

Web Share API — добавьте кнопку «Поделиться» в модальное окно. Если браузер поддерживает Web Share API (navigator.share), используйте его. Если нет — скопируйте ссылку в буфер обмена (navigator.clipboard.writeText). Узнайте как это работает:

prefers-reduced-motion — некоторые пользователи настраивают систему так, чтобы приложения показывали меньше анимаций. Сделайте так, чтобы все ваши анимации отключались для таких пользователей:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
css

Ожидаемый результат

  • кнопка сердечка на карточке добавляет и убирает из избранного
  • состояние избранного сохраняется после перезагрузки страницы
  • кнопка «Избранное» в шапке переключает режим отображения
  • при пустом избранном виден соответствующий экран
  • кнопка избранного в модалке синхронизирована с карточкой
  • приложение корректно выглядит на экранах от 320px до 1280px+
  • в консоли нет ошибок
  • карточки появляются с анимацией

Поздравляем — вы построили полноценное интерактивное веб-приложение с нуля. В нём есть загрузка данных, работа с DOM, обработка событий, фильтрация, модальное окно, сохранение состояния и адаптивный дизайн. Это и есть базовый фронтенд.

Дальше — бесконечное поле для роста: фреймворки (React, Vue), TypeScript, серверный рендеринг, тестирование. Но фундамент теперь у вас есть.