DEV Community

Cover image for How to Create a Custom Countdown Content Type in PWA Studio
Gabriel Lima
Gabriel Lima

Posted on • Edited on

How to Create a Custom Countdown Content Type in PWA Studio

  • Summary
    • Introduction
    • Prerequisites
    • Before You Begin
    • About the Structure
    • Hands-on Code (Step-by-Step)
    • Repository
    • References

Introduction

In this tutorial, you will learn how to create and integrate a custom content type countdown into PWA Studio. By the end of this guide, you will have a fully functional countdown that is editable from Magento's admin panel and rendered in your PWA storefront.

Prerequisites

Before you begin, ensure you have the following:

  • A working installation of Magento and PWA Studio.
  • Basic knowledge of React, JavaScript, and Magento PageBuilder.
  • Node.js and npm installed in your environment.
  • PageBuilder Countdown Module

Before You Begin

Before you start coding this content type, ensure the Content Type is installed in your Magento and PWA Studio setup using the Scaffolding Tool. This tool will help set up the basic storefront environment.

The content type that we will integrate with PWA Studio is a countdown timer, which you can get from the repository of this tutorial.

About the Structure

Here is the final structure of the content type:



pwa-studio
├── src
│   ├── components
│   │   └── Countdown
│   │       ├── ContentType
│   │       │   └── Countdown
│   │       │       ├── configAggregator.js
│   │       │       ├── countdown.js
│   │       │       ├── countdown.module.css
│   │       │       └── index.js
│   │       ├── countdown.js
│   │       ├── detect.js
│   │       └── index.js
│   └── talons
│       └── Countdown
│           └── useCountdown.js
├── local-intercept.js
│


Enter fullscreen mode Exit fullscreen mode

Hands-on Code (Step-by-Step)

Now let's start creating the custom content type.

Step 1: Modify local-intercept.js

Open the local-intercept.js file in your PWA Studio root and update its content as follows:



function localIntercept(targets) {
    targets.of('@magento/pagebuilder').customContentTypes.tap(
        contentTypes => contentTypes.add({
            contentType: 'Countdown',
            importPath: require.resolve("./src/components/Countdown/index.js")
        })
    );
}

moduleexports = localIntercept;


Enter fullscreen mode Exit fullscreen mode

This code instructs the @magento/pagebuilder to add the custom Countdown content type.

Step 2: Create configAggregator.js

Create the file src/components/Countdown/ContentType/Countdown/configAggregator.js with the following content:



import { getAdvanced } from "@magento/pagebuilder/lib/utils";

export default (node, props) => {
    return {
        targetDate: node.childNodes[0].childNodes[0].attributes[1].value,
        ...getAdvanced(node),
    };
};


Enter fullscreen mode Exit fullscreen mode

1. targetDate Extraction:

  • We access the date through: node.childNodes[0].childNodes[0].attributes[1].value. This navigates through the node to reach the attribute that holds the targetDate.

2. getAdvanced(node) Utility Function:

  • getAdvanced(node) is a utility function that retrieves additional styling properties from the node like padding, margins, border, text alignment, and CSS classes.
  • These settings allow the countdown timer to inherit any advanced layout configurations made by the user in the Magento Page Builder's editor.

Step 3: Create Countdown Component

Now we will create the countdown component.

countdown.js

Create the file src/components/Countdown/ContentType/Countdown/countdown.js and add the following code:



import React from 'react';
import { mergeClasses } from '@magento/venia-ui/lib/classify';
import { arrayOf, bool, shape, string } from 'prop-types';

import defaultClasses from './countdown.module.css';
import { FormattedMessage } from 'react-intl';
import { useCountdown } from '../../../../talons/Countdown/useCountdown';

const Countdown = props => {
    const {
        targetDate,
        textAlign,
        border,
        borderColor,
        borderWidth,
        borderRadius,
        isHidden,
        marginTop,
        marginRight,
        marginBottom,
        marginLeft,
        paddingTop,
        paddingRight,
        paddingBottom,
        paddingLeft,
        cssClasses
    } = props;

    const classes = mergeClasses(defaultClasses, props.classes);

    const {
        days,
        hours,
        minutes,
        seconds
    } = useCountdown({ targetDate });

    const formStyles = {
        textAlign,
        border,
        borderColor,
        borderWidth,
        borderRadius,
        isHidden,
        marginTop,
        marginRight,
        marginBottom,
        marginLeft,
        paddingTop,
        paddingRight,
        paddingBottom,
        paddingLeft,
    };

    return (
        <div style={formStyles}>
            <div className={classes.wrapper}>
                <div className={classes.field}>
                    <div className="days">{days}</div>
                    <div className="separator">:</div>
                    <div className="hours">{hours}</div>
                    <div className="separator">:</div>
                    <div className="min">{minutes}</div>
                    <div className="separator">:</div>
                    <div className="sec">{seconds}</div>
                    <div className={classes.label}>
                        <FormattedMessage
                            id="countdown.days"
                            defaultMessage="days"
                        />
                    </div>
                    <div></div>
                    <div className={classes.label}>
                        <FormattedMessage
                            id="countdown.hours"
                            defaultMessage="hours"
                        />
                    </div>
                    <div></div>
                    <div className={classes.label}>
                        <FormattedMessage
                            id="countdown.minutes"
                            defaultMessage="min"
                        />
                    </div>
                    <div></div>
                    <div className={classes.label}>
                        <FormattedMessage
                            id="countdown.seconds"
                            defaultMessage="sec" />
                    </div>
                </div>
            </div>
        </div>
    );
};

Countdown.propTypes = {
    textAlign: string,
    border: string,
    borderColor: string,
    borderWidth: string,
    borderRadius: string,
    isHidden: bool,
    marginTop: string,
    marginRight: string,
    marginBottom: string,
    marginLeft: string,
    paddingTop: string,
    paddingRight: string,
    paddingBottom: string,
    paddingLeft: string,
    cssClasses: arrayOf(string),
    classes: shape({ wrapper: string, field: string, label: string })
};

Countdown.defaultProps = {};

export default Countdown;


Enter fullscreen mode Exit fullscreen mode

Let's break down some key parts of the Countdown component code:

  1. Props Destructuring: In the component's props, we are receiving several variables, most notably the targetDate from the configAggregator.js file. This scaffolding represents the end time for the countdown. Along with targetDate, we also receive various styling-related variables such as textAlign, border, and padding, which are dynamically passed from Page Builder. Lastly, the cssClasses variable comes directly from Page Builder and contains any additional CSS classes that need to be applied to the component.
  2. Calling the Talon: After destructuring the props, we initialize the useCountdown talon. This custom hook (useCountdown) is passed the targetDate as a prop and, in return, provides the values needed to display the countdown—days, hours, minutes, and seconds. These values are automatically updated as time progresses, which ensures that the countdown remains accurate in real-time.
  3. Combining Style Properties: The formStyles variable is an object that combines all the individual style-related variables (such as border, textAlign, margin, and padding) from the props. This aggregation makes it easier to apply these styles in one go, directly onto the container <div>, by using the style attribute:

 <div style={formStyles}>

Enter fullscreen mode Exit fullscreen mode

By passing the formStyles object here, we ensure that the countdown component reflects any custom styling options configured via the Page Builder, such as margins, padding, and borders.

countdown.module.css

Next, create the CSS file src/components/Countdown/ContentType/Countdown/countdown.module.css:



.wrapper {
    composes: flex from global;
}  

.field {
    composes: grid from global;
    composes: items-center from global;
    composes: justify-items-center from global;
    composes: gap-1.5 from global;
    composes: font-light from global;
    grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
}

field div {
    font-size: 1.5rem;
    letter-spacing: normal;
}

.label {
    composes: text-base from global;
    margin-top: -5px;
    letter-spacing: normal;
}


Enter fullscreen mode Exit fullscreen mode

index.js

Finally, create the src/components/Countdown/ContentType/Countdown/index.js file:



import React from 'react';
import configAggregator from './configAggregator';

export default {
    configAggregator,
    component: React.lazy(() => import('./countdown'))
}


Enter fullscreen mode Exit fullscreen mode
  1. configAggregator Import: The configAggregator is imported from the configAggregator.js file. This function is responsible for collecting the necessary data (such as targetDate, margins, borders, etc.) from the Page Builder content type and passing it as props to the component. It ensures that all configurations from the admin panel are correctly applied to the component.
  2. Dynamic Component loading: The React.lazy() function is used to lazily load the countdown component.
  3. Exporting the Configuration Object: The file exports a default object containing two key properties:
    • configAggregator: This defines how the content type's configuration data is retrieved and passed into the component.
    • component: This points to the countdown component, which is lazy loaded via React.lazy().

This file essentially sets up the necessary configurations and ensures that the countdown component is properly integrated with the Page Builder in a performance-efficient manner.

1. src/components/Countdown/countdown.js



import React from 'react';
import { setContentTypeConfig } from '@magento/pagebuilder/lib/config';
import ContentTypeConfig from './ContentType/Countdown';

setContentTypeConfig('example_countdown', ContentTypeConfig);

const Countdown = (props) => {
    return null;
};

export default Countdown;


Enter fullscreen mode Exit fullscreen mode
  • setContentTypeConfig: This function registers the custom content type (example_countdown) with Page Builder. It accepts two arguments:
    • The first argument is the content type name (example_countdown), which must match the name defined in the XML file located at app/code/Example/PageBuilderCountdown/view/adminhtml/pagebuilder/content_type/example_countdown.xml. This code snippet defines show the name of content type:
    • The second argument, ContentTypeConfig, is imported from the Countdown folder and includes the component configuration required by Page Builder.
  • The component itself (Countdown) does not render any UI, as it only serves to register the content type. Therefore, it returns null.

2. src/components/Countdown/detect.js

This file defines a regular expression (RegEx) function to detect if the custom content type is present in the content.



export default function detectMyCustomType(content) {
    return /data-content-type=\"example_countdown/.test(content);
}


Enter fullscreen mode Exit fullscreen mode
  • detectMyCustomType: This function checks if the Page Builder content contains the custom content type (example_countdown) by searching for the data-content-type="example_countdown" attribute in the rendered HTML. If found, it returns true, indicating that the custom content type is present.
  • The same content type name (example_countdown) is used here, matching the one defined in the XML file and the setContentTypeConfig function.

3. src/components/Countdown/index.js

In this file, we export the components and functionality created above.



export { default } from './countdown';
export { default as Component } from './countdown';
export { default as canRender } from './detect';


Enter fullscreen mode Exit fullscreen mode
  • Default Export (Countdown): This file exports the countdown component as the default export.
  • Named Export (Component): The countdown component is also exported as a component, making it easier to refer to the component when necessary.
  • Named Export (canRender): The detect function is exported as canRender, which will be used by Page Builder to determine whether the custom content type should be rendered based on the presence of the data-content-type="example_countdown" attribute.

These files collectively ensure that the custom content type (example_countdown) is properly registered, detected, and rendered within the Page Builder environment.

Step 4: Set up the Talon

In this section, we will create a talon to handle the logic behind the countdown timer. If you're unfamiliar with talons, they are hooks used in the Magento PWA Studio to separate logic from components, making code more modular and reusable. For more details, check out Talons (adobe.com).

Now create the talon file src/talons/Countdown/useCountdown.js:



import { useEffect, useState, useCallback } from 'react';

export const useCountdown = ({ targetDate }) => {
    const validateDate = (date) => {
        const target = new Date(date);
        return isNaN(target.getTime()) ? null : target;
    };

    const target = validateDate(targetDate);

if (!target) {
        console.error('Invalid targetDate provided');
        return { days: '00', hours: '00', minutes: '00', seconds: '00' };
    }

    const calculateTimeLeft = useCallback(() => {
        const difference = target - new Date();
        if (difference <= 0) return { days: '00', hours: '00', minutes: '00', seconds: '00' };  

        const time = (unit, mod) => String(Math.floor((difference / unit) % mod)).padStart(2, '0');

        return {
            days: time(1000 * 60 * 60 * 24, Infinity),
            hours: time(1000 * 60 * 60, 24),
            minutes: time(1000 * 60, 60),
            seconds: time(1000, 60),
        };
    }, [target]);

    const [timeLeft, setTimeLeft] = useState(calculateTimeLeft);

    useEffect(() => {
        if (Object.values(timeLeft).every((v) => v === '00')) return;

        const timer = setInterval(() => setTimeLeft(calculateTimeLeft), 1000);
        return () => clearInterval(timer);
    }, [timeLeft, calculateTimeLeft]);

    return timeLeft;
};


Enter fullscreen mode Exit fullscreen mode
  • validateDate: This function ensures the provided targetDate is valid. If the date is invalid, it returns null, and the countdown will display "00" for all time units.
  • calculateTimeLeft: This function calculates the difference between the current time and the targetDate. It returns the remaining time formatted as days, hours, minutes, and seconds.
  • useState: We use React's useState to store the current countdown time, updating it every second.
  • useEffect: This hook sets up a timer that updates the countdown every second. If the countdown reaches "00", it stops.

Repository

You can find the complete repository, which includes both the final PWA Studio code and the Magento module used in this tutorial, at the following link:

References

Top comments (0)