@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.
- package/CHANGELOG.md +6 -4
- package/app-template/.env.example +10 -1
- package/app-template/AGENTS.md +0 -8
- package/app-template/CHANGELOG.md +38 -81
- package/app-template/README.md +1 -25
- package/app-template/next.config.mjs +1 -7
- package/app-template/package.json +41 -41
- package/app-template/src/app/[pz]/layout.tsx +20 -0
- package/app-template/src/app/api/preview/link/route.ts +115 -0
- package/app-template/src/app/api/preview/route.ts +96 -0
- package/app-template/src/hooks/index.ts +0 -2
- package/app-template/src/hooks/use-fav-button.tsx +13 -35
- package/app-template/src/plugins.js +0 -1
- package/app-template/src/proxy.ts +47 -4
- package/app-template/src/settings.js +17 -0
- package/app-template/src/utils/__tests__/preview-contract.test.ts +223 -0
- package/app-template/src/views/category/category-info.tsx +17 -25
- package/app-template/src/views/header/search/index.tsx +5 -13
- package/app-template/src/views/product/slider.tsx +38 -85
- package/app-template/src/views/product-item/index.tsx +1 -1
- package/app-template/tailwind.config.js +2 -0
- package/commands/plugins.ts +0 -4
- package/dist/commands/plugins.js +0 -4
- package/package.json +1 -1
- package/app-template/src/app/api/client-env/route.ts +0 -5
- package/app-template/src/app/global-error.tsx +0 -22
- package/app-template/src/hooks/favourite-product-ids-context.tsx +0 -104
- package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +0 -42
- 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'
|
|
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
|
|
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 (
|
|
68
|
-
await removeFavorite(
|
|
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
|
-
}, [
|
|
63
|
+
}, [favoriteItem, productPk, addFavorite, removeFavorite, success, showError, t]);
|
|
79
64
|
|
|
80
65
|
useEffect(() => {
|
|
81
|
-
if (
|
|
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:
|
|
90
|
-
name:
|
|
91
|
-
price:
|
|
92
|
-
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) => (
|
|
@@ -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
|
-
|
|
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
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
70
|
+
<input
|
|
78
71
|
value={searchText}
|
|
79
|
-
onChange={
|
|
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={
|
|
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>
|