diff --git a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx index 4278def31c..144e997589 100644 --- a/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/brand/[slug]/page.tsx @@ -212,6 +212,7 @@ export default async function Brand(props: Props) { compareLabel={t('Compare.compare')} compareProducts={streamableCompareProducts} emptyStateSubtitle={t('Brand.Empty.subtitle')} + promotionCalloutsMoreLabel={t('Brand.PromotionCallouts.more')} emptyStateTitle={t('Brand.Empty.title')} filterLabel={t('FacetedSearch.filters')} filters={streamableFilters} diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index ee143281b5..a3199eed53 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -251,6 +251,7 @@ export default async function Category(props: Props) { { + return ( + baseProduct.featuredPromotions?.map((p) => ({ + id: p.entityId.toString(), + text: p.text, + })) ?? [] + ); + }); + const streameableAccordions = Streamable.from(async () => { const product = await streamableProduct; @@ -583,6 +592,7 @@ export default async function Product({ params, searchParams }: Props) { maxQuantity: streamableMaxQuantity, stockDisplayData: streamableStockDisplayData, backorderDisplayData: streamableBackorderDisplayData, + promotionCallouts: streamablePromotionCallouts, }} quantityLabel={t('ProductDetails.quantity')} recaptchaSiteKey={recaptchaSiteKey} diff --git a/core/components/product-card/fragment.ts b/core/components/product-card/fragment.ts index 4c5d18e7af..11cc97c597 100644 --- a/core/components/product-card/fragment.ts +++ b/core/components/product-card/fragment.ts @@ -46,6 +46,10 @@ export const ProductCardFragment = graphql( } } } + featuredPromotions { + entityId + text + } ...PricingFragment } `, diff --git a/core/data-transformers/product-card-transformer.ts b/core/data-transformers/product-card-transformer.ts index 719cec9c04..6d9a0bd08d 100644 --- a/core/data-transformers/product-card-transformer.ts +++ b/core/data-transformers/product-card-transformer.ts @@ -67,6 +67,13 @@ export const singleProductCardTransformer = ( 'variants' in product ? getInventoryMessage(product, outOfStockMessage, showBackorderMessage) : undefined, + promotionCallouts: + 'featuredPromotions' in product + ? product.featuredPromotions.map((p) => ({ + id: p.entityId.toString(), + text: p.text, + })) + : [], }; }; diff --git a/core/lib/makeswift/components/product-card/client.tsx b/core/lib/makeswift/components/product-card/client.tsx new file mode 100644 index 0000000000..b08ac01e26 --- /dev/null +++ b/core/lib/makeswift/components/product-card/client.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useLocale } from 'next-intl'; +import useSWR from 'swr'; + +import { + BcProductSchema, + useBcProductToVibesProduct, +} from '~/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product'; +import { ProductCard, ProductCardSkeleton } from '~/vibes/soul/primitives/product-card'; + +interface Props { + className?: string; + entityId?: string; + aspectRatio: '1:1' | '5:6' | '3:4'; + colorScheme: 'light' | 'dark'; + badge: { show: boolean; text: string }; + showCompare?: boolean; + showPromotionCallouts?: boolean; +} + +export function MakeswiftProductCard({ + className, + entityId, + badge, + showPromotionCallouts = true, + ...props +}: Props) { + const bcProductToVibesProduct = useBcProductToVibesProduct(); + const locale = useLocale(); + const { data, isLoading } = useSWR( + entityId ? `/api/products/${entityId}?locale=${locale}` : null, + async (url) => + fetch(url) + .then((r) => r.json()) + .then(BcProductSchema.parse), + ); + + if (entityId == null || isLoading || data == null) { + return ; + } + + const product = bcProductToVibesProduct(data); + + return ( + + ); +} diff --git a/core/lib/makeswift/components/product-card/register.ts b/core/lib/makeswift/components/product-card/register.ts new file mode 100644 index 0000000000..fb5e8a2da7 --- /dev/null +++ b/core/lib/makeswift/components/product-card/register.ts @@ -0,0 +1,56 @@ +import { Checkbox, Combobox, Group, Select, Style, TextInput } from '@makeswift/runtime/controls'; + +import { runtime } from '~/lib/makeswift/runtime'; + +import { searchProducts } from '../../utils/search-products'; + +import { MakeswiftProductCard } from './client'; + +runtime.registerComponent(MakeswiftProductCard, { + type: 'catalog-product-card', + label: 'Catalog / Product Card', + props: { + className: Style(), + entityId: Combobox({ + label: 'Product', + async getOptions(query) { + const products = await searchProducts(query); + + return products.map((product) => ({ + id: product.entityId.toString(), + label: product.name, + value: product.entityId.toString(), + })); + }, + }), + aspectRatio: Select({ + label: 'Image aspect ratio', + options: [ + { value: '1:1', label: 'Square' }, + { value: '5:6', label: '5:6' }, + { value: '3:4', label: '3:4' }, + ], + defaultValue: '5:6', + }), + colorScheme: Select({ + label: 'Color scheme', + options: [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + ], + defaultValue: 'light', + }), + badge: Group({ + label: 'Badge', + props: { + show: Checkbox({ label: 'Show badge', defaultValue: true }), + text: TextInput({ label: 'Badge text', defaultValue: 'New' }), + }, + }), + showCompare: Checkbox({ label: 'Show compare', defaultValue: true }), + showPromotionCallouts: Checkbox({ + label: 'Show promotion callouts', + defaultValue: true, + }), + }, +}); diff --git a/core/lib/makeswift/components/product-detail/client.tsx b/core/lib/makeswift/components/product-detail/client.tsx new file mode 100644 index 0000000000..394c3263a5 --- /dev/null +++ b/core/lib/makeswift/components/product-detail/client.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React, { + type ComponentPropsWithoutRef, + createContext, + forwardRef, + type PropsWithChildren, + type ReactNode, + type Ref, + useCallback, + useContext, +} from 'react'; + +import { Stream, type Streamable } from '@/vibes/soul/lib/streamable'; +import { ProductDetail, ProductDetailSkeleton } from '@/vibes/soul/sections/product-detail'; +import { mergeSections } from '~/lib/makeswift/utils/merge-sections'; + +type VibesProductDetailProps = ComponentPropsWithoutRef; +type VibesProductDetail = Exclude, null>; + +export type ProductDetail = VibesProductDetail & { + plainTextDescription?: string; +}; + +export type Props = Omit & { + product: Streamable; +}; + +const PropsContext = createContext(null); + +export const PropsContextProvider = ({ value, children }: PropsWithChildren<{ value: Props }>) => ( + {children} +); + +export const DescriptionSource = { + CatalogPlainText: 'CatalogPlainText', + CatalogRichText: 'CatalogRichText', + Custom: 'Custom', +} as const; + +type DescriptionSource = (typeof DescriptionSource)[keyof typeof DescriptionSource]; + +interface EditableProps { + summaryText: string | undefined; + description: { source: DescriptionSource; slot: ReactNode }; + accordions: Exclude, undefined>; +} + +const ProductDetailImpl = ({ + summaryText, + description, + accordions, + product: streamableProduct, + ...props +}: Props & EditableProps) => { + const getProductDescription = useCallback( + (product: ProductDetail): ProductDetail['description'] => { + switch (description.source) { + case DescriptionSource.CatalogPlainText: + return product.plainTextDescription; + + case DescriptionSource.CatalogRichText: + return product.description; + + case DescriptionSource.Custom: + return description.slot; + } + }, + [description.source, description.slot], + ); + + const getProductAccordions = useCallback( + ( + productAccordions: Awaited, + ): Awaited => + productAccordions != null + ? mergeSections(productAccordions, accordions, (left, right) => ({ + ...left, + content: right.content, + })) + : undefined, + [accordions], + ); + + return ( + } value={streamableProduct}> + {(product) => ( + } value={product.accordions}> + {(productAccordions) => ( + + )} + + )} + + ); +}; + +export const MakeswiftProductDetail = forwardRef( + (props: EditableProps, ref: Ref) => { + const passedProps = useContext(PropsContext); + + if (passedProps == null) { + // eslint-disable-next-line no-console + console.error('No context provided for MakeswiftProductDetail'); + + return

There was an error rendering the product detail.

; + } + + return ( +
+ +
+ ); + }, +); diff --git a/core/lib/makeswift/components/products-list/client.tsx b/core/lib/makeswift/components/products-list/client.tsx new file mode 100644 index 0000000000..5c78d3e473 --- /dev/null +++ b/core/lib/makeswift/components/products-list/client.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ComponentPropsWithoutRef } from 'react'; + +import { ProductList, ProductListSkeleton } from '@/vibes/soul/sections/product-list'; + +import { useProducts } from '../../utils/use-products'; + +type MSProductsListProps = Omit, 'products'> & { + className: string; + collection: 'none' | 'best-selling' | 'newest' | 'featured'; + limit: number; + additionalProducts: Array<{ + entityId?: string; + }>; +}; + +export function MSProductsList({ + className, + collection, + limit, + additionalProducts, + ...props +}: MSProductsListProps) { + const additionalProductIds = additionalProducts.map(({ entityId }) => entityId ?? ''); + const { products, isLoading } = useProducts({ + collection, + collectionLimit: limit, + additionalProductIds, + }); + + if (isLoading) { + return ; + } + + if (products == null || products.length === 0) { + return ; + } + + return ; +} diff --git a/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts b/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts new file mode 100644 index 0000000000..94f15a611f --- /dev/null +++ b/core/lib/makeswift/utils/use-bc-product-to-vibes-product/use-bc-product-to-vibes-product.ts @@ -0,0 +1,66 @@ +import { useFormatter } from 'next-intl'; +import { useCallback } from 'react'; +import { string, z } from 'zod'; + +import { Product } from '@/vibes/soul/primitives/product-card'; +import { pricesTransformer } from '~/data-transformers/prices-transformer'; + +const priceSchema = z.object({ + value: z.number(), + currencyCode: z.string(), +}); + +const PricesSchema = z.object({ + price: priceSchema, + basePrice: priceSchema.nullable(), + retailPrice: priceSchema.nullable(), + salePrice: priceSchema.nullable(), + priceRange: z.object({ + min: priceSchema, + max: priceSchema, + }), +}); + +const FeaturedPromotionSchema = z.object({ + entityId: z.number(), + text: z.string(), +}); + +export const BcProductSchema = z.object({ + entityId: z.number(), + name: z.string(), + defaultImage: z.object({ altText: z.string(), url: string() }).nullable(), + brand: z.object({ name: z.string(), path: z.string() }).nullable(), + path: z.string(), + prices: PricesSchema, + featuredPromotions: z.array(FeaturedPromotionSchema).optional().default([]), +}); + +export type BcProductSchema = z.infer; + +export type { Product }; + +export function useBcProductToVibesProduct(): (product: BcProductSchema) => Product { + const format = useFormatter(); + + return useCallback( + (product) => { + const { entityId, name, defaultImage, brand, path, prices, featuredPromotions } = product; + const price = pricesTransformer(prices, format); + + return { + id: entityId.toString(), + title: name, + href: path, + image: defaultImage ? { src: defaultImage.url, alt: defaultImage.altText } : undefined, + price, + subtitle: brand?.name, + promotionCallouts: featuredPromotions.map((p) => ({ + id: p.entityId.toString(), + text: p.text, + })), + }; + }, + [format], + ); +} diff --git a/core/messages/en.json b/core/messages/en.json index 56a54ac058..5100b3d2a9 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -124,6 +124,9 @@ "Empty": { "title": "No products in this brand", "subtitle": "Try using different filters." + }, + "PromotionCallouts": { + "more": "+{count} more" } }, "Category": { @@ -131,6 +134,9 @@ "Empty": { "title": "No products in this category", "subtitle": "Try using different filters." + }, + "PromotionCallouts": { + "more": "+{count} more" } }, "Search": { @@ -144,6 +150,9 @@ "Empty": { "title": "Sorry, no results for \"{term}\".", "subtitle": "Please try another search." + }, + "PromotionCallouts": { + "more": "+{count} more" } }, "FacetedSearch": { @@ -480,6 +489,9 @@ "sku": "SKU", "weight": "Weight", "condition": "Condition" + }, + "PromotionCallouts": { + "more": "+{count} more" } }, "RelatedProducts": { diff --git a/core/vibes/soul/primitives/product-card/index.tsx b/core/vibes/soul/primitives/product-card/index.tsx index 1571440471..2fc5326b0a 100644 --- a/core/vibes/soul/primitives/product-card/index.tsx +++ b/core/vibes/soul/primitives/product-card/index.tsx @@ -2,6 +2,10 @@ import { clsx } from 'clsx'; import { Badge } from '@/vibes/soul/primitives/badge'; import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label'; +import { + PromotionCallout, + PromotionCalloutItem, +} from '@/vibes/soul/primitives/promotion-callout'; import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { Image } from '~/components/image'; import { Link } from '~/components/link'; @@ -21,6 +25,7 @@ export interface Product { rating?: number; inventoryMessage?: string; numberOfReviews?: number; + promotionCallouts?: PromotionCalloutItem[]; } export interface ProductCardProps { @@ -34,6 +39,7 @@ export interface ProductCardProps { compareParamName?: string; product: Product; showRating?: boolean; + promotionCalloutsMoreLabel?: string; } // eslint-disable-next-line valid-jsdoc @@ -70,6 +76,7 @@ export function ProductCard({ inventoryMessage, rating, numberOfReviews, + promotionCallouts, }, showRating = false, colorScheme = 'light', @@ -78,6 +85,7 @@ export function ProductCard({ aspectRatio = '5:6', compareLabel, compareParamName, + promotionCalloutsMoreLabel, imagePriority = false, imageSizes = '(min-width: 80rem) 20vw, (min-width: 64rem) 25vw, (min-width: 42rem) 33vw, (min-width: 24rem) 50vw, 100vw', }: ProductCardProps) { @@ -165,6 +173,14 @@ export function ProductCard({ )} {price != null && } + {promotionCallouts != null && promotionCallouts.length > 0 && ( + + )} {showRating && typeof rating === 'number' && rating > 0 && ( )} diff --git a/core/vibes/soul/primitives/promotion-callout/index.tsx b/core/vibes/soul/primitives/promotion-callout/index.tsx new file mode 100644 index 0000000000..c986892f49 --- /dev/null +++ b/core/vibes/soul/primitives/promotion-callout/index.tsx @@ -0,0 +1,63 @@ +import { clsx } from 'clsx'; +import { Tag } from 'lucide-react'; + +export interface PromotionCalloutItem { + id: string; + text: string; +} + +interface PromotionCalloutProps { + callouts: PromotionCalloutItem[]; + collapsed?: boolean; + moreLabel?: string; + className?: string; +} + +// eslint-disable-next-line valid-jsdoc +/** + * This component supports various CSS variables for theming. Here's a comprehensive list, along + * with their default values: + * + * ```css + * :root { + * --promotion-callout-text: hsl(var(--accent)); + * --promotion-callout-font-family: var(--font-family-body); + * --promotion-callout-icon-color: hsl(var(--accent)); + * } + * ``` + */ +export function PromotionCallout({ + callouts, + collapsed = false, + moreLabel = '+{count} more', + className, +}: PromotionCalloutProps) { + if (callouts.length === 0) { + return null; + } + + const visibleCallouts = collapsed ? callouts.slice(0, 1) : callouts; + const remainingCount = callouts.length - 1; + + return ( +
+ {visibleCallouts.map((callout) => ( +
+ + {callout.text} +
+ ))} + {collapsed && remainingCount > 0 && ( + + {moreLabel.replace('{count}', String(remainingCount))} + + )} +
+ ); +} diff --git a/core/vibes/soul/sections/product-detail/index.tsx b/core/vibes/soul/sections/product-detail/index.tsx index de0afc1c6d..03e81f7207 100644 --- a/core/vibes/soul/sections/product-detail/index.tsx +++ b/core/vibes/soul/sections/product-detail/index.tsx @@ -4,6 +4,10 @@ import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Accordion, AccordionItem } from '@/vibes/soul/primitives/accordion'; import { AnimatedUnderline } from '@/vibes/soul/primitives/animated-underline'; import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label'; +import { + PromotionCallout, + PromotionCalloutItem, +} from '@/vibes/soul/primitives/promotion-callout'; import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { type Breadcrumb, Breadcrumbs } from '@/vibes/soul/sections/breadcrumbs'; import { @@ -48,6 +52,7 @@ interface ProductDetailProduct { maxQuantity?: Streamable; stockDisplayData?: Streamable; backorderDisplayData?: Streamable; + promotionCallouts?: Streamable; } export interface ProductDetailProps { @@ -201,6 +206,17 @@ export function ProductDetail({ )} + {product.promotionCallouts != null && ( +
+ + {(callouts) => + callouts.length > 0 ? ( + + ) : null + } + +
+ )}
} value={product.images}> {(imagesData) => ( diff --git a/core/vibes/soul/sections/product-list/index.tsx b/core/vibes/soul/sections/product-list/index.tsx index 9439010a6f..129533598f 100644 --- a/core/vibes/soul/sections/product-list/index.tsx +++ b/core/vibes/soul/sections/product-list/index.tsx @@ -26,6 +26,7 @@ interface ProductListProps { removeLabel?: Streamable; maxItems?: number; maxCompareLimitMessage?: Streamable; + promotionCalloutsMoreLabel?: string; } // eslint-disable-next-line valid-jsdoc @@ -61,6 +62,7 @@ export function ProductList({ removeLabel: streamableRemoveLabel, maxItems, maxCompareLimitMessage: streamableMaxCompareLimitMessage, + promotionCalloutsMoreLabel, }: ProductListProps) { return ( diff --git a/core/vibes/soul/sections/products-list-section/index.tsx b/core/vibes/soul/sections/products-list-section/index.tsx index 6efc97fd94..3a0960b2ef 100644 --- a/core/vibes/soul/sections/products-list-section/index.tsx +++ b/core/vibes/soul/sections/products-list-section/index.tsx @@ -43,6 +43,7 @@ interface Props { removeLabel?: Streamable; maxItems?: number; maxCompareLimitMessage?: Streamable; + promotionCalloutsMoreLabel?: string; } export function ProductsListSection({ @@ -73,6 +74,7 @@ export function ProductsListSection({ removeLabel, maxItems, maxCompareLimitMessage, + promotionCalloutsMoreLabel, }: Props) { return (
@@ -172,6 +174,7 @@ export function ProductsListSection({ maxItems={maxItems} placeholderCount={placeholderCount} products={products} + promotionCalloutsMoreLabel={promotionCalloutsMoreLabel} removeLabel={removeLabel} showCompare={showCompare} showRating={showRating}