How to Build a Changelog Feature in React for Your App

radzion

Radzion Chachura

Posted on November 2, 2024

How to Build a Changelog Feature in React for Your App

šŸ™ GitHub | šŸŽ® Demo

How to Create an Effective "What's New" Feature for Your Product

Keeping users informed about the latest updates is crucial for any software product. A well-crafted changelog or "What's New" feature can make all the difference in improving user engagement and transparency. Whether you're fixing bugs, rolling out new features, or making performance improvements, a changelog ensures your users stay in the loop. In this post, we'll walk you through how to create a simple and effective "What's New" feature, using Increaser as an example. While Increaser's source code is private, you can find all the reusable code in the RadzionKit repository, so you can easily implement something similar in your own projects.

What's New Page

Why You Need a "What's New" Feature

It's important to show both potential and existing users that your product is continuously evolving. On the website, updates can demonstrate to new visitors that your product is actively being developed, building trust right from the start. For existing users, displaying updates in the app helps them stay informed about new features, improvements, and changes. And by posting those updates on social media, you can attract new users and keep your audience engaged. To achieve this, we need a way to manage our updates in a single place, while allowing us to display them across different platforms in various formats.

Organizing Updates with a Centralized Changelog

Increaser's code is in a monorepo, so we will create a "changelog" package that will serve as a source of truth for all updates, and the core file here will be changelog.txt. Since it's a user-facing application we don't really care about a concept of a version, it's just a date of a release and a list of changes. So our changelog file is incredibly simple. We use a timestamp to separate releases, and each update is separated by a new line, so it looks like this.

Managing Unreleased Updates

In Increaser's monorepo, we organize everything around a single source of truth for our updates by creating a dedicated "changelog" package. The core of this package is a simple file named changelog.txt, where we log every user-facing update. Since we're dealing with a consumer application, there's no need to complicate things with versioning. Instead, we rely on the date of the release and a list of changes.

This approach keeps things minimalistic. Each release is separated by a timestamp, and individual updates are listed one by one, each on a new line. This simplicity allows us to manage updates efficiently while ensuring that all changes are easy to track and understand. Here's a basic example of how the changelog.txt file might look:

Users can now edit and reorder habits right from the "Track" and "Stats" views on the "Habits" page.
User can reset a specific habit from the "Edit" view of a habit.
Every "Tasks" sub-page now has an "Add" button
Users can turn an existing task into a template and templates are now always available when creating or editing tasks.
It's more comfortable to set a weekly goal for a project
Added "Time tracking" and "Taks Management" articles to the "Info" page
1729225980085
Users connect goals to their habits by either adding new habits from the goal view or picking an existing habit.
To help users remember to track their habits, a prompt appears in the sidebar navigation next to the "Habits" item. This prompt will show up if the user hasn't visited the "Habits" page that day.
Added a line to the weekly project report chart to display budgeted time for selected projects or total budgeted time for all projects.
Updated the projects page to display not only the emoji and name, but also the budget, goal, and "Mon-Fri" label for workday projects.
User can set project weekly budget and goal while creating or editing a project.
"Report" subpage was moved from "Projects" page to a dedicated "Timesheet" page for easier access.
1728708465131
Users can now expedite the creation of recurring tasks ahead of schedule by using the 'Create Now' button found in the 'Upcoming' tab of the 'Tasks' page.
Users can now see on the 'Upcoming' tab of the 'Tasks' page when their recurring task will actually be created.
...
Enter fullscreen mode Exit fullscreen mode

If our changelog.txt file doesn't start with a timestamp, it means that those changes haven't been officially announced yet. This simple convention allows us to keep track of updates that are still in development or awaiting release. Once weā€™re ready to make the update public, we add a timestamp to signify the release date.

Structuring Updates for Better Integration

While having a simple text file is useful for logging updates, itā€™s not the most convenient format to work with in our TypeScript codebase, especially when we need to reuse this data in different contexts. To handle this more effectively, we introduce the ProductUpdate type, which structures all the necessary information about each update.

Defining the Product Update Types

type ProductUpdateItem = {
  description: string
}

export const productUpdateSocials = [
  "telegram",
  "x",
  "linkedIn",
  "indieHackers",
  "reddit",
  "youtube",
] as const
export type ProductUpdateSocial = (typeof productUpdateSocials)[number]

export const productUpdateSocialName = {
  telegram: "Telegram",
  x: "X",
  linkedIn: "LinkedIn",
  indieHackers: "Indie Hackers",
  reddit: "Reddit",
  youtube: "YouTube",
} as const

export type ProductUpdateSocials = Partial<Record<ProductUpdateSocial, string>>

export type ProductUpdate = ProductUpdateSocials & {
  releasedAt: number
  name: string
  description: string
  items?: ProductUpdateItem[]
}
Enter fullscreen mode Exit fullscreen mode

The ProductUpdate type builds on the ProductUpdateSocials type, which is essentially a record of URLs to the social media posts related to the update. This allows us to easily track and manage the various announcements across platforms like Telegram, LinkedIn, X, and more.

Following that, we have a releasedAt field, which stores the timestamp of when the update went live. The name field gives each update a title, while description provides a summary or an overview of what the update is about.

Finally, the items array consists of ProductUpdateItem objects. Each item represents an individual change or feature introduced in the update, and for now, each item only includes a description.

Storing Updates: Current vs. Future Approach

At some point, we'll need to store product updates in a database, but since we haven't made that many updates yet, a simple productUpdates.ts file will suffice. This file will contain all the updates in a single array, making it easy to manage and reference in the codebase for now.

import { ProductUpdate } from './ProductUpdate'

export const productUpdates: ProductUpdate[] = [...]
Enter fullscreen mode Exit fullscreen mode

Announcing Updates: The "generate.ts" Script

Once we've collected enough items in the changelog.txt file, it's time to start announcing them to our users. This process begins with the generate.ts script.

import { createTsFile } from "@lib/codegen/utils/createTsFile"
import { injectProductUpdate } from "../utils/injectProductUpdate"
import {
  productUpdatesFileDirectory,
  productUpdatesFileName,
  readProductUpdatesFile,
} from "../utils/productUpdatesFile"
import { createNewYoutubeFile } from "../utils/youtubeFile"
import { createNewYoutubeFolder } from "../utils/youtubeFolder"

const generate = () => {
  const productUpdatesStr = readProductUpdatesFile()

  const newProductUpdate = injectProductUpdate(productUpdatesStr, {
    releasedAt: 0,
  })

  createTsFile({
    directory: productUpdatesFileDirectory,
    fileName: productUpdatesFileName,
    content: newProductUpdate,
  })

  createNewYoutubeFile()

  createNewYoutubeFolder()
}

generate()
Enter fullscreen mode Exit fullscreen mode

First, we read the existing product updates from the productUpdates.ts file using the readProductUpdatesFile function.

import fs from "fs"
import path from "path"

export const productUpdatesFileDirectory = path.resolve(__dirname, "../")
export const productUpdatesFileName = "productUpdates"

const productUpdatesFilePath = path.resolve(
  productUpdatesFileDirectory,
  `${productUpdatesFileName}.ts`
)

export const readProductUpdatesFile = () =>
  fs.readFileSync(productUpdatesFilePath, "utf8")
Enter fullscreen mode Exit fullscreen mode

Injecting New Product Updates

The injectProductUpdate function is responsible for adding a new, empty product update to the existing list of updates. It takes the current file content as a string and a partial ProductUpdate object, which initially only includes the releasedAt field set to 0.

import { ProductUpdate } from "../ProductUpdate"

export const injectProductUpdate = (
  fileContent: string,
  value: Partial<ProductUpdate>
) => {
  const declaration = "export const productUpdates: ProductUpdate[] = ["
  const [before, after] = fileContent.split(declaration)

  const productUpdate: ProductUpdate = {
    releasedAt: 0,
    name: "",
    description: "",
    items: [],
    youtube: "",
    telegram: "",
    x: "",
    linkedIn: "",
    indieHackers: "",
    reddit: "",
    ...value,
  }

  return [before, declaration, `${JSON.stringify(productUpdate)},`, after].join(
    "\n"
  )
}
Enter fullscreen mode Exit fullscreen mode

The function first splits the fileContent string around the productUpdates array declaration, which separates the part before the declaration and the part after. It then creates a ProductUpdate object with default values and overrides any values provided in the value argument. Finally, the function inserts the new product update into the array by reconstructing the file content and returning it as a string. This ensures the update is properly injected into the productUpdates.ts file while maintaining its structure.

Writing Updates Back to the Codebase

With the new string containing the updated product updates, we can use the createTsFile function from RadzionKit's codegen package to write the changes back to the productUpdates.ts file.

import { formatCode } from "./formatCode"
import { createFile } from "./createFile"

interface CreateTsFileParams {
  extension?: "ts" | "tsx"
  directory: string
  fileName: string
  generatedBy?: string
  content: string
}

export const createTsFile = async ({
  extension = "ts",
  directory,
  fileName,
  generatedBy,
  content,
}: CreateTsFileParams) => {
  const code = await formatCode({
    content: generatedBy
      ? [`// This file is generated by ${generatedBy}`, content].join("\n")
      : content,
    extension,
  })

  createFile({
    directory,
    fileName,
    content: code,
    extension,
  })
}
Enter fullscreen mode Exit fullscreen mode

Generating YouTube Scripts for Product Updates

Since weā€™ll also be making a YouTube video about the update, we can streamline this process by creating a new file specifically for the YouTube script. This script file will be stored in a youtube folder inside our changelog package.

import fs from "fs"
import path from "path"

export const createNewYoutubeFile = () =>
  fs.writeFileSync(path.resolve(__dirname, "../youtube/latest.md"), "", "utf8")

export const releaseNewYoutubeFile = (timestamp: number) =>
  fs.renameSync(
    path.resolve(__dirname, "../youtube/latest.md"),
    path.resolve(__dirname, `../youtube/${timestamp}.md`)
  )
Enter fullscreen mode Exit fullscreen mode

Additionally, weā€™ll set up a separate YouTube folder outside of the monorepo to contain all the assets for the video.

import fs from "fs"
import path from "path"

const youTubeFolders = path.resolve(__dirname, "../../../../changelog")

const latestDirectory = path.join(youTubeFolders, "latest")

export const createNewYoutubeFolder = () => fs.mkdirSync(latestDirectory)

export const releaseNewYoutubeFolder = (timestamp: number) =>
  fs.renameSync(
    latestDirectory,
    path.join(youTubeFolders, timestamp.toString())
  )
Enter fullscreen mode Exit fullscreen mode

Using ChatGPT to Generate YouTube Video Scripts

To streamline the process of creating YouTube video scripts for product updates, weā€™ve built a script that generates a prompt specifically for ChatGPT. While using the API is a viable option, I personally choose to use the ChatGPT interface since Iā€™m already paying for a subscriptionā€”this helps avoid paying for API usage on top of that. However, if you're not paying for a subscription, you could easily adapt this process to use the API.

import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"

const youtube = () => {
  const changelog = readChangelogFile()

  const changelogItems = parseChangelog(changelog)
  const { items } = changelogItems[0]

  const prompt = [
    "You will write a script for a YouTube video about new product updates.",
    "It should be a short video, just a few minutes long, enough to make the user understand the value of the updates.",
    "It should not be promotional, but sound human, as it is just a founder talking about what is new in the product.",
    "The script should be plain text, free from any formatting, and be ready to put in a teleprompter.",
    "The ending should be as short as possible, and should have a call to action to try it and give feedback.",
    'Start with "Hey everyone, Radzion here." and launch into the updates as fast as possible.',
    "Return the script as a markdown snippet.",
    "Updates are ordered by their priority:",
    items.map((item) => `  - ${item}`).join("\n"),
  ].join("\n")

  console.log(prompt)
  clipboardy.writeSync(prompt)
}

youtube()
Enter fullscreen mode Exit fullscreen mode

Parsing and Structuring Changelog Data

First, we need to read the changelog file and transform it into a more structured format thatā€™s easier to work with in our code. We define a ChangelogItem type to represent each entry. This type contains two fields: releasedAt, which can either be a timestamp or null for unreleased updates, and items, which is an array of strings representing the individual changes in that update.

export type ChangelogItem = {
  releasedAt: number | null
  items: string[]
}
Enter fullscreen mode Exit fullscreen mode

The parseChangelog function converts a raw changelog string into an array of ChangelogItem objects. It splits the input string into lines and processes each line individually. If a line contains only digits, it is treated as a timestamp, marking the start of a new changelog entry. The current entry is then pushed into an array, and a new ChangelogItem is created. Non-empty lines that aren't timestamps are treated as product updates and added to the current item. If no timestamp has been encountered yet, a new item with releasedAt set to null is created, indicating an unreleased update. At the end, the last changelog item is pushed to ensure all updates are captured.

import { ChangelogItem } from "../ChangelogItem"

export function parseChangelog(changelog: string): ChangelogItem[] {
  const lines = changelog.split("\n")
  const changelogItems: ChangelogItem[] = []
  let currentChangelogItem: ChangelogItem | null = null

  for (const line of lines) {
    if (/^\d+$/.test(line.trim())) {
      // Line is a timestamp
      if (currentChangelogItem) {
        changelogItems.push(currentChangelogItem)
      }
      currentChangelogItem = {
        releasedAt: parseInt(line.trim(), 10),
        items: [],
      }
    } else if (line.trim() !== "") {
      // Line is not empty
      if (!currentChangelogItem) {
        // Create a new changelog item with releasedAt set to null
        currentChangelogItem = {
          releasedAt: null,
          items: [],
        }
      }
      currentChangelogItem.items.push(line)
    }
  }

  // Push the last changelog item if exists
  if (currentChangelogItem) {
    changelogItems.push(currentChangelogItem)
  }

  return changelogItems
}
Enter fullscreen mode Exit fullscreen mode

Once we've parsed the changelog, we can easily extract the unreleased changes and insert them into a prompt for ChatGPT. However, to ensure that ChatGPT understands what Increaser is all about, we rely on a context.md file, which contains all the raw information about the app and its features. While this file may seem a bit lengthy, you only need to write it once and update it as the app evolves. By sending the content of this file before the prompt, ChatGPT has the context it needs to provide high-quality outputs that are tailored to the app's functionality and updates.

Once we receive the script from ChatGPT and refine it, we save it in the latest.md file inside the youtube folder. This file serves as the finalized script for the video. After that, we record the video and post it on YouTube. To save time, I donā€™t create a custom thumbnail for every video; instead, I simply update the date on the existing thumbnail to keep things quick and efficient.

Thumbnail example

Announcing Updates Across Platforms

With the video and thumbnail ready, the next step is to post the content on YouTube and other social media platforms. To streamline this, we use the announce.ts script, which generates prompts tailored for each platform. This ensures that our announcements are formatted appropriately for YouTube, LinkedIn, Telegram, and other channels. As we post the updates, we simultaneously update the first item in the productUpdates.ts file, filling in the URLs for each post.

import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"
import { ProductUpdateSocial, productUpdateSocialName } from "../ProductUpdate"
import { toEntries } from "@lib/utils/record/toEntries"

const socialPrompt: Record<ProductUpdateSocial, string[]> = {
  telegram: [
    "A message for Increaser telegram channel.",
    `Include https://app.increaser.org url in the content.`,
  ],
  x: [
    "A post on X, 280 characters max. Do not use hashtags.",
    `Include https://increaser.org url in the content.`,
  ],
  reddit: [
    `Include Increaser's website url in the content.`,
    "Also provide a title for the post.",
    `Include https://increaser.org url in the content.`,
  ],
  indieHackers: [
    `Include Increaser's app url in the content.`,
    "Also provide a title for the post.",
    `Include https://increaser.org url in the content.`,
  ],
  linkedIn: [
    `Do not use markdown syntax, e.g. no **bold** or bullet lists, LinkedIn posts do not support markdown.`,
    `Include https://increaser.org url in the content.`,
  ],
  youtube: ["A video title and description."],
}

const announce = () => {
  const changelog = readChangelogFile()

  const changelogItems = parseChangelog(changelog)
  const { items } = changelogItems[0]

  const prompt = [
    [
      "You will write an announcement for new product updates.",
      "Use your judgment to make the announcement engaging and informative.",
      "Make each announcement feel native to the platform.",
      "Return copy for each platform in a separate markdown snippet",
      'Do not use words "user" or "users".',
      "Titles should represent an essence of the updates.",
    ].join(" "),

    ...toEntries(socialPrompt).map(({ key, value }) =>
      [`### ${productUpdateSocialName[key]}`, value].join("\n")
    ),

    [
      "Updates are ordered by their priority: ",
      ...items.map((item, index) => `${index + 1}. ${item}`),
    ].join("\n"),
  ].join("\n\n")

  console.log(prompt)
  clipboardy.writeSync(prompt)
}

announce()
Enter fullscreen mode Exit fullscreen mode

Displaying Updates in the App and on the Website

With the social media announcements completed, the final step is to announce the update on the website and in the app. To do this, we first need to complete the productUpdates.ts file with the actual update details. This includes filling in the name, description, and items fields for the latest update. We can achieve this by running the prepare.ts script, which generates a ChatGPT prompt designed to help us craft the remaining fields.

import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"

const getPrompt = (items: string[]) => {
  if (items.length === 1) {
    return [
      "You will write an announcement for a new product update.",
      'It will be displayed in the "What\'s new" section of the app and website.',
      'Return it as a JavaScript object with the "name" and "description" string properties.',
      "Keep the copy short, but make sure the user will understand each update and its value.",
      "Description should be a plain text, emojis are allowed if you think it makes it more engaging.",
      "The name should capture the essence of the update.",
      'Return it as a JavaScript object with the "name" and "description" properties.',
      `Product update: ${items[0]}`,
    ].join("\n")
  }

  return [
    "You will write an announcement for new product updates.",
    'It will be displayed in the "What\'s new" section of the app and website.',
    'Return it as a JavaScript object with the "name" and "description" string properties and "items" property which is an array of objects with a "description" string property.',
    "Keep the copy short, but make sure the user will understand each update and its value.",
    "Updates are ordered by their priority.",
    "The name should capture the essence of the update and should not be generic.",
    "The description should summarize the updates.",
    "Each item should correspond to a product update.",
    "Product updates:",
    items.map((item) => `  - ${item}`).join("\n"),
  ].join("\n")
}

const productUpdate = () => {
  const changelog = readChangelogFile()

  const changelogItems = parseChangelog(changelog)
  const { items } = changelogItems[0]

  const prompt = getPrompt(items)

  console.log(prompt)
  clipboardy.writeSync(prompt)
}

productUpdate()
Enter fullscreen mode Exit fullscreen mode

Now, the final step is to fill in the releasedAt field. For that, we use the release.ts script. This script automatically sets the release date in the changelog.txt file, updates the productUpdates.ts file with the new releasedAt value, and renames both the YouTube script file and its corresponding folder to reflect the release.

import { createTsFile } from "@lib/codegen/utils/createTsFile"
import { readChangelogFile, writeChangelogFile } from "../utils/changelogFile"
import { changelogItemsToString } from "../utils/changelogItemsToString"
import { parseChangelog } from "../utils/parseChangelog"
import {
  productUpdatesFileDirectory,
  productUpdatesFileName,
  readProductUpdatesFile,
} from "../utils/productUpdatesFile"
import { releaseNewYoutubeFile } from "../utils/youtubeFile"
import { releaseNewYoutubeFolder } from "../utils/youtubeFolder"

const generate = () => {
  const changelogStr = readChangelogFile()
  const changelog = parseChangelog(changelogStr)
  const { items } = changelog[0]

  const releasedAt = Date.now()
  const newChangelog = [
    {
      items,
      releasedAt,
    },
    ...changelog.slice(1),
  ]

  const newChangelogStr = changelogItemsToString(newChangelog)
  writeChangelogFile(newChangelogStr)

  const productUpdatesStr = readProductUpdatesFile()

  const newProductUpdatesStr = productUpdatesStr.replace(
    /releasedAt:\s*\d+/,
    `releasedAt: ${releasedAt}`
  )

  createTsFile({
    directory: productUpdatesFileDirectory,
    fileName: productUpdatesFileName,
    content: newProductUpdatesStr,
  })

  releaseNewYoutubeFile(releasedAt)

  releaseNewYoutubeFolder(releasedAt)
}

generate()
Enter fullscreen mode Exit fullscreen mode

Building an Engaging "What's New" UI Component

With the product updates finalized, we can deploy both the app and website, which share the ProductUpdatesList component. This component pulls updates from the productUpdates.ts file, orders them by the releasedAt field in descending order, and displays each update using the ProductUpdateItem component. Each update is separated by a line using SeparatedByLine from RadzionKit for a clean and organized layout.

import { productUpdates } from "@increaser/changelog/productUpdates"
import { ProductUpdateItem } from "@increaser/ui/changelog/ProductUpdateItem"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { order } from "@lib/utils/array/order"
import styled from "styled-components"

const Container = styled(SeparatedByLine)`
  max-width: 640px;
`

export const ProductUpdatesList = () => {
  const items = order(productUpdates, (v) => v.releasedAt, "desc")

  return (
    <Container gap={40}>
      {items.map((value, index) => (
        <ProductUpdateItem key={index} value={value} />
      ))}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Highlighting Updates Within the App

In the first section of the ProductUpdateItem component, we display the release date, update name, and description. The release date is formatted using the date-fns library to show a user-friendly format. Both the update name and description are rendered using the Text component from RadzionKit.

import { ComponentWithValueProps } from "@lib/ui/props"
import {
  ProductUpdate,
  productUpdateSocials,
} from "../../changelog/ProductUpdate"
import { HStack, VStack } from "@lib/ui/css/stack"
import { format } from "date-fns"
import { Text } from "@lib/ui/text"
import { NonEmptyOnly } from "@lib/ui/base/NonEmptyOnly"
import { withoutUndefinedFields } from "@lib/utils/record/withoutUndefinedFields"
import { toEntries } from "@lib/utils/record/toEntries"
import { SocialLink } from "@lib/ui/buttons/SocialLink"
import { Match } from "@lib/ui/base/Match"
import { TelegramIcon } from "@lib/ui/icons/TelegramIcon"
import { XIcon } from "@lib/ui/icons/XIcon"
import { LinkedinIcon } from "@lib/ui/icons/LinkedinIcon"
import { RedditIcon } from "@lib/ui/icons/RedditIcon"
import { IndieHackersIcon } from "@lib/ui/icons/IndieHackersIcon"
import { YouTubeColoredIcon } from "@lib/ui/icons/YouTubeColoredIcon"
import { recordFromKeys } from "@lib/utils/record/recordFromKeys"
import { ProductUpdateYouTubeVideo } from "./ProductUpdateYouTubeVideo"
import { ProductUpdateSubItem } from "./ProductUpdateSubItem"

export const ProductUpdateItem = ({
  value,
}: ComponentWithValueProps<ProductUpdate>) => {
  const socials = toEntries(
    withoutUndefinedFields(
      recordFromKeys(productUpdateSocials, (social) => value[social])
    )
  )

  return (
    <VStack gap={16}>
      <VStack gap={4}>
        <Text size={14} color="supporting">
          {format(value.releasedAt, "MMMM d, yyyy")}
        </Text>
        <Text size={20} weight="500" color="contrast">
          {value.name}
        </Text>
        <Text color="supporting" height="l">
          {value.description}
        </Text>
      </VStack>
      {value.youtube && <ProductUpdateYouTubeVideo value={value.youtube} />}
      <NonEmptyOnly
        value={socials}
        render={(items) => (
          <HStack alignItems="center" gap={8}>
            <Text color="shy" weight="600">
              Discuss on
            </Text>
            <HStack alignItems="center">
              {items.map(({ key, value }) => {
                return (
                  <SocialLink key={key} to={value}>
                    <Match
                      value={key}
                      telegram={() => <TelegramIcon />}
                      x={() => <XIcon />}
                      linkedIn={() => <LinkedinIcon />}
                      reddit={() => <RedditIcon />}
                      indieHackers={() => <IndieHackersIcon />}
                      youtube={() => <YouTubeColoredIcon />}
                    />
                  </SocialLink>
                )
              })}
            </HStack>
          </HStack>
        )}
      />
      <NonEmptyOnly
        value={value.items}
        render={(items) => (
          <VStack>
            {items.map((value, index) => (
              <ProductUpdateSubItem key={index} value={value} />
            ))}
          </VStack>
        )}
      />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

If an update includes a YouTube video, the ProductUpdateYouTubeVideo component embeds a YouTube player directly into the app, allowing users to play the video in place. The video only loads when it becomes visible on the screen, thanks to the IntersectionAware component from RadzionKit, improving performance. The ElementSizeAware component from RadzionKit ensures the player resizes responsively. The video is displayed in a styled container with borders and rounded corners, and the useBoolean hook manages the play and pause state.

import { ComponentWithValueProps } from "@lib/ui/props"

import { VStack } from "@lib/ui/css/stack"

import styled from "styled-components"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { getColor } from "@lib/ui/theme/getters"
import { IntersectionAware } from "@lib/ui/base/IntersectionAware"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import YouTubePlayer from "react-player/lazy"
import { useBoolean } from "@lib/ui/hooks/useBoolean"

const youTubeVideoRatio = 9 / 16

const VideoWrapper = styled.div`
  width: 100%;
  height: auto;
  ${borderRadius.m}
  overflow: hidden;
  border: 2px solid ${getColor("mistExtra")};

  overflow: hidden;
`

export const ProductUpdateYouTubeVideo = ({
  value,
}: ComponentWithValueProps<string>) => {
  const [isPlaying, { set: play, unset: pause }] = useBoolean(false)

  return (
    <IntersectionAware<HTMLDivElement>
      render={({ ref, wasIntersected }) => (
        <VStack fullWidth ref={ref}>
          {wasIntersected && (
            <ElementSizeAware
              render={({ setElement, size }) => {
                return (
                  <VideoWrapper ref={setElement}>
                    {size && (
                      <YouTubePlayer
                        isActive
                        width={size.width}
                        height={size.width * youTubeVideoRatio}
                        url={value}
                        playing={isPlaying}
                        onPause={pause}
                        onPlay={play}
                        config={{
                          youtube: {
                            playerVars: { autoplay: 0, controls: 1 },
                          },
                        }}
                      />
                    )}
                  </VideoWrapper>
                )
              }}
            />
          )}
        </VStack>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To ensure we only render content when there are social media links or update items, we use the NonEmptyOnly component from RadzionKit. This component checks if the array (socials or items) contains any elements and only renders the content if it's not empty. This prevents unnecessary rendering of empty sections and keeps the UI clean.

import { ReactNode } from "react"
import { ComponentWithValueProps } from "../props"

type NonEmptyOnlyProps<T> = Partial<ComponentWithValueProps<T[]>> & {
  render: (array: T[]) => ReactNode
}

export function NonEmptyOnly<T>({ value, render }: NonEmptyOnlyProps<T>) {
  if (value && value.length > 0) {
    return <>{render(value)}</>
  }

  return null
}
Enter fullscreen mode Exit fullscreen mode

Handling Notifications for New Updates

In the app, we display a dot next to the bell icon when there are new updates the user hasn't seen. To handle this, we simply update the viewedNewFeaturesAt timestamp when the user visits the "What's New" page. This is done using the useUpdateUserMutation hook, which triggers an update when the component mounts, marking the updates as viewed.

import { useUpdateUserMutation } from "@increaser/ui/user/mutations/useUpdateUserMutation"

import { useEffect } from "react"
import { ProductUpdatesList } from "./ProductUpdatesList"

export const ProductUpdates = () => {
  const { mutate } = useUpdateUserMutation()
  useEffect(() => {
    mutate({
      viewedNewFeaturesAt: Date.now(),
    })
  }, [mutate])

  return <ProductUpdatesList />
}
Enter fullscreen mode Exit fullscreen mode

In the FeaturesNavigationItem component, we display a notification indicator (a pill with a count) next to the bell icon when there are new product updates that the user hasn't seen. The useUser hook provides the user's data, and useMemo calculates how many updates have been released since the user's last visit to the "What's New" page, using the viewedNewFeaturesAt timestamp or their registration date. If there are unseen updates, the number of updates is displayed in the Pill styled component, otherwise, the bell icon is displayed without the indicator. The user can click the bell icon, which links to the updates page.

import Link from "next/link"
import { getAppPath } from "@increaser/ui/navigation/app"
import { useUser } from "@increaser/ui/user/state/user"
import { useMemo } from "react"
import { productUpdates } from "@increaser/changelog/productUpdates"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { round } from "@lib/ui/css/round"
import { centerContent } from "@lib/ui/css/centerContent"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { BellIcon } from "@lib/ui/icons/BellIcon"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { HeaderActionButton } from "../navigation/HeaderActionButton"

const Pill = styled.div`
  position: absolute;
  right: -12px;
  top: -10px;
  background: ${getColor("primary")};
  ${round};
  font-weight: 500;
  font-size: 12px;
  color: ${getColor("contrast")};
  ${centerContent};
  min-width: 20px;
  min-height: 20px;
  ${horizontalPadding(4)}
`

const Wrapper = styled(IconWrapper)`
  position: relative;
  overflow: visible;
`

export const FeaturesNavigationItem = () => {
  const user = useUser()
  const newFeaturesCount = useMemo(() => {
    if (!user) {
      return 0
    }

    const viewedNewFeaturesAt =
      user.viewedNewFeaturesAt || user.registrationDate

    return productUpdates.filter(
      (update) => update.releasedAt > viewedNewFeaturesAt
    ).length
  }, [user])

  return (
    <Link href={getAppPath("updates")}>
      <HeaderActionButton>
        <Wrapper>
          <BellIcon />
          {newFeaturesCount > 0 && <Pill>{newFeaturesCount}</Pill>}
        </Wrapper>
      </HeaderActionButton>
    </Link>
  )
}
Enter fullscreen mode Exit fullscreen mode

Summary: Creating a Consistent and Transparent Product Update Process

In summary, building a "What's New" feature helps keep users informed and engaged by showcasing the latest updates across your app, website, and social media. By streamlining the process with automated scripts, reusable components, and a centralized changelog, you ensure consistency and efficiency. Whether itā€™s displaying updates in-app or posting them across platforms, this setup keeps your product development transparent and user-focused.

šŸ’– šŸ’Ŗ šŸ™… šŸš©
radzion
Radzion Chachura

Posted on November 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related