diff --git a/default-react-modules/src/components/modules/PostListing-V1/StyledComponentRegistry.jsx b/default-react-modules/src/components/modules/PostListing-V1/StyledComponentRegistry.jsx
new file mode 100644
index 0000000..6d07220
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/StyledComponentRegistry.jsx
@@ -0,0 +1,32 @@
+import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
+import { useInlineHeadAsset } from '@hubspot/cms-components';
+
+export default function StyledComponentsRegistry({ children }) {
+ // On the client, styled-components creates its own stylesheet. We only want
+ // to create this sheet on the server
+ const styledComponentsStyleSheet = import.meta.env.SSR
+ ? new ServerStyleSheet()
+ : null;
+
+ useInlineHeadAsset(() => {
+ if (styledComponentsStyleSheet === null) {
+ return;
+ }
+
+ // Collect styles generated on the server pass and return them to go in the
+ //
+ const styles = styledComponentsStyleSheet.getStyleElement();
+ styledComponentsStyleSheet.seal();
+ return <>{styles}>;
+ });
+
+ if (styledComponentsStyleSheet === null) {
+ return <>{children}>;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/default-react-modules/src/components/modules/PostListing-V1/assets/blog.svg b/default-react-modules/src/components/modules/PostListing-V1/assets/blog.svg
new file mode 100644
index 0000000..7be5786
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/assets/blog.svg
@@ -0,0 +1,15 @@
+
+
+
+ Untitled 2
+ Created with Sketch.
+
+
+
+
+
+
+
+
+
+
diff --git a/default-react-modules/src/components/modules/PostListing-V1/assets/mockBlogPosts.js b/default-react-modules/src/components/modules/PostListing-V1/assets/mockBlogPosts.js
new file mode 100644
index 0000000..dd0caa3
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/assets/mockBlogPosts.js
@@ -0,0 +1,119 @@
+export const mockBlogPosts = [
+ {
+ id: 1,
+ name: 'How to break your ankle and still smile',
+ absoluteUrl: 'https://test-domain/blog/01-01-2023/broken-ankles',
+ featuredImage:
+ 'https://www.mayoclinic.org/-/media/kcms/gbs/patient-consumer/images/2013/08/26/11/10/ds00951_im03497_r7_footanklethu_jpg.jpg',
+ featuredImageAltText: 'Bone structure of a foot',
+ blogAuthor: {
+ avatar:
+ 'https://cdn3.iconfinder.com/data/icons/avatars-9/145/Avatar_Cat-512.png',
+ name: 'Brandon Van Dyck',
+ displayName: 'Brandon Van Dyck',
+ slug: 'brandonvandyck',
+ },
+ tagList: [
+ {
+ name: 'Feet',
+ slug: 'feet',
+ },
+ ],
+ parentBlog: {},
+ publishDate: 1756388800000,
+ publishDateLocalized: { date: 'Jun 30, 2022 12:00:00 AM' },
+ postListContent:
+ "Ankles may break, but spirits don't have to Find out why a broken bone does not mean frowns all around.
",
+ },
+ {
+ id: 2,
+ name: 'E-Bikes: A Guide to Building and Riding',
+ absoluteUrl: 'https://test-domain/blog/01-02-2023/ebikes',
+ featuredImage:
+ 'https://pedegoelectricbikes.com/wp-content/uploads/2020/12/pedego-ridge-rider-classic-600x494.jpg',
+ featuredImageAltText: 'Electric bike',
+ blogAuthor: {
+ avatar:
+ 'https://cdn3.iconfinder.com/data/icons/avatars-9/145/Avatar_Cat-512.png',
+ name: 'Joey Blake',
+ displayName: 'Joey Blake',
+ slug: 'jblake',
+ },
+ tagList: [
+ {
+ name: 'Bikes',
+ slug: 'bikes',
+ },
+ {
+ name: 'Electricity',
+ slug: 'electricity',
+ },
+ ],
+ parentBlog: {},
+ publishDate: 1756388800000,
+ publishDateLocalized: { date: 'Jun 30, 2022 12:00:00 AM' },
+ postListContent:
+ 'Bikes were traditionally powered by calories -- no more! Experience the joy of electricity powered movement.
',
+ },
+ {
+ id: 3,
+ name: 'Mesh Wifi: Boost your home wifi experience',
+ absoluteUrl: 'https://test-domain/blog/01-03-2023/wifi',
+ featuredImage:
+ 'https://m.media-amazon.com/images/I/41bMo8AYiML._AC_SY1000_.jpg',
+ featuredImageAltText: 'Home with mesh wifi ',
+ blog_avatar: 'https://cdn-icons-png.flaticon.com/512/3093/3093444.png',
+ blogAuthor: {
+ avatar:
+ 'https://cdn3.iconfinder.com/data/icons/avatars-9/145/Avatar_Cat-512.png',
+ name: 'Wifi Willy',
+ displayName: 'Wilfred Wilhelm Wifi',
+ slug: 'wwifi',
+ },
+ tagList: [
+ {
+ name: 'Wifi',
+ slug: 'wifi',
+ },
+ {
+ name: 'Mesh',
+ slug: 'mesh',
+ },
+ ],
+ parentBlog: {},
+ publishDate: 1756388800000,
+ publishDateLocalized: { date: 'Jun 30, 2022 12:00:00 AM' },
+ postListContent:
+ '【Eliminate WiFi Dead Spots】Cover more than 6000 square feet from garage to backyard, seamless single WiFi name for whole house. Easy to expand the coverage by install 3 pack mesh system, simply setup mesh to eliminate WiFi dead spots. Support up to 9 dots to build WiFi system for any home size
',
+ },
+ {
+ id: 4,
+ name: 'Being a Chef: To live amongst flame',
+ absoluteUrl: 'https://test-domain/blog/01-04-2023/being-a-chef',
+ featuredImage:
+ 'https://www.finedininglovers.com/sites/g/files/xknfdk626/files/2021-07/chef%20%281%29.jpg',
+ featuredImageAltText: 'Chef using alcohol and fire to cook a dish',
+ blog_avatar: 'https://cdn-icons-png.flaticon.com/512/3093/3093444.png',
+ blogAuthor: {
+ avatar: '',
+ name: 'Chef Knife',
+ displayName: 'Chefred Knifer',
+ slug: 'chefredknifer',
+ },
+ tagList: [
+ {
+ name: 'Chef',
+ slug: 'chef',
+ },
+ {
+ name: 'Fire',
+ slug: 'fire',
+ },
+ ],
+ parentBlog: {},
+ publishDate: 1756388800000,
+ publishDateLocalized: { date: 'Jun 30, 2022 12:00:00 AM' },
+ postListContent:
+ "Being a Chef As much as restaurants are driven by chefs' desire to feed and entertain, and to express themselves, to produce the best possible food, delivered with impeccable service, they are, when it comes down to it, businesses and every chef that wants to be successful must get a handle on that side of things. It is perhaps the most important factor for longevity.
",
+ },
+];
diff --git a/default-react-modules/src/components/modules/PostListing-V1/fields.tsx b/default-react-modules/src/components/modules/PostListing-V1/fields.tsx
new file mode 100644
index 0000000..2451fcd
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/fields.tsx
@@ -0,0 +1,132 @@
+import {
+ FieldGroup,
+ ModuleFields,
+ TextField,
+ FontField,
+ SpacingField,
+ BlogField,
+ ChoiceField,
+ NumberField,
+} from '@hubspot/cms-components/fields';
+
+export const fields = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/default-react-modules/src/components/modules/PostListing-V1/hubl_data.hubl.html b/default-react-modules/src/components/modules/PostListing-V1/hubl_data.hubl.html
new file mode 100644
index 0000000..5f1a60c
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/hubl_data.hubl.html
@@ -0,0 +1,7 @@
+{% if module.selectBlog is number %}
+ {% set selectBlog = module.selectBlog %}
+{% else %}
+ {% set selectBlog = 'default' %}
+{% endif %}
+
+{% set hublData = sign_postlisting_url(selectBlog, module.listingType, module.maxLinks) %}
diff --git a/default-react-modules/src/components/modules/PostListing-V1/index.tsx b/default-react-modules/src/components/modules/PostListing-V1/index.tsx
new file mode 100644
index 0000000..92db9bf
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/index.tsx
@@ -0,0 +1,103 @@
+import { Island } from '@hubspot/cms-components';
+import blogIcon from './assets/blog.svg';
+import StyledComponentsRegistry from './StyledComponentRegistry.jsx';
+import PostListingIsland from './islands/PostListingIsland.js?island';
+import { styled } from 'styled-components';
+import { ModuleMeta } from '../../../types/modules.js';
+import { StyleFields } from './types.js';
+import { FontFieldType } from '@hubspot/cms-components/fields';
+
+type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+
+interface FontProps {
+ $fontStyle: string;
+ $fontHoverStyle?: string;
+}
+
+const HeadingWrapper = styled.span`
+ ${props => props.$fontStyle}
+`;
+
+interface BlogPostHeadingProps {
+ headingLevel: HeadingLevel;
+ postsHeading: string;
+ headingStyle: {
+ font: FontFieldType;
+ };
+}
+
+const BlogPostHeading = ({
+ headingLevel,
+ postsHeading,
+ headingStyle,
+}: BlogPostHeadingProps) => {
+ const HeadingLevel = headingLevel;
+
+ return (
+
+
+ {postsHeading}
+
+
+ );
+};
+
+function stripPublicFromUrl(signedUrl) {
+ if (signedUrl) {
+ return signedUrl.replace(/^public/, '');
+ }
+}
+
+interface ComponentProps {
+ hublData: string;
+ headingLevel: HeadingLevel;
+ postsHeading: string;
+ groupStyle: StyleFields;
+ displayForEachListItem: string[];
+}
+
+export function Component(props: ComponentProps) {
+ const {
+ hublData,
+ headingLevel,
+ postsHeading,
+ groupStyle,
+ displayForEachListItem,
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+export const defaultModuleConfig = {
+ moduleName: 'post_listing',
+ version: 1,
+};
+
+export { fields } from './fields.jsx';
+export const meta: ModuleMeta = {
+ label: 'Post listing',
+ host_template_types: ['BLOG_LISTING', 'BLOG_POST', 'PAGE'],
+ icon: blogIcon,
+ categories: ['blog'],
+};
+
+export { default as hublDataTemplate } from './hubl_data.hubl.html?raw';
diff --git a/default-react-modules/src/components/modules/PostListing-V1/islands/PostListingIsland.tsx b/default-react-modules/src/components/modules/PostListing-V1/islands/PostListingIsland.tsx
new file mode 100644
index 0000000..4146424
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/islands/PostListingIsland.tsx
@@ -0,0 +1,253 @@
+import { useState, useEffect } from 'react';
+import styles from '../styles.module.css';
+import { styled } from 'styled-components';
+import { StyleFields } from '../types.js';
+import {
+ FontFieldType,
+ SpacingFieldType,
+} from '@hubspot/cms-components/fields';
+
+type BlogPost = {
+ label: string;
+ url: string;
+ featuredImage: string;
+ blogAuthor: {
+ name: string;
+ };
+ publishDate: number;
+};
+
+type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+
+function getNextHeadingLevel(headingLevel) {
+ const nextHeadingLevel = {
+ h1: 'h2',
+ h2: 'h3',
+ h3: 'h4',
+ h4: 'h5',
+ h5: 'h6',
+ h6: 'h6',
+ };
+
+ return nextHeadingLevel[headingLevel];
+}
+
+interface FontProps {
+ $fontStyle: string;
+ $fontHoverStyle?: string;
+}
+
+interface SpacingProps {
+ $spacingStyle: string;
+}
+
+interface BlogPostImageProps {
+ featuredImage: string;
+}
+
+const BlogPostImage = ({ featuredImage }: BlogPostImageProps) => {
+ return (
+
+ );
+};
+
+const BlogPostTitleLink = styled.a`
+ display: block;
+ text-decoration: none;
+ ${props => props.$fontStyle}
+ ${props => props.$spacingStyle}
+ &:hover {
+ ${props => props.$fontHoverStyle}
+ }
+`;
+
+interface BlogPostTitleProps {
+ label: string;
+ url: string;
+ titleStyle: {
+ font: FontFieldType;
+ hoverFont: FontFieldType;
+ spacing: SpacingFieldType;
+ };
+ headingLevel: HeadingLevel;
+}
+
+const BlogPostTitle = ({
+ label,
+ url,
+ titleStyle,
+ headingLevel,
+}: BlogPostTitleProps) => {
+ // This ensures that the titles are always one level below the main heading for SEO purposes.
+ const HeadingLevel = getNextHeadingLevel(headingLevel);
+
+ return (
+
+
+ {label}
+
+
+ );
+};
+
+const BlogPostAuthorName = styled.span`
+ ${props => props.$fontStyle}
+`;
+
+interface BlogPostAuthorProps {
+ blogAuthor: {
+ name: string;
+ };
+ authorStyle: {
+ font: FontFieldType;
+ };
+}
+
+const BlogPostAuthor = ({ blogAuthor, authorStyle }: BlogPostAuthorProps) => {
+ return (
+
+ {blogAuthor.name}
+
+ );
+};
+
+const BlogPostPublishTime = styled.time`
+ ${props => props.$fontStyle}
+`;
+
+interface BlogPostPublishDateProps {
+ publishDate: number;
+ publishDateStyle: {
+ font: FontFieldType;
+ };
+}
+
+const BlogPostPublishDate = ({
+ publishDate,
+ publishDateStyle,
+}: BlogPostPublishDateProps) => {
+ const localeDateString = new Date(publishDate).toLocaleDateString();
+
+ return (
+
+ {localeDateString}
+
+ );
+};
+
+interface ListingBlogPostProps {
+ headingLevel: HeadingLevel;
+ blogPost: BlogPost;
+ groupStyle: StyleFields;
+ displayForEachListItem: string[];
+}
+
+const ListingBlogPost = ({
+ blogPost,
+ groupStyle,
+ headingLevel,
+ displayForEachListItem,
+}: ListingBlogPostProps) => {
+ const showPostTitle = displayForEachListItem.includes('title');
+ const showPostAuthorName = displayForEachListItem.includes('authorName');
+ const showPostPublishDate = displayForEachListItem.includes('publishDate');
+ const showPostImage = displayForEachListItem.includes('image');
+ const { label, url, blogAuthor, publishDate, featuredImage } = blogPost;
+ const { groupTitle, groupAuthor, groupPublishDate } = groupStyle;
+
+ return (
+
+ {showPostImage && (
+
+
+
+ )}
+ {showPostTitle && (
+
+ )}
+
+ {showPostAuthorName && (
+
+ )}
+ {showPostPublishDate && (
+
+ )}
+
+
+ );
+};
+
+export default function PostListingIsland({
+ headingLevel,
+ signedUrl,
+ groupStyle,
+ displayForEachListItem,
+ layout,
+}) {
+ const [blogPosts, setBlogPosts] = useState([]);
+
+ useEffect(() => {
+ if (!signedUrl) {
+ return;
+ }
+
+ const fetchBlogPosts = async () => {
+ fetch(signedUrl)
+ .then(result => {
+ if (!result.ok) {
+ throw new Error(`HTTP error. Status: ${result.status}`);
+ }
+ return result.json();
+ })
+ .then(data => {
+ setBlogPosts(data);
+ })
+ .catch(error => {
+ console.error('Error fetching blog posts:', error);
+ setBlogPosts([]);
+ });
+ };
+ fetchBlogPosts();
+ }, [signedUrl]);
+
+ return (
+
+ {blogPosts.map(blogPost => {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/default-react-modules/src/components/modules/PostListing-V1/meta.ts b/default-react-modules/src/components/modules/PostListing-V1/meta.ts
new file mode 100644
index 0000000..640661f
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/meta.ts
@@ -0,0 +1,9 @@
+import blogIcon from './assets/blog.svg';
+import { ModuleMeta } from '../../../types/modules.js'
+
+export const meta: ModuleMeta = {
+ label: 'Post listing',
+ host_template_types: ['BLOG_LISTING', 'BLOG_POST', 'PAGE'],
+ icon: blogIcon,
+ categories: ['blog'],
+};
diff --git a/default-react-modules/src/components/modules/PostListing-V1/styles.module.css b/default-react-modules/src/components/modules/PostListing-V1/styles.module.css
new file mode 100644
index 0000000..b35eb76
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/styles.module.css
@@ -0,0 +1,55 @@
+.hsPostListingWrapper--tiles {
+ display: grid;
+ justify-content: center;
+ grid-template-columns: repeat(auto-fit, minmax(250px, calc(100% / 3 - 20px)));
+ gap: 20px;
+}
+
+.hsPostListingWrapper--minimal {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+}
+
+.hsPostListingHeading {
+ display: block;
+}
+
+.hsPostListing--image {
+ max-width: 250px;
+}
+
+.hsPostListingFeaturedImage {
+ height: 10rem;
+ max-height: 100%;
+ margin-bottom: 1rem;
+}
+
+.hsPostListingImage {
+ height: 100%;
+ width: 100%;
+ -o-object-fit: cover;
+ object-fit: cover;
+}
+
+.hsPostListingBody {
+ display: flex;
+ flex-direction: column;
+}
+
+.hsPostListingTitle {
+ display: block;
+}
+
+.hsPostListingAuthor {
+ display: block;
+}
+
+.hsPostListingPublishDate {
+ display: block;
+}
+
+.hsPostListingAuthorDate {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/default-react-modules/src/components/modules/PostListing-V1/types.ts b/default-react-modules/src/components/modules/PostListing-V1/types.ts
new file mode 100644
index 0000000..4437c70
--- /dev/null
+++ b/default-react-modules/src/components/modules/PostListing-V1/types.ts
@@ -0,0 +1,24 @@
+import {
+ FontFieldType,
+ SpacingFieldType,
+} from '@hubspot/cms-components/fields';
+
+export type StyleFields = {
+ groupLayout: {
+ style: string;
+ };
+ groupHeading: {
+ font: FontFieldType;
+ };
+ groupTitle: {
+ font: FontFieldType;
+ hoverFont: FontFieldType;
+ spacing: SpacingFieldType;
+ };
+ groupAuthor: {
+ font: FontFieldType;
+ };
+ groupPublishDate: {
+ font: FontFieldType;
+ };
+};