diff --git a/src/App.scss b/src/App.scss index 9521d92..cbe5c26 100644 --- a/src/App.scss +++ b/src/App.scss @@ -10,3 +10,9 @@ .firstSelect { z-index: 1; } + +.productCard { + width: 300px; + height: 400px; + margin: 20px; +} diff --git a/src/App.tsx b/src/App.tsx index c57797c..6e71744 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,8 @@ import './components/atoms/Breadcrumbs/Breadcrumbs.scss'; import Toast from './components/atoms/Toast/Toast'; import Select from './components/atoms/Select/Select'; import selectItems from './common/mocks/selectItems'; +import Card from './components/organisms/Card/Card'; +import mockProductDetails from './common/mocks/productDetails'; function App() { const [value, setValue] = useState(''); @@ -27,6 +29,8 @@ function App() { const [progress, setProgress] = useState(0); const [showModal, handleShowModal] = useState(false); const [showToast, setShowToast] = useState(false); + const [isFav, setIsFav] = useState(false); + const [productCount, setProductCount] = useState(0); useEffect(() => { styleReorder(); @@ -130,6 +134,18 @@ function App() { setMultipleSelectOption(option); }} /> + { + setIsFav(!isFav); + }} + onCartIconClick={() => { + setProductCount(productCount + 1); + }} + isOnWishlist={isFav} + numberOfItemsInCart={productCount} + /> ); } diff --git a/src/common/mocks/productDetails.ts b/src/common/mocks/productDetails.ts new file mode 100644 index 0000000..5cc9198 --- /dev/null +++ b/src/common/mocks/productDetails.ts @@ -0,0 +1,7 @@ +export default { + name: 'Croissant', + price: '3.00 EUR', + shortDescription: 'Taste our delicious croissants...', + imageUrl: 'https://cdn.pixabay.com/photo/2016/03/27/21/59/bread-1284438_1280.jpg', + hoverImageUrl: 'https://cdn.pixabay.com/photo/2016/11/29/05/07/baked-goods-1867459_1280.jpg', +} diff --git a/src/components/atoms/Image/Image.tsx b/src/components/atoms/Image/Image.tsx index 3b361b0..fdc91c3 100644 --- a/src/components/atoms/Image/Image.tsx +++ b/src/components/atoms/Image/Image.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; -import {OBJECT_FIT_OPTIONS} from '../../../common/constants/consts'; +import { OBJECT_FIT_OPTIONS } from '../../../common/constants/consts'; interface IImage { className?: string; diff --git a/src/components/atoms/Text/Text.tsx b/src/components/atoms/Text/Text.tsx index 160ff22..02ed26d 100644 --- a/src/components/atoms/Text/Text.tsx +++ b/src/components/atoms/Text/Text.tsx @@ -4,7 +4,7 @@ import { COLOR_OPTIONS, TEXT_ALIGNMENT_OPTIONS, FONT_WEIGHT_OPTIONS } from '../. interface IText { className?: string; - children: string; + children?: string; color?: string; fontColor?: string; fontWeight?: diff --git a/src/components/organisms/Card/Card.spec.js b/src/components/organisms/Card/Card.spec.js new file mode 100644 index 0000000..c9f1adc --- /dev/null +++ b/src/components/organisms/Card/Card.spec.js @@ -0,0 +1,185 @@ +import React from 'react'; +import { create, act } from 'react-test-renderer'; +import { fireEvent, render } from '@testing-library/react'; +import 'jest-styled-components'; +import { ThemeProvider } from 'styled-components'; +import theme from '../../../common/theme'; +import Card, { HeartIcon } from './Card'; +import CardDetails from './CardDetails'; +import { CartIcon } from './CartIconContainer'; +import mockProductDetails from '../../../common/mocks/productDetails'; +import setupIntersectionObserverMock from '../../../helpers/intersectionObserverMock'; +import Badge from '../../atoms/Badge/Badge'; + +describe('Card component', () => { + beforeEach(() => { + setupIntersectionObserverMock(); + }); + + it('should render correctly', () => { + const card = create( + + + + ).toJSON(); + expect(card).toMatchSnapshot(); + }) + + it('should render card with custom class', () => { + const card = create( + + + + ).toJSON(); + expect(card.props.className).toEqual(expect.stringContaining('test-class')); + }); + + it('should render card product details', () => { + const card = create( + + + + ); + const instance = card.root; + const productDetails = instance.findByType(CardDetails).findByType('div'); + expect(productDetails.children[0].props.children).toBe('Croissant'); + expect(productDetails.children[1].props.children).toBe('Taste our delicious croissants...'); + expect(productDetails.children[2].props.children).toBe('3.00 EUR'); + }); + + it('should not render description when hideDescription props is passed', () => { + const card = create( + + + + ); + const instance = card.root; + const productDetails = instance.findByType(CardDetails).findByType('div'); + expect(productDetails.children.length).toEqual(2); + expect(productDetails.children[0].props.children).toBe('Croissant'); + expect(productDetails.children[1].props.children).toBe('3.00 EUR'); + }); + + it('should not render price when hidePrice props is passed', () => { + const card = create( + + + + ); + const instance = card.root; + const productDetails = instance.findByType(CardDetails).findByType('div'); + expect(productDetails.children.length).toEqual(2); + expect(productDetails.children[0].props.children).toBe('Croissant'); + expect(productDetails.children[1].props.children).toBe('Taste our delicious croissants...'); + }); + + it('should render product details with font color based on color prop', () => { + const { getByText } = render( + + + + ); + const productName = getByText('Croissant'); + expect(productName).toHaveStyle('color: #2196f3'); + }); + + it('should render horizontal card by default', () => { + const card = create( + + + + ).toJSON(); + expect(card).toHaveStyleRule('flex-direction', 'column'); + }); + + it('should render vertical card when proper direction props is passed', () => { + const card = create( + + + + ).toJSON(); + expect(card).toHaveStyleRule('flex-direction', 'row'); + }); + + it('should render with passed images', () => { + const { container, getByAltText } = render( + + + + ); + const image = getByAltText('Croissant'); + expect(image).toHaveAttribute( + 'src', + 'https://cdn.pixabay.com/photo/2016/03/27/21/59/bread-1284438_1280.jpg' + ); + fireEvent.mouseEnter(container.firstChild); + expect(image).toHaveAttribute( + 'src', + 'https://cdn.pixabay.com/photo/2016/11/29/05/07/baked-goods-1867459_1280.jpg' + ); + }); + + it('should call onCartIconClick', () => { + const mockOnClick = jest.fn(); + const e = { stopPropagation: jest.fn() }; + const component = create( + + + + ); + const instance = component.root; + const cartIcon = instance.findByType(CartIcon); + act(() => { + cartIcon.props.onClick(e); + }); + expect(mockOnClick.mock.calls.length).toEqual(1); + }); + + it('should call onWishlistIconClick', () => { + const mockOnClick = jest.fn(); + const e = { stopPropagation: jest.fn() }; + const component = create( + + + + ); + const instance = component.root; + const wishlistIcon = instance.findByType(HeartIcon); + act(() => { + wishlistIcon.props.onClick(e); + }) + expect(mockOnClick.mock.calls.length).toEqual(1); + }) + + it('should call onClick', () => { + const mockOnClick = jest.fn(); + const component = create( + + + + ); + const instance = component.root; + const card = instance.findByType(Card); + act(() => { + card.props.onClick(); + }); + expect(mockOnClick.mock.calls.length).toEqual(1); + }); + + it('should show given cart product number', () => { + const component = create( + + + + ); + const instance = component.root; + const badge = instance.findByType(Badge).findByType('span'); + expect(badge.props.children).toBe(3); + }) +}); diff --git a/src/components/organisms/Card/Card.tsx b/src/components/organisms/Card/Card.tsx index 4a3de19..98e3709 100644 --- a/src/components/organisms/Card/Card.tsx +++ b/src/components/organisms/Card/Card.tsx @@ -1,6 +1,174 @@ -import React from 'react'; +import React, {useState, useRef, useEffect} from 'react'; +import styled from 'styled-components'; +import CardDetails from './CardDetails'; +import CartIconContainer from './CartIconContainer'; +import Image from '../../atoms/Image/Image'; +import { ORIENTATION } from '../../../common/constants/consts'; +import { Heart as FullHeart } from '@styled-icons/boxicons-solid/Heart'; +import { Heart } from '@styled-icons/boxicons-regular/Heart'; -export const Card: React.StatelessComponent<{}> = () => ( -
Card -
-); +export interface IProductDetails { + name: string; + price: string; + shortDescription?: string; + imageUrl: string; + hoverImageUrl?: string; +} + +export type directionType = 'vertical' | 'horizontal' + +interface ICard { + className?: string; + productDetails: IProductDetails; + color?: string; + direction?: directionType; + hideDescription?: boolean; + hidePrice?: boolean; + onCartIconClick?: () => void; + onClick?: () => void; + hideCartIcon?: boolean; + hideWishlistIcon?: boolean; + onWishlistIconClick?: () => void; + isOnWishlist?: boolean; + imageWidth?: number; + imageHeight?: number; + imageObjectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'; + hideCartNumberLabel?: boolean; + numberOfItemsInCart?: number; +} + +const CardImageContainer = styled.div<{ direction?: directionType }>` + ${({ direction }) => ({ + height: direction === ORIENTATION.HORIZONTAL ? '60%' : '100%', + width: direction === ORIENTATION.HORIZONTAL ? '100%' : '55%', + position: 'relative', + display: 'flex', + 'justify-content': 'center', + 'align-items': 'center', + })} +`; + +const styleWishlistIcon = (Icon: any) => { + return ( + styled(Icon)<{ color?: string }>`${({ theme, color = defaultProps.color }) => ({ + color: theme.colors[color].base, + width: '30px', + position: 'absolute', + top: '15px', + right: '15px', + cursor: 'pointer', + })}` + ) +} + +export const HeartIcon = styleWishlistIcon(Heart); +export const FullHeartIcon = styleWishlistIcon(FullHeart); + +const Card = ({ + className, + productDetails, + color, + direction, + hideDescription, + hidePrice, + onCartIconClick, + onClick, + hideCartIcon, + hideWishlistIcon, + isOnWishlist, + onWishlistIconClick, + imageHeight, + imageWidth, + imageObjectFit, + hideCartNumberLabel, + numberOfItemsInCart +}: ICard) => { + const [isActive, setIsActive] = useState(false); + const cardRef = useRef(null); + + useEffect(() => { + if (cardRef.current) { + cardRef.current.addEventListener('mouseenter', () => {setIsActive(true)}); + cardRef.current.addEventListener('mouseleave', () => {setIsActive(false)}); + } + }, []) + + const selectImageUrl = () => { + if (productDetails.hoverImageUrl && isActive) { + return productDetails.hoverImageUrl + } + return productDetails.imageUrl; + } + + const handleOnWishlistIconClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onWishlistIconClick && onWishlistIconClick(); + } + + return ( +
+ + {productDetails.name} + { + !hideCartIcon && ( + + ) + } + + + { + !hideWishlistIcon && ( + isOnWishlist ? ( + + ) : ( + + ) + ) + } + +
+ ); +} + +export const defaultProps = { + color: 'primary', + direction: 'horizontal', + hideDescription: false, + hidePrice: false, + hideCartIcon: false, + hideWishlistIcon: false, + isOnWishlist: false, + hideCartNumberLabel: false, + numberOfItemsInCart: 0, +}; + +Card.defaultProps = defaultProps; + +export default styled(Card)` + ${({ direction = defaultProps.direction }) => ({ + display: 'flex', + 'flex-direction': `${direction === ORIENTATION.HORIZONTAL ? 'column' : 'row'}`, + 'justify-content': 'space-between', + '&:hover': { + 'box-shadow': '0px 4px 10px 4px rgba(217,217,217,0.6)', + } + })} +`; diff --git a/src/components/organisms/Card/CardDetails.tsx b/src/components/organisms/Card/CardDetails.tsx new file mode 100644 index 0000000..d6b7e17 --- /dev/null +++ b/src/components/organisms/Card/CardDetails.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styled from 'styled-components'; +import { directionType, IProductDetails } from './Card'; +import Text from '../../atoms/Text/Text'; +import { ORIENTATION } from '../../../common/constants/consts'; + +interface ICardDetails { + productDetails: IProductDetails; + className?: string; + color?: string; + direction?: directionType; + hideDescription?: boolean; + hidePrice?: boolean; + children?: React.ReactNode; +} + +const StyledCardDetails = styled.div<{ direction?: directionType }>` + ${({ direction }) => ({ + height: direction === ORIENTATION.HORIZONTAL ? '40%' : '100%', + width: direction === ORIENTATION.HORIZONTAL ? '100%' : '45%', + padding: '10px', + position: 'relative', + display: 'flex', + 'flex-direction': 'column', + 'justify-content': direction === ORIENTATION.VERTICAL ? 'space-between' : 'normal', +})} +`; + +const CardDetails = ({ productDetails, className, color, direction, hideDescription, hidePrice, children }: ICardDetails) => { + const { name, shortDescription, price } = productDetails; + return ( + + {name} + { + !hideDescription && ( + {shortDescription} + ) + } + { + !hidePrice && ( + {price} + ) + } + { children } + + ) +} + +export default CardDetails; diff --git a/src/components/organisms/Card/CartIconContainer.tsx b/src/components/organisms/Card/CartIconContainer.tsx new file mode 100644 index 0000000..d684441 --- /dev/null +++ b/src/components/organisms/Card/CartIconContainer.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import styled from 'styled-components'; +import { CartPlus } from '@styled-icons/fa-solid/CartPlus'; +import Badge from '../../atoms/Badge/Badge'; +import { defaultProps } from './Card'; +import './card.scss'; + +interface ICartIconContainer { + color?: string; + isActive: boolean; + onCartIconClick?: () => void; + hideCartNumberLabel?: boolean; + numberOfItemsInCart?: number; +} + +export const CartIcon = styled(CartPlus)<{ color?: string, isActive?: boolean }>`${({ theme, color = defaultProps.color, isActive }) => ({ + color: theme.colors[color].light, + 'background-color': theme.colors[color].base, + width: '40px', + padding: '7px', + 'border-radius': '5px', + transition: 'transform .2s', + transform: isActive ? 'scale(1.2)' : 'scale(1)', + cursor: 'pointer', +})}` + +export default ({ color, isActive, onCartIconClick, hideCartNumberLabel, numberOfItemsInCart }: ICartIconContainer) => { + const handleOnCartIconClick = (e: React.MouseEvent) => { + e.stopPropagation() + onCartIconClick && onCartIconClick() + } + + if (hideCartNumberLabel || numberOfItemsInCart === 0) { + return ( + + ) + } + return ( +
+ + + +
+ ) +} + + diff --git a/src/components/organisms/Card/__snapshots__/Card.spec.js.snap b/src/components/organisms/Card/__snapshots__/Card.spec.js.snap new file mode 100644 index 0000000..b7b8bb1 --- /dev/null +++ b/src/components/organisms/Card/__snapshots__/Card.spec.js.snap @@ -0,0 +1,187 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Card component should render correctly 1`] = ` +.c6 { + color: #000000; + font-size: 20px; + line-height: 2; + font-weight: 600; + text-align: justify; +} + +.c7 { + color: #000000; + font-size: 14px; + line-height: 1.5; + font-weight: normal; + text-align: justify; +} + +.c8 { + color: #000000; + font-size: 20px; + line-height: 2; + font-weight: normal; + text-align: justify; +} + +.c5 { + height: 40%; + width: 100%; + padding: 10px; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: normal; + -webkit-justify-content: normal; + -ms-flex-pack: normal; + justify-content: normal; +} + +.c3 { + display: inline-block; + vertical-align: -.125em; + overflow: hidden; +} + +.c9 { + display: inline-block; + vertical-align: middle; + overflow: hidden; +} + +.c4 { + color: #ffffff; + background-color: #000000; + width: 40px; + padding: 7px; + border-radius: 5px; + -webkit-transition: -webkit-transform .2s; + -webkit-transition: transform .2s; + transition: transform .2s; + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + cursor: pointer; +} + +.c2 { + object-fit: cover; + width: 100%; + height: 100%; +} + +.c1 { + height: 60%; + width: 100%; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c10 { + color: #000000; + width: 30px; + position: absolute; + top: 15px; + right: 15px; + cursor: pointer; +} + +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c0:hover { + box-shadow: 0px 4px 10px 4px rgba(217,217,217,0.6); +} + +
+
+ Croissant + +
+
+

+ Croissant +

+

+ Taste our delicious croissants... +

+

+ 3.00 EUR +

+ +
+
+`; diff --git a/src/components/organisms/Card/card.scss b/src/components/organisms/Card/card.scss new file mode 100644 index 0000000..4eefcc0 --- /dev/null +++ b/src/components/organisms/Card/card.scss @@ -0,0 +1,14 @@ +.cartIconContainer--label { + position: absolute; + bottom: 0; + left: 0; + div { + margin: 0; + } +} + +.cartIconContainer--nolabel { + position: absolute; + bottom: 15px; + left: 15px; +} diff --git a/src/stories/Card.stories.jsx b/src/stories/Card.stories.jsx new file mode 100644 index 0000000..5aa643c --- /dev/null +++ b/src/stories/Card.stories.jsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withKnobs, select, text, boolean, number } from '@storybook/addon-knobs'; +import Card from '../components/organisms/Card/Card'; +import {COLOR_OPTIONS, OBJECT_FIT_OPTIONS} from '../common/constants/consts'; +import productDetails from '../common/mocks/productDetails'; +import './styles.scss'; + +const stories = storiesOf('Card', module); +stories.addDecorator(withKnobs); + +const { name, shortDescription, price, hoverImageUrl, imageUrl } = productDetails; +const PRODUCT_DETAILS = 'Product details'; + +stories.add('common (horizontal)', () => { + const [isFav, setIsFav] = useState(false); + const [productCount, setProductCount] = useState(0); + + return ( + { + setIsFav(!isFav); + }} + onClick={action('on card click')} + onCartIconClick={() => { + setProductCount(productCount + 1); + }} + numberOfItemsInCart={productCount} + imageObjectFit={select('Image object fit', OBJECT_FIT_OPTIONS, OBJECT_FIT_OPTIONS.cover)} + imageWidth={number('Image width', '')} + imageHeight={number('Image height', '')} + /> + ); +}); + +stories.add('vertical', () => { + return ( + + ); +}); diff --git a/src/stories/styles.scss b/src/stories/styles.scss index c84f20f..e1e8a40 100644 --- a/src/stories/styles.scss +++ b/src/stories/styles.scss @@ -7,6 +7,9 @@ flex-direction: column; align-items: flex-start; font-family: 'Montserrat', sans-serif; + * { + box-sizing: border-box; + } } .active { @@ -40,3 +43,21 @@ .drawerButton { width: 100px; } + +.cardWrapper_horizontal { + width: 300px; + height: 400px; + margin: 10px; + p { + margin: 0; + } +} + +.cardWrapper_vertical { + width: 400px; + height: 250px; + margin: 10px; + p { + margin: 0; + } +}