728x90
📆리액트 캘린더와 Firebase로 CRD 구현하기
‼️구현하고자 하는 것
1. 사용자가 새로운 루틴을 추가할 수 있는 모달 창 제공
2. 사용자는 시작 날짜와 종료 날짜를 설정하고, 반복 주기 표시하기
3. 사용자가 특정 날짜에 루틴을 완료했다고 표시하기
4. react-calendar를 사용하여 날짜별로 루틴을 완료한 여부를 시각적으로 표시하기
🔥데이터를 Firestore에 저장할 때 사용할 데이터 구조 정의하기
- habitName: 루틴 이름
- startDate: 시작 날짜
- endDate: 종료 날짜
- frequency: 루틴을 수행할 요일들
type Habit = {
id: string
name: string
startDate: string
endDate: string
frequency: string[]
completedDates: string[]
}
🔥루틴 등록 모달 만들기
아래는 react-datepicker로 날짜 선택 후 각 필드를 firebase로 저장되는 로직이다.
type HabitModalProps = {
onClose: () => void
onAddHabit: () => void
}
type Habit = {
name: string
startDate: string
endDate: string
frequency: string[]
completedDates: string[]
}
export default function AddHabitModal({
onClose,
onAddHabit,
}: HabitModalProps) {
const [habitName, setHabitName] = useState('')
const [startDate, setStartDate] = useState<Date | null>(null)
const [endDate, setEndDate] = useState<Date | null>(null)
const [frequency, setFrequency] = useState<string[]>([])
const today = new Date()
// 요일 선택
const handleFrequencyChange = (e: ChangeEvent<HTMLInputElement>) => {
const day = e.target.value
if (e.target.checked) {
setFrequency((prev) => [...prev, day])
} else {
setFrequency((prev) => prev.filter((item) => item !== day))
}
}
const handleSubmit = async () => {
const newHabit: Habit = {
name: habitName,
startDate: startDate?.toISOString().split('T')[0] || '',
endDate: endDate?.toISOString().split('T')[0] || '',
frequency,
completedDates: [],
}
try {
await addDoc(collection(db, 'habits'), newHabit)
onAddHabit()
alert('루틴 등록 완료!')
onClose()
} catch (e) {
console.error('루틴 추가 실패: ', e)
alert('오류가 발생했습니다. 다시 시도해주세요.')
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
onClick={onClose}
>
<div
className="mx-auto max-w-xl rounded-lg bg-white p-4 shadow-md"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-6 text-2xl font-semibold">🔥루틴 등록하기</h2>
<div className="mb-4">
<label className="mb-2 block text-sm">루틴 이름</label>
<input
type="text"
value={habitName}
onChange={(e) => setHabitName(e.target.value)}
placeholder="등록할 루틴명을 입력하세요"
className="w-full rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
/>
</div>
<div className="mb-4">
<label className="mb-2 flex items-center text-sm">시작 날짜</label>
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)}
minDate={today} // 오늘 날짜 이후로 설정
className="w-full cursor-pointer rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
dateFormat="yyyy-MM-dd"
placeholderText="날짜를 선택해주세요"
/>
</div>
<div className="mb-4">
<label className="mb-2 flex items-center text-sm">종료 날짜</label>
<DatePicker
selected={endDate}
onChange={(date) => setEndDate(date)}
minDate={startDate || today} // 시작 날짜 이후로 설정
className="w-full cursor-pointer rounded-full border-2 border-gray-300 px-4 py-2 outline-none focus:border-green-40"
dateFormat="yyyy-MM-dd"
placeholderText="날짜를 선택해주세요"
/>
</div>
<div className="mb-4">
<label className="mb-2 block text-sm">매주 수행할 요일</label>
<div className="flex gap-4">
{['월', '화', '수', '목', '금', '토', '일'].map((day) => (
<label key={day} className="flex cursor-pointer items-center">
<input
type="checkbox"
value={day}
checked={frequency.includes(day)}
onChange={handleFrequencyChange}
className="hidden"
/>
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 ${
frequency.includes(day)
? 'border-green-20 bg-green-20'
: 'border-gray-300'
}`}
>
<span
className={`text-lg ${frequency.includes(day) ? 'text-white' : 'text-black'}`}
>
{day}
</span>
</div>
</label>
))}
</div>
</div>
<button
className="w-full rounded-full bg-green-30 px-6 py-3 text-white shadow-lg transition hover:bg-green-40"
onClick={handleSubmit}
>
루틴 추가
</button>
</div>
</div>
)
}
- 시작일은 오늘 이후로, 종료일은 시작일 이후로 선택할 수 있다.
초간단 모달 UI
<div
className="fixed inset-0 flex items-center justify-center bg-gray-500 bg-opacity-50"
onClick={onClose}
>
<div
className="mx-auto max-w-xl rounded-lg bg-white p-4 shadow-md"
onClick={(e) => e.stopPropagation()}
>
- 모달이기 때문에 바깥 div에 onClose 함수를 넣어 모달이 닫히도록 처리하기
- onClick={(e) => e.stopPropagation()}: 모달 내용Firebase 연동 및 데이터 가져오기 부분을 클릭했을 때, 배경을 클릭하지 않도록 막는 역할!
루틴 카드 띄우기
1. Firebase 연동 및 데이터 가져오기
getDocs() 함수를 사용하여 habits 컬렉션의 모든 데이터를 불러오고, setHabits()로 상태를 업데이트 해준다.
const fetchHabits = async () => {
const habitsCollection = collection(db, 'habits')
const habitSnapshot = await getDocs(habitsCollection)
const habitList = habitSnapshot.docs.map((doc) => {
const habitData = doc.data()
return {
id: doc.id,
name: habitData.name,
startDate: habitData.startDate,
endDate: habitData.endDate,
frequency: habitData.frequency,
completedDates: habitData.completedDates || [],
}
})
setHabits(habitList)
}
2. 날짜 클릭 및 완료 처리
- 시작날짜와 종료날짜 기간 내에 있는 요일들만 날짜 클릭할 수 있도록 하기
const handleDateClick = async (habitId: string, date: Date) => {
const dateString = date.toISOString().split('T')[0] // YYYY-MM-DD 형식
const habit = habits.find((h) => h.id === habitId)
if (habit && !habit.completedDates.includes(dateString)) {
const confirmCompletion = window.confirm(
`${habit.name}을(를) 완료하시겠습니까?`,
)
if (confirmCompletion) {
const habitRef = doc(db, 'habits', habitId)
await updateDoc(habitRef, {
completedDates: arrayUnion(dateString),
})
fetchHabits()
}
}
}
const isDateClickable = (habit: Habit, date: Date) => {
const dayOfWeek = date.getDay()
const frequencyDays = habit.frequency.map((day) =>
['일', '월', '화', '수', '목', '금', '토'].indexOf(day),
)
return (
frequencyDays.includes(dayOfWeek) &&
date >= new Date(habit.startDate) &&
date <= new Date(habit.endDate)
)
}
const isDateCompleted = (habit: Habit, date: Date) => {
const dateString = date.toISOString().split('T')[0]
return habit.completedDates.includes(dateString)
}
const isDateMissed = (habit: Habit, date: Date) => {
const today = new Date()
return date < today && isDateClickable(habit, date)
}
3. 캘린더에서 날짜 클릭 처리
날짜를 클릭했을 때 isDateClickable, isDateCompleted, isDateMissed 함수로 날짜 상태를 체크하고, 맞는 상태에 따른 타일에 css 적용하기
- isDateClickable: 날짜가 클릭 가능한 날짜인지 확인
- isDateCompleted: 날짜가 이미 완료된 날짜인지 확인
- isDateMissed: 날짜가 완료되지 않은 미션 놓친 날짜인지 확인
<Calendar
tileClassName={({ date }) => {
if (isDateCompleted(habit, date)) {
return 'completed'
} else if (isDateMissed(habit, date)) {
return 'missed'
} else if (isDateClickable(habit, date)) {
return 'clickable'
} else {
return 'not-clickable'
}
}}
onClickDay={(date) => {
if (isDateClickable(habit, date) && !isDateMissed(habit, date)) {
handleDateClick(habit.id, date)
}
}}
/>
tailwind 적용이 안 되기 때문에 따로 css 파일을 넣어 import 해주었다...!
.react-calendar {
width: 100%;
max-width: 415px;
margin: auto;
border-radius: 1rem;
border: #f3f4f6 1px solid;
padding: 1rem;
font-family: 'AppleSDGothicNeoB';
}
.react-calendar__tile {
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.3s ease;
margin: 0;
box-sizing: border-box;
}
.clickable {
background-color: #f3f4f6 !important;
border: #1e4a19 2px solid !important;
color: black;
cursor: pointer;
}
.not-clickable {
background-color: #f0f0f0 !important;
color: gray;
cursor: not-allowed;
}
.completed {
background-color: #1e4a19 !important;
color: white;
}
.missed {
background-color: #f44336 !important;
color: white;
text-decoration: line-through;
}
루틴 삭제하기
react-icons라이브러리를 사용해 아이콘 가져왔음..!
const handleDeleteHabit = async (habitId: string) => {
const confirmDelete = window.confirm('이 루틴을 삭제하시겠습니까?')
if (confirmDelete) {
const habitRef = doc(db, 'habits', habitId)
await deleteDoc(habitRef)
fetchHabits()
}
}
<button
onClick={() => handleDeleteHabit(habit.id)}
className="text-red-500"
aria-label="루틴 삭제 버튼"
>
<FiTrash size={20} />
</button>
'🔥Next 뽀개기' 카테고리의 다른 글
Chart.js 라이브러리로 달성률 시각화 구현하기(feat. Doughnut 차트) (0) | 2024.12.20 |
---|---|
Next.js Layout과 Firebase로 AuthGuard 구현하기(feat. cookie) (0) | 2024.12.01 |
Next.js에 firebase 연동해서 회원가입 구현하기 (0) | 2024.11.29 |
Zod + React Hook Form = 회원가입 뚝-딱! (0) | 2024.11.27 |
구글 간편 로그인 구현하기 (0) | 2024.09.07 |