@akinon/projectzero 2.0.79-rc.0 → 2.0.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +6 -4
  2. package/app-template/.env.example +10 -1
  3. package/app-template/AGENTS.md +0 -8
  4. package/app-template/CHANGELOG.md +38 -81
  5. package/app-template/README.md +1 -25
  6. package/app-template/next.config.mjs +1 -7
  7. package/app-template/package.json +41 -41
  8. package/app-template/src/app/[pz]/layout.tsx +20 -0
  9. package/app-template/src/app/api/preview/link/route.ts +115 -0
  10. package/app-template/src/app/api/preview/route.ts +96 -0
  11. package/app-template/src/hooks/index.ts +0 -2
  12. package/app-template/src/hooks/use-fav-button.tsx +13 -35
  13. package/app-template/src/plugins.js +0 -1
  14. package/app-template/src/proxy.ts +47 -4
  15. package/app-template/src/settings.js +17 -0
  16. package/app-template/src/utils/__tests__/preview-contract.test.ts +223 -0
  17. package/app-template/src/views/category/category-info.tsx +17 -25
  18. package/app-template/src/views/header/search/index.tsx +5 -13
  19. package/app-template/src/views/product/slider.tsx +38 -85
  20. package/app-template/src/views/product-item/index.tsx +1 -1
  21. package/app-template/tailwind.config.js +2 -0
  22. package/commands/plugins.ts +0 -4
  23. package/dist/commands/plugins.js +0 -4
  24. package/package.json +1 -1
  25. package/app-template/src/app/api/client-env/route.ts +0 -5
  26. package/app-template/src/app/global-error.tsx +0 -22
  27. package/app-template/src/hooks/favourite-product-ids-context.tsx +0 -104
  28. package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +0 -42
  29. package/app-template/src/utils/favourite-product-ids-fallback.ts +0 -51
@@ -8,11 +8,9 @@ import {
8
8
  useGetFavoritesQuery,
9
9
  useRemoveFavoriteMutation
10
10
  } from '@akinon/next/data/client/wishlist';
11
- import { Product } from '@akinon/next/types';
12
11
  import { Icon } from '@theme/components';
13
12
  import { pushAddToWishlist } from '@theme/utils/gtm';
14
13
  import { useToast, useLocalization } from '@akinon/next/hooks';
15
- import { useFavouriteProductIds } from './favourite-product-ids-context';
16
14
 
17
15
  interface FavButtonProps {
18
16
  label?: string;
@@ -23,25 +21,16 @@ interface FavButtonProps {
23
21
 
24
22
  const FAV_TOAST_OPTIONS = { position: 'bottom-right' as const }
25
23
 
26
- const useFavButton = (
27
- productPk: number,
28
- product?: Pick<Product, 'base_code' | 'name' | 'price' | 'currency_type'>
29
- ) => {
24
+ const useFavButton = (productPk: number) => {
30
25
  const { status } = useSession();
31
26
  const { t } = useLocalization();
32
27
  const { success, error: showError } = useToast();
33
28
 
34
- // Inside a FavouriteProductIdsProvider (PLP) the favourite state comes from
35
- // the batched favourite-product-ids request, so the full favourites list is
36
- // only fetched when that flow is unavailable (no provider, or 404 fallback).
37
- const favouriteProductIds = useFavouriteProductIds();
38
- const useBatchFlow = Boolean(favouriteProductIds?.isActive);
39
-
40
29
  const { data: favorites } = useGetFavoritesQuery(
41
30
  {
42
31
  limit: 1000
43
32
  },
44
- { skip: status !== 'authenticated' || useBatchFlow }
33
+ { skip: status !== 'authenticated' }
45
34
  );
46
35
 
47
36
  const favoriteItem = useMemo(
@@ -52,11 +41,7 @@ const useFavButton = (
52
41
  [favorites, productPk]
53
42
  );
54
43
 
55
- const favouriteId = useBatchFlow
56
- ? favouriteProductIds.getFavouriteId(productPk)
57
- : favoriteItem?.pk;
58
-
59
- const isActive = favouriteId !== undefined;
44
+ const isActive = Boolean(favoriteItem);
60
45
  const [isPushed, setIsPushed] = useState<boolean>(false);
61
46
 
62
47
  const [addFavorite] = useAddFavoriteMutation();
@@ -64,8 +49,8 @@ const useFavButton = (
64
49
 
65
50
  const handleClick = useCallback(async () => {
66
51
  try {
67
- if (favouriteId !== undefined) {
68
- await removeFavorite(favouriteId).unwrap();
52
+ if (favoriteItem) {
53
+ await removeFavorite(favoriteItem.pk).unwrap();
69
54
  success(t('product.removed_from_favorites'), FAV_TOAST_OPTIONS);
70
55
  } else {
71
56
  await addFavorite(productPk).unwrap();
@@ -75,26 +60,19 @@ const useFavButton = (
75
60
  } catch (err) {
76
61
  showError(t('product.favorites_error'), FAV_TOAST_OPTIONS);
77
62
  }
78
- }, [favouriteId, productPk, addFavorite, removeFavorite, success, showError, t]);
63
+ }, [favoriteItem, productPk, addFavorite, removeFavorite, success, showError, t]);
79
64
 
80
65
  useEffect(() => {
81
- if (!isActive || !isPushed) return;
82
-
83
- // The batched flow returns ids only, so the analytics payload falls back
84
- // to the product the card already renders.
85
- const gtmProduct = favoriteItem?.product ?? product;
86
-
87
- if (gtmProduct) {
66
+ if (favoriteItem && isPushed) {
88
67
  pushAddToWishlist({
89
- base_code: gtmProduct.base_code,
90
- name: gtmProduct.name,
91
- price: gtmProduct.price,
92
- currency_type: gtmProduct.currency_type
68
+ base_code: favoriteItem?.product?.base_code,
69
+ name: favoriteItem?.product?.name,
70
+ price: favoriteItem?.product?.price,
71
+ currency_type: favoriteItem?.product?.currency_type
93
72
  });
73
+ setIsPushed(false);
94
74
  }
95
-
96
- setIsPushed(false);
97
- }, [isActive, favoriteItem, product, isPushed]);
75
+ }, [favoriteItem, isPushed]);
98
76
 
99
77
  const FavButton = useMemo(() => {
100
78
  const View = (props: FavButtonProps) => (
@@ -19,7 +19,6 @@ module.exports = [
19
19
  'pz-hepsipay',
20
20
  'pz-flow-payment',
21
21
  'pz-virtual-try-on',
22
- 'pz-similar-products',
23
22
  'pz-cybersource-uc',
24
23
  'pz-hepsipay',
25
24
  'pz-masterpass-rest',
@@ -1,4 +1,5 @@
1
- import { withPzDefault } from '@akinon/next/middlewares';
1
+ import { PzNextRequest, withPzDefault } from '@akinon/next/middlewares';
2
+ import { PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
2
3
  import { NextMiddleware, NextResponse } from 'next/server';
3
4
 
4
5
  /**
@@ -14,8 +15,7 @@ export const config = {
14
15
  '/((?!api|_next|[\\w-\\/*]+\\.\\w+).*)',
15
16
  '/(.*sitemap\\.xml)',
16
17
  '/(.+\\.)(html|htm|aspx|asp|php)',
17
- '/(.*orders\\/checkout-with-token.*)',
18
- '/(.*orders\\/post-checkout.*)'
18
+ '/(.*orders\\/checkout-with-token.*)'
19
19
  ]
20
20
  };
21
21
 
@@ -33,4 +33,47 @@ const proxy: NextMiddleware = () =>
33
33
  return NextResponse.next();
34
34
  };
35
35
 
36
- export default withPzDefault(proxy);
36
+ const pzMiddleware = withPzDefault(proxy);
37
+
38
+ const PREVIEW_EXIT_VALUES = new Set(['exit', 'off', '0', 'false']);
39
+
40
+ /**
41
+ * Widget preview entry: any page url carrying `?preview` is redirected to the
42
+ * draft-mode route handler (/api/preview), which sets/clears the bypass and
43
+ * preview-id cookies and returns to the same page without the params. Runs
44
+ * BEFORE withPzDefault so the redirect is not subject to its response
45
+ * rewriting (pz-override-response).
46
+ *
47
+ * The `?preview` value is a signed token naming WHICH preview to view (see
48
+ * utils/preview-token.ts); it travels only as far as the route handler, which
49
+ * stores the preview id for the rest of the session. A stray `preview_id`
50
+ * from an old-style link is dropped from the return url.
51
+ */
52
+ const middleware: NextMiddleware = (req, event) => {
53
+ const url = req.nextUrl;
54
+
55
+ if (url.searchParams.has('preview')) {
56
+ const value = url.searchParams.get('preview') ?? '';
57
+ const target = url.clone();
58
+ target.searchParams.delete('preview');
59
+ target.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
60
+
61
+ const previewApi = url.clone();
62
+ previewApi.pathname = '/api/preview';
63
+ previewApi.search = '';
64
+ previewApi.searchParams.set(
65
+ 'redirect',
66
+ `${target.pathname}${target.search}`
67
+ );
68
+ if (PREVIEW_EXIT_VALUES.has(value.toLowerCase())) {
69
+ previewApi.searchParams.set('exit', '1');
70
+ } else {
71
+ if (value) previewApi.searchParams.set('token', value);
72
+ }
73
+ return NextResponse.redirect(previewApi);
74
+ }
75
+
76
+ return pzMiddleware(req as PzNextRequest, event);
77
+ };
78
+
79
+ export default middleware;
@@ -1,4 +1,8 @@
1
1
  const { LocaleUrlStrategy } = require('@akinon/next/localization');
2
+ const {
3
+ PREVIEW_PZ_SEGMENT,
4
+ resolvePreviewSegment
5
+ } = require('@akinon/next/utils/preview');
2
6
  const { ROUTES } = require('@theme/routes');
3
7
 
4
8
  const commerceUrl = encodeURI(process.env.SERVICE_BACKEND_URL ?? 'default');
@@ -64,6 +68,19 @@ module.exports = {
64
68
  },
65
69
  customNotFoundEnabled: false,
66
70
  usePzSegment: true,
71
+ pzSegments: {
72
+ segments: [
73
+ {
74
+ // Carries WHICH named preview is being viewed into the render. The
75
+ // widget pages are force-static, where cookies() is blanked, so the
76
+ // middleware folds the preview cookie into the route instead.
77
+ // Empty for ordinary visitors — nothing renders from preview unless
78
+ // draft mode is on as well.
79
+ name: PREVIEW_PZ_SEGMENT,
80
+ resolve: (context) => resolvePreviewSegment(context.req)
81
+ }
82
+ ]
83
+ },
67
84
  commerceRedirectionIgnoreList: ['/users/reset'],
68
85
  csrf: {
69
86
  httpOnly: true
@@ -0,0 +1,223 @@
1
+ import {
2
+ isPreviewCopySlug,
3
+ isPreviewSlug,
4
+ isValidPreviewId,
5
+ previewSlug,
6
+ resolvePreviewSegment
7
+ } from '@akinon/next/utils/preview';
8
+ import {
9
+ decodePzValue,
10
+ encodePzValue,
11
+ getPzSegmentsConfig
12
+ } from '@akinon/next/utils/pz-segments';
13
+ import {
14
+ signPreviewToken,
15
+ verifyPreviewToken
16
+ } from '@akinon/next/utils/preview-token';
17
+ import { webcrypto } from 'node:crypto';
18
+ import { TextEncoder as NodeTextEncoder } from 'node:util';
19
+
20
+ // jsdom ships neither Web Crypto nor TextEncoder; the token code needs both.
21
+ if (!globalThis.crypto?.subtle) {
22
+ Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
23
+ }
24
+ if (typeof globalThis.TextEncoder === 'undefined') {
25
+ Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder });
26
+ }
27
+
28
+ /**
29
+ * The half of the preview contract this app owns: which named preview a
30
+ * request belongs to, and the widget slug that preview's copies live under.
31
+ * The theme editor builds the same slugs — if these change, previews stop
32
+ * resolving and every page silently falls back to live.
33
+ */
34
+
35
+ const req = (cookie?: string, draft = true) => ({
36
+ cookies: {
37
+ get: (name: string) => {
38
+ if (name === '__prerender_bypass') {
39
+ return draft ? { value: 'bypass' } : undefined;
40
+ }
41
+ return name === 'pz-preview-id' && cookie !== undefined
42
+ ? { value: cookie }
43
+ : undefined;
44
+ }
45
+ }
46
+ });
47
+
48
+ describe('preview id validation', () => {
49
+ it('accepts the ids the editor produces', () => {
50
+ expect(isValidPreviewId('staging')).toBe(true);
51
+ expect(isValidPreviewId('black-friday')).toBe(true);
52
+ expect(isValidPreviewId('kis-kampanyasi-2026')).toBe(true);
53
+ expect(isValidPreviewId('a'.repeat(32))).toBe(true);
54
+ });
55
+
56
+ it('rejects anything that could escape the slug', () => {
57
+ // Ids arrive from a cookie, so this is a trust boundary.
58
+ expect(isValidPreviewId('')).toBe(false);
59
+ expect(isValidPreviewId('-leading')).toBe(false);
60
+ expect(isValidPreviewId('Upper')).toBe(false);
61
+ expect(isValidPreviewId('has space')).toBe(false);
62
+ expect(isValidPreviewId('../etc')).toBe(false);
63
+ expect(isValidPreviewId('a'.repeat(33))).toBe(false);
64
+ expect(isValidPreviewId(undefined)).toBe(false);
65
+ // `--` is the pz segment separator; the editor never produces it.
66
+ expect(isValidPreviewId('a--b')).toBe(false);
67
+ expect(isValidPreviewId('trailing-')).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('preview slugs', () => {
72
+ it('suffixes the live slug with the preview id', () => {
73
+ expect(previewSlug('homepage-body-new', 'staging')).toBe(
74
+ 'homepage-body-new-preview-staging'
75
+ );
76
+ expect(previewSlug('homepage-body-new', 'black-friday')).toBe(
77
+ 'homepage-body-new-preview-black-friday'
78
+ );
79
+ });
80
+
81
+ it('recognises its own output so previews are never nested', () => {
82
+ expect(isPreviewSlug('homepage-body-new-preview-staging')).toBe(true);
83
+ expect(isPreviewSlug('homepage-body-new')).toBe(false);
84
+ // The old single-preview suffix carries no id and is not one of ours.
85
+ expect(isPreviewSlug('homepage-body-new-preview')).toBe(false);
86
+ });
87
+
88
+ it('only treats a slug as a copy when it belongs to THIS preview', () => {
89
+ expect(isPreviewCopySlug('header-preview-staging', 'staging')).toBe(true);
90
+ // Another preview's copy is not ours to skip.
91
+ expect(isPreviewCopySlug('header-preview-winter', 'staging')).toBe(false);
92
+ // A live custom page whose name merely looks like a copy still gets its
93
+ // overlay — the shape-only check used to skip it and render live.
94
+ expect(isPreviewCopySlug('page-summer-preview-2026', 'staging')).toBe(
95
+ false
96
+ );
97
+ expect(isPreviewCopySlug('header', '')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('the preview id inside the pz segment', () => {
102
+ const settings = {
103
+ pzSegments: { segments: [{ name: 'preview' }] }
104
+ };
105
+ const config = getPzSegmentsConfig(settings);
106
+ const url = encodeURIComponent('https://shop.test/collections/summer--sale');
107
+
108
+ it('round-trips a url containing the separator with a preview id after it', () => {
109
+ const encoded = encodePzValue(
110
+ { locale: 'en', currency: 'usd', url, preview: 'staging' },
111
+ config
112
+ );
113
+ expect(decodePzValue(encoded, config)).toEqual({
114
+ locale: 'en',
115
+ currency: 'usd',
116
+ url,
117
+ preview: 'staging'
118
+ });
119
+ });
120
+
121
+ it('round-trips such a url when no preview is active', () => {
122
+ const encoded = encodePzValue(
123
+ { locale: 'en', currency: 'usd', url, preview: '' },
124
+ config
125
+ );
126
+ expect(decodePzValue(encoded, config)).toEqual({
127
+ locale: 'en',
128
+ currency: 'usd',
129
+ url,
130
+ preview: ''
131
+ });
132
+ });
133
+
134
+ it('still reads a plain segment positionally', () => {
135
+ expect(decodePzValue('tr--try--%2F--kis', config)).toEqual({
136
+ locale: 'tr',
137
+ currency: 'try',
138
+ url: '%2F',
139
+ preview: 'kis'
140
+ });
141
+ });
142
+ });
143
+
144
+ describe('resolvePreviewSegment', () => {
145
+ it('passes a valid cookie through to the pz segment', () => {
146
+ expect(resolvePreviewSegment(req('staging'))).toBe('staging');
147
+ });
148
+
149
+ it('yields no preview for a missing or tampered cookie', () => {
150
+ expect(resolvePreviewSegment(req())).toBe('');
151
+ expect(resolvePreviewSegment(req(''))).toBe('');
152
+ expect(resolvePreviewSegment(req('../../evil'))).toBe('');
153
+ expect(resolvePreviewSegment(req('Staging'))).toBe('');
154
+ });
155
+
156
+ it('ignores the id cookie unless draft mode is on', () => {
157
+ // An anonymous visitor forging pz-preview-id must not get a private
158
+ // cache key per request — without the draft cookie the id is inert.
159
+ expect(resolvePreviewSegment(req('staging', false))).toBe('');
160
+ });
161
+ });
162
+
163
+ describe('signed preview links', () => {
164
+ const secret = 'k3y';
165
+ const now = 1_800_000_000_000; // ms
166
+ const inAnHour = Math.floor(now / 1000) + 3600;
167
+
168
+ it('round-trips the preview id and expiry it was minted with', async () => {
169
+ const token = await signPreviewToken(
170
+ { previewId: 'kis-kampanyasi', expiresAt: inAnHour },
171
+ secret
172
+ );
173
+ expect(token.startsWith('v1.kis-kampanyasi.')).toBe(true);
174
+ expect(await verifyPreviewToken(token, secret, now)).toEqual({
175
+ ok: true,
176
+ claims: { previewId: 'kis-kampanyasi', expiresAt: inAnHour }
177
+ });
178
+ });
179
+
180
+ it('dies at its expiry', async () => {
181
+ const token = await signPreviewToken(
182
+ { previewId: 'staging', expiresAt: inAnHour },
183
+ secret
184
+ );
185
+ expect(await verifyPreviewToken(token, secret, inAnHour * 1000)).toEqual({
186
+ ok: false,
187
+ reason: 'expired'
188
+ });
189
+ });
190
+
191
+ it('rejects tampering with the id, the expiry or the key', async () => {
192
+ const token = await signPreviewToken(
193
+ { previewId: 'staging', expiresAt: inAnHour },
194
+ secret
195
+ );
196
+ const [v, , exp, sig] = token.split('.');
197
+ expect(
198
+ (await verifyPreviewToken(`${v}.winter.${exp}.${sig}`, secret, now)).ok
199
+ ).toBe(false);
200
+ expect(
201
+ (await verifyPreviewToken(`${v}.staging.${exp + 1}.${sig}`, secret, now))
202
+ .ok
203
+ ).toBe(false);
204
+ expect((await verifyPreviewToken(token, 'other-key', now)).ok).toBe(false);
205
+ });
206
+
207
+ it('treats anything else as malformed — including the old raw secret', async () => {
208
+ expect(await verifyPreviewToken('pz-preview-local', secret, now)).toEqual({
209
+ ok: false,
210
+ reason: 'malformed'
211
+ });
212
+ expect((await verifyPreviewToken('', secret, now)).ok).toBe(false);
213
+ expect(
214
+ (await verifyPreviewToken('v1.Bad Id.123.sig', secret, now)).ok
215
+ ).toBe(false);
216
+ });
217
+
218
+ it('refuses to mint for an invalid id', async () => {
219
+ await expect(
220
+ signPreviewToken({ previewId: '../x', expiresAt: inAnHour }, secret)
221
+ ).rejects.toThrow();
222
+ });
223
+ });
@@ -11,7 +11,6 @@ import { GetCategoryResponse } from '@akinon/next/types';
11
11
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
12
12
  import { setFacets, setMenuOpen } from '@theme/redux/reducers/category';
13
13
  import CategoryActiveFilters from '@theme/views/category/category-active-filters';
14
- import { FavouriteProductIdsProvider } from '@theme/hooks/favourite-product-ids-context';
15
14
  import { useLocalization } from '@akinon/next/hooks';
16
15
  import { Link, LoaderSpinner } from '@akinon/next/components';
17
16
  import { ROUTES } from '@theme/routes';
@@ -63,11 +62,6 @@ export default function ListPage(props: ListPageProps) {
63
62
 
64
63
  const { t } = useLocalization();
65
64
 
66
- const productPks = useMemo(
67
- () => data?.products?.map((product) => product.pk) ?? [],
68
- [data?.products]
69
- );
70
-
71
65
  useEffect(() => {
72
66
  dispatch(setFacets(data.facets));
73
67
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -111,25 +105,23 @@ export default function ListPage(props: ListPageProps) {
111
105
 
112
106
  {data.products?.length === 0 && page > 1 && <LoaderSpinner />}
113
107
 
114
- <FavouriteProductIdsProvider productIds={productPks}>
115
- <div
116
- className={clsx('grid gap-x-4 gap-y-12 grid-cols-2', {
117
- 'md:grid-cols-3': layoutSize === 3,
118
- 'lg:grid-cols-2': layoutSize === 2,
119
- 'lg:grid-cols-3': layoutSize === 3
120
- })}
121
- >
122
- {data?.products?.map((product, index) => (
123
- <ProductItem
124
- key={product.pk}
125
- product={product}
126
- width={itemDimensions.width}
127
- height={itemDimensions.height}
128
- index={index}
129
- />
130
- ))}
131
- </div>
132
- </FavouriteProductIdsProvider>
108
+ <div
109
+ className={clsx('grid gap-x-4 gap-y-12 grid-cols-2', {
110
+ 'md:grid-cols-3': layoutSize === 3,
111
+ 'lg:grid-cols-2': layoutSize === 2,
112
+ 'lg:grid-cols-3': layoutSize === 3
113
+ })}
114
+ >
115
+ {data?.products?.map((product, index) => (
116
+ <ProductItem
117
+ key={product.pk}
118
+ product={product}
119
+ width={itemDimensions.width}
120
+ height={itemDimensions.height}
121
+ index={index}
122
+ />
123
+ ))}
124
+ </div>
133
125
  {data?.products?.length > 0 && (
134
126
  <Pagination
135
127
  total={data.pagination.total_count}
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
5
5
  import { closeSearch } from '@akinon/next/redux/reducers/header';
6
6
  import clsx from 'clsx';
7
- import { Icon, Input } from '@theme/components';
7
+
8
+ import { Icon } from '@theme/components';
8
9
  import Results from './results';
9
10
  import { ROUTES } from '@theme/routes';
10
11
  import { useLocalization, useRouter } from '@akinon/next/hooks';
@@ -41,14 +42,6 @@ export default function Search() {
41
42
  };
42
43
  }, [isSearchOpen, dispatch]);
43
44
 
44
- const handleSearchTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
- setSearchText(e.target.value);
46
- };
47
-
48
- const handleCloseSearch = () => {
49
- dispatch(closeSearch());
50
- };
51
-
52
45
  return (
53
46
  <>
54
47
  <div
@@ -74,9 +67,9 @@ export default function Search() {
74
67
  {t('common.search.results_for')}
75
68
  </span>
76
69
  <div className="flex items-center">
77
- <Input
70
+ <input
78
71
  value={searchText}
79
- onChange={handleSearchTextChange}
72
+ onChange={(e) => setSearchText(e.target.value)}
80
73
  onKeyDown={(e) => {
81
74
  if (e.key === 'Enter' && searchText.trim() !== '') {
82
75
  router.push(`${ROUTES.LIST}/?search_text=${searchText}`);
@@ -98,12 +91,11 @@ export default function Search() {
98
91
  <Icon
99
92
  name="close"
100
93
  size={14}
101
- onClick={handleCloseSearch}
94
+ onClick={() => dispatch(closeSearch())}
102
95
  className="cursor-pointer"
103
96
  />
104
97
  </div>
105
98
  </div>
106
-
107
99
  <Results searchText={searchText} />
108
100
  </div>
109
101
  </div>