From 70c07242b4fb8145b147633fe3987f21009b4fdb Mon Sep 17 00:00:00 2001 From: Rafael Youakeem Date: Thu, 12 Sep 2024 15:20:22 +0200 Subject: [PATCH] Rename ProductCard to ProductGridItem --- apps/store/src/blocks/ProductGridBlock.tsx | 6 +- .../ProductGridItemBlock.helpers.ts | 24 +++ .../ProductGridItemBlock.tsx} | 31 +--- .../components/ProductCard/ProductCard.tsx | 168 ------------------ .../ProductGrid/ProductGrid.stories.tsx | 10 +- .../ProductGridItem.css.ts} | 0 .../ProductGridItem.stories.tsx} | 13 +- .../ProductGridItem/ProductGridItem.tsx | 101 +++++++++++ .../ProductGridItemCategoryCTA.tsx | 50 ++++++ .../ProductGridItemProductCTA.tsx | 37 ++++ .../storyblok/commonStoryblokComponents.ts | 4 +- 11 files changed, 239 insertions(+), 205 deletions(-) create mode 100644 apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.helpers.ts rename apps/store/src/blocks/{ProductCardBlock.tsx => ProductGridItemBlock/ProductGridItemBlock.tsx} (58%) delete mode 100644 apps/store/src/components/ProductCard/ProductCard.tsx rename apps/store/src/components/{ProductCard/ProductCard.css.ts => ProductGridItem/ProductGridItem.css.ts} (100%) rename apps/store/src/components/{ProductCard/ProductCard.stories.tsx => ProductGridItem/ProductGridItem.stories.tsx} (72%) create mode 100644 apps/store/src/components/ProductGridItem/ProductGridItem.tsx create mode 100644 apps/store/src/components/ProductGridItem/ProductGridItemCategoryCTA.tsx create mode 100644 apps/store/src/components/ProductGridItem/ProductGridItemProductCTA.tsx diff --git a/apps/store/src/blocks/ProductGridBlock.tsx b/apps/store/src/blocks/ProductGridBlock.tsx index 1c01a5bc02..f73f6274f6 100644 --- a/apps/store/src/blocks/ProductGridBlock.tsx +++ b/apps/store/src/blocks/ProductGridBlock.tsx @@ -1,8 +1,8 @@ 'use client' import { storyblokEditable } from '@storyblok/react' -import type { ProductCardBlockProps } from '@/blocks/ProductCardBlock' -import { ProductCardBlock } from '@/blocks/ProductCardBlock' +import type { ProductCardBlockProps } from '@/blocks/ProductGridItemBlock/ProductGridItemBlock' +import { ProductGridItemBlock } from '@/blocks/ProductGridItemBlock/ProductGridItemBlock' import { ProductGrid } from '@/components/ProductGrid/ProductGrid' import type { ExpectedBlockType, SbBaseBlockProps } from '@/services/storyblok/storyblok' @@ -15,7 +15,7 @@ export const ProductGridBlock = ({ blok }: ProductGridBlockProps) => { return ( {blok.items.map((nestedBlock) => ( - + ))} ) diff --git a/apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.helpers.ts b/apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.helpers.ts new file mode 100644 index 0000000000..92fef430eb --- /dev/null +++ b/apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.helpers.ts @@ -0,0 +1,24 @@ +import { type useProductMetadata } from '@/components/LayoutWithMenu/productMetadataHooks' +import { isSameLink } from '@/utils/url' + + +export type LinkType = 'product' | 'category' + +export const getLinkType = ( + productMetadata: ReturnType = [], + link: string, +): LinkType | 'content' => { + const isProductLink = productMetadata?.some((product) => isSameLink(link, product.pageLink)) + if (isProductLink) { + return 'product' + } + + const isCategoryLink = productMetadata?.some( + (product) => product.categoryPageLink && isSameLink(link, product.categoryPageLink), + ) + if (isCategoryLink) { + return 'category' + } + + return 'content' +} diff --git a/apps/store/src/blocks/ProductCardBlock.tsx b/apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.tsx similarity index 58% rename from apps/store/src/blocks/ProductCardBlock.tsx rename to apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.tsx index 74c4ef51ed..8d9b253740 100644 --- a/apps/store/src/blocks/ProductCardBlock.tsx +++ b/apps/store/src/blocks/ProductGridItemBlock/ProductGridItemBlock.tsx @@ -1,10 +1,11 @@ 'use client' + import { storyblokEditable } from '@storyblok/react' import { useProductMetadata } from '@/components/LayoutWithMenu/productMetadataHooks' -import { type LinkType, ProductCard } from '@/components/ProductCard/ProductCard' +import { ProductGridItem } from '@/components/ProductGridItem/ProductGridItem' import type { LinkField, SbBaseBlockProps, StoryblokAsset } from '@/services/storyblok/storyblok' import { getImgSrc, getLinkFieldURL } from '@/services/storyblok/Storyblok.helpers' -import { isSameLink } from '@/utils/url' +import { getLinkType } from './ProductGridItemBlock.helpers' export type ImageSize = { aspectRatio?: '1 / 1' | '3 / 2' | '4 / 3' | '5 / 4' | '2 / 3' | '3 / 4' | '4 / 5' @@ -20,20 +21,21 @@ export type ProductCardBlockProps = SbBaseBlockProps< } & ImageSize > -export const ProductCardBlock = ({ blok }: ProductCardBlockProps) => { +export const ProductGridItemBlock = ({ blok }: ProductCardBlockProps) => { const link = getLinkFieldURL(blok.link, blok.title) const productMetadata = useProductMetadata() const linkType = getLinkType(productMetadata, link) if (linkType === 'content') { console.warn( - '[ProductCardBlock]: provided "link" does not refer to a product neither a category. Skipping ProductCard render!', + '[ProductGridItemBlock]: provided "link" does not refer to a product neither a category. Skipping ProductGridItem render!', ) + return null } return ( - { /> ) } - -const getLinkType = ( - productMetadata: ReturnType = [], - link: string, -): LinkType | 'content' => { - const isProductLink = productMetadata?.some((product) => isSameLink(link, product.pageLink)) - if (isProductLink) { - return 'product' - } - - const isCategoryLink = productMetadata?.some( - (product) => product.categoryPageLink && isSameLink(link, product.categoryPageLink), - ) - if (isCategoryLink) { - return 'category' - } - - return 'content' -} diff --git a/apps/store/src/components/ProductCard/ProductCard.tsx b/apps/store/src/components/ProductCard/ProductCard.tsx deleted file mode 100644 index dd5d6b5b5a..0000000000 --- a/apps/store/src/components/ProductCard/ProductCard.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { assignInlineVars } from '@vanilla-extract/dynamic' -import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { useTranslation } from 'next-i18next' -import { useState } from 'react' -import { Button, Text, sprinkles, visuallyHidden, breakpoints } from 'ui' -import type { ImageSize } from '@/blocks/ProductCardBlock' -import { ButtonNextLink } from '@/components/ButtonNextLink' -import * as FullscreenDialog from '@/components/FullscreenDialog/FullscreenDialog' -import { ImageWithPlaceholder } from '@/components/ImageWithPlaceholder/ImageWithPlaceholder' -import { useProductMetadata } from '@/components/LayoutWithMenu/productMetadataHooks' -import { OPEN_PRICE_CALCULATOR_QUERY_PARAM } from '@/components/ProductPage/PurchaseForm/useOpenPriceCalculatorQueryParam' -import { SelectInsuranceGrid } from '@/components/SelectInsuranceGrid/SelectInsuranceGrid' -import { Features } from '@/utils/Features' -import { isSameLink } from '@/utils/url' -import { - card, - image, - imageAspectRatio, - imageWrapper, - cardLinks, - selectInsuranceGrid, - readMoreLink, - mainLink, -} from './ProductCard.css' - -type ImageProps = { - src: string - alt?: string - blurDataURL?: string - objectPosition?: string - priority?: boolean -} - -export type LinkType = 'product' | 'category' - -export type ProductCardProps = { - title: string - subtitle: string - image: ImageProps - link: { url: string; type: LinkType } -} & ImageSize - -/* -SEO and a11y notes -- We want full element to be clickable (UX), so main link is an absolutely positioned overlay that covers full card - except bottom row -- We want keyboard focus to highlight "Read more" button (done with complex nested selector, see CSS part) -- Links cannot be nested, hence "Read more" is actually a button that navigated with 'onClick' - to avoid duplicate hrefs (SEO). It also has negative tabindex to make sure keyboard navigation only sees - main link and extra buttons - */ -export const ProductCard = ({ - title, - subtitle, - image: { alt = '', ...imageProps }, - aspectRatio, - link, -}: ProductCardProps) => { - const { t } = useTranslation('common') - const router = useRouter() - - // Hand-picked to match grid styles on ProductGrid - // Edge cases: - // - single column on small screens - // - 2-column grid with just 2 elements is displayed on wide monitor - const imageSizes = `(max-width: ${breakpoints.sm}px) 100vw, 50vw` - return ( -
-
- -
-
- - {title} - - - {subtitle} - -
- - {link.type === 'product' && } - {link.type === 'category' && } -
-
-
- ) -} - -const ProductCTA = ({ link }: Pick) => { - const { t } = useTranslation('common') - - const products = useProductMetadata() - const product = products?.find((product) => isSameLink(product.pageLink, link.url)) - if (product == null) { - console.warn(`Did not find product for link ${link.url}, skipping CTA render!`) - return - } - - let priceLink: { pathname: string; query?: Record } - if (Features.enabled('PRICE_CALCULATOR_PAGE') && product.priceCalculatorPageLink) { - priceLink = { pathname: product.priceCalculatorPageLink } - } else { - priceLink = { - pathname: product.pageLink, - query: { [OPEN_PRICE_CALCULATOR_QUERY_PARAM]: '1' }, - } - } - - return ( - - {t('GET_PRICE_LINK')} - - ) -} - -const CategoryCTA = ({ link }: Pick) => { - const [isOpen, setIsOpen] = useState(false) - const products = useProductMetadata() - const { t } = useTranslation('common') - - const productsByCategory = (products ?? []).filter( - (product) => product.categoryPageLink && isSameLink(product.categoryPageLink, link.url), - ) - - if (productsByCategory.length < 1) { - console.warn( - `[ProductCard]: No products category link ${link.url} were found. Skipping cta render!`, - ) - return null - } - - return ( - - - - - - - - {t('SELECT_INSURANCE')} - - - - - ) -} diff --git a/apps/store/src/components/ProductGrid/ProductGrid.stories.tsx b/apps/store/src/components/ProductGrid/ProductGrid.stories.tsx index b61c4daff3..e55a568a9a 100644 --- a/apps/store/src/components/ProductGrid/ProductGrid.stories.tsx +++ b/apps/store/src/components/ProductGrid/ProductGrid.stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryFn } from '@storybook/react' -import type { ProductCardProps } from '@/components/ProductCard/ProductCard' -import { ProductCard } from '@/components/ProductCard/ProductCard' +import { + ProductGridItem, + type ProductGridItemProps, +} from '@/components/ProductGridItem/ProductGridItem' import type { ProductGridProps } from './ProductGrid' import { ProductGrid } from './ProductGrid' @@ -16,13 +18,13 @@ const meta: Meta = { export default meta -type ProductItem = ProductCardProps +type ProductItem = ProductGridItemProps const Template: StoryFn }> = (props) => { return ( {props.items.map((itemProps) => ( - + ))} ) diff --git a/apps/store/src/components/ProductCard/ProductCard.css.ts b/apps/store/src/components/ProductGridItem/ProductGridItem.css.ts similarity index 100% rename from apps/store/src/components/ProductCard/ProductCard.css.ts rename to apps/store/src/components/ProductGridItem/ProductGridItem.css.ts diff --git a/apps/store/src/components/ProductCard/ProductCard.stories.tsx b/apps/store/src/components/ProductGridItem/ProductGridItem.stories.tsx similarity index 72% rename from apps/store/src/components/ProductCard/ProductCard.stories.tsx rename to apps/store/src/components/ProductGridItem/ProductGridItem.stories.tsx index 41790eb376..d11a95be13 100644 --- a/apps/store/src/components/ProductCard/ProductCard.stories.tsx +++ b/apps/store/src/components/ProductGridItem/ProductGridItem.stories.tsx @@ -1,9 +1,9 @@ import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport' import type { Meta, StoryObj } from '@storybook/react' -import { ProductCard } from './ProductCard' +import { ProductGridItem } from './ProductGridItem' -const meta: Meta = { - component: ProductCard, +const meta: Meta = { + component: ProductGridItem, argTypes: {}, parameters: { viewport: { @@ -15,7 +15,7 @@ const meta: Meta = { } export default meta -type Story = StoryObj +type Story = StoryObj export const Default: Story = { args: { @@ -27,4 +27,9 @@ export const Default: Story = { aspectRatio: '4 / 5', link: { url: '/', type: 'product' }, }, + parameters: { + nextjs: { + appDirectory: true, + }, + }, } diff --git a/apps/store/src/components/ProductGridItem/ProductGridItem.tsx b/apps/store/src/components/ProductGridItem/ProductGridItem.tsx new file mode 100644 index 0000000000..de2b7eb14c --- /dev/null +++ b/apps/store/src/components/ProductGridItem/ProductGridItem.tsx @@ -0,0 +1,101 @@ +import { assignInlineVars } from '@vanilla-extract/dynamic' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useTranslation } from 'next-i18next' +import { Button, Text, sprinkles, breakpoints } from 'ui' +import { ImageWithPlaceholder } from '@/components/ImageWithPlaceholder/ImageWithPlaceholder' +import type { ImageSize } from '../../blocks/ProductGridItemBlock/ProductGridItemBlock' +import { + card, + image, + imageAspectRatio, + imageWrapper, + cardLinks, + readMoreLink, + mainLink, +} from './ProductGridItem.css' +import { ProductGridItemBlockCategoryCTA } from './ProductGridItemCategoryCTA' +import { ProductGridItemBlockProductCTA } from './ProductGridItemProductCTA' + + +type ImageProps = { + src: string + alt?: string + blurDataURL?: string + objectPosition?: string + priority?: boolean +} + +export type LinkType = 'product' | 'category' + +export type ProductGridItemProps = { + title: string + subtitle: string + image: ImageProps + link: { url: string; type: LinkType } +} & ImageSize + +/* +SEO and a11y notes +- We want full element to be clickable (UX), so main link is an absolutely positioned overlay that covers full card + except bottom row +- We want keyboard focus to highlight "Read more" button (done with complex nested selector, see CSS part) +- Links cannot be nested, hence "Read more" is actually a button that navigated with 'onClick' + to avoid duplicate hrefs (SEO). It also has negative tabindex to make sure keyboard navigation only sees + main link and extra buttons + */ +export const ProductGridItem = ({ + title, + subtitle, + image: { alt = '', ...imageProps }, + aspectRatio, + link, +}: ProductGridItemProps) => { + const { t } = useTranslation('common') + const router = useRouter() + + // Hand-picked to match grid styles on ProductGrid + // Edge cases: + // - single column on small screens + // - 2-column grid with just 2 elements is displayed on wide monitor + const imageSizes = `(max-width: ${breakpoints.sm}px) 100vw, 50vw` + return ( +
+
+ +
+ +
+ + {title} + + + + {subtitle} + + +
+ + + {link.type === 'product' && } + {link.type === 'category' && } +
+
+
+ ) +} diff --git a/apps/store/src/components/ProductGridItem/ProductGridItemCategoryCTA.tsx b/apps/store/src/components/ProductGridItem/ProductGridItemCategoryCTA.tsx new file mode 100644 index 0000000000..6fa0886d37 --- /dev/null +++ b/apps/store/src/components/ProductGridItem/ProductGridItemCategoryCTA.tsx @@ -0,0 +1,50 @@ + +import { useTranslation } from 'next-i18next' +import { useState } from 'react' +import { Button, visuallyHidden } from 'ui' +import * as FullscreenDialog from '@/components/FullscreenDialog/FullscreenDialog' +import { useProductMetadata } from '@/components/LayoutWithMenu/productMetadataHooks' +import { SelectInsuranceGrid } from '@/components/SelectInsuranceGrid/SelectInsuranceGrid' +import { isSameLink } from '@/utils/url' +import { selectInsuranceGrid } from './ProductGridItem.css' + +type Props = { + url: string +} + +export const ProductGridItemBlockCategoryCTA = ({ url }: Props) => { + const [isOpen, setIsOpen] = useState(false) + + const products = useProductMetadata() + const { t } = useTranslation('common') + + const productsByCategory = (products ?? []).filter( + (product) => product.categoryPageLink && isSameLink(product.categoryPageLink, url), + ) + + if (productsByCategory.length < 1) { + console.warn(`[ProductCard]: No products category link ${url} were found. Skipping cta render!`) + return null + } + + return ( + + + + + + + + {t('SELECT_INSURANCE')} + + + + + ) +} diff --git a/apps/store/src/components/ProductGridItem/ProductGridItemProductCTA.tsx b/apps/store/src/components/ProductGridItem/ProductGridItemProductCTA.tsx new file mode 100644 index 0000000000..e985feeeac --- /dev/null +++ b/apps/store/src/components/ProductGridItem/ProductGridItemProductCTA.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'next-i18next' +import { ButtonNextLink } from '@/components/ButtonNextLink' +import { useProductMetadata } from '@/components/LayoutWithMenu/productMetadataHooks' +import { OPEN_PRICE_CALCULATOR_QUERY_PARAM } from '@/components/ProductPage/PurchaseForm/useOpenPriceCalculatorQueryParam' +import { Features } from '@/utils/Features' +import { isSameLink } from '@/utils/url' + +type Props = { + url: string +} + +export const ProductGridItemBlockProductCTA = ({ url }: Props) => { + const { t } = useTranslation('common') + + const products = useProductMetadata() + const product = products?.find((product) => isSameLink(product.pageLink, url)) + if (product == null) { + console.warn(`Did not find product for link ${url}, skipping CTA render!`) + return + } + + let priceLink: { pathname: string; query?: Record } + if (Features.enabled('PRICE_CALCULATOR_PAGE') && product.priceCalculatorPageLink) { + priceLink = { pathname: product.priceCalculatorPageLink } + } else { + priceLink = { + pathname: product.pageLink, + query: { [OPEN_PRICE_CALCULATOR_QUERY_PARAM]: '1' }, + } + } + + return ( + + {t('GET_PRICE_LINK')} + + ) +} diff --git a/apps/store/src/services/storyblok/commonStoryblokComponents.ts b/apps/store/src/services/storyblok/commonStoryblokComponents.ts index 237450393f..a1441950cb 100644 --- a/apps/store/src/services/storyblok/commonStoryblokComponents.ts +++ b/apps/store/src/services/storyblok/commonStoryblokComponents.ts @@ -27,9 +27,9 @@ import { ModalBlock } from '@/blocks/ModalBlock' import { PageBlock } from '@/blocks/PageBlock' import { PerilsBlock } from '@/blocks/PerilsBlock' import { PerilsTableBlock } from '@/blocks/PerilsTableBlock' -import { ProductCardBlock } from '@/blocks/ProductCardBlock' import { ProductDocumentsBlock } from '@/blocks/ProductDocumentsBlock' import { ProductGridBlock } from '@/blocks/ProductGridBlock' +import { ProductGridItemBlock } from '@/blocks/ProductGridItemBlock/ProductGridItemBlock' import { ProductPageBlock } from '@/blocks/ProductPageBlock' import { ProductPageBlockV2 } from '@/blocks/ProductPageBlockV2/ProductPageBlockV2' import { ProductPillowBlock } from '@/blocks/ProductPillowsBlock/ProductPillowBlock' @@ -89,7 +89,7 @@ export const commonStoryblokComponents = { perilsTable: PerilsTableBlock, product: ProductPageBlock, productPage: ProductPageBlockV2, - productCard: ProductCardBlock, + productGridItemBlock: ProductGridItemBlock, productDocuments: ProductDocumentsBlock, productGrid: ProductGridBlock, productPillow: ProductPillowBlock,