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";