@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app-template/CHANGELOG.md +69 -0
  3. package/app-template/docs/plugins.md +6 -0
  4. package/app-template/next-env.d.ts +1 -1
  5. package/app-template/package.json +29 -29
  6. package/app-template/public/locales/en/common.json +5 -0
  7. package/app-template/public/locales/en/product.json +6 -0
  8. package/app-template/public/locales/tr/common.json +5 -0
  9. package/app-template/public/locales/tr/product.json +6 -0
  10. package/app-template/src/__tests__/data-source-landing-page.test.tsx +132 -0
  11. package/app-template/src/app/[pz]/[...prettyurl]/page.tsx +14 -0
  12. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/page.tsx +21 -0
  13. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/loading.tsx +5 -0
  14. package/app-template/src/app/[pz]/data-source-landing-page/[pk]/page.tsx +62 -0
  15. package/app-template/src/app/[pz]/product/[pk]/page.tsx +7 -1
  16. package/app-template/src/types/widgets.ts +58 -0
  17. package/app-template/src/utils/__tests__/sanitize.test.ts +127 -0
  18. package/app-template/src/utils/sanitize.ts +167 -0
  19. package/app-template/src/views/category/category-info.tsx +15 -9
  20. package/app-template/src/views/data-source-page/__tests__/header.test.tsx +173 -0
  21. package/app-template/src/views/data-source-page/__tests__/layout.test.tsx +83 -0
  22. package/app-template/src/views/data-source-page/__tests__/sections.test.tsx +143 -0
  23. package/app-template/src/views/data-source-page/banner.tsx +45 -0
  24. package/app-template/src/views/data-source-page/footer.tsx +27 -0
  25. package/app-template/src/views/data-source-page/header.tsx +140 -0
  26. package/app-template/src/views/data-source-page/layout.tsx +45 -0
  27. package/app-template/src/views/data-source-page/products.tsx +55 -0
  28. package/app-template/src/views/data-source-page/stories.tsx +70 -0
  29. package/app-template/src/views/product/__tests__/product-offers.test.tsx +217 -0
  30. package/app-template/src/views/product/index.ts +1 -0
  31. package/app-template/src/views/product/product-info.tsx +14 -0
  32. package/app-template/src/views/product/product-offers.tsx +125 -0
  33. package/app-template/src/views/product/rate-review.tsx +21 -0
  34. package/app-template/src/views/product-item/index.tsx +5 -0
  35. package/app-template/tsconfig.json +3 -0
  36. package/commands/plugins.ts +4 -0
  37. package/dist/commands/plugins.js +4 -0
  38. package/package.json +1 -1
@@ -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
  }
@@ -0,0 +1,125 @@
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
+ };
@@ -0,0 +1,21 @@
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
+ }
@@ -10,6 +10,7 @@ import useFavButton from '../../hooks/use-fav-button';
10
10
  import { Product } from '@akinon/next/types';
11
11
  import { Image } from '@akinon/next/components/image';
12
12
  import { Price, Link } from '@theme/components';
13
+ import PluginModule, { Component } from '@akinon/next/components/plugin-module';
13
14
 
14
15
  interface Props {
15
16
  product: Product;
@@ -81,6 +82,10 @@ export const ProductItem = (props: Props) => {
81
82
  >
82
83
  {product_name}
83
84
  </Link>
85
+ <PluginModule
86
+ component={Component.RateReviewPlpBadge}
87
+ props={{ product }}
88
+ />
84
89
  <div className="font-semibold">
85
90
  {parseFloat(retail_price) > parseFloat(price) && (
86
91
  <Price
@@ -24,6 +24,9 @@
24
24
  ],
25
25
  "@flat-page/*": [
26
26
  "./app/[pz]/flat-page/*"
27
+ ],
28
+ "@data-source-landing-page/*": [
29
+ "./app/[pz]/data-source-landing-page/*"
27
30
  ]
28
31
  },
29
32
  "allowSyntheticDefaultImports": true,
@@ -180,6 +180,10 @@ export default async () => {
180
180
  name: 'Virtual Try-On',
181
181
  value: 'pz-virtual-try-on'
182
182
  },
183
+ {
184
+ name: 'Rate & Review',
185
+ value: 'pz-rate-review'
186
+ },
183
187
  {
184
188
  name: 'Masterpass Rest',
185
189
  value: 'pz-masterpass-rest'
@@ -199,6 +199,10 @@ exports.default = () => __awaiter(void 0, void 0, void 0, function* () {
199
199
  name: 'Virtual Try-On',
200
200
  value: 'pz-virtual-try-on'
201
201
  },
202
+ {
203
+ name: 'Rate & Review',
204
+ value: 'pz-rate-review'
205
+ },
202
206
  {
203
207
  name: 'Masterpass Rest',
204
208
  value: 'pz-masterpass-rest'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "2.0.152-rc.0",
3
+ "version": "2.0.152-rc.2",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {