useRouter Link Routing

Главная особеность тега <Link>, которую предоставляет нам NextJS является то, что этот тег заставляет перезагружать только отдельную часть документа, где меняется контент, а не весь документ сразу. Это обеспечивает работу со страницей как с клиентским приложением, когда у нас перезагружается на вся форма, а только отдельная его часть

  • href
  • as - он уже позволит указать то, что будет отображаться в браузере, если ссылки не совпадают
  • replace - если false, то при возвращении обратно на эту страницу, будет совершён возврат на предыдущую за ней
  • shallow - если true, то эта настройка запретит запускать получение пропсов и путей с сервера (функции некста)

Чтобы указать наш линк, нужно записать этот тег внутри написать блок с ссылкой <a>. Начиная с NextJS 13 тег <a> для указания ссылки использовать нельзя.

Так выглядит пробрасывание ссылки из Link во вложенный внутрь него компонент

Так же мы имеем возможность прокинуть не просто ссылку, а полноценный объект, в котором укажем все части ссылки. Это предоставит более понятную и читабельную ссылку.

Чтобы тег Link заработал в нашем проекте, нужно просто заменить <a> на <Link>. Конкретно вот так сейчас выглядят функции с ссылками:

layout / menu / Menu.tsx

// Тут мы строим первый уровень меню
const buildFirstLevel = () => {
	return (
		<>
			{/* Проходимся по всем элементам меню */}
			{firstLevelMenu.map((m) => (
				// Будем создавать элементы по роуту
				<div key={m.route}>
					<Link href={`/${m.route}`}>
						<div
							className={cn(styles.firstLevel, {
								[styles.firstLevelActive]: m.id == firstCategory,
							})}
>
							{m.icon}
							<span>{m.name}</span>
						</div>
					</Link>
					{/* Далее мы строим второй уровень, если id меню совпадает с id выбранной категории */}
					{m.id == firstCategory && buildSecondLevel(m)}
				</div>
			))}
		</>
	);
};
 
// функция для построения третьего уровня меню
const buildThirdLevel = (pages: PageItem[], route: string) => {
	// на третьем уровне нужно будет просто вывести ссылки под вторым уровнем
	return pages.map((p) => (
		<Link
			// тут мы указываем главный роут и алиас страницы
			href={`/${route}/${p.alias}`}
			className={cn(styles.thirdLevel, {
				[styles.thirdLevelActive]: false,
			})}
>
			{p.category}
		</Link>
	));
};

Теперь наша страница не перезагружается при переходе по роутам - обновляются только те данные, которые изменились на странице

002 useRouter

useRouter - это хук доступный исключительно в NextJS

Так роутер используется

const router = useRouter();

Роутер состоит из:

  • route - это текущий роут, на котором мы находимся
  • pathname - текущий путь, по которому мы находимся
  • query - параметры пути
  • asPath - если задали as при переходе на Link
  • basePath- базовый путь, который был задан И свойства, которые относятся к языку:
  • locale
  • locales
  • defaultLocale
  • domainLocales
  • isLocaleDomen

Так же у него имеются методы и события, которые позволяют скорректировать роуты:

Pick<Router, 'push' | 'replace' | 'reload' | 'back' | ... >

План работ:

  • Нужно скрывать все остальные уровни, когда у нас выбран определённый раздел
  • Нужно выделить активным то меню, в котором мы сейчас находимся

Для того, чтобы отобразить выбранный элемент, нужно в buildSecondLevel указывать стиль Opened только указанному элементу

layout / menu / Menu.tsx

// функция построения второго уровня меню
const buildSecondLevel = (menuItem: FirstLevelMenuItem) => {
	return (
		<div className={styles.secondBlock}>
			{menu.map((m) => {
 
				// будет отображать только тот блок с курсами, на котором сейчас находится пользователь
				// проверка происходит по наличию в пути страницы значения алиаса страницы
				if (m.pages.map((p) => p.alias).includes(router.asPath.split('/')[2])) m.isOpened = true;
 
				return (
					<div key={m._id.secondCategory}>
						<div className={styles.secondLevel}>{m._id.secondCategory}</div>
						<div
							className={cn(styles.secondLevelBlock, {
								[styles.secondLevelBlockOpened]: m.isOpened,
							})}
>
							{buildThirdLevel(m.pages, menuItem.route)}
						</div>
					</div>
				);
			})}
		</div>
	);
};

Скроем изначально все блоки и будем показывать их содержимое через secondLevelBlockOpened

layout / menu / Menu.module.css

.secondLevelBlock {
	display: none;
}
 
.secondLevelBlockOpened {
	display: block;
}

И сейчас мы видим, что все остальные меню скрылись

Тут мы укажем, что заголовок третьего уровня будет активен, если имя его роута (путь в браузере) будет совпадать с тем, где сейчас находится роутер (в браузере)

layout / menu / Menu.tsx

// функция для построения третьего уровня меню
const buildThirdLevel = (pages: PageItem[], route: string) => {
	// на третьем уровне нужно будет просто вывести ссылки под вторым уровнем
	return pages.map((p) => (
		<Link
			// тут мы указываем главный роут и алиас страницы
			href={`/${route}/${p.alias}`}
			className={cn(styles.thirdLevel, {
				[styles.thirdLevelActive]: `/${route}/${p.alias}` == router.asPath,
			})}
		>
			{p.category}
		</Link>
	));
};

Сейчас мы можем нажать на определённую страницу и она будет выделена в меню

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

layout / menu / Menu.tsx

// данная функция будет скрывать или показывать блок второго уровня меню по клику
const openSecondLevel = (secondCategory: string) => {
	// так как setMenu может и не быть (= null), то нужно обязательно прописать проверку через &&, что нужно выполнять функцию при условии её наличия
	setMenu &&
		setMenu(
			menu.map((m) => {
				if (m._id.secondCategory == secondCategory) {
					m.isOpened = !m.isOpened;
				}
				return m;
			}),
		);
};
 
// функция построения второго уровня меню
const buildSecondLevel = (menuItem: FirstLevelMenuItem) => {
	return (
		<div className={styles.secondBlock}>
			{menu.map((m) => {
	// будет отображать только тот блок с курсами, на котором сейчас находится пользователь
				// проверка происходит по наличию в пути страницы значения алиаса страницы
				if (m.pages.map((p) => p.alias).includes(router.asPath.split('/')[2])) {
					m.isOpened = true;
				}
				return (
					<div key={m._id.secondCategory}>
						<div
							className={styles.secondLevel}
					// при нажатии на уровень будет происходить изменение видимости блока
							onClick={() => openSecondLevel(m._id.secondCategory)}
>
							{m._id.secondCategory}
						</div>
						<div
							className={cn(styles.secondLevelBlock, {
								[styles.secondLevelBlockOpened]: m.isOpened,
							})}
>
							{buildThirdLevel(m.pages, menuItem.route)}
						</div>
					</div>
				);
			})}
		</div>
	);
};

На данном этапе мы уже можем открывать и скрывать определённые разделы с курсами

004 Структура роутинга

Вынесем на главную отдельный модуль хелперов

Из [alias].tsx перенесём в хелпер информацию по заголовкам первого уровня. Делаем это для того, чтобы можно было воспользоваться этими данными сразу на нескольких страницах.

helpers / helpers.tsx

import { FirstLevelMenuItem } from '../interfaces/menu.interface';
import { TopLevelCategory } from '../interfaces/page.interface';
 
import CoursesIcon from './icons/courses.svg';
import ServicesIcon from './icons/services.svg';
import BooksIcon from './icons/books.svg';
import ProductsIcon from './icons/products.svg';
 
export const firstLevelMenu: FirstLevelMenuItem[] = [
	{ route: 'courses', name: 'Курсы', icon: <CoursesIcon />, id: TopLevelCategory.Courses },
	{ route: 'services', name: 'Сервисы', icon: <ServicesIcon />, id: TopLevelCategory.Services },
	{ route: 'books', name: 'Книги', icon: <BooksIcon />, id: TopLevelCategory.Books },
	{ route: 'products', name: 'Продукты', icon: <ProductsIcon />, id: TopLevelCategory.Products },
];

Далее в страницах нужно будет поменять courses на [type], для того, чтобы генерировать вместо тайпа отдельную страницу под каждый заголовок первого уровня меню

Тут уже добавляем функционал, который позволит при неправильно выбранном роуте производить вывод страницы 404. Так же связываем данные firstLevelMenu с теми, что находятся в хелпере.

pages / [type] / [alias].tsx

function Course({ menu, page, products }: CourseProps): JSX.Element {
   return <>{products && products.length}</>;
}
 
export default withLayout(Course);
 
export const getStaticPaths: GetStaticPaths = async () => {
   let paths: string[] = []; // тут будут храниться доступные пути
 
   // тут мы перебираем пути, получаем их и складываем в массив путей   for (const m of firstLevelMenu) {
      // тут происходит получение пути по отправке запроса с именем
      const { data: menu } = await axios.post<MenuItem[]>(
         process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/find',
         {            firstCategory: m.id,
         },      );
 
      // тут происходит конкатенация путей
      paths = paths.concat(menu.flatMap((s) => s.pages.map((p) => `/${m.route}/${p.alias}`)));
   }
 
   return {
      paths,
      fallback: true,
   };
};
 
export const getStaticProps: GetStaticProps<CourseProps> = async ({
   params,
}: GetStaticPropsContext<ParsedUrlQuery>) => {
   if (!params) {
      return {
         notFound: true,
      };   }
 
   // мы ищем категорию, которая совпадает с типом, который передаётся в параметрах
   const firstCategoryItem = firstLevelMenu.find((m) => m.route == params.type);
   // если нет объекта первой категории, то мы возвращаем 404
   if (!firstCategoryItem) {
      return {
         notFound: true,
      };   }
 
   // если роут будет введён неправильно, то чтобы не получить ошибку, нужно обернуть его в try-catch
   try {
      const { data: menu } = await axios.post<MenuItem[]>(
         process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/find',
         {
	         firstCategory: firstCategoryItem.id,
         },
    );
 
      // если нам придёт пустое меню, то нам нужно будет его
      if (menu.length == 0) {
         return {
            notFound: true,
         };
      }
 
      const { data: page } = await axios.get<TopPageModel>(
         process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/byAlias/' + params.alias,
      );
 
      const { data: products } = await axios.post<ProductModel[]>(
         process.env.NEXT_PUBLIC_DOMAIN + '/api/product/find',
	         {
		        category: page.category,
	            limit: 10,
	         },
          );
 
      return {
         props: {
            menu,
            // тут тоже возвращаем id первой категории
            firstCategory: firstCategoryItem.id,
            page,
            products,
         },
      };   } catch (error: unknown) {
      return {
         notFound: true,
      };   }
};
 
interface CourseProps extends Record<string, unknown> {
   menu: MenuItem[];
   firstCategory: TopLevelCategory; // меняем тип на тот, что в интерфейсе
   page: TopPageModel;
   products: ProductModel[];
}

Тут мы уже реализовали вывод страниц заголовков первого порядка меню. Они будут генерироваться по роуту названия этой страницы. Реализация данной страницы будет схожа с [alias].tsx.

pages / [type] / index.tsx

function Type({ firstCategory }: TypeProps): JSX.Element {
	return <>Type: {firstCategory}</>;
}
 
export default withLayout(Type);
 
// чтобы эта страница заработала, нужно сюда добавить статичное получение путей
export const getStaticPaths: GetStaticPaths = async () => {
	return {
	// каждая страница будет называться по названию роута из массива первоуровневого меню
		paths: firstLevelMenu.map((m) => '/' + m.route),
		fallback: true,
	};
};
 
export const getStaticProps: GetStaticProps<TypeProps> = async ({
	params,
}: GetStaticPropsContext<ParsedUrlQuery>) => {
	if (!params) {
		return {
			notFound: true,
		};
	}
 
	const firstCategoryItem = firstLevelMenu.find((m) => m.route == params.type);
	if (!firstCategoryItem) {
		return {
			notFound: true,
		};
	}
 
	const { data: menu } = await axios.post<MenuItem[]>(
		process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/find',
		{
			firstCategory: firstCategoryItem.id,
		},
	);
 
	return {
		props: {
			menu,
			firstCategory: firstCategoryItem.id,
		},
	};
};
 
interface TypeProps extends Record<string, unknown> {
	menu: MenuItem[];
	firstCategory: number;
}

А тут уже была добавлена страница /search, которая будет появляться при поиске определённого курса

pages / search.tsx

function Search(): JSX.Element {
	return (
		<>
			Search
		</>
	);
}
 
export default withLayout(Search);
 
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
	const firstCategory = 0;
	const { data: menu } = await axios.post<MenuItem[]>(process.env.NEXT_PUBLIC_DOMAIN + '/api/top-page/find', {
		firstCategory
	});
	return {
		props: {
			menu,
			firstCategory
		}
	};
};
 
interface HomeProps extends Record<string, unknown> {
	menu: MenuItem[];
	firstCategory: number;
}

Сейчас у нас генерируется страница 404 под каждый неправильный запрос (если пользователь попадёт не туда) благодаря try-catch, который находится в [alias].tsx

Так же при переходе на главную страницу, которую мы описали через [type], мы не получаем ошибку, а видим главную страницу по курсам (и по остальным заголовкам первого уровня)

Если попытаться вывести массив paths, то на выходе мы получим огромное количество готовых ссылок

При попытке сбилдить проект, мы получим полную структуру роутов нашего сайта (количество страниц и как к ним попасть)

005 Вёрстка Sidebar

Добавим в компонент сайдбара логотип с заглушку под будущий поиск

layout / Sidebar / Sidebar.tsx

import { SidebarProps } from './Sidebar.props';
import styles from './Sidebar.module.css';
import cn from 'classnames';
import { Menu } from '../Menu/Menu';
import Logo from '../logo.svg';
 
export const Sidebar = ({ className, ...props }: SidebarProps): JSX.Element => {
	return (
		<div className={cn(className, styles.sidebar)} {...props}>
			<Logo className={styles.logo} />
			<div>Поиск</div>
			<Menu />
		</div>
	);
};

Стили для расположения элементов сбоку страницы:

layout / Sidebar / Sidebar.module.css

.sidebar {
	display: grid;
	align-content: flex-start;
	gap: 20px;
}
 
.logo {
	margin-top: 34px;
}

И примерно так будет выглядеть сайдбар: