@akinon/projectzero 2.0.153 → 2.0.154-rc.5
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 +10 -0
- package/app-template/.env.example +1 -0
- package/app-template/AGENTS.md +8 -0
- package/app-template/CHANGELOG.md +89 -0
- package/app-template/README.md +25 -1
- package/app-template/docs/plugins.md +6 -0
- package/app-template/next-env.d.ts +1 -1
- package/app-template/next.config.mjs +7 -1
- package/app-template/package.json +41 -41
- 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/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 +2 -1
- package/app-template/src/types/widgets.ts +58 -0
- package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +42 -0
- package/app-template/src/utils/__tests__/sanitize.test.ts +127 -0
- package/app-template/src/utils/favourite-product-ids-fallback.ts +51 -0
- package/app-template/src/utils/sanitize.ts +167 -0
- package/app-template/src/views/category/category-info.tsx +31 -17
- 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/header/search/index.tsx +13 -5
- 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/slider.tsx +85 -38
- package/app-template/src/views/product-item/index.tsx +6 -1
- package/app-template/tsconfig.json +3 -0
- package/commands/plugins.ts +8 -0
- package/dist/commands/plugins.js +8 -0
- package/package.json +1 -1
|
@@ -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,10 +7,12 @@ 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';
|
|
13
14
|
import CategoryActiveFilters from '@theme/views/category/category-active-filters';
|
|
15
|
+
import { FavouriteProductIdsProvider } from '@theme/hooks/favourite-product-ids-context';
|
|
14
16
|
import { useLocalization } from '@akinon/next/hooks';
|
|
15
17
|
import { Link, LoaderSpinner } from '@akinon/next/components';
|
|
16
18
|
import { ROUTES } from '@theme/routes';
|
|
@@ -62,6 +64,11 @@ export default function ListPage(props: ListPageProps) {
|
|
|
62
64
|
|
|
63
65
|
const { t } = useLocalization();
|
|
64
66
|
|
|
67
|
+
const productPks = useMemo(
|
|
68
|
+
() => data?.products?.map((product) => product.pk) ?? [],
|
|
69
|
+
[data?.products]
|
|
70
|
+
);
|
|
71
|
+
|
|
65
72
|
useEffect(() => {
|
|
66
73
|
dispatch(setFacets(data.facets));
|
|
67
74
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -105,23 +112,30 @@ export default function ListPage(props: ListPageProps) {
|
|
|
105
112
|
|
|
106
113
|
{data.products?.length === 0 && page > 1 && <LoaderSpinner />}
|
|
107
114
|
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
'
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
115
|
+
<FavouriteProductIdsProvider productIds={productPks}>
|
|
116
|
+
<div
|
|
117
|
+
className={clsx('grid gap-x-4 gap-y-12 grid-cols-2', {
|
|
118
|
+
'md:grid-cols-3': layoutSize === 3,
|
|
119
|
+
'lg:grid-cols-2': layoutSize === 2,
|
|
120
|
+
'lg:grid-cols-3': layoutSize === 3
|
|
121
|
+
})}
|
|
122
|
+
>
|
|
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>
|
|
137
|
+
</div>
|
|
138
|
+
</FavouriteProductIdsProvider>
|
|
125
139
|
{data?.products?.length > 0 && (
|
|
126
140
|
<Pagination
|
|
127
141
|
total={data.pagination.total_count}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { Banner } from '../banner';
|
|
3
|
+
import { Stories } from '../stories';
|
|
4
|
+
import { Products } from '../products';
|
|
5
|
+
import { Footer } from '../footer';
|
|
6
|
+
|
|
7
|
+
jest.mock('@akinon/next/components/image', () => ({
|
|
8
|
+
Image: ({ alt, src }: { alt: string; src: string }) => (
|
|
9
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
10
|
+
<img alt={alt} src={src} />
|
|
11
|
+
)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// `@theme/*` modules resolve to these paths; jest keys mocks by resolved path.
|
|
15
|
+
jest.mock('../../../components', () => ({
|
|
16
|
+
Link: ({ children, href, ...props }) => (
|
|
17
|
+
<a href={href} {...props}>
|
|
18
|
+
{children}
|
|
19
|
+
</a>
|
|
20
|
+
)
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock('../../product-item', () => ({
|
|
24
|
+
ProductItem: ({ product }) => (
|
|
25
|
+
<div data-testid="product-item">{product.name}</div>
|
|
26
|
+
)
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe('Data source page sections', () => {
|
|
30
|
+
it('renders the banner only when it has an image', () => {
|
|
31
|
+
const { container } = render(<Banner data={{ value: { title: 'x' } }} />);
|
|
32
|
+
|
|
33
|
+
expect(container).toBeEmptyDOMElement();
|
|
34
|
+
|
|
35
|
+
render(
|
|
36
|
+
<Banner
|
|
37
|
+
data={{
|
|
38
|
+
value: { title: 'Kış Koleksiyonu', subtitle: 'Yeni sezon' },
|
|
39
|
+
kwargs: { value: { image: { url: 'https://cdn.example.com/b.jpg' } } }
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByRole('heading', { level: 2, name: 'Kış Koleksiyonu' })
|
|
46
|
+
).toBeInTheDocument();
|
|
47
|
+
expect(screen.getByText('Yeni sezon')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders each story as a single link', () => {
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<Stories data={[{ value: { link: '/x/' } }]} />
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(container).toBeEmptyDOMElement();
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<Stories
|
|
59
|
+
storeName="Ayşe Ticaret"
|
|
60
|
+
data={[
|
|
61
|
+
{
|
|
62
|
+
value: { title: 'Elbise', link: '/elbise/' },
|
|
63
|
+
kwargs: {
|
|
64
|
+
value: { image: { url: 'https://cdn.example.com/s.jpg' } }
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
value: { link: '/ceket/' },
|
|
69
|
+
kwargs: {
|
|
70
|
+
value: { image: { url: 'https://cdn.example.com/c.jpg' } }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ value: { title: 'Ayakkabı', link: 'javascript:alert(1)' } }
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(screen.getAllByRole('link')).toHaveLength(2);
|
|
79
|
+
expect(screen.getByRole('link', { name: 'Elbise' })).toHaveAttribute(
|
|
80
|
+
'href',
|
|
81
|
+
'/elbise/'
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByRole('link', { name: 'Ayşe Ticaret' })).toHaveAttribute(
|
|
84
|
+
'href',
|
|
85
|
+
'/ceket/'
|
|
86
|
+
);
|
|
87
|
+
expect(screen.getByText('Ayakkabı').closest('a')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders the collection products and a link to all store products', () => {
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<Products data={{ products: [] }} allProductsText="Tümü" />
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(container).toBeEmptyDOMElement();
|
|
96
|
+
|
|
97
|
+
render(
|
|
98
|
+
<Products
|
|
99
|
+
data={{
|
|
100
|
+
attributes: { title: { value: 'Mağazanın Ürünleri' } },
|
|
101
|
+
products: [{ pk: 1, name: 'Ceket' } as never]
|
|
102
|
+
}}
|
|
103
|
+
dataSourcePk={17}
|
|
104
|
+
allProductsText="Mağazanın tüm ürünleri"
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
screen.getByRole('heading', { name: 'Mağazanın Ürünleri' })
|
|
110
|
+
).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByTestId('product-item')).toHaveTextContent('Ceket');
|
|
112
|
+
expect(
|
|
113
|
+
screen.getByRole('link', { name: 'Mağazanın tüm ürünleri' })
|
|
114
|
+
).toHaveAttribute('href', '/list/?seller_ids=17');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders the footer with seller html made safe', () => {
|
|
118
|
+
const { container: empty } = render(<Footer data={{ value: {} }} />);
|
|
119
|
+
|
|
120
|
+
expect(empty).toBeEmptyDOMElement();
|
|
121
|
+
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<Footer
|
|
124
|
+
data={{
|
|
125
|
+
value: {
|
|
126
|
+
title: 'Ayşe Ticaret Hakkında',
|
|
127
|
+
description:
|
|
128
|
+
'<p>Kargo & İade <strong>garantisi</strong><img src=x onerror=alert(1)><b>bedava'
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole('heading', { level: 2, name: 'Ayşe Ticaret Hakkında' })
|
|
136
|
+
).toBeInTheDocument();
|
|
137
|
+
expect(container.querySelector('strong')).toHaveTextContent('garantisi');
|
|
138
|
+
expect(container).toHaveTextContent('Kargo & İade garantisi');
|
|
139
|
+
expect(container.querySelector('img')).toBeNull();
|
|
140
|
+
expect(container.innerHTML).not.toContain('onerror');
|
|
141
|
+
expect(container.querySelector('b')).toHaveTextContent('bedava');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Image } from '@akinon/next/components/image';
|
|
2
|
+
import { DataSourcePageData } from '@theme/types';
|
|
3
|
+
|
|
4
|
+
export const Banner = ({ data }: { data?: DataSourcePageData['banner'] }) => {
|
|
5
|
+
const image = data?.kwargs?.value?.image?.url;
|
|
6
|
+
const mobileImage = data?.kwargs?.value?.mobile_image?.url;
|
|
7
|
+
const title = data?.value?.title;
|
|
8
|
+
const subtitle = data?.value?.subtitle;
|
|
9
|
+
|
|
10
|
+
if (!image && !mobileImage) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="relative" data-testid="data-source-page-banner">
|
|
16
|
+
<Image
|
|
17
|
+
src={mobileImage ?? image}
|
|
18
|
+
alt=""
|
|
19
|
+
aspectRatio={375 / 540}
|
|
20
|
+
sizes="100vw"
|
|
21
|
+
fill
|
|
22
|
+
className="block sm:hidden"
|
|
23
|
+
/>
|
|
24
|
+
<Image
|
|
25
|
+
src={image ?? mobileImage}
|
|
26
|
+
alt=""
|
|
27
|
+
aspectRatio={1540 / 540}
|
|
28
|
+
sizes="100vw"
|
|
29
|
+
fill
|
|
30
|
+
className="hidden sm:block"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
{(title || subtitle) && (
|
|
34
|
+
<div className="absolute inset-0 px-4 lg:px-0 lg:pl-[15%]">
|
|
35
|
+
<div className="h-full flex flex-col justify-center items-center gap-4 text-center lg:max-w-xs">
|
|
36
|
+
{title && (
|
|
37
|
+
<h2 className="text-white text-5xl font-light">{title}</h2>
|
|
38
|
+
)}
|
|
39
|
+
{subtitle && <p className="text-white text-sm">{subtitle}</p>}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
};
|