@akinon/projectzero 2.0.153-rc.4 → 2.0.154-beta.0
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 +2 -8
- package/app-template/.env.example +0 -1
- package/app-template/AGENTS.md +0 -8
- package/app-template/CHANGELOG.md +65 -88
- package/app-template/README.md +1 -25
- package/app-template/docs/plugins.md +0 -6
- package/app-template/next-env.d.ts +1 -1
- package/app-template/next.config.mjs +1 -7
- package/app-template/package.json +41 -41
- package/app-template/public/locales/en/common.json +0 -5
- package/app-template/public/locales/en/product.json +0 -6
- package/app-template/public/locales/tr/common.json +0 -5
- package/app-template/public/locales/tr/product.json +0 -6
- package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +0 -14
- package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +0 -21
- package/app-template/src/app/[pz]/product/[pk]/page.tsx +1 -7
- package/app-template/src/hooks/index.ts +0 -2
- package/app-template/src/hooks/use-fav-button.tsx +13 -35
- package/app-template/src/plugins.js +0 -1
- package/app-template/src/proxy.ts +1 -2
- package/app-template/src/types/widgets.ts +0 -58
- package/app-template/src/views/category/category-info.tsx +17 -31
- package/app-template/src/views/header/search/index.tsx +5 -13
- package/app-template/src/views/product/index.ts +0 -1
- package/app-template/src/views/product/product-info.tsx +0 -14
- package/app-template/src/views/product/slider.tsx +38 -85
- package/app-template/src/views/product-item/index.tsx +1 -6
- package/app-template/tsconfig.json +0 -3
- package/commands/plugins.ts +0 -8
- package/dist/commands/plugins.js +0 -8
- package/package.json +1 -1
- package/app-template/src/__tests__/data-source-landing-page.test.tsx +0 -132
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +0 -5
- package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +0 -62
- package/app-template/src/app/api/client-env/route.ts +0 -5
- package/app-template/src/app/global-error.tsx +0 -22
- package/app-template/src/hooks/favourite-product-ids-context.tsx +0 -104
- package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +0 -42
- package/app-template/src/utils/__tests__/sanitize.test.ts +0 -127
- package/app-template/src/utils/favourite-product-ids-fallback.ts +0 -51
- package/app-template/src/utils/sanitize.ts +0 -167
- package/app-template/src/views/data-source-page/__tests__/header.test.tsx +0 -173
- package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +0 -83
- package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +0 -143
- package/app-template/src/views/data-source-page/banner.tsx +0 -45
- package/app-template/src/views/data-source-page/footer.tsx +0 -27
- package/app-template/src/views/data-source-page/header.tsx +0 -140
- package/app-template/src/views/data-source-page/layout.tsx +0 -45
- package/app-template/src/views/data-source-page/products.tsx +0 -55
- package/app-template/src/views/data-source-page/stories.tsx +0 -70
- package/app-template/src/views/product/__tests__/product-offers.test.tsx +0 -217
- package/app-template/src/views/product/product-offers.tsx +0 -125
- package/app-template/src/views/product/rate-review.tsx +0 -21
|
@@ -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
|
-
};
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
|
-
import { Product } from '@akinon/next/types';
|
|
3
|
-
import { ProductOffers } from '../product-offers';
|
|
4
|
-
|
|
5
|
-
// Same module the component imports as `@theme/components` — jest keys mocks
|
|
6
|
-
// by resolved path, and the `@theme/*` alias is not resolvable from a test
|
|
7
|
-
// file. Stubbing it also keeps the suite off the storefront settings that the
|
|
8
|
-
// real components pull in.
|
|
9
|
-
jest.mock('../../../components', () => {
|
|
10
|
-
const React = require('react');
|
|
11
|
-
|
|
12
|
-
return {
|
|
13
|
-
Link: ({ href, children, ...rest }) =>
|
|
14
|
-
React.createElement('a', { href, ...rest }, children),
|
|
15
|
-
Price: ({ value }) =>
|
|
16
|
-
React.createElement('span', { 'data-testid': 'offer-price' }, value)
|
|
17
|
-
};
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
jest.mock('@akinon/next/hooks', () => ({
|
|
21
|
-
useLocalization: () => ({ t: (key: string) => key })
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
const buildOffer = (overrides: Partial<Product> = {}) =>
|
|
25
|
-
({
|
|
26
|
-
pk: 5897,
|
|
27
|
-
name: 'Test-product-offer',
|
|
28
|
-
price: '4000',
|
|
29
|
-
in_stock: true,
|
|
30
|
-
attributes: {},
|
|
31
|
-
data_source: { pk: 1, name: 'datasource-1', title: 'Market-1' },
|
|
32
|
-
absolute_url: '/galaxy-tab-a8-wifi/?seller_id=1',
|
|
33
|
-
...overrides
|
|
34
|
-
} as Product);
|
|
35
|
-
|
|
36
|
-
describe('ProductOffers', () => {
|
|
37
|
-
it('renders nothing when the product has no offers', () => {
|
|
38
|
-
const { container, rerender } = render(<ProductOffers />);
|
|
39
|
-
|
|
40
|
-
expect(container).toBeEmptyDOMElement();
|
|
41
|
-
|
|
42
|
-
rerender(<ProductOffers offers={[]} />);
|
|
43
|
-
|
|
44
|
-
expect(container).toBeEmptyDOMElement();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('shows the seller, price and stock of every offer', () => {
|
|
48
|
-
render(
|
|
49
|
-
<ProductOffers
|
|
50
|
-
offers={[
|
|
51
|
-
buildOffer(),
|
|
52
|
-
buildOffer({
|
|
53
|
-
pk: 5883,
|
|
54
|
-
name: 'Product-Offer 2 for galaxy-tab-a8',
|
|
55
|
-
price: '3004',
|
|
56
|
-
in_stock: false,
|
|
57
|
-
data_source: { pk: 2, name: 'datasource-2', title: 'Market-2' }
|
|
58
|
-
})
|
|
59
|
-
]}
|
|
60
|
-
/>
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const [first, second] = screen.getAllByTestId('product-offer');
|
|
64
|
-
|
|
65
|
-
expect(screen.getByText('product.offers.title')).toBeInTheDocument();
|
|
66
|
-
expect(first).toHaveTextContent('Market-1');
|
|
67
|
-
expect(first).toHaveTextContent('Test-product-offer');
|
|
68
|
-
expect(first).toHaveTextContent('4000');
|
|
69
|
-
expect(first).toHaveTextContent('product.offers.in_stock');
|
|
70
|
-
expect(second).toHaveTextContent('Market-2');
|
|
71
|
-
expect(second).toHaveTextContent('3004');
|
|
72
|
-
expect(second).toHaveTextContent('product.offers.out_of_stock');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('falls back to the data source name when it has no title', () => {
|
|
76
|
-
render(
|
|
77
|
-
<ProductOffers
|
|
78
|
-
offers={[
|
|
79
|
-
buildOffer({
|
|
80
|
-
data_source: { pk: 8, name: 'datasource-tester-3', title: null }
|
|
81
|
-
})
|
|
82
|
-
]}
|
|
83
|
-
/>
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
expect(
|
|
87
|
-
screen.getByRole('link', { name: 'datasource-tester-3' })
|
|
88
|
-
).toBeInTheDocument();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('renders an offer without a data source without a seller link', () => {
|
|
92
|
-
render(
|
|
93
|
-
<ProductOffers offers={[buildOffer({ price: '0', data_source: null })]} />
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
97
|
-
expect(screen.getByTestId('product-offer')).toHaveTextContent(
|
|
98
|
-
'Test-product-offer'
|
|
99
|
-
);
|
|
100
|
-
expect(screen.getByTestId('offer-price')).toHaveTextContent('0');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('links every offer to its own url, which carries the seller_id', () => {
|
|
104
|
-
render(
|
|
105
|
-
<ProductOffers
|
|
106
|
-
offers={[
|
|
107
|
-
buildOffer({
|
|
108
|
-
pk: 5897,
|
|
109
|
-
absolute_url: '/black-akn11111/?seller_id=1&size=M&color=Black'
|
|
110
|
-
}),
|
|
111
|
-
buildOffer({
|
|
112
|
-
pk: 5881,
|
|
113
|
-
absolute_url: '/black-akn11111/?seller_id=1&size=L&color=Black'
|
|
114
|
-
})
|
|
115
|
-
]}
|
|
116
|
-
/>
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
const [first, second] = screen.getAllByRole('link', { name: 'Market-1' });
|
|
120
|
-
|
|
121
|
-
expect(first).toHaveAttribute(
|
|
122
|
-
'href',
|
|
123
|
-
'/black-akn11111/?seller_id=1&size=M&color=Black'
|
|
124
|
-
);
|
|
125
|
-
expect(second).toHaveAttribute(
|
|
126
|
-
'href',
|
|
127
|
-
'/black-akn11111/?seller_id=1&size=L&color=Black'
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('leaves out the offer that is the product on screen', () => {
|
|
132
|
-
render(
|
|
133
|
-
<ProductOffers
|
|
134
|
-
productPk={5881}
|
|
135
|
-
offers={[
|
|
136
|
-
buildOffer({ pk: 5897, name: 'Test-product-offer' }),
|
|
137
|
-
buildOffer({ pk: 5881, name: 'Product-Offer for galaxy-tab-a8' }),
|
|
138
|
-
buildOffer({ pk: 5883, name: 'Product-Offer 2 for galaxy-tab-a8' })
|
|
139
|
-
]}
|
|
140
|
-
/>
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const offers = screen.getAllByTestId('product-offer');
|
|
144
|
-
|
|
145
|
-
expect(offers).toHaveLength(2);
|
|
146
|
-
expect(offers[0]).toHaveTextContent('Test-product-offer');
|
|
147
|
-
expect(offers[1]).toHaveTextContent('Product-Offer 2 for galaxy-tab-a8');
|
|
148
|
-
expect(
|
|
149
|
-
screen.queryByRole('button', { name: 'product.offers.see_all' })
|
|
150
|
-
).not.toBeInTheDocument();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('renders nothing when the product on screen is its only offer', () => {
|
|
154
|
-
const { container } = render(
|
|
155
|
-
<ProductOffers productPk={436} offers={[buildOffer({ pk: 436 })]} />
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
expect(container).toBeEmptyDOMElement();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('shows the first two offers until see all is clicked', () => {
|
|
162
|
-
render(
|
|
163
|
-
<ProductOffers
|
|
164
|
-
offers={[
|
|
165
|
-
buildOffer({ pk: 1 }),
|
|
166
|
-
buildOffer({ pk: 2 }),
|
|
167
|
-
buildOffer({ pk: 3 })
|
|
168
|
-
]}
|
|
169
|
-
/>
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
expect(screen.getAllByTestId('product-offer')).toHaveLength(2);
|
|
173
|
-
|
|
174
|
-
fireEvent.click(
|
|
175
|
-
screen.getByRole('button', { name: 'product.offers.see_all' })
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
const offers = screen.getAllByTestId('product-offer');
|
|
179
|
-
|
|
180
|
-
expect(offers).toHaveLength(3);
|
|
181
|
-
expect(offers[2]).toHaveFocus();
|
|
182
|
-
expect(
|
|
183
|
-
screen.queryByRole('button', { name: 'product.offers.see_all' })
|
|
184
|
-
).not.toBeInTheDocument();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('does not offer see all when every offer is already shown', () => {
|
|
188
|
-
render(
|
|
189
|
-
<ProductOffers offers={[buildOffer({ pk: 1 }), buildOffer({ pk: 2 })]} />
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
expect(screen.getAllByTestId('product-offer')).toHaveLength(2);
|
|
193
|
-
expect(
|
|
194
|
-
screen.queryByRole('button', { name: 'product.offers.see_all' })
|
|
195
|
-
).not.toBeInTheDocument();
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('shows the seller star grade only when it is numeric', () => {
|
|
199
|
-
render(
|
|
200
|
-
<ProductOffers
|
|
201
|
-
offers={[
|
|
202
|
-
buildOffer({ pk: 1, attributes: { erp_star_grade: '4.5' } }),
|
|
203
|
-
buildOffer({ pk: 2, attributes: { erp_star_grade: 'n/a' } })
|
|
204
|
-
]}
|
|
205
|
-
/>
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const [graded, ungraded] = screen.getAllByTestId('product-offer');
|
|
209
|
-
|
|
210
|
-
expect(within(graded).getByTestId('offer-star-grade')).toHaveTextContent(
|
|
211
|
-
'4.5'
|
|
212
|
-
);
|
|
213
|
-
expect(
|
|
214
|
-
within(ungraded).queryByTestId('offer-star-grade')
|
|
215
|
-
).not.toBeInTheDocument();
|
|
216
|
-
});
|
|
217
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import clsx from 'clsx';
|
|
4
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
5
|
-
import { Link, Price } from '@theme/components';
|
|
6
|
-
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
-
import { Product } from '@akinon/next/types';
|
|
8
|
-
|
|
9
|
-
const INITIAL_OFFER_COUNT = 2;
|
|
10
|
-
|
|
11
|
-
interface ProductOffersProps {
|
|
12
|
-
offers?: Product[];
|
|
13
|
-
productPk?: Product['pk'];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const ProductOffers: React.FC<ProductOffersProps> = ({
|
|
17
|
-
offers,
|
|
18
|
-
productPk
|
|
19
|
-
}) => {
|
|
20
|
-
const { t } = useLocalization();
|
|
21
|
-
const [showAll, setShowAll] = useState(false);
|
|
22
|
-
const revealedOfferRef = useRef<HTMLDivElement>(null);
|
|
23
|
-
|
|
24
|
-
// See all unmounts itself on click, so hand the focus to the first offer it
|
|
25
|
-
// revealed instead of dropping it on the document.
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
if (showAll) {
|
|
28
|
-
revealedOfferRef.current?.focus();
|
|
29
|
-
}
|
|
30
|
-
}, [showAll]);
|
|
31
|
-
|
|
32
|
-
// The product on screen can itself be one of the offers (a seller's own
|
|
33
|
-
// product), and it is not another seller of the product.
|
|
34
|
-
const otherOffers = offers?.filter((offer) => offer.pk !== productPk) ?? [];
|
|
35
|
-
|
|
36
|
-
if (!otherOffers.length) {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const displayedOffers = showAll
|
|
41
|
-
? otherOffers
|
|
42
|
-
: otherOffers.slice(0, INITIAL_OFFER_COUNT);
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="w-full mb-8" data-testid="product-offers">
|
|
46
|
-
<div className="w-full text-left text-sm pb-1 mb-2">
|
|
47
|
-
{t('product.offers.title')}
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div className="w-full border p-4">
|
|
51
|
-
<div>
|
|
52
|
-
{displayedOffers.map((offer, index) => {
|
|
53
|
-
const sellerName =
|
|
54
|
-
offer.data_source?.title || offer.data_source?.name;
|
|
55
|
-
const starGrade = parseFloat(offer.attributes?.erp_star_grade);
|
|
56
|
-
const isFirstRevealed = index === INITIAL_OFFER_COUNT;
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div
|
|
60
|
-
key={offer.pk}
|
|
61
|
-
ref={isFirstRevealed ? revealedOfferRef : undefined}
|
|
62
|
-
tabIndex={isFirstRevealed ? -1 : undefined}
|
|
63
|
-
className="mb-2 pb-2 border-b last:mb-0 last:pb-0 last:border-none"
|
|
64
|
-
data-testid="product-offer"
|
|
65
|
-
>
|
|
66
|
-
<div className="flex items-center gap-1 mb-1">
|
|
67
|
-
{/* Commerce builds the offer url with the seller_id and the
|
|
68
|
-
variant, so it opens that seller's own product. */}
|
|
69
|
-
{sellerName && (
|
|
70
|
-
<Link
|
|
71
|
-
href={offer.absolute_url}
|
|
72
|
-
className="text-sm text-[#1f6bc1] font-medium hover:underline"
|
|
73
|
-
>
|
|
74
|
-
{sellerName}
|
|
75
|
-
</Link>
|
|
76
|
-
)}
|
|
77
|
-
|
|
78
|
-
{Number.isFinite(starGrade) && (
|
|
79
|
-
<div
|
|
80
|
-
className="text-[10px] px-1 bg-[#08b449] text-white flex items-center justify-center rounded"
|
|
81
|
-
data-testid="offer-star-grade"
|
|
82
|
-
>
|
|
83
|
-
{starGrade.toFixed(1)}
|
|
84
|
-
</div>
|
|
85
|
-
)}
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
{offer.name && (
|
|
89
|
-
<div className="text-sm mb-1 text-gray-800">{offer.name}</div>
|
|
90
|
-
)}
|
|
91
|
-
|
|
92
|
-
<div className="flex items-center gap-2">
|
|
93
|
-
{offer.price && (
|
|
94
|
-
<Price className="font-bold" value={offer.price} />
|
|
95
|
-
)}
|
|
96
|
-
|
|
97
|
-
<span
|
|
98
|
-
className={clsx(
|
|
99
|
-
'ml-auto text-xs',
|
|
100
|
-
offer.in_stock ? 'text-success' : 'text-error'
|
|
101
|
-
)}
|
|
102
|
-
>
|
|
103
|
-
{offer.in_stock
|
|
104
|
-
? t('product.offers.in_stock')
|
|
105
|
-
: t('product.offers.out_of_stock')}
|
|
106
|
-
</span>
|
|
107
|
-
</div>
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
})}
|
|
111
|
-
</div>
|
|
112
|
-
|
|
113
|
-
{!showAll && otherOffers.length > INITIAL_OFFER_COUNT && (
|
|
114
|
-
<button
|
|
115
|
-
type="button"
|
|
116
|
-
className="w-full mt-2 text-center text-sm cursor-pointer hover:underline"
|
|
117
|
-
onClick={() => setShowAll(true)}
|
|
118
|
-
>
|
|
119
|
-
{t('product.offers.see_all')}
|
|
120
|
-
</button>
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
};
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
4
|
-
import { ProductPageProps } from './layout';
|
|
5
|
-
|
|
6
|
-
export default function ProductRateReview({
|
|
7
|
-
data
|
|
8
|
-
}: Pick<ProductPageProps, 'data'>) {
|
|
9
|
-
return (
|
|
10
|
-
<PluginModule
|
|
11
|
-
component={Component.ProductRateReview}
|
|
12
|
-
props={{
|
|
13
|
-
product: {
|
|
14
|
-
sku: data.product.sku,
|
|
15
|
-
base_code: data.product.base_code
|
|
16
|
-
},
|
|
17
|
-
classNames: { root: 'w-full mt-8' }
|
|
18
|
-
}}
|
|
19
|
-
/>
|
|
20
|
-
);
|
|
21
|
-
}
|