|
@@ -0,0 +1,440 @@
|
|
|
+import PropTypes from 'prop-types';
|
|
|
+import React, {Fragment} from 'react';
|
|
|
+import classNames from 'classnames';
|
|
|
+import {FormattedMessage} from 'react-intl';
|
|
|
+import Draggable from 'react-draggable';
|
|
|
+
|
|
|
+import styles from './card.css';
|
|
|
+
|
|
|
+import shrinkIcon from './icon--shrink.svg';
|
|
|
+import expandIcon from './icon--expand.svg';
|
|
|
+
|
|
|
+import rightArrow from './icon--next.svg';
|
|
|
+import leftArrow from './icon--prev.svg';
|
|
|
+
|
|
|
+import helpIcon from '../../lib/assets/icon--tutorials.svg';
|
|
|
+import closeIcon from './icon--close.svg';
|
|
|
+
|
|
|
+import {translateVideo} from '../../lib/libraries/decks/translate-video.js';
|
|
|
+import {translateImage} from '../../lib/libraries/decks/translate-image.js';
|
|
|
+
|
|
|
+const CardHeader = ({onCloseCards, onShrinkExpandCards, onShowAll, totalSteps, step, expanded}) => (
|
|
|
+ <div className={expanded ? styles.headerButtons : classNames(styles.headerButtons, styles.headerButtonsHidden)}>
|
|
|
+ <div
|
|
|
+ className={styles.allButton}
|
|
|
+ onClick={onShowAll}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ className={styles.helpIcon}
|
|
|
+ src={helpIcon}
|
|
|
+ />
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="Tutorials"
|
|
|
+ description="Title for button to return to tutorials library"
|
|
|
+ id="gui.cards.all-tutorials"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ {totalSteps > 1 ? (
|
|
|
+ <div className={styles.stepsList}>
|
|
|
+ {Array(totalSteps).fill(0)
|
|
|
+ .map((_, i) => (
|
|
|
+ <div
|
|
|
+ className={i === step ? styles.activeStepPip : styles.inactiveStepPip}
|
|
|
+ key={`pip-step-${i}`}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ <div className={styles.headerButtonsRight}>
|
|
|
+ <div
|
|
|
+ className={styles.shrinkExpandButton}
|
|
|
+ onClick={onShrinkExpandCards}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ draggable={false}
|
|
|
+ src={expanded ? shrinkIcon : expandIcon}
|
|
|
+ />
|
|
|
+ {expanded ?
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="Shrink"
|
|
|
+ description="Title for button to shrink how-to card"
|
|
|
+ id="gui.cards.shrink"
|
|
|
+ /> :
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="Expand"
|
|
|
+ description="Title for button to expand how-to card"
|
|
|
+ id="gui.cards.expand"
|
|
|
+ />
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ className={styles.removeButton}
|
|
|
+ onClick={onCloseCards}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ className={styles.closeIcon}
|
|
|
+ src={closeIcon}
|
|
|
+ />
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="Close"
|
|
|
+ description="Title for button to close how-to card"
|
|
|
+ id="gui.cards.close"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+);
|
|
|
+
|
|
|
+class VideoStep extends React.Component {
|
|
|
+
|
|
|
+ componentDidMount () {
|
|
|
+ const script = document.createElement('script');
|
|
|
+ script.src = `https://fast.wistia.com/embed/medias/${this.props.video}.jsonp`;
|
|
|
+ script.async = true;
|
|
|
+ script.setAttribute('id', 'wistia-video-content');
|
|
|
+ document.body.appendChild(script);
|
|
|
+
|
|
|
+ const script2 = document.createElement('script');
|
|
|
+ script2.src = 'https://fast.wistia.com/assets/external/E-v1.js';
|
|
|
+ script2.async = true;
|
|
|
+ script2.setAttribute('id', 'wistia-video-api');
|
|
|
+ document.body.appendChild(script2);
|
|
|
+ }
|
|
|
+
|
|
|
+ // We use the Wistia API here to update or pause the video dynamically:
|
|
|
+ // https://wistia.com/support/developers/player-api
|
|
|
+ componentDidUpdate (prevProps) {
|
|
|
+ // Ensure the wistia API is loaded and available
|
|
|
+ if (!(window.Wistia && window.Wistia.api)) return;
|
|
|
+
|
|
|
+ // Get a handle on the currently loaded video
|
|
|
+ const video = window.Wistia.api(prevProps.video);
|
|
|
+
|
|
|
+ // Reset the video source if a new video has been chosen from the library
|
|
|
+ if (prevProps.video !== this.props.video) {
|
|
|
+ video.replaceWith(this.props.video);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Pause the video if the modal is being shrunken
|
|
|
+ if (!this.props.expanded) {
|
|
|
+ video.pause();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ componentWillUnmount () {
|
|
|
+ const script = document.getElementById('wistia-video-content');
|
|
|
+ script.parentNode.removeChild(script);
|
|
|
+
|
|
|
+ const script2 = document.getElementById('wistia-video-api');
|
|
|
+ script2.parentNode.removeChild(script2);
|
|
|
+ }
|
|
|
+
|
|
|
+ render () {
|
|
|
+ return (
|
|
|
+ <div className={styles.stepVideo}>
|
|
|
+ <div
|
|
|
+ className={`wistia_embed wistia_async_${this.props.video}`}
|
|
|
+ id="video-div"
|
|
|
+ style={{height: `257px`, width: `466px`}}
|
|
|
+ >
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+VideoStep.propTypes = {
|
|
|
+ expanded: PropTypes.bool.isRequired,
|
|
|
+ video: PropTypes.string.isRequired
|
|
|
+};
|
|
|
+
|
|
|
+const ImageStep = ({title, image}) => (
|
|
|
+ <Fragment>
|
|
|
+ <div className={styles.stepTitle}>
|
|
|
+ {title}
|
|
|
+ </div>
|
|
|
+ <div className={styles.stepImageContainer}>
|
|
|
+ <img
|
|
|
+ className={styles.stepImage}
|
|
|
+ draggable={false}
|
|
|
+ key={image} /* Use src as key to prevent hanging around on slow connections */
|
|
|
+ src={image}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </Fragment>
|
|
|
+);
|
|
|
+
|
|
|
+ImageStep.propTypes = {
|
|
|
+ image: PropTypes.string.isRequired,
|
|
|
+ title: PropTypes.node.isRequired
|
|
|
+};
|
|
|
+
|
|
|
+const NextPrevButtons = ({isRtl, onNextStep, onPrevStep, expanded}) => (
|
|
|
+ <Fragment>
|
|
|
+ {onNextStep ? (
|
|
|
+ <div>
|
|
|
+ <div className={expanded ? (isRtl ? styles.leftCard : styles.rightCard) : styles.hidden} />
|
|
|
+ <div
|
|
|
+ className={expanded ? (isRtl ? styles.leftButton : styles.rightButton) : styles.hidden}
|
|
|
+ onClick={onNextStep}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ draggable={false}
|
|
|
+ src={isRtl ? leftArrow : rightArrow}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ {onPrevStep ? (
|
|
|
+ <div>
|
|
|
+ <div className={expanded ? (isRtl ? styles.rightCard : styles.leftCard) : styles.hidden} />
|
|
|
+ <div
|
|
|
+ className={expanded ? (isRtl ? styles.rightButton : styles.leftButton) : styles.hidden}
|
|
|
+ onClick={onPrevStep}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ draggable={false}
|
|
|
+ src={isRtl ? rightArrow : leftArrow}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </Fragment>
|
|
|
+);
|
|
|
+
|
|
|
+NextPrevButtons.propTypes = {
|
|
|
+ expanded: PropTypes.bool.isRequired,
|
|
|
+ isRtl: PropTypes.bool,
|
|
|
+ onNextStep: PropTypes.func,
|
|
|
+ onPrevStep: PropTypes.func
|
|
|
+};
|
|
|
+CardHeader.propTypes = {
|
|
|
+ expanded: PropTypes.bool.isRequired,
|
|
|
+ onCloseCards: PropTypes.func.isRequired,
|
|
|
+ onShowAll: PropTypes.func.isRequired,
|
|
|
+ onShrinkExpandCards: PropTypes.func.isRequired,
|
|
|
+ step: PropTypes.number,
|
|
|
+ totalSteps: PropTypes.number
|
|
|
+};
|
|
|
+
|
|
|
+const PreviewsStep = ({deckIds, content, onActivateDeckFactory, onShowAll}) => (
|
|
|
+ <Fragment>
|
|
|
+ <div className={styles.stepTitle}>
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="More things to try!"
|
|
|
+ description="Title card with more things to try"
|
|
|
+ id="gui.cards.more-things-to-try"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className={styles.decks}>
|
|
|
+ {deckIds.slice(0, 2).map(id => (
|
|
|
+ <div
|
|
|
+ className={styles.deck}
|
|
|
+ key={`deck-preview-${id}`}
|
|
|
+ onClick={onActivateDeckFactory(id)}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ className={styles.deckImage}
|
|
|
+ draggable={false}
|
|
|
+ src={content[id].img}
|
|
|
+ />
|
|
|
+ <div className={styles.deckName}>{content[id].name}</div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <div className={styles.seeAll}>
|
|
|
+ <div
|
|
|
+ className={styles.seeAllButton}
|
|
|
+ onClick={onShowAll}
|
|
|
+ >
|
|
|
+ <FormattedMessage
|
|
|
+ defaultMessage="See more"
|
|
|
+ description="Title for button to see more in how-to library"
|
|
|
+ id="gui.cards.see-more"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Fragment>
|
|
|
+);
|
|
|
+
|
|
|
+PreviewsStep.propTypes = {
|
|
|
+ content: PropTypes.shape({
|
|
|
+ id: PropTypes.shape({
|
|
|
+ name: PropTypes.node.isRequired,
|
|
|
+ img: PropTypes.string.isRequired,
|
|
|
+ steps: PropTypes.arrayOf(PropTypes.shape({
|
|
|
+ title: PropTypes.node,
|
|
|
+ image: PropTypes.string,
|
|
|
+ video: PropTypes.string,
|
|
|
+ deckIds: PropTypes.arrayOf(PropTypes.string)
|
|
|
+ }))
|
|
|
+ })
|
|
|
+ }).isRequired,
|
|
|
+ deckIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
|
+ onActivateDeckFactory: PropTypes.func.isRequired,
|
|
|
+ onShowAll: PropTypes.func.isRequired
|
|
|
+};
|
|
|
+
|
|
|
+const Cards = props => {
|
|
|
+ const {
|
|
|
+ activeDeckId,
|
|
|
+ content,
|
|
|
+ dragging,
|
|
|
+ isRtl,
|
|
|
+ locale,
|
|
|
+ onActivateDeckFactory,
|
|
|
+ onCloseCards,
|
|
|
+ onShrinkExpandCards,
|
|
|
+ onDrag,
|
|
|
+ onStartDrag,
|
|
|
+ onEndDrag,
|
|
|
+ onShowAll,
|
|
|
+ onNextStep,
|
|
|
+ onPrevStep,
|
|
|
+ showVideos,
|
|
|
+ step,
|
|
|
+ expanded,
|
|
|
+ ...posProps
|
|
|
+ } = props;
|
|
|
+ let {x, y} = posProps;
|
|
|
+
|
|
|
+ if (activeDeckId === null) return;
|
|
|
+
|
|
|
+ // Tutorial cards need to calculate their own dragging bounds
|
|
|
+ // to allow for dragging the cards off the left, right and bottom
|
|
|
+ // edges of the workspace.
|
|
|
+ const cardHorizontalDragOffset = 400; // ~80% of card width
|
|
|
+ const cardVerticalDragOffset = expanded ? 257 : 0; // ~80% of card height, if expanded
|
|
|
+ const menuBarHeight = 48; // TODO: get pre-calculated from elsewhere?
|
|
|
+ const wideCardWidth = 500;
|
|
|
+
|
|
|
+ if (x === 0 && y === 0) {
|
|
|
+ // initialize positions
|
|
|
+ x = isRtl ? (-190 - wideCardWidth - cardHorizontalDragOffset) : 292;
|
|
|
+ x += cardHorizontalDragOffset;
|
|
|
+ // The tallest cards are about 320px high, and the default position is pinned
|
|
|
+ // to near the bottom of the blocks palette to allow room to work above.
|
|
|
+ const tallCardHeight = 320;
|
|
|
+ const bottomMargin = 60; // To avoid overlapping the backpack region
|
|
|
+ y = window.innerHeight - tallCardHeight - bottomMargin - menuBarHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ const steps = content[activeDeckId].steps;
|
|
|
+
|
|
|
+ return (
|
|
|
+ // Custom overlay to act as the bounding parent for the draggable, using values from above
|
|
|
+ <div
|
|
|
+ className={styles.cardContainerOverlay}
|
|
|
+ style={{
|
|
|
+ width: `${window.innerWidth + (2 * cardHorizontalDragOffset)}px`,
|
|
|
+ height: `${window.innerHeight - menuBarHeight + cardVerticalDragOffset}px`,
|
|
|
+ top: `${menuBarHeight}px`,
|
|
|
+ left: `${-cardHorizontalDragOffset}px`
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Draggable
|
|
|
+ bounds="parent"
|
|
|
+ cancel="#video-div" // disable dragging on video div
|
|
|
+ position={{x: x, y: y}}
|
|
|
+ onDrag={onDrag}
|
|
|
+ onStart={onStartDrag}
|
|
|
+ onStop={onEndDrag}
|
|
|
+ >
|
|
|
+ <div className={styles.cardContainer}>
|
|
|
+ <div className={styles.card}>
|
|
|
+ <CardHeader
|
|
|
+ expanded={expanded}
|
|
|
+ step={step}
|
|
|
+ totalSteps={steps.length}
|
|
|
+ onCloseCards={onCloseCards}
|
|
|
+ onShowAll={onShowAll}
|
|
|
+ onShrinkExpandCards={onShrinkExpandCards}
|
|
|
+ />
|
|
|
+ <div className={expanded ? styles.stepBody : styles.hidden}>
|
|
|
+ {steps[step].deckIds ? (
|
|
|
+ <PreviewsStep
|
|
|
+ content={content}
|
|
|
+ deckIds={steps[step].deckIds}
|
|
|
+ onActivateDeckFactory={onActivateDeckFactory}
|
|
|
+ onShowAll={onShowAll}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ steps[step].video ? (
|
|
|
+ showVideos ? (
|
|
|
+ <VideoStep
|
|
|
+ dragging={dragging}
|
|
|
+ expanded={expanded}
|
|
|
+ video={translateVideo(steps[step].video, locale)}
|
|
|
+ />
|
|
|
+ ) : ( // Else show the deck image and title
|
|
|
+ <ImageStep
|
|
|
+ image={content[activeDeckId].img}
|
|
|
+ title={content[activeDeckId].name}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ ) : (
|
|
|
+ <ImageStep
|
|
|
+ image={translateImage(steps[step].image, locale)}
|
|
|
+ title={steps[step].title}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ )}
|
|
|
+ {steps[step].trackingPixel && steps[step].trackingPixel}
|
|
|
+ </div>
|
|
|
+ <NextPrevButtons
|
|
|
+ expanded={expanded}
|
|
|
+ isRtl={isRtl}
|
|
|
+ onNextStep={step < steps.length - 1 ? onNextStep : null}
|
|
|
+ onPrevStep={step > 0 ? onPrevStep : null}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Draggable>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+Cards.propTypes = {
|
|
|
+ activeDeckId: PropTypes.string.isRequired,
|
|
|
+ content: PropTypes.shape({
|
|
|
+ id: PropTypes.shape({
|
|
|
+ name: PropTypes.node.isRequired,
|
|
|
+ img: PropTypes.string.isRequired,
|
|
|
+ steps: PropTypes.arrayOf(PropTypes.shape({
|
|
|
+ title: PropTypes.node,
|
|
|
+ image: PropTypes.string,
|
|
|
+ video: PropTypes.string,
|
|
|
+ deckIds: PropTypes.arrayOf(PropTypes.string)
|
|
|
+ }))
|
|
|
+ })
|
|
|
+ }),
|
|
|
+ dragging: PropTypes.bool.isRequired,
|
|
|
+ expanded: PropTypes.bool.isRequired,
|
|
|
+ isRtl: PropTypes.bool.isRequired,
|
|
|
+ locale: PropTypes.string.isRequired,
|
|
|
+ onActivateDeckFactory: PropTypes.func.isRequired,
|
|
|
+ onCloseCards: PropTypes.func.isRequired,
|
|
|
+ onDrag: PropTypes.func,
|
|
|
+ onEndDrag: PropTypes.func,
|
|
|
+ onNextStep: PropTypes.func.isRequired,
|
|
|
+ onPrevStep: PropTypes.func.isRequired,
|
|
|
+ onShowAll: PropTypes.func,
|
|
|
+ onShrinkExpandCards: PropTypes.func.isRequired,
|
|
|
+ onStartDrag: PropTypes.func,
|
|
|
+ showVideos: PropTypes.bool,
|
|
|
+ step: PropTypes.number.isRequired,
|
|
|
+ x: PropTypes.number,
|
|
|
+ y: PropTypes.number
|
|
|
+};
|
|
|
+
|
|
|
+Cards.defaultProps = {
|
|
|
+ showVideos: true
|
|
|
+};
|
|
|
+
|
|
|
+export {
|
|
|
+ Cards as default,
|
|
|
+ // Others exported for testability
|
|
|
+ ImageStep,
|
|
|
+ VideoStep
|
|
|
+};
|