diff --git a/.eslintrc b/.eslintrc index a5e8e44a..d43be03c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,6 +10,12 @@ "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint", "prettier"], "rules": { + /** + * Ошибка no-void при использовании + * шаблона предварительной загрузки (Next.js preload) + * подробнее: https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#preloading-data + */ + "no-void": ["error", { "allowAsStatement": true }], // ошибка no-shadow при использовании Enum "no-shadow": "off", "@typescript-eslint/no-shadow": ["error"], diff --git a/app/post/[slug]/page.tsx b/app/post/[slug]/page.tsx index cbc5af52..264b7fe7 100644 --- a/app/post/[slug]/page.tsx +++ b/app/post/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { getPathsToPosts, getPost } from "@/core/ssr"; import { RoutePost } from "@/routes/Post/Route.Post"; +import { preloadOffers } from "@/components/Offer/api"; type Props = { params: { @@ -50,6 +51,7 @@ export const generateMetadata = async ({ const Page = async ({ params }: Props) => { const { slug } = params; + preloadOffers(slug); const post = await getPost({ slug }); if (post === null) { @@ -58,7 +60,7 @@ const Page = async ({ params }: Props) => { return ( ; - -export const Offer: FC = ({ id, categories }) => { - const { data } = useSWR( - { url: `/api/post/offer`, id }, - fetcherData, - config, - ); - - if (!data || !(data.similarPosts?.length || data.postsByCategory?.length)) - return null; - - return ( - - ); -}; diff --git a/components/Offer/api/getOffers/adapter/index.ts b/components/Offer/api/getOffers/adapter/index.ts new file mode 100644 index 00000000..fd23cf0b --- /dev/null +++ b/components/Offer/api/getOffers/adapter/index.ts @@ -0,0 +1,31 @@ +import { SearchResponseFrontend } from "@/core/elastic/type"; +import { PostCardItem } from "src/entities/card/Post"; + +type CardItem = PostCardItem & { + id: string; +}; + +export const offerAdapter = (data: SearchResponseFrontend): CardItem[] => + data.hits.hits.flatMap(({ _id, _source }) => { + if (!_source) return []; + + const { title, link, categories, departments, excerpt, thumbnail } = + _source; + + return { + id: _id.toString(), + title, + uri: link, + categories: { + nodes: departments.concat(categories), + }, + excerpt, + featuredImage: thumbnail.url + ? { + node: { + sourceUrl: thumbnail.url, + }, + } + : null, + }; + }); diff --git a/core/api/offers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts b/components/Offer/api/getOffers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts similarity index 90% rename from core/api/offers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts rename to components/Offer/api/getOffers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts index 32da4ad3..018060dc 100644 --- a/core/api/offers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts +++ b/components/Offer/api/getOffers/getMinimumDataForOffer/gql/getMinimumDataForOfferGQL.ts @@ -4,6 +4,7 @@ import { Nullable } from "@/helpers/typings/utility-types"; export type GetMinimumDataForOfferQuery = { post: Nullable<{ + id: string; categories: { nodes: { name: string; @@ -18,7 +19,8 @@ export type GetMinimumDataForOfferQuery = { export const getMinimumDataForOfferDocument = gql` query GetMinimumDataForOffer($id: ID!) { - post(id: $id) { + post(id: $id, idType: SLUG) { + id categories { nodes { name diff --git a/core/api/offers/getMinimumDataForOffer/getMinimumDataForOffer.ts b/components/Offer/api/getOffers/getMinimumDataForOffer/index.ts similarity index 94% rename from core/api/offers/getMinimumDataForOffer/getMinimumDataForOffer.ts rename to components/Offer/api/getOffers/getMinimumDataForOffer/index.ts index 7e850187..51bdb2c9 100644 --- a/core/api/offers/getMinimumDataForOffer/getMinimumDataForOffer.ts +++ b/components/Offer/api/getOffers/getMinimumDataForOffer/index.ts @@ -12,7 +12,7 @@ import { /** * Возвращаем минимально необходимый набор данных * для последующих запросов на формирование предложения. - * @param id текущей записи + * @param id ярлык текущей записи */ export const getMinimumDataForOffer = async (id: string) => { const { data, error, errors } = @@ -22,6 +22,7 @@ export const getMinimumDataForOffer = async (id: string) => { id, }, }); + if (error !== undefined) throw new ApiError(500, error.message); if (data === undefined) throw errors; const { post } = data; @@ -32,7 +33,7 @@ export const getMinimumDataForOffer = async (id: string) => { ); return { - notIn: id, + excludedId: post.id, keywords: post.postsFields.keywords, categories: categories.nodes.map((c) => c.name).join(","), departments: departments.nodes.map((d) => d.name).join(","), diff --git a/components/Offer/api/getOffers/getOffers.ts b/components/Offer/api/getOffers/getOffers.ts new file mode 100644 index 00000000..73376bf5 --- /dev/null +++ b/components/Offer/api/getOffers/getOffers.ts @@ -0,0 +1,37 @@ +import { searchQuery } from "@/core/elastic"; +import { SearchParams } from "@/core/elastic/type"; +import { exceptionLog } from "@/helpers"; + +import { offerAdapter } from "./adapter"; +import { getMinimumDataForOffer } from "./getMinimumDataForOffer"; + +const fetcher = (arggs: SearchParams) => searchQuery(arggs).then(offerAdapter); + +/** @param id ярлык текущей записи */ +export const getOffers = async (id: string) => { + try { + const { excludedId, keywords, categories, departments, lteDate } = + await getMinimumDataForOffer(id); + + const similarPostsData = keywords + ? fetcher({ text: keywords, lteDate, excludedId }) + : null; + + const postsByCategoryData = fetcher({ + categories, + departments, + lteDate, + excludedId, + }); + + const [similarPosts, postsByCategory] = await Promise.all([ + similarPostsData, + postsByCategoryData, + ]); + + return { similarPosts, postsByCategory }; + } catch (error) { + exceptionLog(error); + return null; + } +}; diff --git a/components/Offer/api/getOffers/preloadOffers.ts b/components/Offer/api/getOffers/preloadOffers.ts new file mode 100644 index 00000000..6dd191dd --- /dev/null +++ b/components/Offer/api/getOffers/preloadOffers.ts @@ -0,0 +1,6 @@ +import { getOffers } from "./getOffers"; + +/** @param id ярлык текущей записи */ +export const preloadOffers = (id: string) => { + void getOffers(id); +}; diff --git a/core/api/offers/types/index.ts b/components/Offer/api/getOffers/types/index.ts similarity index 60% rename from core/api/offers/types/index.ts rename to components/Offer/api/getOffers/types/index.ts index 62a1692a..c720de6b 100644 --- a/core/api/offers/types/index.ts +++ b/components/Offer/api/getOffers/types/index.ts @@ -1,8 +1,8 @@ import { Nullable } from "@/helpers/typings/utility-types"; -import { convertData } from "../utils"; +import { offerAdapter } from "../adapter"; -type ConvertData = Nullable>; +type ConvertData = Nullable>; export type ResponseOfferData = { similarPosts: ConvertData; postsByCategory: ConvertData; diff --git a/components/Offer/api/index.ts b/components/Offer/api/index.ts new file mode 100644 index 00000000..e34b9411 --- /dev/null +++ b/components/Offer/api/index.ts @@ -0,0 +1,2 @@ +export { getOffers } from "./getOffers/getOffers"; +export { preloadOffers } from "./getOffers/preloadOffers"; diff --git a/components/Offer/components/Tabs/Offer.Tabs.tsx b/components/Offer/components/Tabs/Offer.Tabs.tsx index 64d1ba98..0b9001cf 100644 --- a/components/Offer/components/Tabs/Offer.Tabs.tsx +++ b/components/Offer/components/Tabs/Offer.Tabs.tsx @@ -6,8 +6,8 @@ import { Nullable } from "@/helpers/typings/utility-types"; import { CarouselRoot } from "@/components/Carousel/CarouselRoot"; import { PostCard, PostCardItem } from "src/entities/card/Post"; -import { createCategoryName } from "../../utils/createCategoryName"; -import { handleOnClick } from "../../utils/goal"; +import { createCategoryName } from "../../lib/createCategoryName"; +import { handleOnClick } from "../../lib/goal"; import { sxOfferHeader, diff --git a/components/Offer/components/Tabs/index.ts b/components/Offer/components/Tabs/index.ts new file mode 100644 index 00000000..00ed438c --- /dev/null +++ b/components/Offer/components/Tabs/index.ts @@ -0,0 +1,8 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const DynamicOfferTabs = dynamic( + () => import("./Offer.Tabs").then((res) => res.OfferTabs), + { ssr: false }, +); diff --git a/components/Offer/components/index.ts b/components/Offer/components/index.ts new file mode 100644 index 00000000..3c43e058 --- /dev/null +++ b/components/Offer/components/index.ts @@ -0,0 +1 @@ +export { DynamicOfferTabs } from "./Tabs"; diff --git a/components/Offer/index.ts b/components/Offer/index.ts index 82cdf7f2..ce434cad 100644 --- a/components/Offer/index.ts +++ b/components/Offer/index.ts @@ -1,10 +1 @@ -"use client"; - -import dynamic from "next/dynamic"; - -export const DynamicOffer = dynamic( - () => import("./Offer").then((res) => res.Offer), - { - ssr: false, - }, -); +export { Offer } from "./ui"; diff --git a/components/Offer/utils/createCategoryName.ts b/components/Offer/lib/createCategoryName.ts similarity index 100% rename from components/Offer/utils/createCategoryName.ts rename to components/Offer/lib/createCategoryName.ts diff --git a/components/Offer/utils/goal.ts b/components/Offer/lib/goal.ts similarity index 100% rename from components/Offer/utils/goal.ts rename to components/Offer/lib/goal.ts diff --git a/components/Offer/ui/index.tsx b/components/Offer/ui/index.tsx new file mode 100644 index 00000000..d3c0db7f --- /dev/null +++ b/components/Offer/ui/index.tsx @@ -0,0 +1,22 @@ +import { getOffers } from "../api"; +import { DynamicOfferTabs } from "../components"; + +type OfferProps = { + id: string; + categories: string[]; +}; + +export const Offer = async ({ id, categories }: OfferProps) => { + const data = await getOffers(id); + + if (!data || !(data.similarPosts?.length || data.postsByCategory?.length)) + return null; + + return ( + + ); +}; diff --git a/core/api/offers/index.ts b/core/api/offers/index.ts deleted file mode 100644 index b9ab3b8f..00000000 --- a/core/api/offers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./getMinimumDataForOffer/getMinimumDataForOffer"; diff --git a/core/api/offers/utils/convertData.ts b/core/api/offers/utils/convertData.ts deleted file mode 100644 index e1b5582c..00000000 --- a/core/api/offers/utils/convertData.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { SearchHitsNode } from "@/core/elastic/type"; -import { IData } from "@/components/Widget/Card/Card"; - -export const convertData = (data: SearchHitsNode[]): IData[] => - data.map( - ({ - _id, - _source: { title, link, categories, departments, excerpt, thumbnail }, - }) => ({ - id: `${_id}`, - title, - uri: link, - categories: { - nodes: departments.concat(categories), - }, - excerpt, - featuredImage: thumbnail.url - ? { - node: { - sourceUrl: thumbnail.url, - }, - } - : null, - }), - ); diff --git a/core/api/offers/utils/index.ts b/core/api/offers/utils/index.ts deleted file mode 100644 index eb01dd05..00000000 --- a/core/api/offers/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./convertData"; diff --git a/package-lock.json b/package-lock.json index 2850223c..dcadf0e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,9 +38,9 @@ "node-fetch": "^3.2.4", "node-vibrant": "^3.2.1-alpha.1", "plaiceholder": "^2.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-device-detect": "^2.2.2", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "sharp": "^0.33.3", "smoothscroll-polyfill": "^0.4.4", "swr": "^2.2.4", @@ -51,7 +51,7 @@ "@types/autosuggest-highlight": "^3.2.0", "@types/elasticsearch": "^5.0.40", "@types/node": "17.0.32", - "@types/react": "18.0.9", + "@types/react": "^18.3.3", "@types/smoothscroll-polyfill": "^0.3.1", "@types/url-file-size": "^1.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", @@ -4406,12 +4406,12 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { - "version": "18.0.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", - "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, @@ -4423,11 +4423,6 @@ "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" - }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -12716,9 +12711,10 @@ } }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, @@ -12739,15 +12735,16 @@ } }, "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.3.1" } }, "node_modules/react-fast-compare": { @@ -13187,9 +13184,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" } diff --git a/package.json b/package.json index 732cca00..a7c6e6d9 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ "node-fetch": "^3.2.4", "node-vibrant": "^3.2.1-alpha.1", "plaiceholder": "^2.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-device-detect": "^2.2.2", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "sharp": "^0.33.3", "smoothscroll-polyfill": "^0.4.4", "swr": "^2.2.4", @@ -57,7 +57,7 @@ "@types/autosuggest-highlight": "^3.2.0", "@types/elasticsearch": "^5.0.40", "@types/node": "17.0.32", - "@types/react": "18.0.9", + "@types/react": "^18.3.3", "@types/smoothscroll-polyfill": "^0.3.1", "@types/url-file-size": "^1.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", diff --git a/pages/api/post/offer.ts b/pages/api/post/offer.ts deleted file mode 100644 index 69695bcc..00000000 --- a/pages/api/post/offer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { ApiError } from "next/dist/server/api-utils"; - -import { getMinimumDataForOffer } from "@/core/api/offers"; -import { ResponseOfferData } from "@/core/api/offers/types"; -import { convertData } from "@/core/api/offers/utils"; -import { SearchParams, SearchResponseFrontend } from "@/core/elastic/type"; -import { exceptionLog, fetcherData } from "@/helpers"; -import { ERROR_MESSAGE } from "@/constants"; - -const url = process.env.NEXT_PUBLIC_API_ES_URL; -const fetcher = ( - arggs: Parameters< - typeof fetcherData - >[0], -) => - fetcherData(arggs).then((data) => - convertData(data.hits.hits), - ); - -export default async function offerHandler( - req: NextApiRequest, - res: NextApiResponse, -) { - try { - const { id } = req.query; - if (typeof id !== "string") { - throw new ApiError(400, `${ERROR_MESSAGE.API_QUERY_KEY_UNDEFINED} "id"`); - } - - const { keywords, categories, departments, lteDate } = - await getMinimumDataForOffer(id); - - const similarPostsData = keywords - ? fetcher({ - url, - text: keywords, - lteDate, - excludedId: id, - }) - : null; - - const postsByCategoryData = fetcher({ - url, - categories, - departments, - lteDate, - excludedId: id, - }); - - const [similarPosts, postsByCategory] = await Promise.all([ - similarPostsData, - postsByCategoryData, - ]); - - res.status(200).json({ similarPosts, postsByCategory }); - } catch (error) { - exceptionLog(error); - if (error instanceof ApiError) { - res.status(500).end(); - } else { - res.status(500).end(); - } - } -} diff --git a/routes/Post/Route.Post.tsx b/routes/Post/Route.Post.tsx index 028799c3..ef9f6c5a 100644 --- a/routes/Post/Route.Post.tsx +++ b/routes/Post/Route.Post.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import { FC, Suspense } from "react"; import { Defaultize } from "@/helpers/typings/utility-types"; import { Article, ArticleProps } from "@/components/Article/Article"; -import { DynamicOffer } from "@/components/Offer"; +import { Offer } from "@/components/Offer"; import classes from "./Route.Post.module.css"; @@ -27,6 +27,10 @@ export const RoutePost: FC = ({ imageUrl={imageUrl} /> - {id && c.name)} />} + {id && ( + + c.name)} /> + + )} );