Repository: Re-pill/RePill_FE
Pull request #19: feat: add NotificationCard component and story
Author: login: ehlung
Labels: name: enhancement
description: New feature or request
Created: 2025-05-22T23:02:40Z | Merged: 2025-05-23T10:41:17Z

## 작업 내용

약 알림 정보를 표시하는 컴포넌트입니다.

## 참고 사항

약 모양(파우터, 기타 타입) 검정테두리 구현이 어려워서 일단 제외했습니다.
etc 타입이 이미지로 되어 있어서 비활성화 상태일 땐 비슷한 모양으로 가렸는데, 모양이 이상해서 어떻게 수정하면 좋을지 의견 부탁드립니다!

## 연관 이슈


## 체크리스트
- [x] 코드 스타일을 따랐습니다.
- [x] 테스트가 완료되었거나 필요하지 않습니다.
- [x] 문서화 또는 주석 추가가 완료되었거나 필요하지 않습니다.


Diff:
diff --git a/public/images/etc-pill-type-gray.svg b/public/images/etc-pill-type-gray.svg
new file mode 100644
index 0000000..9503a3a
--- /dev/null
+++ b/public/images/etc-pill-type-gray.svg
@@ -0,0 +1,3 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M29.2389 25.3534C29.6377 27.7882 27.7588 30 25.2915 30L4.78915 30C2.29477 30 0.408787 27.7419 0.853133 25.2874L4.83589 3.28745C5.18046 1.3841 6.83762 1.78978e-06 8.77191 1.95888e-06L21.6881 3.08805e-06C23.6477 1.35201e-06 25.3187 1.41963 25.6355 3.35344L29.2389 25.3534Z" fill="#6B7280"/>
+</svg>
diff --git a/src/components/page/notifications/notificationCard.stories.tsx b/src/components/page/notifications/notificationCard.stories.tsx
new file mode 100644
index 0000000..5e376bf
--- /dev/null
+++ b/src/components/page/notifications/notificationCard.stories.tsx
@@ -0,0 +1,45 @@
+import { Meta, StoryObj } from '@storybook/react'
+import { NotificationCard, NotificationCardProps } from '@/components/page/notifications/notificationCard'
+import { PillTypes } from '@/types/pill'
+
+const meta: Meta<typeof NotificationCard> = {
+  title: 'Components/Page/Notifications/NotificationCard',
+  component: NotificationCard,
+  argTypes: {
+    type: {
+      control: { type: 'select' },
+      options: Object.keys(PillTypes)
+    },
+    href: { control: 'text' },
+    name: { control: 'text' },
+    daysLeft: { control: 'number' },
+    expirationDate: { control: 'text' },
+    isActive: { control: 'boolean' }
+  }
+}
+
+export default meta
+
+type Story = StoryObj<NotificationCardProps>
+
+export const Default: Story = {
+  args: {
+    href: '/notification/1',
+    name: '타이레놀',
+    type: 'pill',
+    daysLeft: 3,
+    expirationDate: '2025.05.25',
+    isActive: false
+  }
+}
+
+export const Active: Story = {
+  args: {
+    href: '/notification/1',
+    name: '아목시실린',
+    type: 'powder',
+    daysLeft: 0,
+    expirationDate: '2025.05.22',
+    isActive: true
+  }
+}
diff --git a/src/components/page/notifications/notificationCard.tsx b/src/components/page/notifications/notificationCard.tsx
new file mode 100644
index 0000000..0e22540
--- /dev/null
+++ b/src/components/page/notifications/notificationCard.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight } from 'lucide-react'
+import Image from 'next/image'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+} & React.ComponentProps<'div'>
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
+  ...props
+}) => {
+  const { ShapeComp, buttonClass } = PillTypes[type]
+
+  const iconWrapperBase = 'w-7 h-7 rounded-full flex items-center justify-center -mt-6'
+  const iconWrapperClass = isActive
+    ? cn(iconWrapperBase, buttonClass.replace('bg', 'border'), 'bg-transparent')
+    : cn(iconWrapperBase, buttonClass.replace('border', 'bg-gray-500/20'))
+
+  const shapeClass = cn(
+    'w-6 h-6',
+    !isActive && type === 'powder' && '!text-gray-500',
+    !isActive && (type === 'pill' || type === 'liquid') && '!bg-gray-500',
+    !isActive && type !== 'etc' && '!fill-gray-500 !stroke-0'
+  )
+
+  let iconContent
+
+  if (type === 'etc') {
+    const iconSrc = isActive
+      ? '/images/etc-pill-type.svg'
+      : '/images/etc-pill-type-gray.svg'
+
+    iconContent = (
+      <Image
+        src={iconSrc}
+        alt=''
+        width={24}
+        height={24}
+        className='w-6 h-6'
+      />
+    )
+  } else {
+    iconContent = <ShapeComp className={shapeClass} />
+  }
+
+  const iconElement = (
+    <div className={iconWrapperClass}>
+      {iconContent}
+    </div>
+  )
+
+  const nameClass = cn(
+    'text-lg font-semibold',
+    isActive ? 'text-primary' : 'text-secondary'
+  )
+
+  const ddayClass = cn(
+    'text-lg font-normal',
+    isActive ? 'text-error' : 'text-secondary'
+  )
+
+  const chevronClass = cn(
+    'w-5 h-5',
+    isActive ? 'text-primary' : 'text-secondary'
+  )
+
+  const cardBackgroundClass = isActive
+    ? 'bg-secondary-bg'
+    : 'bg-gray-100'
+
+  return (
+    <div
+      onClick={onClick}
+      className={cn(
+        'cursor-pointer group flex items-center justify-between py-5 px-5 rounded-2xl',
+        cardBackgroundClass
+      )}
+      {...props}
+    >
+      <div className='flex items-center gap-3'>
+        {iconElement}
+
+        <div className='flex flex-col'>
+          <div className='flex items-baseline gap-1'>
+            <span className={nameClass}>{name}</span>
+            <span className={ddayClass}>D-{daysLeft}</span>
+          </div>
+          <div className='mt-2 text-sm font-normal text-secondary'>
+            {expirationDate}
+          </div>
+        </div>
+      </div>
+
+      <ChevronRight className={chevronClass} />
+    </div>
+  )
+}


Reviews:
user: login: Helloyunho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T01:30:22Z
user: login: Helloyunho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T01:52:35Z
user: login: Helloyunho
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T02:09:47Z
user: login: Helloyunho
body: LGTM, merge 해주세요.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T10:39:16Z

Review comments:
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
+}) => {
+  const { ShapeComp, buttonClass } = PillTypes[type]
+
+  const iconWrapperBase = 'w-7 h-7 rounded-full flex items-center justify-center -mt-6'
+  const iconWrapperClass = isActive
+    ? cn(iconWrapperBase, buttonClass.replace('bg', 'border'), 'bg-transparent')
+    : cn(iconWrapperBase, buttonClass.replace('border', 'bg'), 'bg-opacity-20')
+
+  let iconElement
+
+  if (isActive) {
+    iconElement = (
+      <div className={iconWrapperClass}>
+        <ShapeComp className='w-full h-full' />
+      </div>
+    )
+  } else {
+    if (type === 'powder') {
+      iconElement = (
+        <div className={iconWrapperClass}>
+          <Triangle className='w-full h-full fill-gray-500 stroke-0' />
+        </div>
+      )
+    } else if (type === 'etc') { // 이미지라서 이렇게라도 해놨는데, 사다리꼴 모양을 어떻게 만들어야할지 모르겠어요ㅠㅠ
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: 이건 Image 컴포넌트 쓰면 안되려나요? width와 height 정하는건 `w-6` -> `width={24}`, `h-6` -> `height={24}` 하면 될거에요.
created_at: 2025-05-23T01:30:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
+}) => {
+  const { ShapeComp, buttonClass } = PillTypes[type]
+
+  const iconWrapperBase = 'w-7 h-7 rounded-full flex items-center justify-center -mt-6'
+  const iconWrapperClass = isActive
+    ? cn(iconWrapperBase, buttonClass.replace('bg', 'border'), 'bg-transparent')
+    : cn(iconWrapperBase, buttonClass.replace('border', 'bg'), 'bg-opacity-20')
+
+  let iconElement
+
+  if (isActive) {
+    iconElement = (
+      <div className={iconWrapperClass}>
+        <ShapeComp className='w-full h-full' />
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: 그냥 `ShapeComp`만 써도 될 것 같은데... 굳이 다 나눈 이유가 있을까요?
created_at: 2025-05-23T01:52:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
+}) => {
+  const { ShapeComp, buttonClass } = PillTypes[type]
+
+  const iconWrapperBase = 'w-7 h-7 rounded-full flex items-center justify-center -mt-6'
+  const iconWrapperClass = isActive
+    ? cn(iconWrapperBase, buttonClass.replace('bg', 'border'), 'bg-transparent')
+    : cn(iconWrapperBase, buttonClass.replace('border', 'bg'), 'bg-opacity-20')
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: `bg-opacity`가 있나요? 찾아보니 옛날에만 있던것 같네요.
created_at: 2025-05-23T02:01:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: Props에 `React.ComponentProps<'div'>` 추가해주세요.
created_at: 2025-05-23T02:02:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
+}) => {
+  const { ShapeComp, buttonClass } = PillTypes[type]
+
+  const iconWrapperBase = 'w-7 h-7 rounded-full flex items-center justify-center -mt-6'
+  const iconWrapperClass = isActive
+    ? cn(iconWrapperBase, buttonClass.replace('bg', 'border'), 'bg-transparent')
+    : cn(iconWrapperBase, buttonClass.replace('border', 'bg'), 'bg-opacity-20')
+
+  let iconElement
+
+  if (isActive) {
+    iconElement = (
+      <div className={iconWrapperClass}>
+        <ShapeComp className='w-full h-full' />
+      </div>
+    )
+  } else {
+    if (type === 'powder') {
+      iconElement = (
+        <div className={iconWrapperClass}>
+          <Triangle className='w-full h-full fill-gray-500 stroke-0' />
+        </div>
+      )
+    } else if (type === 'etc') { // 이미지라서 이렇게라도 해놨는데, 사다리꼴 모양을 어떻게 만들어야할지 모르겠어요ㅠㅠ
+      iconElement = (
+        <div className={iconWrapperClass}>
+          <div
+            className='w-6 h-6 bg-gray-500'
+            style={{
+              clipPath: 'polygon(15% 0%, 85% 0%, 100% 100%, 0% 100%)',
+              borderRadius: '7px'
+            }}
+          />
+        </div>
+      )
+    } else {
+      iconElement = (
+        <div className={iconWrapperClass}>
+          <ShapeComp className='w-full h-full bg-gray-500' />
+        </div>
+      )
+    }
+  }
+
+  const nameClass = cn(
+    'text-lg font-semibold',
+    isActive ? 'text-primary' : 'text-secondary'
+  )
+
+  const ddayClass = cn(
+    'text-lg font-normal',
+    isActive ? 'text-error' : 'text-secondary'
+  )
+
+  const chevronClass = cn(
+    'w-5 h-5',
+    isActive ? 'text-primary' : 'text-secondary'
+  )
+
+  const cardBackgroundClass = isActive
+    ? 'bg-secondary-bg'
+    : 'bg-gray-100' // 비활성화일 때 더 연한 배경
+
+  return (
+    <div
+      onClick={onClick}
+      className={cn(
+        'cursor-pointer group flex items-center justify-between py-5 px-5 rounded-2xl',
+        cardBackgroundClass
+      )}
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: 이 밑 줄에 `{...props}` 추가해주세요.
created_at: 2025-05-23T02:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
+  name,
+  type,
+  daysLeft,
+  expirationDate,
+  isActive = false,
+  onClick,
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: 밑 줄에 `...props` 추가해주세요.
created_at: 2025-05-23T02:08:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+'use client'
+
+import { FC } from 'react'
+import { cn } from '@/utils/cn'
+import { PillTypes } from '@/types/pill'
+import { ChevronRight, Triangle } from 'lucide-react'
+
+export type NotificationCardProps = {
+  href: string
+  name: string
+  type: keyof typeof PillTypes
+  daysLeft: number
+  expirationDate: string
+  isActive?: boolean
+  onClick?: () => void
+}
+
+export const NotificationCard: FC<NotificationCardProps> = ({
+  href,
path: src/components/page/notifications/notificationCard.tsx
user: login: Helloyunho
body: `href`는 사용한 흔적이 없는데, 제거해주세요.
created_at: 2025-05-23T02:09:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T23:04:27Z
body: ## Storybook Preview
[View Storybook(컴포넌트 테스트)](https://68282405f33c49157382daf8-afyrmrxqdz.chromatic.com/)
[View Chromatic(컴포넌트 리뷰 남기기)](https://www.chromatic.com/build?appId=68282405f33c49157382daf8&number=76)
author_association: NONE
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-05-23T10:29:36Z
body: ## Deploying repill-fe with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>c6ff2db</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://f494a6f7.repill-fe.pages.dev'>https://f494a6f7.repill-fe.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feat-componenet-notification.repill-fe.pages.dev'>https://feat-componenet-notification.repill-fe.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/560937f16abb7f77f2524c89d77668c4/pages/view/repill-fe/f494a6f7-82b6-4681-b194-ecc285999452)

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-23T10:30:46Z
body: ## Storybook Preview
[View Storybook(컴포넌트 테스트)](https://68282405f33c49157382daf8-vkbjekhvhw.chromatic.com/)
[View Chromatic(컴포넌트 리뷰 남기기)](https://www.chromatic.com/build?appId=68282405f33c49157382daf8&number=78)
author_association: NONE