@akinon/projectzero 2.0.79-beta.1 → 2.0.79-rc.0
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.
- package/CHANGELOG.md +6 -2
- package/app-template/.env.example +1 -10
- package/app-template/AGENTS.md +8 -0
- package/app-template/CHANGELOG.md +81 -65
- package/app-template/README.md +25 -1
- package/app-template/next.config.mjs +7 -1
- package/app-template/package.json +41 -41
- package/app-template/src/app/[pz]/layout.tsx +0 -20
- package/app-template/src/app/api/client-env/route.ts +5 -0
- package/app-template/src/app/global-error.tsx +22 -0
- package/app-template/src/hooks/favourite-product-ids-context.tsx +104 -0
- package/app-template/src/hooks/index.ts +2 -0
- package/app-template/src/hooks/use-fav-button.tsx +35 -13
- package/app-template/src/plugins.js +1 -0
- package/app-template/src/proxy.ts +4 -47
- package/app-template/src/settings.js +0 -17
- package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +42 -0
- package/app-template/src/utils/favourite-product-ids-fallback.ts +51 -0
- package/app-template/src/views/category/category-info.tsx +25 -17
- package/app-template/src/views/header/search/index.tsx +13 -5
- package/app-template/src/views/product/slider.tsx +85 -38
- package/app-template/src/views/product-item/index.tsx +1 -1
- package/commands/plugins.ts +4 -0
- package/dist/commands/plugins.js +4 -0
- package/package.json +1 -1
- package/app-template/src/app/api/preview/link/route.ts +0 -115
- package/app-template/src/app/api/preview/route.ts +0 -96
- package/app-template/src/utils/__tests__/preview-contract.test.ts +0 -223
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
useGetFavoritesQuery,
|
|
9
9
|
useRemoveFavoriteMutation
|
|
10
10
|
} from '@akinon/next/data/client/wishlist';
|
|
11
|
+
import { Product } from '@akinon/next/types';
|
|
11
12
|
import { Icon } from '@theme/components';
|
|
12
13
|
import { pushAddToWishlist } from '@theme/utils/gtm';
|
|
13
14
|
import { useToast, useLocalization } from '@akinon/next/hooks';
|
|
15
|
+
import { useFavouriteProductIds } from './favourite-product-ids-context';
|
|
14
16
|
|
|
15
17
|
interface FavButtonProps {
|
|
16
18
|
label?: string;
|
|
@@ -21,16 +23,25 @@ interface FavButtonProps {
|
|
|
21
23
|
|
|
22
24
|
const FAV_TOAST_OPTIONS = { position: 'bottom-right' as const }
|
|
23
25
|
|
|
24
|
-
const useFavButton = (
|
|
26
|
+
const useFavButton = (
|
|
27
|
+
productPk: number,
|
|
28
|
+
product?: Pick<Product, 'base_code' | 'name' | 'price' | 'currency_type'>
|
|
29
|
+
) => {
|
|
25
30
|
const { status } = useSession();
|
|
26
31
|
const { t } = useLocalization();
|
|
27
32
|
const { success, error: showError } = useToast();
|
|
28
33
|
|
|
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
|
+
|
|
29
40
|
const { data: favorites } = useGetFavoritesQuery(
|
|
30
41
|
{
|
|
31
42
|
limit: 1000
|
|
32
43
|
},
|
|
33
|
-
{ skip: status !== 'authenticated' }
|
|
44
|
+
{ skip: status !== 'authenticated' || useBatchFlow }
|
|
34
45
|
);
|
|
35
46
|
|
|
36
47
|
const favoriteItem = useMemo(
|
|
@@ -41,7 +52,11 @@ const useFavButton = (productPk: number) => {
|
|
|
41
52
|
[favorites, productPk]
|
|
42
53
|
);
|
|
43
54
|
|
|
44
|
-
const
|
|
55
|
+
const favouriteId = useBatchFlow
|
|
56
|
+
? favouriteProductIds.getFavouriteId(productPk)
|
|
57
|
+
: favoriteItem?.pk;
|
|
58
|
+
|
|
59
|
+
const isActive = favouriteId !== undefined;
|
|
45
60
|
const [isPushed, setIsPushed] = useState<boolean>(false);
|
|
46
61
|
|
|
47
62
|
const [addFavorite] = useAddFavoriteMutation();
|
|
@@ -49,8 +64,8 @@ const useFavButton = (productPk: number) => {
|
|
|
49
64
|
|
|
50
65
|
const handleClick = useCallback(async () => {
|
|
51
66
|
try {
|
|
52
|
-
if (
|
|
53
|
-
await removeFavorite(
|
|
67
|
+
if (favouriteId !== undefined) {
|
|
68
|
+
await removeFavorite(favouriteId).unwrap();
|
|
54
69
|
success(t('product.removed_from_favorites'), FAV_TOAST_OPTIONS);
|
|
55
70
|
} else {
|
|
56
71
|
await addFavorite(productPk).unwrap();
|
|
@@ -60,19 +75,26 @@ const useFavButton = (productPk: number) => {
|
|
|
60
75
|
} catch (err) {
|
|
61
76
|
showError(t('product.favorites_error'), FAV_TOAST_OPTIONS);
|
|
62
77
|
}
|
|
63
|
-
}, [
|
|
78
|
+
}, [favouriteId, productPk, addFavorite, removeFavorite, success, showError, t]);
|
|
64
79
|
|
|
65
80
|
useEffect(() => {
|
|
66
|
-
if (
|
|
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) {
|
|
67
88
|
pushAddToWishlist({
|
|
68
|
-
base_code:
|
|
69
|
-
name:
|
|
70
|
-
price:
|
|
71
|
-
currency_type:
|
|
89
|
+
base_code: gtmProduct.base_code,
|
|
90
|
+
name: gtmProduct.name,
|
|
91
|
+
price: gtmProduct.price,
|
|
92
|
+
currency_type: gtmProduct.currency_type
|
|
72
93
|
});
|
|
73
|
-
setIsPushed(false);
|
|
74
94
|
}
|
|
75
|
-
|
|
95
|
+
|
|
96
|
+
setIsPushed(false);
|
|
97
|
+
}, [isActive, favoriteItem, product, isPushed]);
|
|
76
98
|
|
|
77
99
|
const FavButton = useMemo(() => {
|
|
78
100
|
const View = (props: FavButtonProps) => (
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
1
|
+
import { withPzDefault } from '@akinon/next/middlewares';
|
|
3
2
|
import { NextMiddleware, NextResponse } from 'next/server';
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -15,7 +14,8 @@ export const config = {
|
|
|
15
14
|
'/((?!api|_next|[\\w-\\/*]+\\.\\w+).*)',
|
|
16
15
|
'/(.*sitemap\\.xml)',
|
|
17
16
|
'/(.+\\.)(html|htm|aspx|asp|php)',
|
|
18
|
-
'/(.*orders\\/checkout-with-token.*)'
|
|
17
|
+
'/(.*orders\\/checkout-with-token.*)',
|
|
18
|
+
'/(.*orders\\/post-checkout.*)'
|
|
19
19
|
]
|
|
20
20
|
};
|
|
21
21
|
|
|
@@ -33,47 +33,4 @@ const proxy: NextMiddleware = () =>
|
|
|
33
33
|
return NextResponse.next();
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
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;
|
|
36
|
+
export default withPzDefault(proxy);
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
const { LocaleUrlStrategy } = require('@akinon/next/localization');
|
|
2
|
-
const {
|
|
3
|
-
PREVIEW_PZ_SEGMENT,
|
|
4
|
-
resolvePreviewSegment
|
|
5
|
-
} = require('@akinon/next/utils/preview');
|
|
6
2
|
const { ROUTES } = require('@theme/routes');
|
|
7
3
|
|
|
8
4
|
const commerceUrl = encodeURI(process.env.SERVICE_BACKEND_URL ?? 'default');
|
|
@@ -68,19 +64,6 @@ module.exports = {
|
|
|
68
64
|
},
|
|
69
65
|
customNotFoundEnabled: false,
|
|
70
66
|
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
|
-
},
|
|
84
67
|
commerceRedirectionIgnoreList: ['/users/reset'],
|
|
85
68
|
csrf: {
|
|
86
69
|
httpOnly: true
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FAVOURITE_PRODUCT_IDS_RETRY_AFTER_MS,
|
|
3
|
+
isFavouriteProductIdsSuspended,
|
|
4
|
+
suspendFavouriteProductIds
|
|
5
|
+
} from '../favourite-product-ids-fallback';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'pz-favourite-product-ids-unavailable-until';
|
|
8
|
+
|
|
9
|
+
describe('favourite-product-ids-fallback', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
window.localStorage.clear();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('is not suspended by default', () => {
|
|
15
|
+
expect(isFavouriteProductIdsSuspended()).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('suspends after a 404 marker is written', () => {
|
|
19
|
+
suspendFavouriteProductIds();
|
|
20
|
+
|
|
21
|
+
expect(isFavouriteProductIdsSuspended()).toBe(true);
|
|
22
|
+
|
|
23
|
+
const until = Number(window.localStorage.getItem(STORAGE_KEY));
|
|
24
|
+
expect(until).toBeGreaterThan(Date.now());
|
|
25
|
+
expect(until).toBeLessThanOrEqual(
|
|
26
|
+
Date.now() + FAVOURITE_PRODUCT_IDS_RETRY_AFTER_MS
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('expires the suspension and clears the marker', () => {
|
|
31
|
+
window.localStorage.setItem(STORAGE_KEY, String(Date.now() - 1000));
|
|
32
|
+
|
|
33
|
+
expect(isFavouriteProductIdsSuspended()).toBe(false);
|
|
34
|
+
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('treats a corrupt marker as not suspended', () => {
|
|
38
|
+
window.localStorage.setItem(STORAGE_KEY, 'not-a-number');
|
|
39
|
+
|
|
40
|
+
expect(isFavouriteProductIdsSuspended()).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const STORAGE_KEY = 'pz-favourite-product-ids-unavailable-until';
|
|
2
|
+
|
|
3
|
+
// Not every brand's backend ships the favourite-product-ids endpoint at the
|
|
4
|
+
// same time, so a 404 suspends the new flow for this long before probing again.
|
|
5
|
+
export const FAVOURITE_PRODUCT_IDS_RETRY_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
const getStorage = (): Storage | null => {
|
|
8
|
+
try {
|
|
9
|
+
// The property access itself is what throws when storage is blocked, so it
|
|
10
|
+
// has to be inside the try rather than guarded by a typeof check.
|
|
11
|
+
return typeof window === 'undefined' ? null : window.localStorage ?? null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Fails open: when storage is unavailable the endpoint is probed again, which
|
|
18
|
+
// at worst costs one extra 404 per page view.
|
|
19
|
+
export const isFavouriteProductIdsSuspended = (): boolean => {
|
|
20
|
+
const storage = getStorage();
|
|
21
|
+
if (!storage) return false;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
25
|
+
if (!raw) return false;
|
|
26
|
+
|
|
27
|
+
const until = Number(raw);
|
|
28
|
+
if (!Number.isFinite(until) || Date.now() >= until) {
|
|
29
|
+
storage.removeItem(STORAGE_KEY);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const suspendFavouriteProductIds = (): void => {
|
|
40
|
+
const storage = getStorage();
|
|
41
|
+
if (!storage) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
storage.setItem(
|
|
45
|
+
STORAGE_KEY,
|
|
46
|
+
String(Date.now() + FAVOURITE_PRODUCT_IDS_RETRY_AFTER_MS)
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
// Fail open; see isFavouriteProductIdsSuspended.
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -11,6 +11,7 @@ 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';
|
|
14
15
|
import { useLocalization } from '@akinon/next/hooks';
|
|
15
16
|
import { Link, LoaderSpinner } from '@akinon/next/components';
|
|
16
17
|
import { ROUTES } from '@theme/routes';
|
|
@@ -62,6 +63,11 @@ export default function ListPage(props: ListPageProps) {
|
|
|
62
63
|
|
|
63
64
|
const { t } = useLocalization();
|
|
64
65
|
|
|
66
|
+
const productPks = useMemo(
|
|
67
|
+
() => data?.products?.map((product) => product.pk) ?? [],
|
|
68
|
+
[data?.products]
|
|
69
|
+
);
|
|
70
|
+
|
|
65
71
|
useEffect(() => {
|
|
66
72
|
dispatch(setFacets(data.facets));
|
|
67
73
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -105,23 +111,25 @@ export default function ListPage(props: ListPageProps) {
|
|
|
105
111
|
|
|
106
112
|
{data.products?.length === 0 && page > 1 && <LoaderSpinner />}
|
|
107
113
|
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
'
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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>
|
|
125
133
|
{data?.products?.length > 0 && (
|
|
126
134
|
<Pagination
|
|
127
135
|
total={data.pagination.total_count}
|
|
@@ -4,8 +4,7 @@ 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
|
-
|
|
8
|
-
import { Icon } from '@theme/components';
|
|
7
|
+
import { Icon, Input } from '@theme/components';
|
|
9
8
|
import Results from './results';
|
|
10
9
|
import { ROUTES } from '@theme/routes';
|
|
11
10
|
import { useLocalization, useRouter } from '@akinon/next/hooks';
|
|
@@ -42,6 +41,14 @@ export default function Search() {
|
|
|
42
41
|
};
|
|
43
42
|
}, [isSearchOpen, dispatch]);
|
|
44
43
|
|
|
44
|
+
const handleSearchTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
setSearchText(e.target.value);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleCloseSearch = () => {
|
|
49
|
+
dispatch(closeSearch());
|
|
50
|
+
};
|
|
51
|
+
|
|
45
52
|
return (
|
|
46
53
|
<>
|
|
47
54
|
<div
|
|
@@ -67,9 +74,9 @@ export default function Search() {
|
|
|
67
74
|
{t('common.search.results_for')}
|
|
68
75
|
</span>
|
|
69
76
|
<div className="flex items-center">
|
|
70
|
-
<
|
|
77
|
+
<Input
|
|
71
78
|
value={searchText}
|
|
72
|
-
onChange={
|
|
79
|
+
onChange={handleSearchTextChange}
|
|
73
80
|
onKeyDown={(e) => {
|
|
74
81
|
if (e.key === 'Enter' && searchText.trim() !== '') {
|
|
75
82
|
router.push(`${ROUTES.LIST}/?search_text=${searchText}`);
|
|
@@ -91,11 +98,12 @@ export default function Search() {
|
|
|
91
98
|
<Icon
|
|
92
99
|
name="close"
|
|
93
100
|
size={14}
|
|
94
|
-
onClick={
|
|
101
|
+
onClick={handleCloseSearch}
|
|
95
102
|
className="cursor-pointer"
|
|
96
103
|
/>
|
|
97
104
|
</div>
|
|
98
105
|
</div>
|
|
106
|
+
|
|
99
107
|
<Results searchText={searchText} />
|
|
100
108
|
</div>
|
|
101
109
|
</div>
|
|
@@ -36,53 +36,100 @@ export default function ProductInfoSlider({ product }: ProductSliderItem) {
|
|
|
36
36
|
carouselRef.current?.next();
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const handleThumbnailClick = (index) => {
|
|
39
|
+
const handleThumbnailClick = (index: number) => {
|
|
40
40
|
setActiveIndex(index);
|
|
41
41
|
carouselRef.current?.goToSlide(index);
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
return (
|
|
45
|
-
|
|
46
|
-
<div className="lg:
|
|
47
|
-
<div className="
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
<>
|
|
46
|
+
<div className="lg:grid lg:grid-cols-6">
|
|
47
|
+
<div className="lg:col-span-1">
|
|
48
|
+
<div className="flex flex-col items-center justify-center md:mr-[6px]">
|
|
49
|
+
<button
|
|
50
|
+
onClick={goToPrev}
|
|
51
|
+
className={twMerge(
|
|
52
|
+
'hidden justify-center p-2 mb-3 border border-gray-100 rounded-full cursor-pointer lg:block',
|
|
53
|
+
[activeIndex === 0 && 'cursor-not-allowed opacity-45']
|
|
54
|
+
)}
|
|
55
|
+
disabled={activeIndex === 0}
|
|
56
|
+
>
|
|
57
|
+
<Icon name="chevron-up" size={15} className="fill-[#000000]" />
|
|
58
|
+
</button>
|
|
59
|
+
<div className="hidden flex-col items-center overflow-scroll w-[80px] max-h-[620px] lg:block">
|
|
60
|
+
{product?.productimage_set?.map((item, index) => (
|
|
61
|
+
<Image
|
|
62
|
+
key={index}
|
|
63
|
+
src={item.image}
|
|
64
|
+
alt={`Thumbnail ${index}`}
|
|
65
|
+
width={80}
|
|
66
|
+
height={128}
|
|
67
|
+
aspectRatio={80 / 128}
|
|
68
|
+
className={twMerge('cursor-pointer', [
|
|
69
|
+
activeIndex === index && 'border-2 border-primary'
|
|
70
|
+
])}
|
|
71
|
+
onClick={() => handleThumbnailClick(index)}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
<button
|
|
76
|
+
onClick={goToNext}
|
|
77
|
+
className={twMerge(
|
|
78
|
+
'hidden justify-center p-2 mt-3 border border-gray-100 rounded-full cursor-pointer lg:block',
|
|
79
|
+
[
|
|
80
|
+
activeIndex === product.productimage_set.length - 1 &&
|
|
81
|
+
'cursor-not-allowed opacity-45'
|
|
82
|
+
]
|
|
83
|
+
)}
|
|
84
|
+
disabled={activeIndex === product.productimage_set.length - 1}
|
|
85
|
+
>
|
|
86
|
+
<Icon name="chevron-down" size={15} className="fill-[#000000]" />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="relative lg:col-span-5">
|
|
92
|
+
<FavButton className="absolute right-8 top-6 z-[20] sm:hidden" />
|
|
93
|
+
|
|
94
|
+
<PluginModule
|
|
95
|
+
component={Component.ProductImageSearchFeature}
|
|
96
|
+
props={{
|
|
97
|
+
product,
|
|
98
|
+
activeIndex,
|
|
99
|
+
showResetButton: true
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
<CarouselCore
|
|
104
|
+
responsive={{
|
|
105
|
+
all: {
|
|
106
|
+
breakpoint: { max: 5000, min: 0 },
|
|
107
|
+
items: 1
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
arrows={false}
|
|
111
|
+
swipeable={true}
|
|
112
|
+
ref={carouselRef}
|
|
113
|
+
afterChange={(previousSlide, { currentSlide }) => {
|
|
114
|
+
setActiveIndex(currentSlide);
|
|
115
|
+
}}
|
|
116
|
+
containerAspectRatio={{ mobile: 520 / 798, desktop: 484 / 726 }}
|
|
55
117
|
>
|
|
56
|
-
|
|
57
|
-
</button>
|
|
58
|
-
<div className="hidden flex-col items-center overflow-scroll w-[80px] max-h-[620px] lg:block">
|
|
59
|
-
{product?.productimage_set?.map((item, index) => (
|
|
118
|
+
{product?.productimage_set?.map((item, i) => (
|
|
60
119
|
<Image
|
|
61
|
-
key={
|
|
120
|
+
key={i}
|
|
62
121
|
src={item.image}
|
|
63
|
-
alt={
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
122
|
+
alt={product?.name || 'Product image'}
|
|
123
|
+
draggable={false}
|
|
124
|
+
aspectRatio={484 / 726}
|
|
125
|
+
sizes="(min-width: 425px) 512px,
|
|
126
|
+
(min-width: 601px) 576px,
|
|
127
|
+
(min-width: 768px) 336px,
|
|
128
|
+
(min-width: 1024px) 484px, 368px"
|
|
129
|
+
fill
|
|
70
130
|
/>
|
|
71
131
|
))}
|
|
72
|
-
</
|
|
73
|
-
<button
|
|
74
|
-
onClick={goToNext}
|
|
75
|
-
className={twMerge(
|
|
76
|
-
'hidden justify-center p-2 mt-3 border border-gray-100 rounded-full cursor-pointer lg:block',
|
|
77
|
-
[
|
|
78
|
-
activeIndex === product.productimage_set.length - 1 &&
|
|
79
|
-
'cursor-not-allowed opacity-45'
|
|
80
|
-
]
|
|
81
|
-
)}
|
|
82
|
-
disabled={activeIndex === product.productimage_set.length - 1}
|
|
83
|
-
>
|
|
84
|
-
<Icon name="chevron-down" size={15} className="fill-[#000000]" />
|
|
85
|
-
</button>
|
|
132
|
+
</CarouselCore>
|
|
86
133
|
</div>
|
|
87
134
|
</div>
|
|
88
135
|
|
|
@@ -140,6 +187,6 @@ export default function ProductInfoSlider({ product }: ProductSliderItem) {
|
|
|
140
187
|
))}
|
|
141
188
|
</CarouselCore>
|
|
142
189
|
</div>
|
|
143
|
-
|
|
190
|
+
</>
|
|
144
191
|
);
|
|
145
192
|
}
|
|
@@ -22,7 +22,7 @@ export const ProductItem = (props: Props) => {
|
|
|
22
22
|
// TODO: Static image will change (TR)
|
|
23
23
|
const { product, width, height, index } = props;
|
|
24
24
|
const [viewed, setViewed] = useState(false);
|
|
25
|
-
const { FavButton } = useFavButton(product.pk);
|
|
25
|
+
const { FavButton } = useFavButton(product.pk, product);
|
|
26
26
|
const { ref, inView } = useInView();
|
|
27
27
|
|
|
28
28
|
const image_url = product.productimage_set[0]?.image;
|
package/commands/plugins.ts
CHANGED
package/dist/commands/plugins.js
CHANGED
|
@@ -183,6 +183,10 @@ exports.default = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
|
183
183
|
name: 'Tamara Payment Extension',
|
|
184
184
|
value: 'pz-tamara-extension'
|
|
185
185
|
},
|
|
186
|
+
{
|
|
187
|
+
name: 'Similar Products',
|
|
188
|
+
value: 'pz-similar-products'
|
|
189
|
+
},
|
|
186
190
|
{
|
|
187
191
|
name: 'Hepsipay',
|
|
188
192
|
value: 'pz-hepsipay'
|