From 5b1d9465754af1cc6bfc14e5bd5e20f1d4cb37ef Mon Sep 17 00:00:00 2001 From: Artur Santiago Date: Thu, 20 Feb 2025 10:41:30 -0300 Subject: [PATCH] feat: add review card (#2645) ## What's the purpose of this pull request? - This pull request adds the new molecule ReviewCard component. ## How does it work? - The ReviewCard will display the following information: - User's name - Verified user (boolean) - Review date - Review title and text - "Read more" and "Read less" options - Tooltip (displayed only for verified users when hovering over the verified icon next to the name) - The card must be **responsive** and rearrange itself for both desktop and mobile/tablet views. - Compose components were utilized to create the **ReviewAuthor** and **ReviewDate** components. - This component will be used in the listing of reviews for a product in the new **Reviews and Ratings** section. ## How to test it? ### Starters Deploy Preview [Preview](https://starter-git-feat-add-review-card-custom-section-vtex.vercel.app/review-and-ratings-playground) ## References [JIRA TASK: SFS-2071](https://vtex-dev.atlassian.net/browse/SFS-2071) [Figma](https://www.figma.com/design/YXU6IX2htN2yg7udpCumZv/Reviews-%26-Ratings?node-id=131-64429&t=0rv1XYm6XsgBeDwA-4) ![image](https://github.com/user-attachments/assets/3cbab6a6-7d9a-424b-96e5-91605bd26a17) ![image](https://github.com/user-attachments/assets/3c0b4c03-c19f-415e-a277-ad35c148f1eb) --------- Co-authored-by: Fanny Chien Co-authored-by: Pedro Soares <32311264+pedromtec@users.noreply.github.com> --- packages/components/src/index.ts | 2 + .../src/molecules/ReviewCard/ReviewCard.tsx | 143 ++++++++++++++ .../molecules/ReviewCard/ReviewCardAuthor.tsx | 44 +++++ .../molecules/ReviewCard/ReviewCardDate.tsx | 36 ++++ .../src/molecules/ReviewCard/index.ts | 2 + packages/components/src/utils/date.ts | 16 ++ packages/storybook/.storybook/main.js | 3 +- packages/storybook/public/icons.svg | 128 ++++++++++++ .../storybook/stories/review-card.stories.tsx | 35 ++++ .../molecules/ReviewCard/styles.scss | 184 ++++++++++++++++++ packages/ui/src/styles/components.scss | 1 + 11 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/molecules/ReviewCard/ReviewCard.tsx create mode 100644 packages/components/src/molecules/ReviewCard/ReviewCardAuthor.tsx create mode 100644 packages/components/src/molecules/ReviewCard/ReviewCardDate.tsx create mode 100644 packages/components/src/molecules/ReviewCard/index.ts create mode 100644 packages/components/src/utils/date.ts create mode 100644 packages/storybook/public/icons.svg create mode 100644 packages/storybook/stories/review-card.stories.tsx create mode 100644 packages/ui/src/components/molecules/ReviewCard/styles.scss diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index c0cbd3ee2c..746ca7b222 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -157,6 +157,8 @@ export { default as RegionBar } from './molecules/RegionBar' export type { RegionBarProps } from './molecules/RegionBar' export { default as Tooltip } from './molecules/Tooltip' export type { TooltipProps } from './molecules/Tooltip' +export { default as ReviewCard } from './molecules/ReviewCard' +export type { ReviewCardProps } from './molecules/ReviewCard' export { default as SearchProvider } from './molecules/SearchProvider' export type { SearchProviderContextValue } from './molecules/SearchProvider' diff --git a/packages/components/src/molecules/ReviewCard/ReviewCard.tsx b/packages/components/src/molecules/ReviewCard/ReviewCard.tsx new file mode 100644 index 0000000000..dc47e868a4 --- /dev/null +++ b/packages/components/src/molecules/ReviewCard/ReviewCard.tsx @@ -0,0 +1,143 @@ +import React, { forwardRef, useEffect, useRef, useState } from 'react' +import type { HTMLAttributes } from 'react' +import Rating from '../Rating' +import Link from '../../atoms/Link' +import ReviewCardAuthor, { + type ReviewCardAuthorProps, +} from './ReviewCardAuthor' +import ReviewCardDate, { type ReviewCardDateProps } from './ReviewCardDate' + +export interface ReviewCardProps + extends HTMLAttributes, + Partial, + Partial { + /** + * ID to find this component in testing tools (e.g.: cypress, testing library, and jest). + */ + testId?: string + /** + * The title of the review. + */ + title: string + /** + * The text of the review. + */ + text: string + /** + * The rating of the review. + */ + rating: number + /** + * Text to be displayed in the read more button. Defaults to 'Read More'. + */ + readMoreText?: string + /** + * Text to be displayed in the read less button. Defaults to 'Read Less'. + */ + readLessText?: string +} + +const ReviewCard = forwardRef( + function ReviewCard( + { + title, + text, + rating, + author, + date, + locale = 'en-US', + todayLabel = 'Today', + isVerified, + verifiedText = 'Verified User', + readMoreText = 'Read More', + readLessText = 'Read Less', + testId = 'fs-review-card', + ...otherProps + }, + ref + ) { + const [isExpanded, setIsExpanded] = useState(false) + const [isClamped, setIsClamped] = useState(false) + const textContentRef = useRef(null) + + useEffect(() => { + if (textContentRef.current) { + const { scrollHeight, clientHeight } = textContentRef.current + setIsClamped(scrollHeight > clientHeight) + } + /** + * Including [text, isExpanded] in dependencies even though they're not directly used + * because: + * - text changes affect scrollHeight and require recalculation + * - isExpanded affects layout/styling, requiring fresh calculation + */ + }, [text, isExpanded]) + + const toggleExpanded = () => { + setIsExpanded((previousIsExpanded) => !previousIsExpanded) + } + + return ( +
+
+ + {author && ( + + )} + {date && ( + + )} +
+
+
+

{title}

+ + {date && ( + + )} +
+

+ {text} +

+ {(isClamped || isExpanded) && ( + + {isExpanded ? readLessText : readMoreText} + + )} +
+ {author && ( + + )} +
+ ) + } +) + +export default ReviewCard diff --git a/packages/components/src/molecules/ReviewCard/ReviewCardAuthor.tsx b/packages/components/src/molecules/ReviewCard/ReviewCardAuthor.tsx new file mode 100644 index 0000000000..4079e30ec5 --- /dev/null +++ b/packages/components/src/molecules/ReviewCard/ReviewCardAuthor.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from 'react' +import type { HTMLAttributes } from 'react' +import Tooltip from '../Tooltip' +import Icon from '../../atoms/Icon' + +export interface ReviewCardAuthorProps extends HTMLAttributes { + /** + * The author of the review. + */ + author: string + /** + * Whether the author is verified or not. Defaults to false. + */ + isVerified: boolean + /** + * Text to be displayed in the tooltip when hovering over the verified icon. Defaults to 'Verified User'. + */ + verifiedText?: string +} + +const ReviewCardAuthor = forwardRef( + function ReviewCardAuthor( + { author, isVerified, verifiedText = 'Verified User', ...otherProps }, + ref + ) { + return ( +
+ {author} + {isVerified && ( + + + + )} +
+ ) + } +) + +export default ReviewCardAuthor diff --git a/packages/components/src/molecules/ReviewCard/ReviewCardDate.tsx b/packages/components/src/molecules/ReviewCard/ReviewCardDate.tsx new file mode 100644 index 0000000000..242305ad57 --- /dev/null +++ b/packages/components/src/molecules/ReviewCard/ReviewCardDate.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef } from 'react' +import type { HTMLAttributes } from 'react' +import { formatDate } from '../../utils/date' + +export interface ReviewCardDateProps extends HTMLAttributes { + /** + * The date of the review. + */ + date: Date + /** + * Optional locale to format the date. Defaults to 'en-US'. + */ + locale?: string + /** + * The string label to display when the date is today. + */ + todayLabel: string +} + +const ReviewCardDate = forwardRef( + function ReviewCardDate( + { date, locale = 'en-US', todayLabel = 'Today', ...otherProps }, + ref + ) { + const todayString = formatDate(new Date(), locale) + const dateString = formatDate(date, locale) + + return ( + + {dateString === todayString ? todayLabel : dateString} + + ) + } +) + +export default ReviewCardDate diff --git a/packages/components/src/molecules/ReviewCard/index.ts b/packages/components/src/molecules/ReviewCard/index.ts new file mode 100644 index 0000000000..13c74e3d3c --- /dev/null +++ b/packages/components/src/molecules/ReviewCard/index.ts @@ -0,0 +1,2 @@ +export { default } from './ReviewCard' +export { ReviewCardProps } from './ReviewCard' diff --git a/packages/components/src/utils/date.ts b/packages/components/src/utils/date.ts new file mode 100644 index 0000000000..942c391ecd --- /dev/null +++ b/packages/components/src/utils/date.ts @@ -0,0 +1,16 @@ +/** + * Formats date into a localized string. + * @param date - The Date object to format + * @param locale - The locale identifier (e.g., 'en-US', 'fr-FR'). Defaults to 'en-US' + * @returns A string in the format "MMM D, YYYY" for en-US (e.g., "Jan 15, 2024") + * Format will vary based on the provided locale + */ +export function formatDate(date: Date, locale = 'en-US'): string { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + } + + return date.toLocaleDateString(locale, options) +} diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 1565a19c89..151eb0ee64 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -1,4 +1,4 @@ -import { join, dirname } from 'path' +import { dirname, join } from 'path' /** * This function is used to resolve the absolute path of a package. @@ -24,5 +24,6 @@ const config = { name: getAbsolutePath('@storybook/nextjs'), options: {}, }, + staticDirs: ['../public'], } export default config diff --git a/packages/storybook/public/icons.svg b/packages/storybook/public/icons.svg new file mode 100644 index 0000000000..f0c616e74d --- /dev/null +++ b/packages/storybook/public/icons.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/storybook/stories/review-card.stories.tsx b/packages/storybook/stories/review-card.stories.tsx new file mode 100644 index 0000000000..dab343e81f --- /dev/null +++ b/packages/storybook/stories/review-card.stories.tsx @@ -0,0 +1,35 @@ +import { ReviewCard } from '@faststore/ui' +import React from 'react' + +export default { + title: 'Review Card', +} + +export function Default() { + return ( +
+ +
+ ) +} + +export function ClampedText() { + return ( +
+ +
+ ) +} diff --git a/packages/ui/src/components/molecules/ReviewCard/styles.scss b/packages/ui/src/components/molecules/ReviewCard/styles.scss new file mode 100644 index 0000000000..3810a66b25 --- /dev/null +++ b/packages/ui/src/components/molecules/ReviewCard/styles.scss @@ -0,0 +1,184 @@ +[data-fs-review-card] { + // -------------------------------------------------------- + // Design Tokens for ReviewCard + // -------------------------------------------------------- + + // Default properties + --fs-review-card-border-color : var(--fs-border-color-light); + --fs-review-card-border-width : var(--fs-border-width); + --fs-review-card-padding-mobile : 1.25rem 0; // 20px + --fs-review-card-gap-mobile : var(--fs-grid-gap-0); + + // Header + --fs-review-card-header-width-desktop : 7rem; // 112px + --fs-review-card-header-gap : var(--fs-grid-gap-0); + + // Date + --fs-review-card-date-color : var(--fs-color-text-light); + --fs-review-card-date-font-size : var(--fs-text-size-0); + --fs-review-card-date-line-height : var(--fs-text-size-base); + --fs-review-card-date-font-weight : var(--fs-text-weight-regular); + + // Text + --fs-review-card-text-gap : var(--fs-spacing-0); + + // Title + --fs-review-card-title-color : var(--fs-color-text); + --fs-review-card-title-font-size : var(--fs-text-size-2); + --fs-review-card-title-font-weight : var(--fs-text-weight-medium); + --fs-review-card-title-line-height : 1.5; + + // Text Content + --fs-review-card-text-content-color : var(--fs-color-text); + --fs-review-card-text-content-font-size : var(--fs-text-size-1); + --fs-review-card-text-content-font-weight : var(--fs-text-weight-regular); + --fs-review-card-text-content-line-height : 1.5; + + // Author + --fs-review-card-author-gap : var(--fs-spacing-1); + --fs-review-card-author-color : var(--fs-color-success-text); + --fs-review-card-author-font-size : var(--fs-text-size-0); + --fs-review-card-author-font-weight : var(--fs-text-weight-regular); + --fs-review-card-author-line-height : 1.33; + + // Desktop + --fs-review-card-padding-desktop : var(--fs-spacing-3) 0 1.25rem; // 20px + --fs-review-card-gap-desktop : 50px; + + // -------------------------------------------------------- + // Structural Styles + // -------------------------------------------------------- + + display: flex; + flex-direction: column; + gap: var(--fs-review-card-gap-mobile); + padding: var(--fs-review-card-padding-mobile); + border-bottom: + var(--fs-review-card-border-width) solid + var(--fs-review-card-border-color); + + @include media(">=notebook") { + flex-direction: row; + gap: var(--fs-review-card-gap-desktop); + padding: var(--fs-review-card-padding-desktop); + } + + [data-fs-review-card-header] { + display: flex; + gap: var(--fs-review-card-header-gap); + align-items: center; + justify-content: space-between; + + @include media(">=notebook") { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + width: 100%; + max-width: var(--fs-review-card-header-width-desktop); + } + } + + [data-fs-review-card-author] { + display: flex; + gap: var(--fs-review-card-author-gap); + align-items: center; + width: 100%; + + &[data-fs-review-card-author="desktop"] { + display: none; + visibility: hidden; + } + + @include media(">=notebook") { + &[data-fs-review-card-author="mobile"] { + display: none; + visibility: hidden; + } + + &[data-fs-review-card-author="desktop"] { + display: flex; + visibility: visible; + } + } + } + + [data-fs-review-card-author-name] { + font-size: var(--fs-review-card-author-font-size); + font-weight: var(--fs-review-card-author-font-weight); + line-height: var(--fs-review-card-author-line-height); + color: var(--fs-review-card-author-color); + + @include truncate-title(1); + } + + [data-fs-review-card-date] { + flex-shrink: 0; + font-size: var(--fs-review-card-date-font-size); + font-weight: var(--fs-review-card-date-font-weight); + line-height: var(--fs-review-card-date-line-height); + color: var(--fs-review-card-date-color); + + &[data-fs-review-card-date="desktop"] { + display: none; + visibility: hidden; + } + + @include media(">=notebook") { + &[data-fs-review-card-date="mobile"] { + display: none; + visibility: hidden; + } + + &[data-fs-review-card-date="desktop"] { + display: initial; + visibility: visible; + } + } + } + + [data-fs-review-card-text] { + display: flex; + flex-direction: column; + gap: var(--fs-review-card-text-gap); + } + + [data-fs-review-card-text-header] { + display: flex; + gap: var(--fs-review-card-header-gap); + justify-content: space-between; + } + + [data-fs-review-card-text-title] { + font-size: var(--fs-review-card-title-font-size); + font-weight: var(--fs-review-card-title-font-weight); + line-height: var(--fs-review-card-title-line-height); + color: var(--fs-review-card-title-color); + + @include truncate-title; + } + + [data-fs-review-card-text-content] { + font-size: var(--fs-review-card-text-content-font-size); + font-weight: var(--fs-review-card-text-content-font-weight); + line-height: var(--fs-review-card-text-content-line-height); + color: var(--fs-review-card-text-content-color); + + @include truncate-title(3); + } + + [data-fs-review-card-text-content="expanded"] { + display: block; + overflow: visible; + line-clamp: unset; + } + + [data-fs-review-card-text-read-more] { + align-self: flex-start; + padding: 0; + cursor: pointer; + } + + [data-fs-review-card-author-verified] { + color: var(--fs-review-card-author-color); + } +} diff --git a/packages/ui/src/styles/components.scss b/packages/ui/src/styles/components.scss index 6f70a4fd91..a693c501d2 100644 --- a/packages/ui/src/styles/components.scss +++ b/packages/ui/src/styles/components.scss @@ -59,6 +59,7 @@ @import "../components/molecules/Toggle/styles"; @import "../components/molecules/ToggleField/styles"; @import "../components/molecules/Tooltip/styles"; +@import "../components/molecules/ReviewCard/styles"; // Organisms @import "../components/organisms/BannerText/styles";