此文已使用 Salesforce 機器翻譯系統翻譯。更多詳細資料請參見此處。
假設您想要在頁面上新增一個元件,但目前沒有可以覆寫的現有元件。頁面級擴充性可解決這個問題。在這篇教學中,我們將使用範本擴充性,為頁面新增元件來自訂產品詳細資料頁面 (PDP)。在此範例中,我們將會為 PDP 新增促銷橫幅。
在執行本教學的命令之前,請以實際值取代預留位置。預留位置的格式為:$PLACEHOLDER
使用頁面右側的標題瀏覽本教學。
Tip
若要完成本教學,請執行以下任一操作:
使用以 PWA Kit 版本 3.x 建立的專案。
或
如果沒有專案,請執行以下命令,建立 PWA Kit 專案:
npx @salesforce/pwa-kit-create-app@latest ——outputDir $PATH/TO/NEW/LOCAL/PROJECT
overrides/app/components 下,建立名為 banner 的資料夾。banner 資料夾中,建立名為 index.jsx 的檔案。overrides/app/pages 下,建立名為 product-detail 的資料夾。product-detail 資料夾中,建立名為 index.jsx 的檔案。新資料夾與檔案具有以下結構:

將產品詳細資料路由新增至 overrides/app/routes.jsx 中的路由清單:
1const routes = [
2 {
3 path: "/",
4 component: Home,
5 exact: true,
6 },
7 {
8 path: "/my-new-route",
9 component: MyNewRoute,
10 },
11 // Add a product detail route.
12 {
13 path: "/product/:productId",
14 component: ProductDetail,
15 },
16 ..._routes,
17];將此程式碼新增至 overrides/app/components/banner/index.jsx:
1import React from "react";
2import { Box, Flex, Text } from "@chakra-ui/react";
3import Link from "@salesforce/retail-react-app/app/components/link";
4const BannerWithImage = () => {
5 return (
6 <Link to="/category/womens" bg="teal.200">
7 <Box py={4} maxH="150px" overflow="hidden">
8 <Flex
9 maxW="1200px"
10 mx="auto"
11 align="center"
12 justify="center"
13 position="relative"
14 >
15 <Text fontSize="2xl" fontWeight="bold" color="black">
16 Flash Sale on Women's Clothes. Up to 50% Off.
17 </Text>
18 </Flex>
19 </Box>
20 </Link>
21 );
22};
23
24export default BannerWithImage;將以下程式碼新增至 overrides/app/pages/product-detail/index.jsx。
我們希望維持 PDP 目前的版面配置,只是在頁面上新增一個元件。因此,我們會將基本範本中的程式碼複製到新建立的 index.jsx 檔案中,來重複使用該程式碼。然後,我們就能根據需求自訂程式碼。
在此範例中:
BannerWithImage 元件。BannerWithImage 元件。1import React, { Fragment, useCallback, useEffect, useState } from "react";
2import PropTypes from "prop-types";
3import { Helmet } from "react-helmet";
4import { FormattedMessage, useIntl } from "react-intl";
5// This is line 5. Import your banner.
6import BannerWithImage from "../../components/banner";
7
8// Components
9import {
10 Box,
11 Button,
12 Stack,
13} from "@salesforce/retail-react-app/app/components/shared/ui";
14import {
15 useProduct,
16 useCategory,
17 useShopperBasketsMutation,
18 useShopperCustomersMutation,
19 useCustomerId,
20} from "@salesforce/commerce-sdk-react";
21
22// Hooks
23import { useCurrentBasket } from "@salesforce/retail-react-app/app/hooks/use-current-basket";
24import { useVariant } from "@salesforce/retail-react-app/app/hooks";
25import useNavigation from "@salesforce/retail-react-app/app/hooks/use-navigation";
26import useEinstein from "@salesforce/retail-react-app/app/hooks/use-einstein";
27import useActiveData from "@salesforce/retail-react-app/app/hooks/use-active-data";
28import { useServerContext } from "@salesforce/pwa-kit-react-sdk/ssr/universal/hooks";
29// Project Components
30import RecommendedProducts from "@salesforce/retail-react-app/app/components/recommended-products";
31import ProductView from "@salesforce/retail-react-app/app/components/product-view";
32import InformationAccordion from "@salesforce/retail-react-app/app/pages/product-detail/partials/information-accordion";
33
34import {
35 HTTPNotFound,
36 HTTPError,
37} from "@salesforce/pwa-kit-react-sdk/ssr/universal/errors";
38
39// constant
40import {
41 API_ERROR_MESSAGE,
42 EINSTEIN_RECOMMENDERS,
43 MAX_CACHE_AGE,
44 TOAST_ACTION_VIEW_WISHLIST,
45 TOAST_MESSAGE_ADDED_TO_WISHLIST,
46 TOAST_MESSAGE_ALREADY_IN_WISHLIST,
47} from "@salesforce/retail-react-app/app/constants";
48import { rebuildPathWithParams } from "@salesforce/retail-react-app/app/utils/url";
49import { useHistory, useLocation, useParams } from "react-router-dom";
50import { useToast } from "@salesforce/retail-react-app/app/hooks/use-toast";
51import { useWishList } from "@salesforce/retail-react-app/app/hooks/use-wish-list";
52
53const ProductDetail = () => {
54 const { formatMessage } = useIntl();
55 const history = useHistory();
56 const location = useLocation();
57 const einstein = useEinstein();
58 const activeData = useActiveData();
59 const toast = useToast();
60 const navigate = useNavigation();
61 const [productSetSelection, setProductSetSelection] = useState({});
62 const childProductRefs = React.useRef({});
63 const customerId = useCustomerId();
64 /****************************** Basket *********************************/
65 const { data: basket } = useCurrentBasket();
66 const addItemToBasketMutation = useShopperBasketsMutation("addItemToBasket");
67 const { res } = useServerContext();
68 if (res) {
69 res.set("Cache-Control", `s-maxage=${MAX_CACHE_AGE}`);
70 }
71 const isBasketLoading = !basket?.basketId;
72
73 /*************************** Product Detail and Category ********************/
74 const { productId } = useParams();
75 const urlParams = new URLSearchParams(location.search);
76 const {
77 data: product,
78 isLoading: isProductLoading,
79 isError: isProductError,
80 error: productError,
81 } = useProduct(
82 {
83 parameters: {
84 id: urlParams.get("pid") || productId,
85 allImages: true,
86 },
87 },
88 {
89 // When shoppers select a different variant (and the app fetches the new data),
90 // the old data is still rendered (and not the skeletons).
91 keepPreviousData: true,
92 }
93 );
94
95 // Note: Since category needs id from product detail, it can't be server side rendered atm
96 // until we can do dependent query on server
97 const {
98 data: category,
99 isError: isCategoryError,
100 error: categoryError,
101 } = useCategory({
102 parameters: {
103 id: product?.primaryCategoryId,
104 levels: 1,
105 },
106 });
107
108 /**************** Error Handling ****************/
109
110 if (isProductError) {
111 const errorStatus = productError?.response?.status;
112 switch (errorStatus) {
113 case 404:
114 throw new HTTPNotFound("Product Not Found.");
115 default:
116 throw new HTTPError(`HTTP Error ${errorStatus} occurred.`);
117 }
118 }
119 if (isCategoryError) {
120 const errorStatus = categoryError?.response?.status;
121 switch (errorStatus) {
122 case 404:
123 throw new HTTPNotFound("Category Not Found.");
124 default:
125 throw new HTTPError(`HTTP Error ${errorStatus} occurred.`);
126 }
127 }
128
129 const isProductASet = product?.type.set;
130
131 const [primaryCategory, setPrimaryCategory] = useState(category);
132 const variant = useVariant(product);
133 // This page uses the `primaryCategoryId` to retrieve the category data. This attribute
134 // is only available on `master` products. Since a variation will be loaded once all the
135 // attributes are selected (to get the correct inventory values), the category information
136 // is overridden. This will allow us to keep the initial category around until a different
137 // master product is loaded.
138 useEffect(() => {
139 if (category) {
140 setPrimaryCategory(category);
141 }
142 }, [category]);
143
144 /**************** Product Variant ****************/
145 useEffect(() => {
146 if (!variant) {
147 return;
148 }
149 // update the variation attributes parameter on
150 // the url accordingly as the variant changes
151 const updatedUrl = rebuildPathWithParams(
152 `${location.pathname}${location.search}`,
153 {
154 pid: variant?.productId,
155 }
156 );
157 history.replace(updatedUrl);
158 }, [variant]);
159
160 /**************** Wishlist ****************/
161 const { data: wishlist, isLoading: isWishlistLoading } = useWishList();
162 const createCustomerProductListItem = useShopperCustomersMutation(
163 "createCustomerProductListItem"
164 );
165
166 const handleAddToWishlist = (product, variant, quantity) => {
167 const isItemInWishlist = wishlist?.customerProductListItems?.find(
168 (i) => i.productId === variant?.productId || i.productId === product?.id
169 );
170
171 if (!isItemInWishlist) {
172 createCustomerProductListItem.mutate(
173 {
174 parameters: {
175 listId: wishlist.id,
176 customerId,
177 },
178 body: {
179 // NOTE: APi does not respect quantity, it always adds 1
180 quantity,
181 productId: variant?.productId || product?.id,
182 public: false,
183 priority: 1,
184 type: "product",
185 },
186 },
187 {
188 onSuccess: () => {
189 toast({
190 title: formatMessage(TOAST_MESSAGE_ADDED_TO_WISHLIST, {
191 quantity: 1,
192 }),
193 status: "success",
194 action: (
195 // it would be better if we could use <Button as={Link}>
196 // but unfortunately the Link component is not compatible
197 // with Chakra Toast, since the ToastManager is rendered via portal
198 // and the toast doesn't have access to intl provider, which is a
199 // requirement of the Link component.
200 <Button
201 variant="link"
202 onClick={() => navigate("/account/wishlist")}
203 >
204 {formatMessage(TOAST_ACTION_VIEW_WISHLIST)}
205 </Button>
206 ),
207 });
208 },
209 onError: () => {
210 showError();
211 },
212 }
213 );
214 } else {
215 toast({
216 title: formatMessage(TOAST_MESSAGE_ALREADY_IN_WISHLIST),
217 status: "info",
218 action: (
219 <Button variant="link" onClick={() => navigate("/account/wishlist")}>
220 {formatMessage(TOAST_ACTION_VIEW_WISHLIST)}
221 </Button>
222 ),
223 });
224 }
225 };
226
227 /**************** Add To Cart ****************/
228 const showToast = useToast();
229 const showError = () => {
230 showToast({
231 title: formatMessage(API_ERROR_MESSAGE),
232 status: "error",
233 });
234 };
235
236 const handleAddToCart = async (productSelectionValues) => {
237 try {
238 const productItems = productSelectionValues.map(
239 ({ variant, quantity }) => ({
240 productId: variant.productId,
241 price: variant.price,
242 quantity,
243 })
244 );
245
246 await addItemToBasketMutation.mutateAsync({
247 parameters: { basketId: basket.basketId },
248 body: productItems,
249 });
250
251 einstein.sendAddToCart(productItems);
252
253 // If the items were successfully added, set the return value to be used
254 // by the add to cart modal.
255 return productSelectionValues;
256 } catch (error) {
257 showError(error);
258 }
259 };
260
261 /**************** Product Set Handlers ****************/
262 const handleProductSetValidation = useCallback(() => {
263 // Run validation for all child products. This will ensure the error
264 // messages are shown.
265 Object.values(childProductRefs.current).forEach(
266 ({ validateOrderability }) => {
267 validateOrderability({ scrollErrorIntoView: false });
268 }
269 );
270
271 // Using ot state for which child products are selected, scroll to the first
272 // one that isn't selected.
273 const selectedProductIds = Object.keys(productSetSelection);
274 const firstUnselectedProduct = product.setProducts.find(
275 ({ id }) => !selectedProductIds.includes(id)
276 );
277
278 if (firstUnselectedProduct) {
279 // Get the reference to the product view and scroll to it.
280 const { ref } = childProductRefs.current[firstUnselectedProduct.id];
281
282 if (ref.scrollIntoView) {
283 ref.scrollIntoView({
284 behavior: "smooth",
285 block: "end",
286 });
287 }
288
289 return false;
290 }
291
292 return true;
293 }, [product, productSetSelection]);
294
295 const handleProductSetAddToCart = () => {
296 // Get all the selected products, and pass them to the addToCart handler which
297 // accepts an array.
298 const productSelectionValues = Object.values(productSetSelection);
299 return handleAddToCart(productSelectionValues);
300 };
301
302 /**************** Einstein ****************/
303 useEffect(() => {
304 if (product && product.type.set) {
305 einstein.sendViewProduct(product);
306 const childrenProducts = product.setProducts;
307 childrenProducts.map((child) => {
308 try {
309 einstein.sendViewProduct(child);
310 } catch (err) {
311 console.error(err);
312 }
313 activeData.sendViewProduct(category, child, "detail");
314 });
315 } else if (product) {
316 try {
317 einstein.sendViewProduct(product);
318 } catch (err) {
319 console.error(err);
320 }
321 activeData.sendViewProduct(category, product, "detail");
322 }
323 }, [product]);
324
325 return (
326 <Box
327 className="sf-product-detail-page"
328 layerStyle="page"
329 data-testid="product-details-page"
330 >
331 <Helmet>
332 <title>{product?.pageTitle}</title>
333 <meta name="description" content={product?.pageDescription} />
334 </Helmet>
335
336 <Stack spacing={16}>
337 // This is line 333. Add the banner component to the page.
338 <BannerWithImage />
339 {isProductASet ? (
340 <Fragment>
341 {/* Product Set: parent product */}
342 <ProductView
343 product={product}
344 category={primaryCategory?.parentCategoryTree || []}
345 addToCart={handleProductSetAddToCart}
346 addToWishlist={handleAddToWishlist}
347 isProductLoading={isProductLoading}
348 isBasketLoading={isBasketLoading}
349 isWishlistLoading={isWishlistLoading}
350 validateOrderability={handleProductSetValidation}
351 />
352
353 <hr />
354
355 {/* TODO: consider `childProduct.belongsToSet` */}
356 {
357 // Product Set: render the child products
358 product.setProducts.map((childProduct) => (
359 <Box key={childProduct.id} data-testid="child-product">
360 <ProductView
361 // Do no use an arrow function as we are manipulating the functions scope.
362 ref={function (ref) {
363 // Assign the "set" scope of the ref, this is how we access the internal
364 // validation.
365 childProductRefs.current[childProduct.id] = {
366 ref,
367 validateOrderability: this.validateOrderability,
368 };
369 }}
370 product={childProduct}
371 isProductPartOfSet={true}
372 addToCart={(variant, quantity) =>
373 handleAddToCart([
374 { product: childProduct, variant, quantity },
375 ])
376 }
377 addToWishlist={handleAddToWishlist}
378 onVariantSelected={(product, variant, quantity) => {
379 if (quantity) {
380 setProductSetSelection((previousState) => ({
381 ...previousState,
382 [product.id]: {
383 product,
384 variant,
385 quantity,
386 },
387 }));
388 } else {
389 const selections = { ...productSetSelection };
390 delete selections[product.id];
391 setProductSetSelection(selections);
392 }
393 }}
394 isProductLoading={isProductLoading}
395 isBasketLoading={isBasketLoading}
396 isWishlistLoading={isWishlistLoading}
397 />
398 <InformationAccordion product={childProduct} />
399
400 <Box display={["none", "none", "none", "block"]}>
401 <hr />
402 </Box>
403 </Box>
404 ))
405 }
406 </Fragment>
407 ) : (
408 <Fragment>
409 <ProductView
410 product={product}
411 category={primaryCategory?.parentCategoryTree || []}
412 addToCart={(variant, quantity) =>
413 handleAddToCart([{ product, variant, quantity }])
414 }
415 addToWishlist={handleAddToWishlist}
416 isProductLoading={isProductLoading}
417 isBasketLoading={isBasketLoading}
418 isWishlistLoading={isWishlistLoading}
419 />
420 <InformationAccordion product={product} />
421 </Fragment>
422 )}
423 {/* Product Recommendations */}
424 <Stack spacing={16}>
425 {!isProductASet && (
426 <RecommendedProducts
427 title={
428 <FormattedMessage
429 defaultMessage="Complete the Set"
430 id="product_detail.recommended_products.title.complete_set"
431 />
432 }
433 recommender={EINSTEIN_RECOMMENDERS.PDP_COMPLETE_SET}
434 products={[product]}
435 mx={{ base: -4, md: -8, lg: 0 }}
436 shouldFetch={() => product?.id}
437 />
438 )}
439 <RecommendedProducts
440 title={
441 <FormattedMessage
442 defaultMessage="You might also like"
443 id="product_detail.recommended_products.title.might_also_like"
444 />
445 }
446 recommender={EINSTEIN_RECOMMENDERS.PDP_MIGHT_ALSO_LIKE}
447 products={[product]}
448 mx={{ base: -4, md: -8, lg: 0 }}
449 shouldFetch={() => product?.id}
450 />
451
452 <RecommendedProducts
453 // The Recently Viewed recommender doesn't use `products`, so instead we
454 // provide a key to update the recommendations on navigation.
455 key={location.key}
456 title={
457 <FormattedMessage
458 defaultMessage="Recently Viewed"
459 id="product_detail.recommended_products.title.recently_viewed"
460 />
461 }
462 recommender={EINSTEIN_RECOMMENDERS.PDP_RECENTLY_VIEWED}
463 mx={{ base: -4, md: -8, lg: 0 }}
464 />
465 </Stack>
466 </Stack>
467 </Box>
468 );
469};
470
471ProductDetail.getTemplateName = () => "product-detail";
472
473ProductDetail.propTypes = {
474 /**
475 * The current react router match object. (Provided internally)
476 */
477 match: PropTypes.object,
478};
479
480export default ProductDetail;npm start。每個 PDP 都會顯示您的客戶橫幅,如下例所示。在這個情況下,當您按一下橫幅時,它會帶您到女士產品清單頁面。
