@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,83 +0,0 @@
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
- });
@@ -1,143 +0,0 @@
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
- });
@@ -1,45 +0,0 @@
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
- };
@@ -1,27 +0,0 @@
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
- };
@@ -1,140 +0,0 @@
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
- };
@@ -1,45 +0,0 @@
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
- }
@@ -1,55 +0,0 @@
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
- };
@@ -1,70 +0,0 @@
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
- };