@akinon/projectzero 2.0.152 → 2.0.153-rc.4

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 +10 -0
  2. package/app-template/.env.example +1 -0
  3. package/app-template/AGENTS.md +8 -0
  4. package/app-template/CHANGELOG.md +89 -0
  5. package/app-template/README.md +25 -1
  6. package/app-template/docs/plugins.md +6 -0
  7. package/app-template/next-env.d.ts +1 -1
  8. package/app-template/next.config.mjs +7 -1
  9. package/app-template/package.json +41 -41
  10. package/app-template/public/locales/en/common.json +5 -0
  11. package/app-template/public/locales/en/product.json +6 -0
  12. package/app-template/public/locales/tr/common.json +5 -0
  13. package/app-template/public/locales/tr/product.json +6 -0
  14. package/app-template/src/__tests__/data-source-landing-page.test.tsx +132 -0
  15. package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +14 -0
  16. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +21 -0
  17. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +5 -0
  18. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +62 -0
  19. package/app-template/src/app/[pz]/product/[pk]/page.tsx +7 -1
  20. package/app-template/src/app/api/client-env/route.ts +5 -0
  21. package/app-template/src/app/global-error.tsx +22 -0
  22. package/app-template/src/hooks/favourite-product-ids-context.tsx +104 -0
  23. package/app-template/src/hooks/index.ts +2 -0
  24. package/app-template/src/hooks/use-fav-button.tsx +35 -13
  25. package/app-template/src/plugins.js +1 -0
  26. package/app-template/src/proxy.ts +2 -1
  27. package/app-template/src/types/widgets.ts +58 -0
  28. package/app-template/src/utils/__tests__/favourite-product-ids-fallback.test.ts +42 -0
  29. package/app-template/src/utils/__tests__/sanitize.test.ts +127 -0
  30. package/app-template/src/utils/favourite-product-ids-fallback.ts +51 -0
  31. package/app-template/src/utils/sanitize.ts +167 -0
  32. package/app-template/src/views/category/category-info.tsx +31 -17
  33. package/app-template/src/views/data-source-page/__tests__/header.test.tsx +173 -0
  34. package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +83 -0
  35. package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +143 -0
  36. package/app-template/src/views/data-source-page/banner.tsx +45 -0
  37. package/app-template/src/views/data-source-page/footer.tsx +27 -0
  38. package/app-template/src/views/data-source-page/header.tsx +140 -0
  39. package/app-template/src/views/data-source-page/layout.tsx +45 -0
  40. package/app-template/src/views/data-source-page/products.tsx +55 -0
  41. package/app-template/src/views/data-source-page/stories.tsx +70 -0
  42. package/app-template/src/views/header/search/index.tsx +13 -5
  43. package/app-template/src/views/product/__tests__/product-offers.test.tsx +217 -0
  44. package/app-template/src/views/product/index.ts +1 -0
  45. package/app-template/src/views/product/product-info.tsx +14 -0
  46. package/app-template/src/views/product/product-offers.tsx +125 -0
  47. package/app-template/src/views/product/rate-review.tsx +21 -0
  48. package/app-template/src/views/product/slider.tsx +85 -38
  49. package/app-template/src/views/product-item/index.tsx +6 -1
  50. package/app-template/tsconfig.json +3 -0
  51. package/commands/plugins.ts +8 -0
  52. package/dist/commands/plugins.js +8 -0
  53. package/package.json +1 -1
@@ -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
+ };
@@ -4,8 +4,7 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
5
5
  import { closeSearch } from '@akinon/next/redux/reducers/header';
6
6
  import clsx from 'clsx';
7
-
8
- import { Icon } from '@theme/components';
7
+ import { Icon, Input } from '@theme/components';
9
8
  import Results from './results';
10
9
  import { ROUTES } from '@theme/routes';
11
10
  import { useLocalization, useRouter } from '@akinon/next/hooks';
@@ -42,6 +41,14 @@ export default function Search() {
42
41
  };
43
42
  }, [isSearchOpen, dispatch]);
44
43
 
44
+ const handleSearchTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ setSearchText(e.target.value);
46
+ };
47
+
48
+ const handleCloseSearch = () => {
49
+ dispatch(closeSearch());
50
+ };
51
+
45
52
  return (
46
53
  <>
47
54
  <div
@@ -67,9 +74,9 @@ export default function Search() {
67
74
  {t('common.search.results_for')}
68
75
  </span>
69
76
  <div className="flex items-center">
70
- <input
77
+ <Input
71
78
  value={searchText}
72
- onChange={(e) => setSearchText(e.target.value)}
79
+ onChange={handleSearchTextChange}
73
80
  onKeyDown={(e) => {
74
81
  if (e.key === 'Enter' && searchText.trim() !== '') {
75
82
  router.push(`${ROUTES.LIST}/?search_text=${searchText}`);
@@ -91,11 +98,12 @@ export default function Search() {
91
98
  <Icon
92
99
  name="close"
93
100
  size={14}
94
- onClick={() => dispatch(closeSearch())}
101
+ onClick={handleCloseSearch}
95
102
  className="cursor-pointer"
96
103
  />
97
104
  </div>
98
105
  </div>
106
+
99
107
  <Results searchText={searchText} />
100
108
  </div>
101
109
  </div>
@@ -0,0 +1,217 @@
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
+ });
@@ -6,4 +6,5 @@ export { default as ProductGroupInfo } from './product-group-info';
6
6
  export { default as MiscButtons } from './misc-buttons';
7
7
  export { default as ProductInfoSlider } from './slider';
8
8
  export { default as AccordionWrapper } from './accordion-wrapper';
9
+ export { default as ProductRateReview } from './rate-review';
9
10
  export { default as CombineProductCard } from './combine-product-card';
@@ -13,6 +13,7 @@ import { useStockAlert } from '../../hooks/use-stock-alert';
13
13
  import { ProductVariants } from './product-variants';
14
14
  import { ProductActions } from './product-actions';
15
15
  import { ProductShare } from './product-share';
16
+ import { ProductOffers } from './product-offers';
16
17
  import PluginModule, { Component } from '@akinon/next/components/plugin-module';
17
18
 
18
19
  export default function ProductInfo({ data }: ProductPageProps) {
@@ -73,6 +74,17 @@ export default function ProductInfo({ data }: ProductPageProps) {
73
74
 
74
75
  return (
75
76
  <>
77
+ <PluginModule
78
+ component={Component.RateReviewBadge}
79
+ props={{
80
+ product: {
81
+ sku: data.product.sku,
82
+ base_code: data.product.base_code
83
+ },
84
+ classNames: { root: 'mt-2' }
85
+ }}
86
+ />
87
+
76
88
  <div
77
89
  className={clsx(
78
90
  'fixed bottom-0 left-0 w-1/2 h-14 z-[20] bg-white mt-0 border-t border-gray-500 items-center justify-center',
@@ -121,6 +133,8 @@ export default function ProductInfo({ data }: ProductPageProps) {
121
133
  />
122
134
 
123
135
  <ProductShare productName={data.product.name} className="my-2 sm:mb-4" />
136
+
137
+ <ProductOffers offers={data.offers} productPk={data.product.pk} />
124
138
  </>
125
139
  );
126
140
  }