+
+ ← Back
+
+
+
+ {status === 'loading' && (
+
+ )}
+
+ {status === 'failed' && (
+
dispatch(loadRecipeDetails(recipeId))}>
+ Retry
+
+ }
+ />
+ )}
+
+ {status === 'succeeded' && item && (
+
+
{title}
+ {item.image &&

}
+
{JSON.stringify(item, null, 2)}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/recipe-details/recipeDetailsSlice.ts b/src/pages/recipe-details/recipeDetailsSlice.ts
new file mode 100644
index 0000000..b3cf025
--- /dev/null
+++ b/src/pages/recipe-details/recipeDetailsSlice.ts
@@ -0,0 +1,49 @@
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+import { getRecipeById } from '@/shared/api/recipesApi';
+
+export type RecipeDetailsState = {
+ item: any | null;
+ status: 'idle' | 'loading' | 'succeeded' | 'failed';
+ error: string | null;
+
+ // NOTE: caching by ID / normalized entities intentionally NOT implemented (interview challenge).
+};
+
+const initialState: RecipeDetailsState = {
+ item: null,
+ status: 'idle',
+ error: null,
+};
+
+export const loadRecipeDetails = createAsyncThunk('recipeDetails/load', async (id: number) => {
+ return await getRecipeById(id);
+});
+
+const slice = createSlice({
+ name: 'recipeDetails',
+ initialState,
+ reducers: {
+ clear(state) {
+ state.item = null;
+ state.status = 'idle';
+ state.error = null;
+ },
+ },
+ extraReducers: (b) => {
+ b.addCase(loadRecipeDetails.pending, (state) => {
+ state.status = 'loading';
+ state.error = null;
+ });
+ b.addCase(loadRecipeDetails.fulfilled, (state, action) => {
+ state.status = 'succeeded';
+ state.item = action.payload;
+ });
+ b.addCase(loadRecipeDetails.rejected, (state, action) => {
+ state.status = 'failed';
+ state.error = action.error.message ?? 'Request failed';
+ });
+ },
+});
+
+export const recipeDetailsReducer = slice.reducer;
+export const recipeDetailsActions = slice.actions;
diff --git a/src/pages/recipes/RecipesPage.module.scss b/src/pages/recipes/RecipesPage.module.scss
new file mode 100644
index 0000000..a2186ec
--- /dev/null
+++ b/src/pages/recipes/RecipesPage.module.scss
@@ -0,0 +1,67 @@
+.root {}
+
+.toolbar {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-bottom: 14px;
+}
+
+.search {
+ flex: 1;
+ padding: 8px 10px;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+}
+
+.sortBtn {
+ padding: 8px 10px;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 8px;
+ background: transparent;
+}
+
+.grid {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px;
+}
+
+.card {
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.cardLink {
+ display: block;
+ color: inherit;
+ text-decoration: none;
+}
+
+.image {
+ width: 100%;
+ height: 140px;
+ object-fit: cover;
+ display: block;
+}
+
+.name {
+ padding: 10px;
+ font-weight: 600;
+}
+
+.pager {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+ margin-top: 14px;
+}
+
+.pageInfo {
+ opacity: 0.8;
+}
diff --git a/src/pages/recipes/RecipesPage.tsx b/src/pages/recipes/RecipesPage.tsx
new file mode 100644
index 0000000..a4013cc
--- /dev/null
+++ b/src/pages/recipes/RecipesPage.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import Menu, { Item as MenuItem } from 'rc-menu';
+import Dropdown from 'rc-dropdown';
+import clsx from 'clsx';
+import debounce from 'lodash/debounce';
+
+import { useAppDispatch, useAppSelector } from '@/app/hooks';
+import { StatusBlock } from '@/shared/ui/StatusBlock/StatusBlock';
+import { loadRecipes, recipesActions, selectVisibleRecipes } from './recipesSlice';
+
+import styles from './RecipesPage.module.scss';
+import 'rc-menu/assets/index.css';
+
+export function RecipesPage() {
+ const dispatch = useAppDispatch();
+ const { status, error, page, pageSize, total, query } = useAppSelector((s) => s.recipes);
+ const items = useAppSelector(selectVisibleRecipes);
+
+ useEffect(() => {
+ dispatch(loadRecipes());
+ }, [dispatch, page, pageSize]);
+
+ // NOTE: debouncedSetQuery is here, but re-fetch and URL sync are intentionally left as interview challenges.
+ const debouncedSetQuery = useMemo(
+ () => debounce((value: string) => dispatch(recipesActions.setQuery(value)), 300),
+ [dispatch],
+ );
+
+ useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ const sortMenu = (
+
+
+ {
+ // Intentionally does NOT trigger re-fetch. Candidate decides behavior.
+ debouncedSetQuery(e.target.value);
+ }}
+ />
+
+
+
+
+
+
+ {status === 'loading' && (
+
+ )}
+
+ {status === 'failed' && (
+
dispatch(loadRecipes())}>
+ Retry
+
+ }
+ />
+ )}
+
+ {status === 'succeeded' && items.length === 0 && (
+
+ )}
+
+ {status === 'succeeded' && items.length > 0 && (
+ <>
+
+ {items.map((r) => (
+ -
+
+
+ {r.name}
+
+
+ ))}
+
+
+
+
+
+ {page} / {totalPages}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/pages/recipes/recipesSlice.ts b/src/pages/recipes/recipesSlice.ts
new file mode 100644
index 0000000..ac62ac3
--- /dev/null
+++ b/src/pages/recipes/recipesSlice.ts
@@ -0,0 +1,85 @@
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { getRecipes, type Recipe } from '@/shared/api/recipesApi';
+import type { RootState } from '@/app/store';
+
+export type RecipesState = {
+ items: Recipe[];
+ total: number;
+ status: 'idle' | 'loading' | 'succeeded' | 'failed';
+ error: string | null;
+
+ // Query state (intentionally basic)
+ query: string;
+ page: number;
+ pageSize: number;
+
+ // NOTE: Advanced interview challenges are intentionally NOT implemented:
+ // - URL sync (Router query params)
+ // - request cancellation / stale response protection
+ // - server-side search / sorting
+ // - normalized entities caching
+};
+
+const initialState: RecipesState = {
+ items: [],
+ total: 0,
+ status: 'idle',
+ error: null,
+ query: '',
+ page: 1,
+ pageSize: 10,
+};
+
+export const loadRecipes = createAsyncThunk(
+ 'recipes/load',
+ async (_, { getState }) => {
+ const state = getState() as RootState;
+ const { page, pageSize } = state.recipes;
+ const skip = (page - 1) * pageSize;
+ const limit = pageSize;
+ return await getRecipes({ limit, skip });
+ }
+);
+
+const slice = createSlice({
+ name: 'recipes',
+ initialState,
+ reducers: {
+ setQuery(state, action: PayloadAction