@akinon/projectzero 2.0.152-rc.1 → 2.0.152-rc.3

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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/app-template/CHANGELOG.md +68 -0
  3. package/app-template/next-env.d.ts +1 -1
  4. package/app-template/package.json +29 -29
  5. package/app-template/public/locales/en/common.json +5 -0
  6. package/app-template/public/locales/en/product.json +6 -0
  7. package/app-template/public/locales/tr/common.json +5 -0
  8. package/app-template/public/locales/tr/product.json +6 -0
  9. package/app-template/src/__tests__/data-source-landing-page.test.tsx +132 -0
  10. package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +14 -0
  11. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +5 -0
  12. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +62 -0
  13. package/app-template/src/types/widgets.ts +58 -0
  14. package/app-template/src/utils/__tests__/sanitize.test.ts +127 -0
  15. package/app-template/src/utils/sanitize.ts +167 -0
  16. package/app-template/src/views/data-source-page/__tests__/header.test.tsx +173 -0
  17. package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +83 -0
  18. package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +143 -0
  19. package/app-template/src/views/data-source-page/banner.tsx +45 -0
  20. package/app-template/src/views/data-source-page/footer.tsx +27 -0
  21. package/app-template/src/views/data-source-page/header.tsx +140 -0
  22. package/app-template/src/views/data-source-page/layout.tsx +45 -0
  23. package/app-template/src/views/data-source-page/products.tsx +55 -0
  24. package/app-template/src/views/data-source-page/stories.tsx +70 -0
  25. package/app-template/src/views/product/__tests__/product-offers.test.tsx +217 -0
  26. package/app-template/src/views/product/product-info.tsx +3 -0
  27. package/app-template/src/views/product/product-offers.tsx +125 -0
  28. package/app-template/tsconfig.json +3 -0
  29. 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
+ '<': '&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
+ };
@@ -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 &amp; İ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
+ };
@@ -0,0 +1,27 @@
1
+ import { DataSourcePageData } from '@theme/types';
2
+ import { toSafeHtml } from '@theme/utils/sanitize';
3
+
4
+ export const Footer = ({ data }: { data?: DataSourcePageData['footer'] }) => {
5
+ const title = data?.value?.title;
6
+ const description = data?.value?.description;
7
+
8
+ if (!title && !description) {
9
+ return null;
10
+ }
11
+
12
+ return (
13
+ <div className="container border-t">
14
+ <div className="p-4 lg:px-32 2xl:px-64">
15
+ {title && (
16
+ <h2 className="text-3xl font-light border-b pb-4 mb-4">{title}</h2>
17
+ )}
18
+ {description && (
19
+ <div
20
+ className="text-xs [&_ul]:list-disc [&_ul]:ps-5 [&_ol]:list-decimal [&_ol]:ps-5 [&_p+p]:mt-2 [&_h2]:font-semibold [&_h3]:font-semibold [&_h4]:font-semibold"
21
+ dangerouslySetInnerHTML={{ __html: toSafeHtml(description) }}
22
+ />
23
+ )}
24
+ </div>
25
+ </div>
26
+ );
27
+ };