@akinon/projectzero 2.0.152-rc.3 → 2.0.152
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 +1 -21
- package/app-template/.env.example +0 -1
- package/app-template/AGENTS.md +0 -8
- package/app-template/CHANGELOG.md +32 -184
- package/app-template/README.md +1 -25
- package/app-template/docs/plugins.md +0 -6
- package/app-template/next-env.d.ts +1 -1
- package/app-template/next.config.mjs +1 -7
- package/app-template/package.json +41 -41
- package/app-template/public/locales/en/common.json +0 -5
- package/app-template/public/locales/en/product.json +0 -6
- package/app-template/public/locales/tr/common.json +0 -5
- package/app-template/public/locales/tr/product.json +0 -6
- package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +0 -14
- package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +0 -21
- package/app-template/src/app/[pz]/product/[pk]/page.tsx +1 -7
- 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 +1 -2
- package/app-template/src/types/widgets.ts +0 -58
- package/app-template/src/views/category/category-info.tsx +17 -31
- package/app-template/src/views/header/search/index.tsx +5 -13
- package/app-template/src/views/product/index.ts +0 -1
- package/app-template/src/views/product/product-info.tsx +0 -14
- package/app-template/src/views/product/slider.tsx +38 -85
- package/app-template/src/views/product-item/index.tsx +1 -6
- package/app-template/tsconfig.json +0 -3
- package/commands/plugins.ts +0 -8
- package/dist/commands/plugins.js +0 -8
- package/package.json +1 -1
- package/app-template/src/__tests__/data-source-landing-page.test.tsx +0 -132
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +0 -5
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +0 -62
- 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/__tests__/sanitize.test.ts +0 -127
- package/app-template/src/utils/favourite-product-ids-fallback.ts +0 -51
- package/app-template/src/utils/sanitize.ts +0 -167
- package/app-template/src/views/data-source-page/__tests__/header.test.tsx +0 -173
- package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +0 -83
- package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +0 -143
- package/app-template/src/views/data-source-page/banner.tsx +0 -45
- package/app-template/src/views/data-source-page/footer.tsx +0 -27
- package/app-template/src/views/data-source-page/header.tsx +0 -140
- package/app-template/src/views/data-source-page/layout.tsx +0 -45
- package/app-template/src/views/data-source-page/products.tsx +0 -55
- package/app-template/src/views/data-source-page/stories.tsx +0 -70
- package/app-template/src/views/product/__tests__/product-offers.test.tsx +0 -217
- package/app-template/src/views/product/product-offers.tsx +0 -125
- package/app-template/src/views/product/rate-review.tsx +0 -21
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
-
import { getDataSourcePageData, getWidgetData } from '@akinon/next/data/server';
|
|
3
|
-
import Page, {
|
|
4
|
-
generateMetadata
|
|
5
|
-
} from '../app/[pz]/data-source-landing-page/[pk]/page';
|
|
6
|
-
|
|
7
|
-
jest.mock('@akinon/next/data/server', () => ({
|
|
8
|
-
getDataSourcePageData: jest.fn(),
|
|
9
|
-
getWidgetData: jest.fn()
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
jest.mock('@akinon/next/hocs/server', () => ({
|
|
13
|
-
withSegmentDefaults: (component) => component
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
jest.mock('@akinon/next/utils/pz-segments', () => ({
|
|
17
|
-
parsePzParams: () => ({ locale: 'tr' })
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
jest.mock('next/navigation', () => ({
|
|
21
|
-
notFound: jest.fn(() => {
|
|
22
|
-
throw new Error('NEXT_NOT_FOUND');
|
|
23
|
-
})
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
// `@theme/*` modules resolve to these paths; jest keys mocks by resolved path.
|
|
27
|
-
jest.mock('../settings', () => ({ __esModule: true, default: {} }));
|
|
28
|
-
jest.mock('../views/data-source-page/layout', () => ({
|
|
29
|
-
__esModule: true,
|
|
30
|
-
default: ({ data }) => <div data-testid="store-page">{data.pk}</div>
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
const mockGetDataSourcePageData = getDataSourcePageData as jest.Mock;
|
|
34
|
-
const mockGetWidgetData = getWidgetData as jest.Mock;
|
|
35
|
-
|
|
36
|
-
const page = {
|
|
37
|
-
pk: 3,
|
|
38
|
-
data_source: {
|
|
39
|
-
pk: 17,
|
|
40
|
-
name: 'ayse-ticaret',
|
|
41
|
-
slug: 'ayse-ticaret',
|
|
42
|
-
title: 'Ayşe Ticaret'
|
|
43
|
-
},
|
|
44
|
-
widget: 'ayse-ticaret-0',
|
|
45
|
-
product_collection_widget: 'ayse-ticaret-collection',
|
|
46
|
-
is_active: true
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const renderPage = async (pk: string) =>
|
|
50
|
-
render(await Page({ params: { pk }, searchParams: {} }));
|
|
51
|
-
|
|
52
|
-
// Route params arrive as strings even though the page types them as numbers.
|
|
53
|
-
const metadataFor = (pk: string) =>
|
|
54
|
-
generateMetadata({
|
|
55
|
-
params: Promise.resolve({ pk })
|
|
56
|
-
} as unknown as Parameters<typeof generateMetadata>[0]);
|
|
57
|
-
|
|
58
|
-
describe('data source landing page route', () => {
|
|
59
|
-
beforeEach(() => {
|
|
60
|
-
jest.clearAllMocks();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('renders the store page for an active landing page', async () => {
|
|
64
|
-
mockGetDataSourcePageData.mockResolvedValue(page);
|
|
65
|
-
|
|
66
|
-
await renderPage('3');
|
|
67
|
-
|
|
68
|
-
expect(mockGetDataSourcePageData).toHaveBeenCalledWith({ pk: 3 });
|
|
69
|
-
expect(screen.getByTestId('store-page')).toHaveTextContent('3');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it.each([
|
|
73
|
-
['a missing page', null],
|
|
74
|
-
['a failed request', undefined],
|
|
75
|
-
['an inactive page', { ...page, is_active: false }]
|
|
76
|
-
])('responds with not found for %s', async (_, data) => {
|
|
77
|
-
mockGetDataSourcePageData.mockResolvedValue(data);
|
|
78
|
-
|
|
79
|
-
await expect(renderPage('3')).rejects.toThrow('NEXT_NOT_FOUND');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('responds with not found for an invalid pk without fetching', async () => {
|
|
83
|
-
await expect(renderPage('03')).rejects.toThrow('NEXT_NOT_FOUND');
|
|
84
|
-
await expect(renderPage('abc')).rejects.toThrow('NEXT_NOT_FOUND');
|
|
85
|
-
expect(mockGetDataSourcePageData).not.toHaveBeenCalled();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('builds the metadata from the store and its page widget', async () => {
|
|
89
|
-
mockGetDataSourcePageData.mockResolvedValue(page);
|
|
90
|
-
mockGetWidgetData.mockResolvedValue({
|
|
91
|
-
attributes: {
|
|
92
|
-
footer: {
|
|
93
|
-
value: { description: '<p>Kargo & İade</p><p>Hızlı teslimat</p>' }
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
await expect(metadataFor('3')).resolves.toEqual({
|
|
99
|
-
title: 'Ayşe Ticaret',
|
|
100
|
-
description: 'Kargo & İade Hızlı teslimat'
|
|
101
|
-
});
|
|
102
|
-
expect(mockGetDataSourcePageData).toHaveBeenCalledWith({
|
|
103
|
-
pk: 3,
|
|
104
|
-
locale: 'tr'
|
|
105
|
-
});
|
|
106
|
-
expect(mockGetWidgetData).toHaveBeenCalledWith({
|
|
107
|
-
slug: 'ayse-ticaret-0',
|
|
108
|
-
locale: 'tr'
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
mockGetWidgetData.mockResolvedValue({
|
|
112
|
-
attributes: { banner: { value: { subtitle: 'Yeni sezon' } } }
|
|
113
|
-
});
|
|
114
|
-
mockGetDataSourcePageData.mockResolvedValue({
|
|
115
|
-
...page,
|
|
116
|
-
data_source: { ...page.data_source, title: '' }
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
await expect(metadataFor('3')).resolves.toEqual({
|
|
120
|
-
title: 'ayse-ticaret',
|
|
121
|
-
description: 'Yeni sezon'
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('returns empty metadata when there is no page', async () => {
|
|
126
|
-
mockGetDataSourcePageData.mockResolvedValue(null);
|
|
127
|
-
|
|
128
|
-
await expect(metadataFor('3')).resolves.toEqual({});
|
|
129
|
-
await expect(metadataFor('x')).resolves.toEqual({});
|
|
130
|
-
expect(mockGetWidgetData).not.toHaveBeenCalled();
|
|
131
|
-
});
|
|
132
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { getDataSourcePageData, getWidgetData } from '@akinon/next/data/server';
|
|
2
|
-
import { withSegmentDefaults } from '@akinon/next/hocs/server';
|
|
3
|
-
import {
|
|
4
|
-
AsyncPageProps,
|
|
5
|
-
Metadata,
|
|
6
|
-
ResolvedPageProps
|
|
7
|
-
} from '@akinon/next/types';
|
|
8
|
-
import { parsePzParams } from '@akinon/next/utils/pz-segments';
|
|
9
|
-
import settings from '@theme/settings';
|
|
10
|
-
import { DataSourcePageData } from '@theme/types';
|
|
11
|
-
import { toPlainText } from '@theme/utils/sanitize';
|
|
12
|
-
import DataSourcePageLayout from '@theme/views/data-source-page/layout';
|
|
13
|
-
import { notFound } from 'next/navigation';
|
|
14
|
-
|
|
15
|
-
const isValidPk = (pk: unknown) => /^[1-9]\d*$/.test(String(pk));
|
|
16
|
-
|
|
17
|
-
export async function generateMetadata(
|
|
18
|
-
props: AsyncPageProps<{ pk: number }>
|
|
19
|
-
): Promise<Metadata> {
|
|
20
|
-
const params = await props.params;
|
|
21
|
-
|
|
22
|
-
if (!isValidPk(params.pk)) {
|
|
23
|
-
return {};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const { locale } = parsePzParams(params, settings);
|
|
27
|
-
const data = await getDataSourcePageData({ pk: Number(params.pk), locale });
|
|
28
|
-
const title = data?.data_source?.title || data?.data_source?.name;
|
|
29
|
-
|
|
30
|
-
if (!title) {
|
|
31
|
-
return {};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const pageData = await getWidgetData<DataSourcePageData>({
|
|
35
|
-
slug: data.widget,
|
|
36
|
-
locale
|
|
37
|
-
});
|
|
38
|
-
const description = (
|
|
39
|
-
pageData?.attributes?.banner?.value?.subtitle ||
|
|
40
|
-
toPlainText(pageData?.attributes?.footer?.value?.description)
|
|
41
|
-
).slice(0, 160);
|
|
42
|
-
|
|
43
|
-
return description ? { title, description } : { title };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function Page({ params }: ResolvedPageProps<{ pk: number }>) {
|
|
47
|
-
if (!isValidPk(params.pk)) {
|
|
48
|
-
notFound();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const data = await getDataSourcePageData({ pk: Number(params.pk) });
|
|
52
|
-
|
|
53
|
-
if (!data?.pk || data.is_active === false) {
|
|
54
|
-
notFound();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return <DataSourcePageLayout data={data} />;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export default withSegmentDefaults(Page, {
|
|
61
|
-
segmentType: 'page'
|
|
62
|
-
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useSentryUncaughtErrors } from '@akinon/next/hooks';
|
|
4
|
-
|
|
5
|
-
export default function GlobalError({
|
|
6
|
-
error,
|
|
7
|
-
reset
|
|
8
|
-
}: {
|
|
9
|
-
error: Error & { digest?: string };
|
|
10
|
-
reset: () => void;
|
|
11
|
-
}) {
|
|
12
|
-
useSentryUncaughtErrors(error);
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<html>
|
|
16
|
-
<body>
|
|
17
|
-
<h2>Something went wrong!</h2>
|
|
18
|
-
<button onClick={() => reset()}>Try again</button>
|
|
19
|
-
</body>
|
|
20
|
-
</html>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
ReactNode,
|
|
6
|
-
useContext,
|
|
7
|
-
useEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
useState
|
|
10
|
-
} from 'react';
|
|
11
|
-
import { useSession } from 'next-auth/react';
|
|
12
|
-
import { useGetFavouriteProductIdsQuery } from '@akinon/next/data/client/wishlist';
|
|
13
|
-
import {
|
|
14
|
-
isFavouriteProductIdsSuspended,
|
|
15
|
-
suspendFavouriteProductIds
|
|
16
|
-
} from '@theme/utils/favourite-product-ids-fallback';
|
|
17
|
-
|
|
18
|
-
interface FavouriteProductIdsContextValue {
|
|
19
|
-
// False while the batch flow is unavailable (endpoint 404'd or errored), so
|
|
20
|
-
// consumers can fall back to the legacy favourites-list request.
|
|
21
|
-
isActive: boolean;
|
|
22
|
-
getFavouriteId: (productPk: number) => number | undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const FavouriteProductIdsContext =
|
|
26
|
-
createContext<FavouriteProductIdsContextValue | null>(null);
|
|
27
|
-
|
|
28
|
-
export const useFavouriteProductIds = () =>
|
|
29
|
-
useContext(FavouriteProductIdsContext);
|
|
30
|
-
|
|
31
|
-
type Phase = 'init' | 'batch' | 'fallback';
|
|
32
|
-
|
|
33
|
-
interface FavouriteProductIdsProviderProps {
|
|
34
|
-
productIds: Array<number | string>;
|
|
35
|
-
children: ReactNode;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function FavouriteProductIdsProvider({
|
|
39
|
-
productIds,
|
|
40
|
-
children
|
|
41
|
-
}: FavouriteProductIdsProviderProps) {
|
|
42
|
-
const { status } = useSession();
|
|
43
|
-
|
|
44
|
-
// Storage can only be read on the client, so the first render stays neutral
|
|
45
|
-
// to keep SSR and hydration output identical.
|
|
46
|
-
const [phase, setPhase] = useState<Phase>('init');
|
|
47
|
-
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
setPhase(isFavouriteProductIdsSuspended() ? 'fallback' : 'batch');
|
|
50
|
-
}, []);
|
|
51
|
-
|
|
52
|
-
const sortedProductIds = useMemo(() => {
|
|
53
|
-
const unique = Array.from(
|
|
54
|
-
new Set(productIds.map(Number).filter((id) => Number.isFinite(id)))
|
|
55
|
-
);
|
|
56
|
-
// Stable order keeps the RTK Query cache key deterministic for the page.
|
|
57
|
-
return unique.sort((a, b) => a - b);
|
|
58
|
-
}, [productIds]);
|
|
59
|
-
|
|
60
|
-
const { data, error } = useGetFavouriteProductIdsQuery(
|
|
61
|
-
{ productIds: sortedProductIds },
|
|
62
|
-
{
|
|
63
|
-
skip:
|
|
64
|
-
status !== 'authenticated' ||
|
|
65
|
-
phase !== 'batch' ||
|
|
66
|
-
sortedProductIds.length === 0
|
|
67
|
-
}
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (!error) return;
|
|
72
|
-
|
|
73
|
-
if ('status' in error && error.status === 404) {
|
|
74
|
-
suspendFavouriteProductIds();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
setPhase('fallback');
|
|
78
|
-
}, [error]);
|
|
79
|
-
|
|
80
|
-
const favouriteIdByProductPk = useMemo(() => {
|
|
81
|
-
const map = new Map<number, number>();
|
|
82
|
-
|
|
83
|
-
data?.favourite_products?.forEach((item) => {
|
|
84
|
-
map.set(Number(item.product_id), item.favourite_id);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
return map;
|
|
88
|
-
}, [data]);
|
|
89
|
-
|
|
90
|
-
const value = useMemo<FavouriteProductIdsContextValue>(
|
|
91
|
-
() => ({
|
|
92
|
-
isActive: phase !== 'fallback',
|
|
93
|
-
getFavouriteId: (productPk: number) =>
|
|
94
|
-
favouriteIdByProductPk.get(Number(productPk))
|
|
95
|
-
}),
|
|
96
|
-
[phase, favouriteIdByProductPk]
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
return (
|
|
100
|
-
<FavouriteProductIdsContext.Provider value={value}>
|
|
101
|
-
{children}
|
|
102
|
-
</FavouriteProductIdsContext.Provider>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getSafeLinkProps,
|
|
3
|
-
toPlainText,
|
|
4
|
-
toSafeColor,
|
|
5
|
-
toSafeHtml
|
|
6
|
-
} from '../sanitize';
|
|
7
|
-
|
|
8
|
-
describe('toSafeHtml', () => {
|
|
9
|
-
it('returns an empty string for empty values', () => {
|
|
10
|
-
expect(toSafeHtml()).toBe('');
|
|
11
|
-
expect(toSafeHtml('')).toBe('');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('keeps plain text and existing character references', () => {
|
|
15
|
-
expect(toSafeHtml('Kargo & İade garantisi ş')).toBe(
|
|
16
|
-
'Kargo & İade garantisi ş'
|
|
17
|
-
);
|
|
18
|
-
expect(toSafeHtml(`Tom & "Jerry's" <3`)).toBe(
|
|
19
|
-
'Tom & "Jerry's" <3'
|
|
20
|
-
);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('keeps allowed tags and drops their attributes', () => {
|
|
24
|
-
expect(
|
|
25
|
-
toSafeHtml(
|
|
26
|
-
'<P style="text-align:center">Hızlı <strong>kargo</strong></P><br/><ul><li>a</li></ul><h3 class="x">Hakkımızda</h3>'
|
|
27
|
-
)
|
|
28
|
-
).toBe(
|
|
29
|
-
'<p>Hızlı <strong>kargo</strong></p><br><ul><li>a</li></ul><h3>Hakkımızda</h3>'
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('removes other tags but keeps their text', () => {
|
|
34
|
-
expect(
|
|
35
|
-
toSafeHtml(
|
|
36
|
-
'<span style="color:red">kırmızı</span> <a href="https://x.com">Instagram</a>'
|
|
37
|
-
)
|
|
38
|
-
).toBe('kırmızı Instagram');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('removes scripts, styles and event handlers', () => {
|
|
42
|
-
expect(toSafeHtml('<script>alert(1)</script>ok<style>p{}</style>')).toBe(
|
|
43
|
-
'ok'
|
|
44
|
-
);
|
|
45
|
-
expect(toSafeHtml('<img src=x onerror=alert(1)>')).toBe('');
|
|
46
|
-
expect(toSafeHtml('<p onclick="alert(1)">x</p>')).toBe('<p>x</p>');
|
|
47
|
-
expect(toSafeHtml('<a href="javascript:alert(1)">x</a>')).toBe('x');
|
|
48
|
-
expect(toSafeHtml('<scr<script></script>ipt>alert(1)</script>')).toBe(
|
|
49
|
-
'alert(1)'
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('never turns malformed tags into markup', () => {
|
|
54
|
-
expect(toSafeHtml('<p/onclick=alert(1)>x')).toBe(
|
|
55
|
-
'<p/onclick=alert(1)>x'
|
|
56
|
-
);
|
|
57
|
-
expect(toSafeHtml('<p title="><script>">x</p>')).not.toMatch(
|
|
58
|
-
/<script|onclick|onerror/i
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('balances tags so nothing leaks out of the container', () => {
|
|
63
|
-
expect(toSafeHtml('<b>Kargo bedava')).toBe('<b>Kargo bedava</b>');
|
|
64
|
-
expect(toSafeHtml('</b>x')).toBe('x');
|
|
65
|
-
expect(toSafeHtml('<b/>x')).toBe('x');
|
|
66
|
-
expect(toSafeHtml('<b><i>x</b>y</i>')).toBe('<b><i>x</i></b>y');
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('toPlainText', () => {
|
|
71
|
-
it('strips tags, decodes common entities and collapses whitespace', () => {
|
|
72
|
-
expect(
|
|
73
|
-
toPlainText(
|
|
74
|
-
'<p>Kargo & İade</p>\n<script>x</script><p>Hızlı teslimat</p>'
|
|
75
|
-
)
|
|
76
|
-
).toBe('Kargo & İade Hızlı teslimat');
|
|
77
|
-
expect(toPlainText()).toBe('');
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('toSafeColor', () => {
|
|
82
|
-
it('accepts plain colors', () => {
|
|
83
|
-
expect(toSafeColor('#7b9d76', '#000')).toBe('#7b9d76');
|
|
84
|
-
expect(toSafeColor(' rgb(10, 20, 30) ', '#000')).toBe('rgb(10, 20, 30)');
|
|
85
|
-
expect(toSafeColor('rgba(0 0 0 / 50%)', '#000')).toBe('rgba(0 0 0 / 50%)');
|
|
86
|
-
expect(toSafeColor('white', '#000')).toBe('white');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('falls back for anything that could add declarations', () => {
|
|
90
|
-
expect(toSafeColor('red;position:fixed;inset:0', '#000')).toBe('#000');
|
|
91
|
-
expect(toSafeColor('url(https://tracker.example/p.gif)', '#000')).toBe(
|
|
92
|
-
'#000'
|
|
93
|
-
);
|
|
94
|
-
expect(toSafeColor(undefined, '#fff')).toBe('#fff');
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('getSafeLinkProps', () => {
|
|
99
|
-
const siteUrl = 'https://shop.example.com';
|
|
100
|
-
|
|
101
|
-
it('keeps site-relative paths and same-site urls', () => {
|
|
102
|
-
expect(getSafeLinkProps('/list/?seller_ids=17', siteUrl)).toEqual({
|
|
103
|
-
href: '/list/?seller_ids=17'
|
|
104
|
-
});
|
|
105
|
-
expect(getSafeLinkProps('https://shop.example.com/x/', siteUrl)).toEqual({
|
|
106
|
-
href: 'https://shop.example.com/x/'
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('opens other http(s) urls in a new tab without passing link equity', () => {
|
|
111
|
-
expect(getSafeLinkProps('https://instagram.com/ayse', siteUrl)).toEqual({
|
|
112
|
-
href: 'https://instagram.com/ayse',
|
|
113
|
-
target: '_blank',
|
|
114
|
-
rel: 'nofollow ugc noopener noreferrer'
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('drops empty, protocol-relative and non-http links', () => {
|
|
119
|
-
expect(getSafeLinkProps(undefined, siteUrl)).toBeNull();
|
|
120
|
-
expect(getSafeLinkProps(' ', siteUrl)).toBeNull();
|
|
121
|
-
expect(getSafeLinkProps('//evil.example', siteUrl)).toBeNull();
|
|
122
|
-
expect(getSafeLinkProps('/\\evil.example', siteUrl)).toBeNull();
|
|
123
|
-
expect(getSafeLinkProps('javascript:alert(1)', siteUrl)).toBeNull();
|
|
124
|
-
expect(getSafeLinkProps('data:text/html,x', siteUrl)).toBeNull();
|
|
125
|
-
expect(getSafeLinkProps('list/', siteUrl)).toBeNull();
|
|
126
|
-
});
|
|
127
|
-
});
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
};
|