@akinon/projectzero 2.0.153-rc.4 → 2.0.153

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +1 -9
  2. package/app-template/.env.example +0 -1
  3. package/app-template/AGENTS.md +0 -8
  4. package/app-template/CHANGELOG.md +32 -88
  5. package/app-template/README.md +1 -25
  6. package/app-template/docs/plugins.md +0 -6
  7. package/app-template/next-env.d.ts +1 -1
  8. package/app-template/next.config.mjs +1 -7
  9. package/app-template/package.json +41 -41
  10. package/app-template/public/locales/en/common.json +0 -5
  11. package/app-template/public/locales/en/product.json +0 -6
  12. package/app-template/public/locales/tr/common.json +0 -5
  13. package/app-template/public/locales/tr/product.json +0 -6
  14. package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +0 -14
  15. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +0 -21
  16. package/app-template/src/app/[pz]/product/[pk]/page.tsx +1 -7
  17. package/app-template/src/hooks/index.ts +0 -2
  18. package/app-template/src/hooks/use-fav-button.tsx +13 -35
  19. package/app-template/src/plugins.js +0 -1
  20. package/app-template/src/proxy.ts +1 -2
  21. package/app-template/src/types/widgets.ts +0 -58
  22. package/app-template/src/views/category/category-info.tsx +17 -31
  23. package/app-template/src/views/header/search/index.tsx +5 -13
  24. package/app-template/src/views/product/index.ts +0 -1
  25. package/app-template/src/views/product/product-info.tsx +0 -14
  26. package/app-template/src/views/product/slider.tsx +38 -85
  27. package/app-template/src/views/product-item/index.tsx +1 -6
  28. package/app-template/tsconfig.json +0 -3
  29. package/commands/plugins.ts +0 -8
  30. package/dist/commands/plugins.js +0 -8
  31. package/package.json +1 -1
  32. package/app-template/src/__tests__/data-source-landing-page.test.tsx +0 -132
  33. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +0 -5
  34. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +0 -62
  35. package/app-template/src/app/api/client-env/route.ts +0 -5
  36. package/app-template/src/app/global-error.tsx +0 -22
  37. package/app-template/src/hooks/favourite-product-ids-context.tsx +0 -104
  38. package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +0 -42
  39. package/app-template/src/utils/__tests__/sanitize.test.ts +0 -127
  40. package/app-template/src/utils/favourite-product-ids-fallback.ts +0 -51
  41. package/app-template/src/utils/sanitize.ts +0 -167
  42. package/app-template/src/views/data-source-page/__tests__/header.test.tsx +0 -173
  43. package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +0 -83
  44. package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +0 -143
  45. package/app-template/src/views/data-source-page/banner.tsx +0 -45
  46. package/app-template/src/views/data-source-page/footer.tsx +0 -27
  47. package/app-template/src/views/data-source-page/header.tsx +0 -140
  48. package/app-template/src/views/data-source-page/layout.tsx +0 -45
  49. package/app-template/src/views/data-source-page/products.tsx +0 -55
  50. package/app-template/src/views/data-source-page/stories.tsx +0 -70
  51. package/app-template/src/views/product/__tests__/product-offers.test.tsx +0 -217
  52. package/app-template/src/views/product/product-offers.tsx +0 -125
  53. package/app-template/src/views/product/rate-review.tsx +0 -21
@@ -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 &amp; İade&nbsp;garantisi &#351;')).toBe(
16
- 'Kargo &amp; İade&nbsp;garantisi &#351;'
17
- );
18
- expect(toSafeHtml(`Tom & "Jerry's" <3`)).toBe(
19
- 'Tom &amp; &quot;Jerry&#39;s&quot; &lt;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
- '&lt;p/onclick=alert(1)&gt;x'
56
- );
57
- expect(toSafeHtml('<p title="&gt;<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 &amp; İade</p>\n<script>x</script><p>Hızlı&nbsp;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
- };
@@ -1,167 +0,0 @@
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
- '<': '&lt;',
23
- '>': '&gt;',
24
- '"': '&quot;',
25
- "'": '&#39;'
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 (&amp;, &nbsp;, &#351;) 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 = /&lt;(\/?)([a-z][a-z\d]*)(?:\s[\s\S]*?)?(\/?)&gt;/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, '&amp;')
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
- '&nbsp;': ' ',
97
- '&amp;': '&',
98
- '&lt;': '<',
99
- '&gt;': '>',
100
- '&quot;': '"',
101
- '&#39;': "'"
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
- };
@@ -1,173 +0,0 @@
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
- });