오늘은 마이페이지 창에서 현재 복약 중인 약을 보여주는 medilist에 대해 작성해보고자 한다.
마이페이지 웹 사이즈에선 최대 3개의 약물을, 모바일 사이즈에선 최대 4개의 약물을 보여주고 더보기 버튼을 클릭 시 복약 전체 리스트를 보여주는 새 창으로 이동하게 된다.
이해를 돕기 위해 버셀 배포 버전을 첨부해두겠다.
"use client";
import React, { useEffect, useState } from "react";
import axios from "axios";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { supabase } from "@/utils/supabase/client";
import MediModal from "./myPageModal/MediModal";
interface MediRecord {
id: string;
medi_name: string;
medi_nickname: string;
times: {
morning: boolean;
afternoon: boolean;
evening: boolean;
};
notes: string;
start_date: string;
end_date: string;
created_at: string;
itemImage: string | null;
user_id: string;
notification_time?: string[];
day_of_week?: string[];
repeat?: boolean;
}
interface MediListsProps {
className?: string;
}
const MediLists: React.FC<MediListsProps> = ({ className }) => {
const [mediRecords, setMediRecords] = useState<MediRecord[]>([]);
const [selectedMediRecord, setSelectedMediRecord] = useState<MediRecord | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [displayedMediRecords, setDisplayedMediRecords] = useState<MediRecord[]>([]);
const router = useRouter();
useEffect(() => {
const fetchMediRecords = async () => {
const session = await supabase.auth.getSession();
if (!session.data.session) {
console.error("Auth session missing!");
return;
}
const userId = session.data.session.user.id;
try {
const response = await axios.get(`/api/mypage/medi/names?user_id=${userId}`);
setMediRecords(response.data);
} catch (error) {
console.error("Error fetching medi records:", error);
}
};
fetchMediRecords();
}, []);
useEffect(() => {
const handleResize = () => {
const isMobile = window.innerWidth < 768;
setDisplayedMediRecords(mediRecords.slice(0, isMobile ? 4 : 3));
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [mediRecords]);
const handleShowAllClick = () => {
router.push("/mypage/Medications");
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }).replace(/\. /g, '.');
};
return (
<div className={`${className} w-full flex justify-center desktop:block`}>
<div className="w-[335px] desktop:w-[670px] desktop:h-[352px] overflow-hidden desktop:rounded-2xl desktop:bg-brand-gray-50 desktop:shadow-sm">
<div className="desktop:px-[49px] desktop:pt-[41px] desktop:pb-[50px] h-full">
<h2
className="text-[16px] font-bold text-brand-gray-1000 text-left cursor-pointer mb-2 flex items-center"
onClick={handleShowAllClick}
>
<span className="mb-3 text-[16px]">나의 복용약</span>
<span className="text-[#279ef9] ml-1 mb-3 text-[16px]">
{mediRecords.length}개
</span>
<span className="text-[#279ef9] ml-1 mb-3 text-[16px]">
>
</span>
</h2>
<div className="w-full h-full">
<div className="grid grid-cols-2 gap-[17px] desktop:grid-cols-3 desktop:gap-4">
{displayedMediRecords.map((record) => (
<div
key={record.id}
className="w-[159px] desktop:w-auto"
>
<div className="bg-white border border-brand-gray-50 rounded-xl flex flex-col items-center w-[159px] h-[200px] desktop:w-[180px] desktop:h-[217px] p-4">
<div className="w-[127px] h-[72px] desktop:w-[148px] desktop:h-[84px] mb-2">
{record.itemImage ? (
<Image
src={record.itemImage}
alt={record.medi_nickname || "약 이미지"}
width={127}
height={72}
layout="responsive"
objectFit="cover"
className="rounded-lg"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-brand-gray-200 rounded-lg">
<p className="text-brand-gray-400 text-xs">이미지 없음</p>
</div>
)}
</div>
<div className="flex flex-col justify-between w-full flex-grow">
<div>
<p className="text-[14px] desktop:text-sm font-bold text-brand-gray-1000 line-clamp-1">
{record.medi_nickname}
</p>
<p className="text-[12px] desktop:text-xs text-brand-gray-800 line-clamp-1 mt-1">
{record.medi_name}
</p>
</div>
<p className="text-[10px] desktop:text-xs text-brand-primary-500 truncate mt-4">
{formatDate(record.start_date)} ~ {formatDate(record.end_date)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
export default MediLists;
"use client";
import React, { useEffect, useState } from "react";
import axios from "axios";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { supabase } from "@/utils/supabase/client";
import MediModal from "./myPageModal/MediModal";
클라이언트에서만 실행 될 코드이기 때문에 useclient를 선언
axios: HTTP 요청을 보낼 때 사용하는 라이브러리로, 여기서는 약 데이터를 서버에서 가져오기 위해 사용
Image: Next.js에서 제공하는 이미지 최적화 컴포넌트. 이미지를 불러올 때 최적화된 상태로 렌더링
useRouter: Next.js의 라우팅 기능을 사용하기 위한 훅. 페이지 이동 시 push 메서드를 사용
interface MediRecord {
id: string;
medi_name: string;
medi_nickname: string;
times: {
morning: boolean;
afternoon: boolean;
evening: boolean;
};
notes: string;
start_date: string;
end_date: string;
created_at: string;
itemImage: string | null;
user_id: string;
notification_time?: string[];
day_of_week?: string[];
repeat?: boolean;
}
interface MediListsProps {
className?: string;
}
약 정보를 나타내는 데이터 구조.
MediListsProps 인터페이스: MediLists 컴포넌트에 전달되는 className이라는 선택적 prop을 정의. -> 컴포넌트가 외부에서 전달된 데이터를 활용할 수 있게 하기 위함.
이는 부모 컴포넌트에서 전달받은 클래스명을 컴포넌트의 최상단 div에 추가해 주기 위해 작성.
const MediLists: React.FC<MediListsProps> = ({ className }) => {
약 목록을 보여주는 컴포넌트. className prop를 받아서 컴포넌트에 추가.
React.FC (Function Component) 타입으로 선언된 함수형 컴포넌트
MediListsProps 타입을 사용하여 props로 받은 값이 className임을 나타냄
const [mediRecords, setMediRecords] = useState<MediRecord[]>([]);
const [selectedMediRecord, setSelectedMediRecord] = useState<MediRecord | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [displayedMediRecords, setDisplayedMediRecords] = useState<MediRecord[]>([]);
const router = useRouter();
상태 변수 선언.
mediRecords: 서버에서 가져온 모든 약 목록 데이터를 저장하는 상태
selectedMediRecord: 선택된 약 정보.
모달을 열 때 이 상태를 사용하여 상세 정보를 보여줌.
isModalOpen: 모달이 열렸는지 여부를 관리하는 상태.
displayedMediRecords: 화면에 보여줄 약 목록을 저장하는 상태.
router: Next.js에서 페이지 이동을 처리하기 위해 사용하는 객체. 전체 약 목록 페이지로 이동할 때 사용.
useEffect(() => {
const fetchMediRecords = async () => {
const session = await supabase.auth.getSession();
if (!session.data.session) {
console.error("Auth session missing!");
return;
}
const userId = session.data.session.user.id;
try {
const response = await axios.get(`/api/mypage/medi/names?user_id=${userId}`);
setMediRecords(response.data);
} catch (error) {
console.error("Error fetching medi records:", error);
}
};
fetchMediRecords();
}, []);
약 목록 가져오기.
useEffect: 컴포넌트가 처음 렌더링될 때(마운트) 두 번째 인자인 빈 배열 덕분에 한 번만 실행
useEffect 훅은 React의 함수형 컴포넌트에서 사이드 이펙트를 관리하기 위해 사용.
사이드 이펙트란 ?
-데이터 fetching, 구독, 타이머 설정, 수동 DOM 조작 등 컴포넌트의 렌더링과 직접적인 관련이 없는 작업을 의미
useEffect(() => {
// 사이드 이펙트 로직
return () => {
// 정리(cleanup) 로직 (옵션)
};
}, [의존성 배열]);
async 키워드가 사용되어 비동기 작업을 처리
supabase.auth.getSession(): Supabase의 인증 API를 사용하여 현재 사용자 세션 가져오기.
세션이 없다면 에러 메시지를 출력하고 실행 중단.
axios.get(...): 사용자의 약 목록을 서버로부터 가져오기 위해 /api/mypage/medi/names?user_id=${userId}로 GET 요청을 보내고 ( user_id 파라미터를 추가하여 특정 사용자에 대한 약 정보를 서버에 요청), 성공하면 setMediRecords를 통해 상태를 업데이트
useEffect(() => {
const handleResize = () => {
const isMobile = window.innerWidth < 768;
setDisplayedMediRecords(mediRecords.slice(0, isMobile ? 4 : 3));
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [mediRecords]);
화면 크기에 따라 약 목록 개수 조정
useEffect: 약 목록(mediRecords)이 변경될 때마다 실행.
첫 렌더링 시와 화면 크기가 변경될 때 handleResize가 호출되어 displayedMediRecords가 업데이트.
const handleShowAllClick = () => {
router.push("/mypage/Medications");
};
복용 중인 약 전체 보기
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }).replace(/\. /g, '.');
};
날자 문자열을 받아와서 yymmdd 형식으로 변환.
return (
<div className={`${className} w-full flex justify-center desktop:block`}>
<div className="w-[335px] desktop:w-[670px] desktop:h-[352px] overflow-hidden desktop:rounded-2xl desktop:bg-brand-gray-50 desktop:shadow-sm">
<div className="desktop:px-[49px] desktop:pt-[41px] desktop:pb-[50px] h-full">
<h2
className="text-[16px] font-bold text-brand-gray-1000 text-left cursor-pointer mb-2 flex items-center"
onClick={handleShowAllClick}
>
<span className="mb-3 text-[16px]">나의 복용약</span>
<span className="text-[#279ef9] ml-1 mb-3 text-[16px]">
{mediRecords.length}개
</span>
<span className="text-[#279ef9] ml-1 mb-3 text-[16px]">
>
</span>
</h2>
<div className="w-full h-full">
<div className="grid grid-cols-2 gap-[17px] desktop:grid-cols-3 desktop:gap-4">
{displayedMediRecords.map((record) => (
<div key={record.id} className="w-[159px] desktop:w-auto">
<div className="bg-white border border-brand-gray-50 rounded-xl flex flex-col items-center w-[159px] h-[200px] desktop:w-[180px] desktop:h-[217px] p-4">
<div className="w-[127px] h-[72px] desktop:w-[148px] desktop:h-[84px] mb-2">
{record.itemImage ? (
<Image
src={record.itemImage}
alt={record.medi_nickname || "약 이미지"}
width={127}
height={72}
layout="responsive"
objectFit="cover"
className="rounded-lg"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-brand-gray-200 rounded-lg">
<p className="text-brand-gray-400 text-xs">이미지 없음</p>
</div>
)}
</div>
<div className="flex flex-col justify-between w-full flex-grow">
<div>
<p className="text-[14px] desktop:text-sm font-bold text-brand-gray-1000 line-clamp-1">
{record.medi_nickname}
</p>
<p className="text-[12px] desktop:text-xs text-brand-gray-800 line-clamp-1 mt-1">
{record.medi_name}
</p>
</div>
<p className="text-[10px] desktop:text-xs text-brand-primary-500 truncate mt-4">
{formatDate(record.start_date)} ~ {formatDate(record.end_date)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
반응형 레이아웃이 들어간 리턴 코드.