Skip to content

StepCard component - #65

Merged
fabiorodriguesdev merged 6 commits into
mainfrom
feat/15-stepcard-component
Oct 12, 2026
Merged

fabiorodriguesdev merged 6 commits into
mainfrom
feat/15-stepcard-component

Conversation

@riddhi-dudhat-7

@riddhi-dudhat-7 riddhi-dudhat-7 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Hello @fabiorodriguesdev

I’ve created the StepCard component and all the acceptance criteria are met.

I have one question regarding the CSS. I’ve applied the styling directly in the .tsx file where the component is rendered, since there are no variants required for the StepCard component.

Could you please review my PR and let me know if this is the correct approach, or if you would recommend a different way to structure the styling?

Screenshot 2025-04-30 204726

Thank you!

@vercel

vercel Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
redicycle Ready Ready Preview Oct 12, 2026 1:00pm UTC

Request Review

@fabiorodriguesdev fabiorodriguesdev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work with the StepCard! The component is already looking good and very close to the design. I have a few suggestions that will make the code easier to read, safer to use, and nicer to document in Storybook.

The main idea here is to separate responsibilities:

  • StepCard.tsx describes the HTML structure.
  • StepCard.styles.ts contains the visual styles.
  • StepCard.types.ts explains how the component can be used.
  • StepCard.stories.tsx documents real examples.

This separation might feel like a little more code now, but it helps a lot when the component grows or needs changes later.


Keeping the Tailwind classes in the styles file

I suggest moving the Tailwind classes out of StepCard.tsx and into StepCard.styles.ts.

When many classes are written directly inside the JSX, the HTML structure becomes very busy:

<div className="shrink-0 flex h-10 w-10 items-center justify-center rounded-full bg-tertiary text-white font-display font-black text-[17px]">

It becomes harder to quickly understand what the component is rendering because the styles take up most of the line.

Using tv slots gives a name to each part of the component:

export const stepCardStyles = tv({
  slots: {
    base: [],
    row: [],
    marker: [],
    content: [],
    title: [],
    text: [],
  },
});

Now we can immediately understand that marker styles the circle, title styles the heading, and so on.

I also recommend writing the classes as arrays:

marker: [
  'shrink-0',
  'flex',
  'h-10',
  'w-10',
  'items-center',
  'justify-center',
],

tv accepts arrays and joins the classes for us. The result in the browser is the same, but this format is much easier to scan. When the list is long, having one class below another also makes adding, removing, or reviewing a class simpler.

Here is the complete styles file:

import { tv } from 'tailwind-variants';

/**
 * White card with radius-card: a 40px tertiary circle, then the title and description.
 */
export const stepCardStyles = tv({
  slots: {
    base: [
      'min-w-0',
      'w-full',
      'border',
      'border-border',
      'rounded-card',
      'bg-white',
      'py-6.5',
      'px-7',
    ],
    row: ['flex', 'items-start', 'gap-4.5'],
    marker: [
      'shrink-0',
      'flex',
      'h-10',
      'w-10',
      'items-center',
      'justify-center',
      'rounded-full',
      'bg-tertiary',
      'text-white',
      'font-display',
      'font-black',
      'text-[17px]',
    ],
    content: ['min-w-0', 'flex', 'flex-col', 'gap-1'],
    title: ['font-display', 'text-[17px]', 'font-extrabold'],
    text: ['text-[14px]', 'leading-relaxed', 'text-body'],
  },
});

Then the JSX becomes much cleaner:

const styles = stepCardStyles();

return (
  <div className={styles.base()}>
    <div className={styles.row()}>
      <div className={styles.marker()}>{/* marker */}</div>

      <div className={styles.content()}>
        <h3 className={styles.title()}>{title}</h3>
        <div className={styles.text()}>{text}</div>
      </div>
    </div>
  </div>
);

Looking at this code, we can understand the card structure without needing to mentally skip over many Tailwind classes.


The component should not have default content

The title and text are the actual content of the card. They should always come from the page that uses the component.

For example, we should avoid this:

const StepCard = ({
  title = 'Browse & find',
  text = 'Search or wander the categories...',
}: StepCardProps) => {

If another developer forgets to pass the content, the component still renders, but it renders the wrong text. That can hide a mistake.

Instead, TypeScript should tell us that these props are missing:

const StepCard = (props: StepCardProps) => {
  const { title, text } = props;
};

Because title and text are required in the type, this will produce an error:

<StepCard />

That error is useful. It tells us about the problem while we are coding instead of letting the wrong content reach the browser.


Why the props use a union

This component has two valid ways of being used.

A numbered card needs:

<StepCard
  markerType="number"
  step={1}
  title="Browse & find"
  text="Search or wander the categories."
/>

A checkmark card does not need a step:

<StepCard
  title="Meet in public places"
  text="Cafés, train stations, campus — anywhere with people around."
/>

A simple interface like this would not fully describe those rules:

interface StepCardProps {
  title: string;
  text: string;
  markerType?: 'number' | 'check';
  step?: number;
}

Here, step is always optional. TypeScript would therefore accept a number marker without a number:

<StepCard
  markerType="number"
  title="Browse & find"
  text="Search or wander the categories."
/>

That is valid according to this simple interface, but it is not valid for our component. The circle would have nothing to display.

This is where a union helps us.

In TypeScript, the | symbol means "one type or the other." We describe each valid version separately and then join them:

type StepCardProps = StepCardNumberProps | StepCardCheckProps;

You can read this as:

StepCardProps must be either a numbered StepCard or a checkmark StepCard.

First, we put the props that both versions need into a base interface:

interface StepCardBaseProps {
  title: string;
  text: string;
}

Then we describe the numbered version:

interface StepCardNumberProps extends StepCardBaseProps {
  markerType: 'number';
  step: number;
}

extends StepCardBaseProps means this interface also receives title and text.

For this version, markerType must be exactly 'number', and step is required. It is a number, because values such as 1, 2, and 3 are numbers and not text.

Next, we describe the checkmark version:

interface StepCardCheckProps extends StepCardBaseProps {
  markerType?: 'check';
}

This version also receives title and text, but it does not have a step.

markerType is optional here. That means both of these usages show the checkmark:

<StepCard markerType="check" title="Trust your gut" text="Walk away if the deal feels wrong." />

<StepCard title="Trust your gut" text="Walk away if the deal feels wrong." />

The complete type looks like this:

interface StepCardBaseProps {
  /**
   * The title of the step
   */
  title: string;

  /**
   * The description of the step
   */
  text: string;
}

interface StepCardNumberProps extends StepCardBaseProps {
  /**
   * The type of marker to display in the circle.
   */
  markerType: 'number';

  /**
   * The number of the step.
   */
  step: number;
}

interface StepCardCheckProps extends StepCardBaseProps {
  /**
   * Omit this prop to show a checkmark.
   */
  markerType?: 'check';
}

type StepCardProps = StepCardNumberProps | StepCardCheckProps;

export type { StepCardProps };

markerType is also acting as a label that tells TypeScript which side of the union we are using. This is called a discriminated union.

When we write this condition:

props.markerType === 'number'

TypeScript understands that we are now inside StepCardNumberProps. Because of that, it knows that props.step exists:

props.markerType === 'number' ? props.step : '✓'

On the other side of the condition, TypeScript knows we have the checkmark version, so we do not try to access step.

This is called narrowing: we start with two possible prop shapes, check markerType, and TypeScript narrows the value down to one shape.

That is also why I would only destructure the common props at the beginning:

const { title, text } = props;

We should not destructure step there because step does not exist on every possible version of StepCardProps. It only exists after TypeScript knows that markerType is 'number'.

The component can then look like this:

import { stepCardStyles } from './StepCard.styles';
import type { StepCardProps } from './StepCard.types';

const StepCard = (props: StepCardProps) => {
  const { title, text } = props;
  const styles = stepCardStyles();

  return (
    <div className={styles.base()}>
      <div className={styles.row()}>
        <div className={styles.marker()}>
          {props.markerType === 'number' ? props.step : '✓'}
        </div>

        <div className={styles.content()}>
          <h3 className={styles.title()}>{title}</h3>
          <div className={styles.text()}>{text}</div>
        </div>
      </div>
    </div>
  );
};

export default StepCard;

Now TypeScript protects the component rules for us:

// Correct: number marker with a step
<StepCard
  markerType="number"
  step={1}
  title="Browse & find"
  text="Search the available items."
/>

// Correct: checkmark does not need a step
<StepCard
  title="Meet in public places"
  text="Choose a safe place for the handover."
/>

// TypeScript error: a number marker needs a step
<StepCard
  markerType="number"
  title="Browse & find"
  text="Search the available items."
/>

The nice part is that a developer using the component does not have to remember all these rules. The editor can guide them.


Storybook stories are also documentation

A Storybook story is not only a place to quickly check whether a component renders. Every story becomes a real documentation example inside Storybook.

When someone opens the StepCard page, they can see:

  • What the component looks like.
  • Which props it accepts.
  • How it behaves with a number.
  • How it behaves with a checkmark.
  • How multiple cards look together in a real layout.

That is why it is helpful to create stories for different usages instead of having only one generic example.

The Default story documents one simple card:

export const Default: Story = {
  args: {
    title: 'Browse & find',
    markerType: 'number',
    step: 1,
    text: 'Search or wander the categories. Every item shows its condition, price, and the shop it belongs to.',
  },
};

The HowToBuy story documents how numbered cards are used together:

export const HowToBuy: Story = {
  render: () => (
    <div className="grid grid-cols-2 gap-4">
      <StepCard
        markerType="number"
        step={1}
        title="Browse & find"
        text="Search or wander the categories. Every item shows its condition, price, and the shop it belongs to."
      />

      <StepCard
        markerType="number"
        step={2}
        title="Say you're interested"
        text={'Tap "I\'m interested" on the item. The seller gets a notification and you get a thread in your inbox.'}
      />

      <StepCard
        markerType="number"
        step={3}
        title="Agree on the details"
        text="Chat about price, payment method, and whether it's pickup or shipping. Everything is between you two."
      />

      <StepCard
        markerType="number"
        step={4}
        title="Meet, pay, enjoy"
        text="Settle the payment however suits you both — cash on pickup or a bank transfer. ReDiCycle never touches the money."
      />
    </div>
  ),
};

The StaySafe story documents the checkmark version:

export const StaySafe: Story = {
  render: () => (
    <div className="grid grid-cols-2 gap-4">
      <StepCard
        title="Meet in public places"
        text="Cafés, train stations, campus — anywhere with people around. Bring a friend for bigger handovers."
      />

      <StepCard
        title="Check before you pay"
        text="Inspect the item at handover. Plug it in, try it on, flip through it — then pay."
      />

      <StepCard
        title="Keep chat on ReDiCycle"
        text="Agree on everything in your inbox thread, so there's a record if something goes sideways."
      />

      <StepCard
        title="Trust your gut"
        text="Deal feels off? Price too good? Walk away and report the listing — no explanation needed."
      />
    </div>
  ),
};

These stories become usage references for the whole team. The next person who needs a StepCard does not have to guess how it works or search through another page. They can open Storybook, see the available versions, and copy the usage that matches their case.

That is one of the best parts of Storybook: while we build and test the component, we are also creating living documentation for it.

Comment thread frontend/src/components/StepCard/StepCard.tsx
Comment thread frontend/src/components/StepCard/StepCard.tsx Outdated
Comment thread frontend/src/components/StepCard/StepCard.tsx Outdated
Comment thread frontend/src/components/StepCard/StepCard.tsx Outdated
Comment thread frontend/src/components/StepCard/StepCard.tsx Outdated
Comment thread frontend/src/components/StepCard/StepCard.types.ts Outdated

@mirwaisdoost mirwaisdoost left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up! The layout and spacing look really close
to the design, and nice catch on min-w-0 + shrink-0 — that keeps
the circle round with long text.

Main thing blocking for me is that the styling isn't going through
StepCard.styles.ts — that file is imported but unused, and the rest
of the component library puts its classes there. Left a few smaller
notes inline too. Happy to pair on the tv() conversion if that helps!

@riddhi-dudhat-7 riddhi-dudhat-7 changed the title StepCard component Done StepCard component Oct 7, 2026
@fabiorodriguesdev

Copy link
Copy Markdown
Collaborator

Hi! The failing Vercel check on this PR isn't caused by your changes. Every preview was running migrations against the production database, and one stuck migration there broke all previews (Prisma error P3009).

That's fixed in #83: previews now have their own database, separate from production. To get a green preview, please update your branch with main, either with the Update branch button below or locally:

git fetch origin
git merge origin/main
git push

One rule to keep in mind from now on: never edit, rename or regenerate a migration once it has been pushed; add a new one instead. Details are in ARCHITECTURE.md.

@fabiorodriguesdev
fabiorodriguesdev merged commit 5a370d7 into main Oct 12, 2026
5 checks passed
@fabiorodriguesdev
fabiorodriguesdev deleted the feat/15-stepcard-component branch October 12, 2026 13:01

This branch was successfully deployed

1 active deployment
Preview — a3b17221 Deployed Oct 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build the StepCard component

3 participants