Building a Feature Proposal and Voting System with React, NodeJS, and DynamoDB

Building a Feature Proposal and Voting System with React, NodeJS, and DynamoDB

April 21, 2024

18 min read

Building a Feature Proposal and Voting System with React, NodeJS, and DynamoDB
Watch on YouTube

In this article, we'll create a lightweight solution that enables users to propose new features for our web application and vote on them. We will utilize React for the front-end and construct a simple NodeJS API for the back-end, integrating DynamoDB as our database. Although the source code for this project is hosted in a private repository, all reusable components and utilities are accessible in the RadzionKit repository.

Introduction to Feature Proposal and Voting System

At Increaser, our community page is the central hub for all social interactions within the application. Currently in its early stages, the page features a panel for users to edit their profiles, a leaderboard, the founder's contact information, and a widget for proposed features, which we'll explore further in this article. We have adopted a minimalist design for the features widget, using a single list with a toggle to switch between proposed ideas and those that have already been implemented. Although an alternative layout could include a "TO DO," "IN PROGRESS," and "DONE" board, our workflow typically involves focusing on one feature at a time, making the "IN PROGRESS" column redundant. Additionally, we aim to keep users focused on voting for new ideas rather than being distracted by completed features.

Community page
Community page

We use a dedicated DynamoDB table to store proposed features for Increaser. Each item in this table includes several attributes:

  • id: A unique identifier for the feature.
  • name: The name of the feature.
  • description: A brief description of the feature.
  • createdAt: The timestamp marking when the feature was proposed.
  • proposedBy: The ID of the user who proposed the feature.
  • upvotedBy: An array of user IDs who have upvoted the feature.
  • isApproved: A boolean indicating whether the feature has been approved by the founder.
  • status: The current status of the feature, with possible values including "idea" or "done". If you prefer to display the features on a board, you might consider adding a status such as "in progress".
export const productFeatureStatuses = ["idea", "done"] as const
export type ProductFeatureStatus = (typeof productFeatureStatuses)[number]

export type ProductFeature = {
  id: string
  name: string
  description: string
  createdAt: number
  proposedBy: string
  upvotedBy: string[]
  isApproved: boolean
  status: ProductFeatureStatus
}

API Design and Feature Management Workflow

Our API includes just three endpoints dedicated to managing features. If you're interested in learning how to efficiently build backends within TypeScript monorepos, be sure to explore this insightful article.

import { ApiMethod } from "./ApiMethod"
import { ProductFeature } from "@increaser/entities/ProductFeature"
import { ProductFeatureResponse } from "./ProductFeatureResponse"

export interface ApiInterface {
  proposeFeature: ApiMethod<
    Omit<ProductFeature, "isApproved" | "status" | "proposedBy" | "upvotedBy">,
    undefined
  >
  voteForFeature: ApiMethod<{ id: string }, undefined>
  features: ApiMethod<undefined, ProductFeatureResponse[]>

  // other methods...
}

export type ApiMethodName = keyof ApiInterface

The proposeFeature method is crucial to our feature proposal process. It identifies the user's ID from a JWT token included in the request, which is used for user authentication. To stay informed about new proposals, I've set up a Telegram channel where the API sends notifications detailing the proposed features. Upon receiving a message on this channel, I access the DynamoDB explorer on AWS to verify the feature's validity and refine the name and description for easier comprehension by other users. Although we could monitor new features with a separate Lambda function that listens to the DynamoDB stream, the current setup of direct notifications from the API is effective, especially as this is the only method for proposing features.

import { getUser } from "@increaser/db/user"
import { assertUserId } from "../../auth/assertUserId"
import { getEnvVar } from "../../getEnvVar"
import { getTelegramBot } from "../../notifications/telegram"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { putFeature } from "@increaser/db/features"
import { getProductFeautureDefaultFields } from "@increaser/entities/ProductFeature"

export const proposeFeature: ApiResolver<"proposeFeature"> = async ({
  input: feature,
  context,
}) => {
  const proposedBy = assertUserId(context)
  const { email } = await getUser(proposedBy, ["email"])

  await getTelegramBot().sendMessage(
    getEnvVar("TELEGRAM_CHAT_ID"),
    [
      "New feature proposal",
      feature.name,
      feature.description,
      `Proposed by ${email}`,
      feature.id,
    ].join("\n\n")
  )

  await putFeature({
    ...feature,
    ...getProductFeautureDefaultFields({ proposedBy }),
  })
}

Before adding a new feature to the DynamoDB table, we initialize default fields. The isApproved field is set to false, indicating that the feature has not yet been reviewed. The status is set to idea. The proposedBy field captures the user ID of the proposer. Additionally, the upvotedBy field starts with an array containing the proposer’s ID, ensuring that each new feature begins with one upvote.

export const getProductFeautureDefaultFields = ({
  proposedBy,
}: Pick<ProductFeature, "proposedBy">): Pick<
  ProductFeature,
  "isApproved" | "status" | "proposedBy" | "upvotedBy"
> => ({
  isApproved: false,
  status: "idea",
  proposedBy,
  upvotedBy: [proposedBy],
})

We organize all functions for interacting with the "features" table into a single file. Utilizing helpers from RadzionKit, such as makeGetItem, updateItem, and totalScan, makes it easy to add new tables to our application.

import { PutCommand } from "@aws-sdk/lib-dynamodb"
import { ProductFeature } from "@increaser/entities/ProductFeature"
import { tableName } from "./tableName"
import { dbDocClient } from "@lib/dynamodb/client"
import { totalScan } from "@lib/dynamodb/totalScan"
import { getPickParams } from "@lib/dynamodb/getPickParams"
import { makeGetItem } from "@lib/dynamodb/makeGetItem"
import { updateItem } from "@lib/dynamodb/updateItem"

export const putFeature = (value: ProductFeature) => {
  const command = new PutCommand({
    TableName: tableName.features,
    Item: value,
  })

  return dbDocClient.send(command)
}

export const getFeature = makeGetItem<string, ProductFeature>({
  tableName: tableName.features,
  getKey: (id: string) => ({ id }),
})

export const updateFeature = async (
  id: string,
  fields: Partial<ProductFeature>
) => {
  return updateItem({
    tableName: tableName.features,
    key: { id },
    fields,
  })
}

export const getAllFeatures = async <T extends (keyof ProductFeature)[]>(
  attributes?: T
) =>
  totalScan<Pick<ProductFeature, T[number]>>({
    TableName: tableName.features,
    ...getPickParams(attributes),
  })

The voteForFeature method toggles the user's vote for a feature. If the user has already upvoted the feature, the method removes their vote; otherwise, it adds it. This approach ensures that users can only vote once for each feature.

import { without } from "@lib/utils/array/without"
import { assertUserId } from "../../auth/assertUserId"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { getFeature, updateFeature } from "@increaser/db/features"

export const voteForFeature: ApiResolver<"voteForFeature"> = async ({
  input: { id },
  context,
}) => {
  const userId = assertUserId(context)
  const { upvotedBy } = await getFeature(id, ["upvotedBy"])

  await updateFeature(id, {
    upvotedBy: upvotedBy.includes(userId)
      ? without(upvotedBy, userId)
      : [...upvotedBy, userId],
  })
}

The features method retrieves all features from the DynamoDB table but filters out unapproved features, ensuring that only the proposer can view their unapproved ideas. Additionally, this method calculates the number of upvotes for each feature and checks if the current user has upvoted the feature. Instead of returning the entire list of user IDs who have upvoted, it provides a more streamlined output.

import { ApiResolver } from "../../resolvers/ApiResolver"
import { getAllFeatures } from "@increaser/db/features"
import { ProductFeatureResponse } from "@increaser/api-interface/ProductFeatureResponse"
import { pick } from "@lib/utils/record/pick"

export const features: ApiResolver<"features"> = async ({
  context: { userId },
}) => {
  const features = await getAllFeatures()

  const result: ProductFeatureResponse[] = []
  features.forEach((feature) => {
    if (!feature.isApproved && feature.proposedBy !== userId) {
      return
    }

    result.push({
      ...pick(feature, [
        "id",
        "name",
        "description",
        "isApproved",
        "status",
        "proposedBy",
        "createdAt",
      ]),
      upvotes: feature.upvotedBy.length,
      upvotedByMe: Boolean(userId && feature.upvotedBy.includes(userId)),
    })
  })

  return result
}

Front-End Implementation: Building the Feature Voting Interface

With the server-side logic established, we can now turn our attention to the front-end implementation. The widget is displayed on the right side of the community page using the ProductFeaturesBoard component.

import { Page } from "@lib/next-ui/Page"
import { FixedWidthContent } from "@increaser/app/components/reusable/fixed-width-content"
import { PageTitle } from "@increaser/app/ui/PageTitle"
import { VStack } from "@lib/ui/layout/Stack"
import { UserStateOnly } from "@increaser/app/user/state/UserStateOnly"
import { ClientOnly } from "@increaser/app/ui/ClientOnly"
import { ManageProfile } from "./ManageProfile"
import { Scoreboard } from "@increaser/ui/scoreboard/Scoreboard"
import { RequiresOnboarding } from "../../onboarding/RequiresOnboarding"
import { ProductFeaturesBoard } from "../../productFeatures/components/ProductFeaturesBoard"
import { FounderContacts } from "./FounderContacts"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"

export const CommunityPage: Page = () => {
  return (
    <FixedWidthContent>
      <ClientOnly>
        <PageTitle documentTitle={`👋 Community`} title="Community" />
      </ClientOnly>
      <UserStateOnly>
        <RequiresOnboarding>
          <UniformColumnGrid minChildrenWidth={320} gap={40}>
            <VStack style={{ width: "fit-content" }} gap={40}>
              <ManageProfile />
              <Scoreboard />
              <FounderContacts />
            </VStack>
            <ProductFeaturesBoard />
          </UniformColumnGrid>
        </RequiresOnboarding>
      </UserStateOnly>
    </FixedWidthContent>
  )
}

We render the content within a Panel component, which is set to have a minimum width of 320px and occupies the remaining space in the parent container. The header displays the title "Product Features" and includes the ProductFeaturesViewSelector component, allowing users to toggle between the "idea" and "done" views. The RenderProductFeaturesView component is used to conditionally display a prompt for proposing new features, ensuring it is visible only when the "idea" view is selected. The ProductFeatureList component is then used to display the list of features.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Panel } from "@lib/ui/panel/Panel"
import { Text } from "@lib/ui/text"
import styled from "styled-components"
import {
  ProductFeaturesViewProvider,
  ProductFeaturesViewSelector,
  RenderProductFeaturesView,
} from "./ProductFeaturesView"
import { ProposeFeaturePrompt } from "./ProposeFeaturePrompt"
import { ProductFeatureList } from "./ProductFeatureList"

const Container = styled(Panel)`
  min-width: 320px;
  flex: 1;
`

export const ProductFeaturesBoard = () => {
  return (
    <ProductFeaturesViewProvider>
      <Container>
        <VStack gap={20}>
          <HStack
            alignItems="center"
            gap={20}
            justifyContent="space-between"
            wrap="wrap"
            fullWidth
          >
            <Text size={18} weight="bold">
              Product Features
            </Text>
            <ProductFeaturesViewSelector />
          </HStack>
          <RenderProductFeaturesView
            idea={() => <ProposeFeaturePrompt />}
            done={() => null}
          />
          <VStack gap={8}>
            <ProductFeatureList />
          </VStack>
        </VStack>
      </Container>
    </ProductFeaturesViewProvider>
  )
}

It's a common scenario to need a filter or selector for switching between different views. To facilitate this, we utilize the getViewSetup utility from RadzionKit. This utility accepts a default view and a setup name, returning a provider, hook, and renderer that enable convenient conditional rendering based on the current view. For the selector component, we use the TabNavigation component from RadzionKit, which takes an array of views, a function to get the view name, the active view, and a callback to set the view.

import { getViewSetup } from "@lib/ui/view/getViewSetup"
import { TabNavigation } from "@lib/ui/navigation/TabNavigation"
import {
  ProductFeatureStatus,
  productFeatureStatuses,
} from "@increaser/entities/ProductFeature"

export const {
  ViewProvider: ProductFeaturesViewProvider,
  useView: useProductFeaturesView,
  RenderView: RenderProductFeaturesView,
} = getViewSetup<ProductFeatureStatus>({
  defaultView: "idea",
  name: "productFeatures",
})

const taskViewName: Record<ProductFeatureStatus, string> = {
  idea: "Ideas",
  done: "Done",
}

export const ProductFeaturesViewSelector = () => {
  const { view, setView } = useProductFeaturesView()

  return (
    <TabNavigation
      views={productFeatureStatuses}
      getViewName={(view) => taskViewName[view]}
      activeView={view}
      onSelect={setView}
    />
  )
}

Enhancing User Interaction: Feature Proposal Components

The ProposeFeaturePrompt component displays a call-to-action using the PanelPrompt component. When activated, it reveals the ProposeFeatureForm component. Additionally, we employ the Opener component from RadzionKit, which acts as a wrapper around useState for conditional rendering. While I prefer using the Opener for its streamlined syntax, you might find using a simple useState hook more to your liking.

import { Opener } from "@lib/ui/base/Opener"
import { ProposeFeatureForm } from "./ProposeFeatureForm"
import { PanelPrompt } from "@lib/ui/panel/PanelPrompt"

export const ProposeFeaturePrompt = () => {
  return (
    <Opener
      renderOpener={({ onOpen, isOpen }) =>
        !isOpen && (
          <PanelPrompt onClick={onOpen} title="Make Increaser Yours">
            Tell us what feature you want to see next
          </PanelPrompt>
        )
      }
      renderContent={({ onClose }) => <ProposeFeatureForm onFinish={onClose} />}
    />
  )
}

In the ProposeFeatureForm component, users input a name and description for their feature idea. We keep validation simple, only ensuring that these fields are not empty, as I manually approve and edit each feature later. The form's onSubmit function checks if the submit button is disabled and, if not, it calls the mutate function from the useProposeFeatureMutation hook with the new feature details. Once the mutation is initiated, the onFinish callback is invoked to notify the parent component that the submission process is complete, prompting the ProposeFeaturePrompt to display the PanelPrompt again.

import { Button } from "@lib/ui/buttons/Button"
import { Form } from "@lib/ui/form/components/Form"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { Panel } from "@lib/ui/panel/Panel"
import { FinishableComponentProps } from "@lib/ui/props"
import styled from "styled-components"
import { useProposeFeatureMutation } from "../hooks/useProposeFeatureMutation"
import { useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { Field } from "@lib/ui/inputs/Field"
import { TextInput } from "@lib/ui/inputs/TextInput"
import { TextArea } from "@lib/ui/inputs/TextArea"
import { Validators } from "@lib/ui/form/utils/Validators"
import { validate } from "@lib/ui/form/utils/validate"
import { getId } from "@increaser/entities-utils/shared/getId"

const Container = styled(Panel)``

type FeatureFormShape = {
  name: string
  description: string
}

const featureFormValidator: Validators<FeatureFormShape> = {
  name: (name) => {
    if (!name) {
      return "Name is required"
    }
  },
  description: (description) => {
    if (!description) {
      return "Description is required"
    }
  },
}

export const ProposeFeatureForm = ({ onFinish }: FinishableComponentProps) => {
  const { mutate } = useProposeFeatureMutation()

  const [value, setValue] = useState<FeatureFormShape>({
    name: "",
    description: "",
  })

  const errors = validate(value, featureFormValidator)

  const [isDisabled] = Object.values(errors)

  return (
    <Container kind="secondary">
      <Form
        onSubmit={() => {
          if (isDisabled) return

          mutate({
            name: value.name,
            description: value.description,
            id: getId(),
            createdAt: Date.now(),
          })
          onFinish()
        }}
        content={
          <Fields>
            <Field>
              <TextInput
                value={value.name}
                onValueChange={(name) => setValue({ ...value, name })}
                label="Title"
                placeholder="Give your feature a clear name"
              />
            </Field>
            <Field>
              <TextArea
                rows={4}
                value={value.description}
                onValueChange={(description) =>
                  setValue({ ...value, description })
                }
                label="Description"
                placeholder="Detail your feature for easy understanding"
              />
            </Field>
          </Fields>
        }
        actions={
          <UniformColumnGrid gap={20}>
            <Button size="l" type="button" kind="secondary" onClick={onFinish}>
              Cancel
            </Button>
            <Button
              isDisabled={isDisabled}
              size="l"
              type="submit"
              kind="primary"
            >
              Submit
            </Button>
          </UniformColumnGrid>
        }
      />
    </Container>
  )
}

Dynamic Feature Listing and User Interaction Components

To display the list of features, we first retrieve the query result from the API using the useApiQuery hook, which requires the name of the method and the input parameters. The QueryDependant component from RadzionKit is utilized to manage the query state effectively. During the loading state, we display a spinner; in the error state, an error message is shown; and in the success state, we render the list of features. The retrieved features are then divided into two arrays: myUnapprovedFeatures, which contains features proposed by the current user but not yet approved, and otherFeatures, which includes all other features sorted by the number of upvotes in descending order. Each feature is rendered using the ProductFeatureItem component.

import { useApiQuery } from "@increaser/api-ui/hooks/useApiQuery"
import { QueryDependant } from "@lib/ui/query/components/QueryDependant"
import { getQueryDependantDefaultProps } from "@lib/ui/query/utils/getQueryDependantDefaultProps"
import { splitBy } from "@lib/utils/array/splitBy"
import { order } from "@lib/utils/array/order"
import { ProductFeatureItem } from "./ProductFeatureItem"
import { useProductFeaturesView } from "./ProductFeaturesView"
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { CurrentProductFeatureProvider } from "./CurrentProductFeatureProvider"

export const ProductFeatureList = () => {
  const featuresQuery = useApiQuery("features", undefined)
  const { view } = useProductFeaturesView()
  const { id } = useAssertUserState()

  return (
    <QueryDependant
      query={featuresQuery}
      {...getQueryDependantDefaultProps("features")}
      success={(features) => {
        const [myUnapprovedFeatures, otherFeatures] = splitBy(
          features.filter((feature) => view === feature.status),
          (feature) =>
            feature.proposedBy === id && !feature.isApproved ? 0 : 1
        )

        return (
          <>
            {[
              ...myUnapprovedFeatures,
              ...order(otherFeatures, (f) => f.upvotes, "desc"),
            ].map((feature) => (
              <CurrentProductFeatureProvider key={feature.id} value={feature}>
                <ProductFeatureItem />
              </CurrentProductFeatureProvider>
            ))}
          </>
        )
      }}
    />
  )
}

To minimize prop drilling, the ProductFeatureItem component is provided with the current feature using the CurrentProductFeatureProvider component. Recognizing the frequent need to pass a single value through a component tree, I created the utility function getValueProviderSetup in RadzionKit. This generic function accepts the name of the entity and returns both a provider and a hook for that entity, streamlining the process of passing contextual data to nested components.

import { ProductFeatureResponse } from "@increaser/api-interface/ProductFeatureResponse"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"

export const {
  useValue: useCurrentProductFeature,
  provider: CurrentProductFeatureProvider,
} = getValueProviderSetup<ProductFeatureResponse>("ProductFeature")

The ProductFeatureItem component displays the feature's name, a cropped description, and includes a voting button. To facilitate two actions within a single card, the component uses a specific layout pattern. Users can click on the card to open the feature details in a modal, while the "Vote" button allows them to vote for the feature separately. Due to HTML constraints that prevent nesting buttons, we utilize a relatively positioned container for the card, with the "Vote" button absolutely positioned within it. This layout pattern is common enough that RadzionKit provides an abstraction for it, known as ActionInsideInteractiveElement, which simplifies the implementation of multiple interactive elements in a single component.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Panel, panelDefaultPadding } from "@lib/ui/panel/Panel"
import { Text } from "@lib/ui/text"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import styled from "styled-components"
import { maxTextLines } from "@lib/ui/css/maxTextLines"
import { ActionInsideInteractiveElement } from "@lib/ui/base/ActionInsideInteractiveElement"
import { Spacer } from "@lib/ui/layout/Spacer"
import { Opener } from "@lib/ui/base/Opener"
import { Modal } from "@lib/ui/modal"
import { interactive } from "@lib/ui/css/interactive"
import { getColor } from "@lib/ui/theme/getters"
import { transition } from "@lib/ui/css/transition"
import { useCurrentProductFeature } from "./CurrentProductFeatureProvider"
import { ProductFeatureDetails } from "./ProductFeatureDetails"
import { VoteForFeature } from "./VoteForFeature"

const Description = styled(Text)`
  ${maxTextLines(2)}
`

const Container = styled(Panel)`
  ${interactive};
  ${transition};
  &:hover {
    background: ${getColor("foreground")};
  }
`

export const ProductFeatureItem = () => {
  const { name, description, isApproved } = useCurrentProductFeature()

  return (
    <ActionInsideInteractiveElement
      render={({ actionSize }) => (
        <Opener
          renderOpener={({ onOpen }) => (
            <Container onClick={onOpen} kind="secondary">
              <VStack gap={8}>
                <HStack
                  justifyContent="space-between"
                  alignItems="start"
                  fullWidth
                  gap={20}
                >
                  <VStack gap={8}>
                    <Text weight="semibold" style={{ flex: 1 }} height="large">
                      {name}
                    </Text>

                    <Description height="large" color="supporting" size={14}>
                      {description}
                    </Description>
                  </VStack>
                  <Spacer {...actionSize} />
                </HStack>
                {!isApproved && (
                  <ShyInfoBlock>
                    Thank you! Your feature is awaiting approval and will be
                    open for voting soon."
                  </ShyInfoBlock>
                )}
              </VStack>
            </Container>
          )}
          renderContent={({ onClose }) => (
            <Modal width={480} onClose={onClose} title={name}>
              <ProductFeatureDetails />
            </Modal>
          )}
        />
      )}
      action={<VoteForFeature />}
      actionPlacerStyles={{
        top: panelDefaultPadding,
        right: panelDefaultPadding,
      }}
    />
  )
}

We utilize the Opener component again to manage the modal state for displaying feature details. To ensure that the title does not overlap with the absolutely positioned "Vote" button, we insert a "Spacer" component with the same dimensions as the "Vote" button, as determined by ActionInsideInteractiveElement. To keep the card's appearance concise, we crop the description using the maxTextLines CSS utility from RadzionKit. Additionally, if the feature has not been approved yet, we display a ShyInfoBlock component to inform the user that their feature is awaiting approval.

import { UpvoteButton } from "@lib/ui/buttons/UpvoteButton"
import { useVoteForFeatureMutation } from "../hooks/useVoteForFeatureMutation"
import { useCurrentProductFeature } from "./CurrentProductFeatureProvider"

export const VoteForFeature = () => {
  const { id, upvotedByMe, upvotes } = useCurrentProductFeature()
  const { mutate } = useVoteForFeatureMutation()

  return (
    <UpvoteButton
      onClick={() => {
        mutate({
          id,
        })
      }}
      value={upvotedByMe}
      upvotes={upvotes}
    />
  )
}

The VoteForFeature component utilizes the UpvoteButton to provide a straightforward and intuitive voting interface. When clicked, the component triggers the mutate function from the useVoteForFeatureMutation hook, with the feature ID passed as an input parameter. The UpvoteButton features a chevron icon and displays the count of upvotes. It dynamically changes color based on the value prop to visually indicate whether the user has already voted for the feature.

import styled from "styled-components"
import { UnstyledButton } from "./UnstyledButton"
import { borderRadius } from "../css/borderRadius"
import { interactive } from "../css/interactive"
import { getColor, matchColor } from "../theme/getters"
import { transition } from "../css/transition"
import { getHoverVariant } from "../theme/getHoverVariant"
import { VStack } from "../layout/Stack"
import { IconWrapper } from "../icons/IconWrapper"
import { Text } from "../text"
import { CaretUpIcon } from "../icons/CaretUpIcon"
import { ClickableComponentProps } from "../props"

type UpvoteButtonProps = ClickableComponentProps & {
  value: boolean
  upvotes: number
}

const Cotainer = styled(UnstyledButton)<{ value: boolean }>`
  padding: 8px;
  min-width: 48px;
  ${borderRadius.s};
  border: 1px solid;
  ${interactive};

  color: ${matchColor("value", {
    true: "primary",
    false: "text",
  })};
  ${transition};
  &:hover {
    background: ${getColor("mist")};
    color: ${(value) =>
      value ? getHoverVariant("primary") : getColor("contrast")};
  }
`

export const UpvoteButton = ({
  value,
  upvotes,
  ...rest
}: UpvoteButtonProps) => (
  <Cotainer {...rest} value={value}>
    <VStack alignItems="center">
      <IconWrapper style={{ fontSize: 20 }}>
        <CaretUpIcon />
      </IconWrapper>
      <Text size={14} weight="bold">
        {upvotes}
      </Text>
    </VStack>
  </Cotainer>
)

The ProductFeatureDetails component displays the feature's creation date, the user who proposed the feature alongside the voting button, and the full feature description. To fetch the proposer's profile details, we use the UserProfileQueryDependant component. This component determines if the user has a public profile, displaying their name and country, or labels them as "Anonymous" if they maintain an anonymous account. The UserProfileQueryDependant is an enhancement of the QueryDependant component, providing a more streamlined approach to accessing user profile information.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { LabeledValue } from "@lib/ui/text/LabeledValue"
import { format } from "date-fns"
import { UserProfileQueryDependant } from "../../community/components/UserProfileQueryDependant"
import { ScoreboardDisplayName } from "@increaser/ui/scoreboard/ScoreboardDisplayName"
import { VoteForFeature } from "./VoteForFeature"
import { useCurrentProductFeature } from "./CurrentProductFeatureProvider"

export const ProductFeatureDetails = () => {
  const { createdAt, proposedBy, description } = useCurrentProductFeature()

  return (
    <VStack gap={18}>
      <HStack fullWidth alignItems="center" justifyContent="space-between">
        <VStack style={{ fontSize: 14 }} gap={8}>
          <LabeledValue name="Proposed at">
            {format(createdAt, "dd MMM yyyy")}
          </LabeledValue>
          <LabeledValue name="Proposed by">
            <UserProfileQueryDependant
              id={proposedBy}
              success={(profile) => {
                return (
                  <ScoreboardDisplayName
                    name={profile?.name || "Anonymous"}
                    country={profile?.country}
                  />
                )
              }}
            />
          </LabeledValue>
        </VStack>
        <VoteForFeature />
      </HStack>
      <Text height="large">{description}</Text>
    </VStack>
  )
}