Setting the file. One moment.
Use Product Card · Wix Vibe Headless · wix/skills · Skills Docs
ContentsBack to the top of the page 22.10
Post Detail
(opens in a new tab)
references/storefront/app/hooks/ useProductCard.js
JavaScript · 104 lines · 6 KB
"@/lib/storeImage"
;
13 import { sellingPrice } from "@/rest/wix-store-catalog" ;
14
15 export function useProductCard ( product ) {
16 return useMemo (() => {
17 const status = product?.inventory?.availabilityStatus;
18 const isSoldOut = status === "OUT_OF_STOCK" ;
19 // preorderStatus is only meaningful when the product is out of stock
20 const isPreorder = isSoldOut && product?.inventory?.preorderStatus === "ENABLED" ;
21 const isPartiallyOutOfStock = status === "PARTIALLY_OUT_OF_STOCK" ;
22
23 // Left-side badges (stacked, top-left of the image): stock / pre-order state.
24 const leftBadges = [];
25 if (isPreorder) leftBadges. push ({ type: "pre-order" , label: "Pre-order" });
26 else if (isSoldOut) leftBadges. push ({ type: "sold-out" , label: "Sold out" });
27 if (isPartiallyOutOfStock) leftBadges. push ({ type: "limited-stock" , label: "Limited stock" });
28
29 // Merchant ribbons — the primary one plus every additional one. Render ALL of them, in one shared
30 // style (a "Sale" accent is fine, applied by label). Never compute a "-20%" badge from the price
31 // range: a range's minimum says nothing about the variant the buyer picks, and a ribbon is a
32 // label, not a price claim.
33 const ribbons = [product?.ribbon?.name, ... (product?.additionalRibbons ?? []). map (( r ) => r?.name)]
34 . filter (( name , i , all ) => name && all. indexOf (name) === i);
35 const promoBadge = ribbons[ 0 ] ? { type: "ribbon" , label: ribbons[ 0 ] } : null ;
36
37 // Price. A single-price product shows the price the buyer pays — the lowest-priced variant's
38 // discounted price when an automatic discount applies (priceAfterDiscount), else its regular
39 // price — with the struck "was" price beside it. Variants priced differently show a min–max
40 // range with NO struck price: one lone "was" against a range implies a saving that may not
41 // apply to the variant the buyer picks; the PDP shows the real comparison once a variant is chosen.
42 const min = product?.actualPriceRange?.minValue;
43 const max = product?.actualPriceRange?.maxValue;
44 const isRange = !! (min?.amount && max?.amount && min.amount !== max.amount);
45 const minVariant = product?.variantSummary?.minPriceVariant ?? null ;
46 const { current , original } = sellingPrice (minVariant?.price);
47 const priceDisplay = isRange
48 ? `${ min ?. formattedAmount ?? ""} – ${ max ?. formattedAmount ?? ""}`
49 : current?.formattedAmount ?? min?.formattedAmount;
50 const compareAtDisplay =
51 ! isRange && original?.formattedAmount && Number (original.amount) > Number (current?.amount ?? min?.amount)
52 ? original.formattedAmount
53 : null ;
54
55 // Options preview for the tile summary row.
56 // Colour options → real hex dots (more informative than "3 colours").
57 // Non-colour options → "3 sizes · 2 materials" (pluralised from the merchant's own name).
58 const labels = [], colors = [];
59 for ( const o of product?.options || []) {
60 const choices = (o.choicesSettings?.choices || []). filter (( c ) => c.visible !== false );
61 if ( ! choices. length ) continue ;
62 const swatches = choices. map (( c ) => c.colorCode). filter (Boolean);
63 if (swatches. length ) { colors. push ( ... swatches); continue ; }
64 const n = o.name. toLowerCase ();
65 labels. push ( `${ choices . length } ${ choices . length === 1 || n . endsWith ( "s" ) ? n : `${ n }s`}` );
66 }
67 const optionLabel = labels. join ( " · " );
68 const hasOptions = (product?.options?. length || 0 ) > 0 ;
69
70 // Quick-add is only safe for single-variant products (no option choices to resolve).
71 // Sold-out with pre-order still shows a CTA, but it links to the PDP, not quick-add.
72 const isQuickAddable = ! hasOptions && ! isSoldOut;
73 // The variant a direct add sends (a product with no options still has one variant).
74 const directAddVariantId = isQuickAddable ? minVariant?.id ?? null : null ;
75
76 // plainDescription is an HTML string despite its name (<p>…</p>). A card wants plain text:
77 // strip the tags and cut at a word boundary, so no tile ever prints a literal "<p>".
78 const text = (product?.plainDescription || "" ). replace ( /< [ ^ >] + >/ g , " " ). replace ( / \s + / g , " " ). trim ();
79 const teaser = text. length > 140 ? `${ text . slice ( 0 , 140 ). replace ( / \s + \S *$ / , "" ) }…` : text;
80
81 // Images: normalised through lib/storeImage so URLs are consistent across the tile,
82 // the PDP gallery, and the cart. Hover image is the second gallery shot (if one exists).
83 const image = productImage (product);
84 const hoverImage = productGallery (product)[ 1 ]?.url ?? null ;
85
86 return {
87 isSoldOut,
88 isPreorder,
89 isPartiallyOutOfStock,
90 leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
91 ribbons, // every merchant ribbon label, primary first — render all of them
92 promoBadge, // { type: 'ribbon', label } | null — the primary ribbon, for a single-badge slot
93 priceDisplay, // formatted price the buyer pays, or a min–max range
94 compareAtDisplay, // formatted struck "was" price | null (never beside a range)
95 teaser, // plain-text description for the tile (tags stripped, ~140 chars) — never render plainDescription raw on a card
96 colors, // hex strings — render as dots; the tile shows up to however many you want
97 optionLabel, // "3 sizes · 2 materials" or empty string
98 isQuickAddable,
99 directAddVariantId, // pass as variantId to addToCart on quick-add
100 image, // primary image URL | null
101 hoverImage, // second image URL for hover effect | null
102 };
103 }, [product]);
104 }