@akinon/projectzero 2.0.152-rc.0 → 2.0.152-rc.2
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 +12 -0
- package/app-template/CHANGELOG.md +69 -0
- package/app-template/docs/plugins.md +6 -0
- package/app-template/next-env.d.ts +1 -1
- package/app-template/package.json +29 -29
- package/app-template/public/locales/en/common.json +5 -0
- package/app-template/public/locales/en/product.json +6 -0
- package/app-template/public/locales/tr/common.json +5 -0
- package/app-template/public/locales/tr/product.json +6 -0
- package/app-template/src/__tests__/data-source-landing-page.test.tsx +132 -0
- package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +14 -0
- package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +21 -0
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +5 -0
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +62 -0
- package/app-template/src/app/[pz]/product/[pk]/page.tsx +7 -1
- package/app-template/src/types/widgets.ts +58 -0
- package/app-template/src/utils/__tests__/sanitize.test.ts +127 -0
- package/app-template/src/utils/sanitize.ts +167 -0
- package/app-template/src/views/category/category-info.tsx +15 -9
- package/app-template/src/views/data-source-page/__tests__/header.test.tsx +173 -0
- package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +83 -0
- package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +143 -0
- package/app-template/src/views/data-source-page/banner.tsx +45 -0
- package/app-template/src/views/data-source-page/footer.tsx +27 -0
- package/app-template/src/views/data-source-page/header.tsx +140 -0
- package/app-template/src/views/data-source-page/layout.tsx +45 -0
- package/app-template/src/views/data-source-page/products.tsx +55 -0
- package/app-template/src/views/data-source-page/stories.tsx +70 -0
- package/app-template/src/views/product/__tests__/product-offers.test.tsx +217 -0
- package/app-template/src/views/product/index.ts +1 -0
- package/app-template/src/views/product/product-info.tsx +14 -0
- package/app-template/src/views/product/product-offers.tsx +125 -0
- package/app-template/src/views/product/rate-review.tsx +21 -0
- package/app-template/src/views/product-item/index.tsx +5 -0
- package/app-template/tsconfig.json +3 -0
- package/commands/plugins.ts +4 -0
- package/dist/commands/plugins.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Helpers for rendering third-party (e.g. seller-entered) content.
|
|
2
|
+
|
|
3
|
+
const ALLOWED_TAGS = [
|
|
4
|
+
'b',
|
|
5
|
+
'strong',
|
|
6
|
+
'i',
|
|
7
|
+
'em',
|
|
8
|
+
'u',
|
|
9
|
+
'br',
|
|
10
|
+
'p',
|
|
11
|
+
'ul',
|
|
12
|
+
'ol',
|
|
13
|
+
'li',
|
|
14
|
+
'h2',
|
|
15
|
+
'h3',
|
|
16
|
+
'h4'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const VOID_TAGS = ['br'];
|
|
20
|
+
|
|
21
|
+
const ESCAPED_CHARACTERS: Record<string, string> = {
|
|
22
|
+
'<': '<',
|
|
23
|
+
'>': '>',
|
|
24
|
+
'"': '"',
|
|
25
|
+
"'": '''
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DROPPED_ELEMENTS = /<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
|
|
29
|
+
|
|
30
|
+
// `&` is escaped only when it does not already start a character reference,
|
|
31
|
+
// so entities written by an editor (&, , ş) keep rendering.
|
|
32
|
+
const BARE_AMPERSAND = /&(?!(?:#\d{1,7}|#x[\da-f]{1,6}|[a-z][a-z\d]{1,31});)/gi;
|
|
33
|
+
|
|
34
|
+
const ESCAPED_TAG = /<(\/?)([a-z][a-z\d]*)(?:\s[\s\S]*?)?(\/?)>/gi;
|
|
35
|
+
|
|
36
|
+
// Everything is escaped first; then only the allowed tags are turned back into
|
|
37
|
+
// markup, without any attribute, and balanced so nothing leaks past the
|
|
38
|
+
// container. Other tags are removed and their text is kept.
|
|
39
|
+
export const toSafeHtml = (value?: string) => {
|
|
40
|
+
if (!value) {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const openTags: string[] = [];
|
|
45
|
+
const escaped = value
|
|
46
|
+
.replace(DROPPED_ELEMENTS, '')
|
|
47
|
+
.replace(BARE_AMPERSAND, '&')
|
|
48
|
+
.replace(/[<>"']/g, (character) => ESCAPED_CHARACTERS[character]);
|
|
49
|
+
|
|
50
|
+
const html = escaped.replace(
|
|
51
|
+
ESCAPED_TAG,
|
|
52
|
+
(_match, closing: string, name: string, selfClosing: string) => {
|
|
53
|
+
const tag = name.toLowerCase();
|
|
54
|
+
|
|
55
|
+
if (!ALLOWED_TAGS.includes(tag)) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (VOID_TAGS.includes(tag)) {
|
|
60
|
+
return closing ? '' : `<${tag}>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (selfClosing) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!closing) {
|
|
68
|
+
openTags.push(tag);
|
|
69
|
+
return `<${tag}>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const index = openTags.lastIndexOf(tag);
|
|
73
|
+
|
|
74
|
+
if (index === -1) {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return openTags
|
|
79
|
+
.splice(index)
|
|
80
|
+
.reverse()
|
|
81
|
+
.map((openTag) => `</${openTag}>`)
|
|
82
|
+
.join('');
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
html +
|
|
88
|
+
openTags
|
|
89
|
+
.reverse()
|
|
90
|
+
.map((openTag) => `</${openTag}>`)
|
|
91
|
+
.join('')
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const TEXT_ENTITIES: Record<string, string> = {
|
|
96
|
+
' ': ' ',
|
|
97
|
+
'&': '&',
|
|
98
|
+
'<': '<',
|
|
99
|
+
'>': '>',
|
|
100
|
+
'"': '"',
|
|
101
|
+
''': "'"
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const toPlainText = (value?: string) =>
|
|
105
|
+
(value ?? '')
|
|
106
|
+
.replace(DROPPED_ELEMENTS, ' ')
|
|
107
|
+
.replace(/<[^>]*>/g, ' ')
|
|
108
|
+
.replace(
|
|
109
|
+
/&(?:nbsp|amp|lt|gt|quot|#39);/gi,
|
|
110
|
+
(entity) => TEXT_ENTITIES[entity.toLowerCase()]
|
|
111
|
+
)
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
|
|
115
|
+
const SAFE_COLOR =
|
|
116
|
+
/^(?:#[\da-f]{3,8}|(?:rgb|hsl)a?\(\s*[\d.%]+(?:\s*[,/\s]\s*[\d.%]+){2,3}\s*\)|[a-z]{3,20})$/i;
|
|
117
|
+
|
|
118
|
+
// Inline styles are not escaped by React beyond HTML entities, so a value
|
|
119
|
+
// like `red;position:fixed` would add declarations; only plain colors pass.
|
|
120
|
+
export const toSafeColor = (value: string | undefined, fallback: string) => {
|
|
121
|
+
const color = value?.trim();
|
|
122
|
+
|
|
123
|
+
return color && SAFE_COLOR.test(color) ? color : fallback;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const EXTERNAL_LINK_REL = 'nofollow ugc noopener noreferrer';
|
|
127
|
+
|
|
128
|
+
const getHost = (url?: string) => {
|
|
129
|
+
try {
|
|
130
|
+
return new URL(url).host;
|
|
131
|
+
} catch {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Site-relative paths and same-site urls are kept as is; other http(s) urls
|
|
137
|
+
// open in a new tab without passing link equity; anything else is dropped.
|
|
138
|
+
export const getSafeLinkProps = (
|
|
139
|
+
href?: string,
|
|
140
|
+
siteUrl: string = process.env.NEXT_PUBLIC_URL
|
|
141
|
+
): { href: string; target?: string; rel?: string } | null => {
|
|
142
|
+
const value = href?.trim();
|
|
143
|
+
|
|
144
|
+
if (!value) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (/^\/(?![/\\])/.test(value)) {
|
|
149
|
+
return { href: value };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!/^https?:\/\//i.test(value)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const host = getHost(value);
|
|
157
|
+
|
|
158
|
+
if (!host) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (host === getHost(siteUrl)) {
|
|
163
|
+
return { href: value };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { href: value, target: '_blank', rel: EXTERNAL_LINK_REL };
|
|
167
|
+
};
|
|
@@ -7,6 +7,7 @@ import { CategoryHeader } from './category-header';
|
|
|
7
7
|
import { Filters } from './filters';
|
|
8
8
|
import { Pagination } from '@theme/components';
|
|
9
9
|
import { ProductItem } from '@theme/views/product-item';
|
|
10
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
10
11
|
import { GetCategoryResponse } from '@akinon/next/types';
|
|
11
12
|
import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
|
|
12
13
|
import { setFacets, setMenuOpen } from '@theme/redux/reducers/category';
|
|
@@ -119,15 +120,20 @@ export default function ListPage(props: ListPageProps) {
|
|
|
119
120
|
'lg:grid-cols-3': layoutSize === 3
|
|
120
121
|
})}
|
|
121
122
|
>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
<PluginModule
|
|
124
|
+
component={Component.RateReviewPlpProvider}
|
|
125
|
+
props={{ products: data?.products ?? [] }}
|
|
126
|
+
>
|
|
127
|
+
{data?.products?.map((product, index) => (
|
|
128
|
+
<ProductItem
|
|
129
|
+
key={product.pk}
|
|
130
|
+
product={product}
|
|
131
|
+
width={itemDimensions.width}
|
|
132
|
+
height={itemDimensions.height}
|
|
133
|
+
index={index}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
</PluginModule>
|
|
131
137
|
</div>
|
|
132
138
|
</FavouriteProductIdsProvider>
|
|
133
139
|
{data?.products?.length > 0 && (
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { Header } from '../header';
|
|
3
|
+
|
|
4
|
+
const mockPush = jest.fn();
|
|
5
|
+
|
|
6
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
7
|
+
useLocalization: () => ({ t: (key: string) => key }),
|
|
8
|
+
useRouter: () => ({ push: mockPush })
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
jest.mock('@akinon/next/components/image', () => ({
|
|
12
|
+
Image: ({ alt, src }: { alt: string; src: string }) => (
|
|
13
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
14
|
+
<img alt={alt} src={src} />
|
|
15
|
+
)
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// `@theme/components` resolves to this module; jest keys mocks by resolved path.
|
|
19
|
+
jest.mock('../../../components', () => ({
|
|
20
|
+
Button: ({ children, ...props }) => <button {...props}>{children}</button>,
|
|
21
|
+
Icon: ({ name }) => <i data-icon={name} />,
|
|
22
|
+
Link: ({ children, href, ...props }) => (
|
|
23
|
+
<a href={href} {...props}>
|
|
24
|
+
{children}
|
|
25
|
+
</a>
|
|
26
|
+
)
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const dataSource = {
|
|
30
|
+
pk: 17,
|
|
31
|
+
name: 'ayse-ticaret',
|
|
32
|
+
slug: 'ayse-ticaret',
|
|
33
|
+
title: 'Ayşe Ticaret'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const header = {
|
|
37
|
+
value: {
|
|
38
|
+
logo_link: '/ayse-ticaret/',
|
|
39
|
+
menu_1_title: 'Yeni Gelenler',
|
|
40
|
+
menu_1_link: '/yeni-gelenler/',
|
|
41
|
+
menu_2_title: 'İndirim',
|
|
42
|
+
menu_2_link: '',
|
|
43
|
+
search_placeholder: 'Ayşe Ticaret’te ara',
|
|
44
|
+
follow_button_title: 'Takip Et',
|
|
45
|
+
follow_button_background: '#ff0000',
|
|
46
|
+
follow_button_title_color: '#000000'
|
|
47
|
+
},
|
|
48
|
+
kwargs: {
|
|
49
|
+
value: {
|
|
50
|
+
logo: { url: 'https://cdn.example.com/logo.png' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe('Data source page header', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
mockPush.mockClear();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders the store logo as the page heading with its link', () => {
|
|
61
|
+
render(<Header data={header} dataSource={dataSource} />);
|
|
62
|
+
|
|
63
|
+
const heading = screen.getByRole('heading', { level: 1 });
|
|
64
|
+
|
|
65
|
+
expect(heading).toContainElement(screen.getByAltText('Ayşe Ticaret'));
|
|
66
|
+
expect(screen.getByAltText('Ayşe Ticaret').closest('a')).toHaveAttribute(
|
|
67
|
+
'href',
|
|
68
|
+
'/ayse-ticaret/'
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders only the menu items that have a usable link', () => {
|
|
73
|
+
render(<Header data={header} dataSource={dataSource} />);
|
|
74
|
+
|
|
75
|
+
expect(screen.getByText('Yeni Gelenler')).toHaveAttribute(
|
|
76
|
+
'href',
|
|
77
|
+
'/yeni-gelenler/'
|
|
78
|
+
);
|
|
79
|
+
expect(screen.queryByText('İndirim')).not.toBeInTheDocument();
|
|
80
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('applies valid follow button colors and drops unsafe ones', () => {
|
|
84
|
+
const { rerender } = render(
|
|
85
|
+
<Header data={header} dataSource={dataSource} />
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(screen.getByText('Takip Et')).toHaveStyle({
|
|
89
|
+
backgroundColor: '#ff0000',
|
|
90
|
+
color: '#000000'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
rerender(
|
|
94
|
+
<Header
|
|
95
|
+
data={{
|
|
96
|
+
...header,
|
|
97
|
+
value: {
|
|
98
|
+
...header.value,
|
|
99
|
+
follow_button_background: 'red;position:fixed;inset:0',
|
|
100
|
+
follow_button_title_color: 'url(https://tracker.example/p.gif)'
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
dataSource={dataSource}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(screen.getByText('Takip Et')).toHaveStyle({
|
|
108
|
+
backgroundColor: '#000000',
|
|
109
|
+
color: '#ffffff'
|
|
110
|
+
});
|
|
111
|
+
expect(screen.getByText('Takip Et').getAttribute('style')).not.toMatch(
|
|
112
|
+
/position|url\(/
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to the store name and the default placeholder', () => {
|
|
117
|
+
render(<Header data={{ value: {} }} dataSource={dataSource} />);
|
|
118
|
+
|
|
119
|
+
expect(
|
|
120
|
+
screen.getByRole('heading', { level: 1, name: 'Ayşe Ticaret' })
|
|
121
|
+
).toBeInTheDocument();
|
|
122
|
+
expect(screen.queryByText('Takip Et')).not.toBeInTheDocument();
|
|
123
|
+
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
|
124
|
+
expect(
|
|
125
|
+
screen.getByRole('searchbox', {
|
|
126
|
+
name: 'common.data_source_page.search_placeholder'
|
|
127
|
+
})
|
|
128
|
+
).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('renders the logo without a link when the link is unusable', () => {
|
|
132
|
+
render(
|
|
133
|
+
<Header
|
|
134
|
+
data={{
|
|
135
|
+
...header,
|
|
136
|
+
value: { ...header.value, logo_link: 'javascript:alert(1)' }
|
|
137
|
+
}}
|
|
138
|
+
dataSource={dataSource}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(screen.getByAltText('Ayşe Ticaret').closest('a')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('searches inside the store with an encoded query', () => {
|
|
146
|
+
render(<Header data={header} dataSource={dataSource} />);
|
|
147
|
+
|
|
148
|
+
fireEvent.change(
|
|
149
|
+
screen.getByRole('searchbox', { name: 'Ayşe Ticaret’te ara' }),
|
|
150
|
+
{ target: { value: ' kırmızı elbise & ceket ' } }
|
|
151
|
+
);
|
|
152
|
+
fireEvent.submit(screen.getByRole('search'));
|
|
153
|
+
|
|
154
|
+
expect(mockPush).toHaveBeenCalledWith(
|
|
155
|
+
'/list/?search_text=k%C4%B1rm%C4%B1z%C4%B1+elbise+%26+ceket&seller_ids=17'
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('lists all store products for a blank query', () => {
|
|
160
|
+
render(<Header data={header} dataSource={dataSource} />);
|
|
161
|
+
|
|
162
|
+
fireEvent.change(screen.getByTestId('data-source-page-search-input'), {
|
|
163
|
+
target: { value: ' ' }
|
|
164
|
+
});
|
|
165
|
+
fireEvent.click(
|
|
166
|
+
screen.getByRole('button', {
|
|
167
|
+
name: 'common.data_source_page.search_button'
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(mockPush).toHaveBeenCalledWith('/list/?seller_ids=17');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { getWidgetData } from '@akinon/next/data/server';
|
|
3
|
+
import DataSourcePageLayout from '../layout';
|
|
4
|
+
|
|
5
|
+
jest.mock('server-only', () => ({}));
|
|
6
|
+
|
|
7
|
+
jest.mock('@akinon/next/data/server', () => ({
|
|
8
|
+
getWidgetData: jest.fn()
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
jest.mock('@akinon/next/utils/server-translation', () => ({
|
|
12
|
+
t: (key: string) => key
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('../banner', () => ({
|
|
16
|
+
Banner: ({ data }) => <div data-testid="banner">{data?.value?.title}</div>
|
|
17
|
+
}));
|
|
18
|
+
jest.mock('../header', () => ({
|
|
19
|
+
Header: ({ dataSource }) => <div data-testid="header">{dataSource.pk}</div>
|
|
20
|
+
}));
|
|
21
|
+
jest.mock('../stories', () => ({
|
|
22
|
+
Stories: ({ data, storeName }) => (
|
|
23
|
+
<div data-testid="stories">
|
|
24
|
+
{data?.length} {storeName}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}));
|
|
28
|
+
jest.mock('../products', () => ({
|
|
29
|
+
Products: ({ data, dataSourcePk, allProductsText }) => (
|
|
30
|
+
<div data-testid="products">
|
|
31
|
+
{data?.products?.length} {dataSourcePk} {allProductsText}
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}));
|
|
35
|
+
jest.mock('../footer', () => ({
|
|
36
|
+
Footer: ({ data }) => <div data-testid="footer">{data?.value?.title}</div>
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const mockGetWidgetData = getWidgetData as jest.Mock;
|
|
40
|
+
|
|
41
|
+
const page = {
|
|
42
|
+
pk: 3,
|
|
43
|
+
data_source: {
|
|
44
|
+
pk: 17,
|
|
45
|
+
name: 'ayse-ticaret',
|
|
46
|
+
slug: 'ayse-ticaret',
|
|
47
|
+
title: 'Ayşe Ticaret'
|
|
48
|
+
},
|
|
49
|
+
widget: 'ayse-ticaret-0',
|
|
50
|
+
product_collection_widget: 'ayse-ticaret-collection',
|
|
51
|
+
is_active: true
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe('DataSourcePageLayout', () => {
|
|
55
|
+
it('composes the page from the landing page widgets', async () => {
|
|
56
|
+
mockGetWidgetData.mockImplementation(async ({ slug }) =>
|
|
57
|
+
slug === 'ayse-ticaret-0'
|
|
58
|
+
? {
|
|
59
|
+
attributes: {
|
|
60
|
+
banner: { value: { title: 'Kış Koleksiyonu' } },
|
|
61
|
+
stories: [{ value: { title: 'a' } }, { value: { title: 'b' } }],
|
|
62
|
+
footer: { value: { title: 'Hakkında' } }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
: { products: [{ pk: 1 }, { pk: 2 }, { pk: 3 }] }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
render(await DataSourcePageLayout({ data: page }));
|
|
69
|
+
|
|
70
|
+
expect(mockGetWidgetData).toHaveBeenCalledTimes(2);
|
|
71
|
+
expect(mockGetWidgetData).toHaveBeenCalledWith({ slug: 'ayse-ticaret-0' });
|
|
72
|
+
expect(mockGetWidgetData).toHaveBeenCalledWith({
|
|
73
|
+
slug: 'ayse-ticaret-collection'
|
|
74
|
+
});
|
|
75
|
+
expect(screen.getByTestId('banner')).toHaveTextContent('Kış Koleksiyonu');
|
|
76
|
+
expect(screen.getByTestId('header')).toHaveTextContent('17');
|
|
77
|
+
expect(screen.getByTestId('stories')).toHaveTextContent('2 Ayşe Ticaret');
|
|
78
|
+
expect(screen.getByTestId('products')).toHaveTextContent(
|
|
79
|
+
'3 17 common.data_source_page.all_products'
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByTestId('footer')).toHaveTextContent('Hakkında');
|
|
82
|
+
});
|
|
83
|
+
});
|