@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,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
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { FormEvent, ReactNode, useState } from 'react';
|
|
4
|
+
import { Image } from '@akinon/next/components/image';
|
|
5
|
+
import { useLocalization, useRouter } from '@akinon/next/hooks';
|
|
6
|
+
import { DataSourcePage } from '@akinon/next/types';
|
|
7
|
+
import { Button, Icon, Link } from '@theme/components';
|
|
8
|
+
import { ROUTES } from '@theme/routes';
|
|
9
|
+
import { DataSourcePageData } from '@theme/types';
|
|
10
|
+
import { getSafeLinkProps, toSafeColor } from '@theme/utils/sanitize';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
data?: DataSourcePageData['header'];
|
|
14
|
+
dataSource: DataSourcePage['data_source'];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Header = ({ data, dataSource }: Props) => {
|
|
18
|
+
const { t } = useLocalization();
|
|
19
|
+
const router = useRouter();
|
|
20
|
+
const [searchText, setSearchText] = useState('');
|
|
21
|
+
|
|
22
|
+
const value = data?.value;
|
|
23
|
+
const logo = data?.kwargs?.value?.logo?.url;
|
|
24
|
+
const logoLink = getSafeLinkProps(value?.logo_link);
|
|
25
|
+
const storeName = dataSource?.title || dataSource?.name;
|
|
26
|
+
const searchPlaceholder =
|
|
27
|
+
value?.search_placeholder ||
|
|
28
|
+
t('common.data_source_page.search_placeholder');
|
|
29
|
+
const menuItems = [
|
|
30
|
+
{ title: value?.menu_1_title, link: getSafeLinkProps(value?.menu_1_link) },
|
|
31
|
+
{ title: value?.menu_2_title, link: getSafeLinkProps(value?.menu_2_link) }
|
|
32
|
+
].filter((item) => item.title && item.link);
|
|
33
|
+
|
|
34
|
+
const searchInStore = (event: FormEvent<HTMLFormElement>) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
|
|
37
|
+
if (!dataSource?.pk) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const text = searchText.trim();
|
|
42
|
+
const params = new URLSearchParams({
|
|
43
|
+
...(text && { search_text: text }),
|
|
44
|
+
seller_ids: String(dataSource.pk)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
router.push(`${ROUTES.LIST}/?${params.toString()}`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const logoContent: ReactNode = (
|
|
51
|
+
<>
|
|
52
|
+
<Image
|
|
53
|
+
src={logo}
|
|
54
|
+
alt={storeName ?? ''}
|
|
55
|
+
aspectRatio={285 / 27}
|
|
56
|
+
sizes="(min-width: 640px) 320px, 160px"
|
|
57
|
+
crop="none"
|
|
58
|
+
imageClassName="object-contain"
|
|
59
|
+
fill
|
|
60
|
+
/>
|
|
61
|
+
<Icon name="home" size={25} className="text-primary" aria-hidden="true" />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-wrap items-center justify-between gap-4 py-5 px-4 border-b lg:px-20 lg:py-6">
|
|
67
|
+
<h1 className="order-1 lg:order-none">
|
|
68
|
+
{logo ? (
|
|
69
|
+
logoLink ? (
|
|
70
|
+
<Link
|
|
71
|
+
{...logoLink}
|
|
72
|
+
className="flex items-center gap-2 w-40 sm:w-80"
|
|
73
|
+
>
|
|
74
|
+
{logoContent}
|
|
75
|
+
</Link>
|
|
76
|
+
) : (
|
|
77
|
+
<div className="flex items-center gap-2 w-40 sm:w-80">
|
|
78
|
+
{logoContent}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
) : (
|
|
82
|
+
<span className="text-xl font-medium">{storeName}</span>
|
|
83
|
+
)}
|
|
84
|
+
</h1>
|
|
85
|
+
|
|
86
|
+
{menuItems.length > 0 && (
|
|
87
|
+
<ul className="order-3 w-full flex flex-wrap items-center gap-4 md:gap-8 lg:order-none lg:w-auto lg:ml-auto lg:gap-14">
|
|
88
|
+
{menuItems.map((item, index) => (
|
|
89
|
+
<li key={`data-source-menu__${index}`}>
|
|
90
|
+
<Link {...item.link}>{item.title}</Link>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<form
|
|
97
|
+
role="search"
|
|
98
|
+
aria-label={t('common.data_source_page.search_button')}
|
|
99
|
+
onSubmit={searchInStore}
|
|
100
|
+
className="order-4 w-full flex items-center justify-between border rounded focus-within:border-primary lg:order-none lg:ml-auto lg:w-56"
|
|
101
|
+
>
|
|
102
|
+
<input
|
|
103
|
+
type="search"
|
|
104
|
+
name="search_text"
|
|
105
|
+
enterKeyHint="search"
|
|
106
|
+
value={searchText}
|
|
107
|
+
onChange={(event) => setSearchText(event.target.value)}
|
|
108
|
+
placeholder={searchPlaceholder}
|
|
109
|
+
aria-label={searchPlaceholder}
|
|
110
|
+
className="w-full flex-1 h-10 px-2 text-xs border-none outline-none bg-transparent"
|
|
111
|
+
data-testid="data-source-page-search-input"
|
|
112
|
+
/>
|
|
113
|
+
<button
|
|
114
|
+
type="submit"
|
|
115
|
+
aria-label={t('common.data_source_page.search_button')}
|
|
116
|
+
className="px-2"
|
|
117
|
+
>
|
|
118
|
+
<Icon name="search" size={20} aria-hidden="true" />
|
|
119
|
+
</button>
|
|
120
|
+
</form>
|
|
121
|
+
|
|
122
|
+
{value?.follow_button_title && (
|
|
123
|
+
<Button
|
|
124
|
+
type="button"
|
|
125
|
+
appearance="ghost"
|
|
126
|
+
className="order-2 rounded border-none font-medium lg:order-none"
|
|
127
|
+
style={{
|
|
128
|
+
backgroundColor: toSafeColor(
|
|
129
|
+
value.follow_button_background,
|
|
130
|
+
'#000000'
|
|
131
|
+
),
|
|
132
|
+
color: toSafeColor(value.follow_button_title_color, '#ffffff')
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{value.follow_button_title}
|
|
136
|
+
</Button>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { getWidgetData } from '@akinon/next/data/server';
|
|
4
|
+
import { DataSourcePage } from '@akinon/next/types';
|
|
5
|
+
import { t } from '@akinon/next/utils/server-translation';
|
|
6
|
+
import {
|
|
7
|
+
DataSourcePageData,
|
|
8
|
+
DataSourceProductRecommendation
|
|
9
|
+
} from '@theme/types';
|
|
10
|
+
|
|
11
|
+
import { Banner } from './banner';
|
|
12
|
+
import { Header } from './header';
|
|
13
|
+
import { Stories } from './stories';
|
|
14
|
+
import { Products } from './products';
|
|
15
|
+
import { Footer } from './footer';
|
|
16
|
+
|
|
17
|
+
export default async function DataSourcePageLayout({
|
|
18
|
+
data
|
|
19
|
+
}: {
|
|
20
|
+
data: DataSourcePage;
|
|
21
|
+
}) {
|
|
22
|
+
const [pageData, productCollectionData] = await Promise.all([
|
|
23
|
+
getWidgetData<DataSourcePageData>({ slug: data.widget }),
|
|
24
|
+
getWidgetData<DataSourceProductRecommendation>({
|
|
25
|
+
slug: data.product_collection_widget
|
|
26
|
+
})
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const attributes = pageData?.attributes;
|
|
30
|
+
const storeName = data.data_source?.title || data.data_source?.name;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<Banner data={attributes?.banner} />
|
|
35
|
+
<Header data={attributes?.header} dataSource={data.data_source} />
|
|
36
|
+
<Stories data={attributes?.stories} storeName={storeName} />
|
|
37
|
+
<Products
|
|
38
|
+
data={productCollectionData}
|
|
39
|
+
dataSourcePk={data.data_source?.pk}
|
|
40
|
+
allProductsText={t('common.data_source_page.all_products')}
|
|
41
|
+
/>
|
|
42
|
+
<Footer data={attributes?.footer} />
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { WidgetResultType } from '@akinon/next/types';
|
|
2
|
+
import { Link } from '@theme/components';
|
|
3
|
+
import { ROUTES } from '@theme/routes';
|
|
4
|
+
import { DataSourceProductRecommendation } from '@theme/types';
|
|
5
|
+
import { ProductItem } from '@theme/views/product-item';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
data?: WidgetResultType<DataSourceProductRecommendation>;
|
|
9
|
+
dataSourcePk?: number;
|
|
10
|
+
allProductsText: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const Products = ({ data, dataSourcePk, allProductsText }: Props) => {
|
|
14
|
+
const products = data?.products ?? [];
|
|
15
|
+
const title = data?.attributes?.title?.value;
|
|
16
|
+
|
|
17
|
+
if (!products.length && !dataSourcePk) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section className="container px-4 my-8">
|
|
23
|
+
{products.length > 0 && (
|
|
24
|
+
<>
|
|
25
|
+
{title && (
|
|
26
|
+
<h2 className="text-2xl text-secondary font-light mb-4">{title}</h2>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4">
|
|
30
|
+
{products.map((product, index) => (
|
|
31
|
+
<ProductItem
|
|
32
|
+
key={product.pk}
|
|
33
|
+
product={product}
|
|
34
|
+
width={272}
|
|
35
|
+
height={416}
|
|
36
|
+
index={index}
|
|
37
|
+
/>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
42
|
+
|
|
43
|
+
{dataSourcePk && (
|
|
44
|
+
<div className="flex justify-center mt-8">
|
|
45
|
+
<Link
|
|
46
|
+
href={`${ROUTES.LIST}/?seller_ids=${dataSourcePk}`}
|
|
47
|
+
className="text-sm font-medium underline"
|
|
48
|
+
>
|
|
49
|
+
{allProductsText}
|
|
50
|
+
</Link>
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</section>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Image } from '@akinon/next/components/image';
|
|
2
|
+
import { Link } from '@theme/components';
|
|
3
|
+
import { DataSourcePageStory } from '@theme/types';
|
|
4
|
+
import { getSafeLinkProps } from '@theme/utils/sanitize';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
data?: DataSourcePageStory[];
|
|
8
|
+
storeName?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const Stories = ({ data, storeName }: Props) => {
|
|
12
|
+
const stories = data?.filter(
|
|
13
|
+
(story) => story?.kwargs?.value?.image?.url || story?.value?.title
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
if (!stories?.length) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="container">
|
|
22
|
+
<ul className="flex items-center gap-8 py-9 overflow-x-auto lg:flex-wrap lg:justify-center">
|
|
23
|
+
{stories.map((story, index) => {
|
|
24
|
+
const title = story.value?.title;
|
|
25
|
+
const image = story.kwargs?.value?.image?.url;
|
|
26
|
+
const link = getSafeLinkProps(story.value?.link);
|
|
27
|
+
const content = (
|
|
28
|
+
<>
|
|
29
|
+
{image && (
|
|
30
|
+
<Image
|
|
31
|
+
src={image}
|
|
32
|
+
alt=""
|
|
33
|
+
aspectRatio={1}
|
|
34
|
+
sizes="(max-width: 768px) 112px, 144px"
|
|
35
|
+
className="mb-2"
|
|
36
|
+
imageClassName="rounded-full"
|
|
37
|
+
fill
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
{title && (
|
|
41
|
+
<span className="block text-center underline text-sm font-medium">
|
|
42
|
+
{title}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<li
|
|
50
|
+
className="flex-shrink-0 first:ms-4 w-28 md:w-36"
|
|
51
|
+
key={`data-source-story__${index}`}
|
|
52
|
+
>
|
|
53
|
+
{link ? (
|
|
54
|
+
<Link
|
|
55
|
+
{...link}
|
|
56
|
+
className="block"
|
|
57
|
+
aria-label={title ? undefined : storeName}
|
|
58
|
+
>
|
|
59
|
+
{content}
|
|
60
|
+
</Link>
|
|
61
|
+
) : (
|
|
62
|
+
content
|
|
63
|
+
)}
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</ul>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|