diff --git a/package.json b/package.json index a2bf432a..4bbb8d83 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lucide-react": "^0.468.0", "next": "16.1.6", "posthog-js": "^1.364.4", + "qr-code-styling": "^1.9.2", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69b87522..c2d7f08f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: posthog-js: specifier: ^1.364.4 version: 1.364.4 + qr-code-styling: + specifier: ^1.9.2 + version: 1.9.2 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -4352,6 +4355,13 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qr-code-styling@1.9.2: + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} + engines: {node: '>=18.18.0'} + + qrcode-generator@1.5.2: + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -9520,6 +9530,12 @@ snapshots: pure-rand@7.0.1: {} + qr-code-styling@1.9.2: + dependencies: + qrcode-generator: 1.5.2 + + qrcode-generator@1.5.2: {} + query-selector-shadow-dom@1.0.1: {} queue-microtask@1.2.3: {} diff --git a/src/app/(private)/(main)/attendance/history/page.tsx b/src/app/(private)/(main)/attendance/history/page.tsx index 70903737..4f697374 100644 --- a/src/app/(private)/(main)/attendance/history/page.tsx +++ b/src/app/(private)/(main)/attendance/history/page.tsx @@ -1,55 +1,26 @@ +import { cookies } from 'next/headers'; + import { AttendanceHistoryContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; +import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceSummary } from '@/types/attendance'; -// TODO: API 연동 시 실제 데이터로 교체 -const mockSummary: AttendanceSummary = { - total: 5, - attendanceCount: 3, - absenceCount: 2, - attendances: [ - { - id: 5, - status: 'ABSENT', - title: '5주차 정기모임', - start: '2026-02-23T10:00:00.000Z', - end: '2026-02-23T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 4, - status: 'ATTEND', - title: '4주차 정기모임', - start: '2026-02-16T10:00:00.000Z', - end: '2026-02-16T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 3, - status: 'ABSENT', - title: '3주차 정기모임', - start: '2026-02-09T10:00:00.000Z', - end: '2026-02-09T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 2, - status: 'ATTEND', - title: '2주차 정기모임', - start: '2026-02-02T10:00:00.000Z', - end: '2026-02-02T12:00:00.000Z', - location: '공학관 401호', - }, - { - id: 1, - status: 'ATTEND', - title: '1주차 정기모임', - start: '2026-01-26T10:00:00.000Z', - end: '2026-01-26T12:00:00.000Z', - location: '공학관 401호', - }, - ], -}; +export default async function AttendanceHistoryPage() { + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + + let summary: AttendanceSummary | undefined; + let errorMessage: string | undefined; + + if (!clubId) { + errorMessage = '동아리 정보를 찾을 수 없습니다.'; + } else { + try { + const response = await attendanceServerApi.getDetail(clubId); + summary = response.data; + } catch { + errorMessage = '출석 기록을 불러오지 못했습니다.'; + } + } -export default function AttendanceHistoryPage() { - return ; + return ; } diff --git a/src/app/(private)/(main)/attendance/page.tsx b/src/app/(private)/(main)/attendance/page.tsx index 09050ad0..a40e4cc2 100644 --- a/src/app/(private)/(main)/attendance/page.tsx +++ b/src/app/(private)/(main)/attendance/page.tsx @@ -1,25 +1,39 @@ +import { cookies } from 'next/headers'; + import { AttendanceContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; +import { attendanceServerApi } from '@/lib/apis/attendance.server'; import type { AttendanceData } from '@/types/attendance'; -// TODO: API 연동 시 실제 데이터로 교체 -function createMockAttendance(): AttendanceData { - const now = new Date(); - const start = now; - const end = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 - - return { - attendanceRate: 80, - title: '1주차 정기모임', - status: 'ATTEND', - code: 123456, - start: start.toISOString(), - end: end.toISOString(), - location: '공학관 401호', - }; +interface AttendancePageProps { + searchParams: Promise<{ sessionId?: string; code?: string }>; } -export default function AttendancePage() { - // TODO: API 연동 시 실제 사용자 이름으로 교체 - const displayName = '사용자'; - return ; +export default async function AttendancePage({ searchParams }: AttendancePageProps) { + const { sessionId: qrSessionId, code: qrCode } = await searchParams; + + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + + let attendance: AttendanceData | undefined; + let errorMessage: string | undefined; + + if (!clubId) { + errorMessage = '동아리 정보를 찾을 수 없습니다.'; + } else { + try { + const response = await attendanceServerApi.getAttendance(clubId); + attendance = response.data; + } catch { + errorMessage = '출석 정보를 불러오지 못했습니다.'; + } + } + + return ( + + ); } diff --git a/src/app/(private)/(main)/attendance/qr/page.tsx b/src/app/(private)/(main)/attendance/qr/page.tsx index 6c916fcd..0f0d32ca 100644 --- a/src/app/(private)/(main)/attendance/qr/page.tsx +++ b/src/app/(private)/(main)/attendance/qr/page.tsx @@ -1,18 +1,31 @@ -import { AttendanceQRContent } from '@/components/attendance'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; -// TODO: API 연동 시 실제 데이터로 교체 -function createMockQRData() { - const now = new Date(); - const end = new Date(now.getTime() + 10 * 60 * 1000); // 10분 후 +import { AttendanceQRContent } from '@/components/attendance'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; +import { homeServerApi } from '@/lib/apis/home.server'; - return { - title: '1주차 정기모임', - code: '123456', - endTime: end.toISOString(), - }; +interface AttendanceQRPageProps { + searchParams: Promise<{ sessionId?: string }>; } -export default function AttendanceQRPage() { - const { title, code, endTime } = createMockQRData(); - return ; +export default async function AttendanceQRPage({ searchParams }: AttendanceQRPageProps) { + const { sessionId: rawSessionId } = await searchParams; + const sessionId = Number(rawSessionId); + + if (!Number.isInteger(sessionId) || sessionId <= 0) { + redirect('/attendance'); + } + + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) redirect('/attendance'); + + const { data } = await homeServerApi.getDashboard(clubId); + const role = data.myInfo.userInfo.role; + + if (role !== 'LEAD' && role !== 'ADMIN') { + redirect('/attendance'); + } + + return ; } diff --git a/src/app/(private)/(main)/layout.tsx b/src/app/(private)/(main)/layout.tsx index b0053098..ff04d24f 100644 --- a/src/app/(private)/(main)/layout.tsx +++ b/src/app/(private)/(main)/layout.tsx @@ -1,18 +1,31 @@ import type { ReactNode } from 'react'; +import { cookies } from 'next/headers'; +import { homeServerApi } from '@/lib/apis/home.server'; +import { CLUB_ID_KEY } from '@/lib/apis/cookies'; +import { UserHydrator } from '@/providers/user-hydrator'; import { ClubGuard, Header } from '@/components/layout'; -export default function MainLayout({ +export default async function MainLayout({ children, }: Readonly<{ children: ReactNode; }>) { + const clubId = (await cookies()).get(CLUB_ID_KEY)?.value; + if (!clubId) return null; + + const { data } = await homeServerApi.getDashboard(clubId); + const { userInfo } = data.myInfo; + const { id: resolvedClubId, name: clubName } = data.club; + return ( -
-
- {children} -
+ +
+
+ {children} +
+
); } diff --git a/src/assets/icons/attendance/ic_attendance_qr.svg b/src/assets/icons/attendance/ic_attendance_qr.svg deleted file mode 100644 index 303e5455..00000000 --- a/src/assets/icons/attendance/ic_attendance_qr.svg +++ /dev/null @@ -1,362 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 0edac0be..1d1bd7ec 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -35,7 +35,6 @@ export { default as HomeIcon } from './home.svg'; export { default as MoreHorizIcon } from './more-horiz.svg'; export { default as CompleteIcon } from './complete.svg'; -export { default as AttendanceQRIcon } from './attendance/ic_attendance_qr.svg'; export { default as InfoCircleIcon } from './info_circle.svg'; export { default as CautionIcon } from './caution.svg'; export { default as KakaoLogoIcon } from './kakao_logo.svg'; diff --git a/src/components/attendance/AttendanceCompleteModal.tsx b/src/components/attendance/AttendanceCompleteModal.tsx index a9ffdc22..f5bbdaeb 100644 --- a/src/components/attendance/AttendanceCompleteModal.tsx +++ b/src/components/attendance/AttendanceCompleteModal.tsx @@ -15,9 +15,16 @@ import { interface AttendanceCompleteModalProps { open: boolean; onOpenChange: (open: boolean) => void; + title?: string; + description?: string; } -function AttendanceCompleteModal({ open, onOpenChange }: AttendanceCompleteModalProps) { +function AttendanceCompleteModal({ + open, + onOpenChange, + title = '이미 출석을 완료했네요!', + description = '오늘도 즐거운 활동을 이어가세요.', +}: AttendanceCompleteModalProps) { return ( 출석 완료
-

이미 출석을 완료했네요!

-

오늘도 즐거운 활동을 이어가세요.

+

{title}

+

{description}

diff --git a/src/components/attendance/AttendanceContent.tsx b/src/components/attendance/AttendanceContent.tsx index 5841db91..66bf9c83 100644 --- a/src/components/attendance/AttendanceContent.tsx +++ b/src/components/attendance/AttendanceContent.tsx @@ -1,70 +1,126 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbPage, Card } from '@/components/ui'; +import { AttendanceCompleteModal } from '@/components/attendance/AttendanceCompleteModal'; import { AttendanceStatus } from '@/components/attendance/AttendanceStatus'; import { AttendanceTodayCard } from '@/components/attendance/AttendanceTodayCard'; +import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; +import { attendanceApi } from '@/lib/apis/attendance'; import { formatAttendanceDescription } from '@/lib/formatTime'; +import { useQRCheckIn } from '@/hooks/useQRCheckIn'; +import { useClubId } from '@/stores/useClubStore'; +import { toastError } from '@/stores/useToastStore'; +import { useUserName, useUserRole } from '@/stores/useUserStore'; import type { AttendanceData } from '@/types/attendance'; interface AttendanceContentProps { - name: string; - attendance: AttendanceData; - isAdmin?: boolean; + attendance?: AttendanceData; + errorMessage?: string; + qrSessionId?: string; + qrCode?: string; } -function AttendanceContent({ name, attendance, isAdmin = false }: AttendanceContentProps) { +function AttendanceContent({ + attendance, + errorMessage, + qrSessionId, + qrCode, +}: AttendanceContentProps) { + const name = useUserName() ?? ''; + const role = useUserRole(); + const clubId = useClubId(); + const isAdmin = role === 'LEAD' || role === 'ADMIN'; const router = useRouter(); - const [isChecked, setIsChecked] = useState(false); - const { attendanceRate, title, start, end, location } = attendance; + const [isManualChecked, setIsManualChecked] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); + + const { isChecked: isQRChecked } = useQRCheckIn({ + qrSessionId, + qrCode, + onSuccess: () => setCompleteModalOpen(true), + }); + + const isChecked = isManualChecked || isQRChecked; + + useEffect(() => { + if (errorMessage) toastError(errorMessage); + }, [errorMessage]); + + const { + sessionId = null, + attendanceRate = 0, + title = null, + start = null, + end = null, + location = null, + } = attendance ?? {}; const description = formatAttendanceDescription(start ?? '', end ?? '', location ?? ''); - function handleAttendanceComplete(_code: string) { - // TODO: API 연결 시 출석 코드 검증 로직 추가 - setIsChecked(true); + async function handleAttendanceComplete(code: string) { + if (!clubId || !sessionId) return; + + try { + await attendanceApi.checkIn(clubId, sessionId, Number(code)); + setIsManualChecked(true); + setCompleteModalOpen(true); + } catch (error) { + const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data?.code; + toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); + } } return ( -
- - - - 출석 - - - - - - -
- - - router.push('/attendance/history')} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - router.push('/attendance/history'); - } - }} - /> + <> +
+ + + + 출석 + + + + + + +
+ + + router.push('/attendance/history')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + router.push('/attendance/history'); + } + }} + /> +
-
+ + + ); } diff --git a/src/components/attendance/AttendanceHistoryContent.tsx b/src/components/attendance/AttendanceHistoryContent.tsx index c33abf01..1a333c97 100644 --- a/src/components/attendance/AttendanceHistoryContent.tsx +++ b/src/components/attendance/AttendanceHistoryContent.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import Link from 'next/link'; import { @@ -15,11 +16,13 @@ import { import { cn } from '@/lib/cn'; import { formatKoreanDate, formatTime } from '@/lib/formatTime'; import { ATTENDANCE_STATUS_CONFIG } from '@/constants/attendance'; +import { toastError } from '@/stores/useToastStore'; import type { AttendanceSummary } from '@/types/attendance'; import { StatBox } from './StatBox'; interface AttendanceHistoryContentProps { - summary: AttendanceSummary; + summary?: AttendanceSummary; + errorMessage?: string; } function toDisplayRecord(record: AttendanceSummary['attendances'][number]) { @@ -37,10 +40,14 @@ function toDisplayRecord(record: AttendanceSummary['attendances'][number]) { }; } -function AttendanceHistoryContent({ summary }: AttendanceHistoryContentProps) { - const { total = 0, attendanceCount = 0, absenceCount = 0, attendances = [] } = summary; +function AttendanceHistoryContent({ summary, errorMessage }: AttendanceHistoryContentProps) { + const { total, attendanceCount, absenceCount, attendances = [] } = summary ?? {}; const records = attendances.map(toDisplayRecord); + useEffect(() => { + if (errorMessage) toastError(errorMessage); + }, [errorMessage]); + return (
@@ -64,45 +71,51 @@ function AttendanceHistoryContent({ summary }: AttendanceHistoryContentProps) {

출석 조회

-
-
-
- - - -
+ {summary ? ( +
+
+
+ + + +
- + -
- {records.length === 0 ? ( -

- 출석 기록이 없습니다. -

- ) : ( - records.map((record) => ( -
-
- - {record.statusLabel} - - {record.title} +
+ {records.length === 0 ? ( +

+ 출석 기록이 없습니다. +

+ ) : ( + records.map((record) => ( +
+
+ + {record.statusLabel} + + {record.title} +
+
+ 날짜 : {record.date} + 장소 : {record.location} +
-
- 날짜 : {record.date} - 장소 : {record.location} -
-
- )) - )} + )) + )} +
-
+ ) : ( +

+ 출석 정보를 불러올 수 없습니다. +

+ )}
); } diff --git a/src/components/attendance/AttendanceQRContent.tsx b/src/components/attendance/AttendanceQRContent.tsx index 43789e8f..a669e385 100644 --- a/src/components/attendance/AttendanceQRContent.tsx +++ b/src/components/attendance/AttendanceQRContent.tsx @@ -1,9 +1,7 @@ 'use client'; -import Image from 'next/image'; import Link from 'next/link'; -import { AttendanceQRIcon } from '@/assets/icons'; import { Breadcrumb, BreadcrumbList, @@ -12,16 +10,19 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui'; -import { useRemainingTime } from '@/hooks/useRemainingTime'; +import { useAttendanceQR } from '@/hooks/useAttendanceQR'; +import { useClubId } from '@/stores/useClubStore'; interface AttendanceQRContentProps { - title: string; - code: string; - endTime: string; + sessionId: number; } -function AttendanceQRContent({ title, code, endTime }: AttendanceQRContentProps) { - const { minutes, seconds, isExpired } = useRemainingTime(endTime); +function AttendanceQRContent({ sessionId }: AttendanceQRContentProps) { + const clubId = useClubId(); + const { qrRef, qrData, isLoading, minutes, seconds, isExpired } = useAttendanceQR( + clubId, + sessionId, + ); return (
@@ -55,19 +56,27 @@ function AttendanceQRContent({ title, code, endTime }: AttendanceQRContentProps)
- QR 코드 - -
-
- 출석 가능 시간 - - {isExpired ? '마감' : `${minutes}:${seconds}`} - + {isLoading ? ( +
+

QR 코드 생성 중...

-

QR코드는 모바일만 제공하고 있어요.

-
+ ) : ( + <> +
+ +
+
+ 출석 가능 시간 + + {isExpired ? '마감' : `${minutes}:${seconds}`} + +
+

QR코드는 모바일만 제공하고 있어요.

+
-

{code}

+

{qrData?.code}

+ + )}
diff --git a/src/components/attendance/AttendanceTodayCard.tsx b/src/components/attendance/AttendanceTodayCard.tsx index 84e33614..3fdc7cff 100644 --- a/src/components/attendance/AttendanceTodayCard.tsx +++ b/src/components/attendance/AttendanceTodayCard.tsx @@ -7,7 +7,6 @@ import { useState } from 'react'; import { CompleteIcon } from '@/assets/icons'; import { Card } from '@/components/ui'; import { AttendanceCodeModal } from '@/components/attendance/AttendanceCodeModal'; -import { AttendanceCompleteModal } from '@/components/attendance/AttendanceCompleteModal'; import { toastError } from '@/stores/useToastStore'; interface AttendanceTodayCardProps { @@ -17,8 +16,10 @@ interface AttendanceTodayCardProps { start: string; endTime: string; location: string; + sessionId?: number | null; isAdmin?: boolean; isChecked?: boolean; + disabled?: boolean; onAttendanceComplete?: (code: string) => void; } @@ -41,17 +42,22 @@ function AttendanceTodayCard({ start, endTime, location, + sessionId, isAdmin = false, isChecked = false, + disabled = false, onAttendanceComplete, }: AttendanceTodayCardProps) { const router = useRouter(); const [codeModalOpen, setCodeModalOpen] = useState(false); - const [completeModalOpen, setCompleteModalOpen] = useState(false); - function handleCodeConfirm(code: string) { - onAttendanceComplete?.(code); - setCompleteModalOpen(true); + function handleSecondaryClick() { + if (!isAdmin) { + toastError('관리자만 사용할 수 있는 기능입니다.'); + return; + } + if (sessionId == null) return; + router.push(`/attendance/qr?sessionId=${sessionId}`); } return ( @@ -62,14 +68,12 @@ function AttendanceTodayCard({ title={title} description={description} showArrow={false} - onPrimaryClick={isChecked ? () => setCompleteModalOpen(true) : () => setCodeModalOpen(true)} + onPrimaryClick={() => setCodeModalOpen(true)} primaryButtonText={isChecked ? '출석 완료' : '출석하기'} - onSecondaryClick={ - isAdmin - ? () => router.push('/attendance/qr') - : () => toastError('관리자만 사용할 수 있는 기능입니다.') - } + primaryButtonDisabled={disabled || isChecked} + onSecondaryClick={handleSecondaryClick} secondaryButtonText="출석코드 확인" + secondaryButtonDisabled={disabled || sessionId == null} > {isChecked && } @@ -77,14 +81,12 @@ function AttendanceTodayCard({ onAttendanceComplete?.(code)} title={title} start={start} endTime={endTime} location={location} /> - - ); } diff --git a/src/components/home/HomePageSections.tsx b/src/components/home/HomePageSections.tsx index 407d6990..87a3012a 100644 --- a/src/components/home/HomePageSections.tsx +++ b/src/components/home/HomePageSections.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect } from 'react'; +import { Suspense } from 'react'; import { BannerSkeleton, LeftContainerSkeleton, @@ -13,21 +13,9 @@ import { MainContainer, RightContainer, } from '@/components/home/DynamicSections'; -import { useHomeQuery } from '@/hooks/home'; import { Header } from '@/components/layout'; -import { useUserActions } from '@/stores'; export function HomePageSections() { - const { setUser } = useUserActions(); - const { data: myUserInfo } = useHomeQuery({ - select: (data) => data.myInfo.userInfo, - }); - - useEffect(() => { - if (!myUserInfo) return; - setUser(myUserInfo); - }, [myUserInfo, setUser]); - return ( <> }> diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index b1933890..09e9430d 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -30,6 +30,8 @@ interface CardProps extends React.ComponentProps<'div'>, VariantProps void; secondaryButtonText?: string; onSecondaryClick?: () => void; + primaryButtonDisabled?: boolean; + secondaryButtonDisabled?: boolean; /** arrow 아이콘 표시 여부 (기본값: true) */ showArrow?: boolean; } @@ -45,6 +47,8 @@ function Card({ onPrimaryClick, secondaryButtonText = '출석코드 확인', onSecondaryClick, + primaryButtonDisabled, + secondaryButtonDisabled, showArrow = true, children, ...props @@ -94,12 +98,24 @@ function Card({
{onPrimaryClick && ( - )} {onSecondaryClick && ( - )} diff --git a/src/constants/attendance.ts b/src/constants/attendance/attendance.ts similarity index 100% rename from src/constants/attendance.ts rename to src/constants/attendance/attendance.ts diff --git a/src/constants/attendance/error.ts b/src/constants/attendance/error.ts new file mode 100644 index 00000000..5cf0298e --- /dev/null +++ b/src/constants/attendance/error.ts @@ -0,0 +1,11 @@ +export const ATTENDANCE_ERROR_CODE = { + NOT_FOUND: 20200, + CODE_MISMATCH: 20201, + ALREADY_ATTENDED: 20204, +} as const; + +export const ATTENDANCE_ERROR_MESSAGE: Record = { + [ATTENDANCE_ERROR_CODE.NOT_FOUND]: '출석 정보가 존재하지 않습니다.', + [ATTENDANCE_ERROR_CODE.CODE_MISMATCH]: '출석 코드가 일치하지 않습니다.', + [ATTENDANCE_ERROR_CODE.ALREADY_ATTENDED]: '이미 출석 처리된 세션입니다.', +}; diff --git a/src/constants/attendance/index.ts b/src/constants/attendance/index.ts new file mode 100644 index 00000000..cb89cb3c --- /dev/null +++ b/src/constants/attendance/index.ts @@ -0,0 +1,2 @@ +export { ATTENDANCE_STATUS_CONFIG } from './attendance'; +export { ATTENDANCE_ERROR_CODE, ATTENDANCE_ERROR_MESSAGE } from './error'; diff --git a/src/hooks/useAttendanceQR.ts b/src/hooks/useAttendanceQR.ts new file mode 100644 index 00000000..36baa8dc --- /dev/null +++ b/src/hooks/useAttendanceQR.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import QRCodeStyling from 'qr-code-styling'; + +import { useRemainingTime } from '@/hooks/useRemainingTime'; +import { useQRCode } from '@/hooks/useQRCode'; + +function useAttendanceQR(clubId: string | null, sessionId: number) { + const { data: qrData, isLoading, refetch } = useQRCode(clubId, sessionId); + const qrRef = useRef(null); + const qrCodeRef = useRef(null); + const { minutes, seconds, isExpired } = useRemainingTime(qrData?.expiredAt ?? ''); + + useEffect(() => { + if (isExpired && qrData) refetch(); + }, [isExpired, qrData, refetch]); + + useEffect(() => { + if (!qrData || !qrRef.current) return; + + const checkInUrl = `${window.location.origin}/attendance?sessionId=${qrData.sessionId}&code=${qrData.code}`; + + if (!qrCodeRef.current) { + qrCodeRef.current = new QRCodeStyling({ + width: 256, + height: 256, + data: checkInUrl, + qrOptions: { errorCorrectionLevel: 'L' }, + type: 'svg', + dotsOptions: { type: 'dots' }, + cornersSquareOptions: { type: 'extra-rounded' }, + cornersDotOptions: { type: 'extra-rounded' }, + }); + qrCodeRef.current.append(qrRef.current); + } else { + qrCodeRef.current.update({ data: checkInUrl }); + } + }, [qrData]); + + return { qrRef, qrData, isLoading, minutes, seconds, isExpired }; +} + +export { useAttendanceQR }; diff --git a/src/hooks/useQRCheckIn.ts b/src/hooks/useQRCheckIn.ts new file mode 100644 index 00000000..1f5bc9c5 --- /dev/null +++ b/src/hooks/useQRCheckIn.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { ATTENDANCE_ERROR_MESSAGE } from '@/constants/attendance'; +import { attendanceApi } from '@/lib/apis/attendance'; +import { useClubId } from '@/stores/useClubStore'; +import { toastError } from '@/stores/useToastStore'; + +interface UseQRCheckInParams { + qrSessionId?: string; + qrCode?: string; + onSuccess?: () => void; +} + +function useQRCheckIn({ qrSessionId, qrCode, onSuccess }: UseQRCheckInParams) { + const router = useRouter(); + const clubId = useClubId(); + const [isChecked, setIsChecked] = useState(false); + const checkedKey = useRef(null); + + useEffect(() => { + if (!clubId || !qrSessionId || !qrCode) return; + + const key = `${qrSessionId}:${qrCode}`; + if (checkedKey.current === key) return; + + const checkIn = async () => { + try { + await attendanceApi.checkIn(clubId, Number(qrSessionId), Number(qrCode)); + checkedKey.current = key; + setIsChecked(true); + onSuccess?.(); + } catch (error) { + const errorCode = (error as { response?: { data?: { code?: number } } }).response?.data + ?.code; + toastError(errorCode ? ATTENDANCE_ERROR_MESSAGE[errorCode] : undefined); + router.replace('/attendance'); + } + }; + + checkIn(); + }, [clubId, qrSessionId, qrCode, router, onSuccess]); + + return { isChecked }; +} + +export { useQRCheckIn }; diff --git a/src/hooks/useQRCode.ts b/src/hooks/useQRCode.ts new file mode 100644 index 00000000..60f72ddb --- /dev/null +++ b/src/hooks/useQRCode.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; + +import { attendanceApi } from '@/lib/apis/attendance'; + +function useQRCode(clubId: string | null, sessionId: number) { + return useQuery({ + queryKey: ['attendance', 'qr', clubId, sessionId], + queryFn: async () => { + const response = await attendanceApi.generateQR(clubId!, sessionId); + return response.data.data; + }, + enabled: !!clubId, + staleTime: 0, + gcTime: 0, + }); +} + +export { useQRCode }; diff --git a/src/hooks/useRemainingTime.ts b/src/hooks/useRemainingTime.ts index 6fbfae77..ad82c5ff 100644 --- a/src/hooks/useRemainingTime.ts +++ b/src/hooks/useRemainingTime.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useSyncExternalStore } from 'react'; /** * 종료 시각까지 남은 시간을 1초 간격으로 카운트다운하는 훅 @@ -11,23 +11,26 @@ import { useState, useEffect } from 'react'; * @returns isExpired - 시간 만료 여부 */ function getRemainingSeconds(endTime: string) { + if (!endTime) return 0; const end = new Date(endTime).getTime(); if (Number.isNaN(end)) return 0; return Math.max(0, Math.floor((end - Date.now()) / 1000)); } function useRemainingTime(endTime: string) { - const [remaining, setRemaining] = useState(() => getRemainingSeconds(endTime)); + const subscribe = (onStoreChange: () => void) => { + if (!endTime || getRemainingSeconds(endTime) <= 0) return () => {}; - useEffect(() => { const interval = setInterval(() => { - const seconds = getRemainingSeconds(endTime); - setRemaining(seconds); - if (seconds <= 0) clearInterval(interval); + onStoreChange(); + if (getRemainingSeconds(endTime) <= 0) clearInterval(interval); }, 1000); - return () => clearInterval(interval); - }, [endTime]); + }; + + const getSnapshot = () => getRemainingSeconds(endTime); + + const remaining = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const minutes = String(Math.floor(remaining / 60)).padStart(2, '0'); const seconds = String(remaining % 60).padStart(2, '0'); diff --git a/src/lib/apis/attendance.server.ts b/src/lib/apis/attendance.server.ts new file mode 100644 index 00000000..a5175deb --- /dev/null +++ b/src/lib/apis/attendance.server.ts @@ -0,0 +1,14 @@ +import { apiServer } from '@/lib/apis/server'; +import type { AttendanceResponse, AttendanceSummaryResponse } from '@/types/attendance'; + +export const attendanceServerApi = { + getAttendance: (clubId: string) => + apiServer.get(`/clubs/${clubId}/attendances`, { + cache: 'no-store', + }), + + getDetail: (clubId: string) => + apiServer.get(`/clubs/${clubId}/attendances/detail`, { + cache: 'no-store', + }), +}; diff --git a/src/lib/apis/attendance.ts b/src/lib/apis/attendance.ts index 8dddff38..26029d4a 100644 --- a/src/lib/apis/attendance.ts +++ b/src/lib/apis/attendance.ts @@ -1,7 +1,13 @@ import { apiClient } from '@/lib/apis/client'; -import type { AttendanceResponse } from '@/types/attendance'; +import type { AttendanceResponse, QRCodeResponse } from '@/types/attendance'; export const attendanceApi = { getAttendance: (clubId: string) => apiClient.get(`/clubs/${clubId}/attendances`), + + checkIn: (clubId: string, sessionId: number, code: number) => + apiClient.post(`/clubs/${clubId}/attendances/sessions/${sessionId}/check-in`, { code }), + + generateQR: (clubId: string, sessionId: number) => + apiClient.post(`/admin/clubs/${clubId}/attendances/${sessionId}/qr`), }; diff --git a/src/lib/apis/home.server.ts b/src/lib/apis/home.server.ts new file mode 100644 index 00000000..43045b2b --- /dev/null +++ b/src/lib/apis/home.server.ts @@ -0,0 +1,9 @@ +import { apiServer } from '@/lib/apis/server'; +import type { HomeDashboardResponse } from '@/types/home'; + +export const homeServerApi = { + getDashboard: (clubId: string) => + apiServer.get(`/clubs/${clubId}/dashboard/home`, { + next: { revalidate: 300, tags: [`dashboard-${clubId}`] }, + }), +}; diff --git a/src/providers/user-hydrator.tsx b/src/providers/user-hydrator.tsx new file mode 100644 index 00000000..497dcf68 --- /dev/null +++ b/src/providers/user-hydrator.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useRef } from 'react'; + +import { useClubStore } from '@/stores/useClubStore'; +import { useUserStore } from '@/stores/useUserStore'; +import type { ClubIdentifier } from '@/types/club'; +import type { UserInfo } from '@/types/user'; + +interface UserHydratorProps { + userInfo: UserInfo; + clubInfo: ClubIdentifier; + children: React.ReactNode; +} + +function UserHydrator({ userInfo, clubInfo, children }: UserHydratorProps) { + const hydrated = useRef(false); + + if (!hydrated.current) { + useUserStore.setState(userInfo, false, 'setUser'); + useClubStore.setState(clubInfo, false, 'setClub'); + hydrated.current = true; + } + + return children; +} + +export { UserHydrator, type UserHydratorProps }; diff --git a/src/proxy.ts b/src/proxy.ts index 171b972f..7018ce0c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -63,7 +63,8 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(new URL(`/club/${clubId}`, request.url)); } const loginUrl = new URL('/login', request.url); - loginUrl.searchParams.set('redirect', pathname); + const redirect = request.nextUrl.search ? `${pathname}${request.nextUrl.search}` : pathname; + loginUrl.searchParams.set('redirect', redirect); return NextResponse.redirect(loginUrl); } diff --git a/src/stores/useUserStore.ts b/src/stores/useUserStore.ts index 8f392d18..175b2f00 100644 --- a/src/stores/useUserStore.ts +++ b/src/stores/useUserStore.ts @@ -2,11 +2,13 @@ import { create } from 'zustand'; import { combine, devtools } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; +import type { UserInfo } from '@/types/user'; + const initialState = { id: null as number | null, name: null as string | null, profileImageUrl: null as string | null, - role: null as 'LEAD' | 'USER' | null, + role: null as 'LEAD' | 'ADMIN' | 'USER' | null, }; export type UserState = typeof initialState; @@ -14,12 +16,7 @@ export type UserState = typeof initialState; export const useUserStore = create( devtools( combine(initialState, (set) => ({ - setUser: (user: { - id: number; - name: string; - profileImageUrl: string | null; - role: 'LEAD' | 'USER'; - }) => set(user, false, 'setUser'), + setUser: (user: UserInfo) => set(user, false, 'setUser'), reset: () => set(initialState, false, 'reset'), })), { name: 'UserStore' }, diff --git a/src/types/attendance.ts b/src/types/attendance.ts index 883e6c45..a6423b6b 100644 --- a/src/types/attendance.ts +++ b/src/types/attendance.ts @@ -3,6 +3,7 @@ import type { ApiResponse } from '@/types/common'; type AttendanceStatus = 'ATTEND' | 'ABSENT' | 'PENDING'; interface AttendanceData { + sessionId: number | null; attendanceRate: number; title: string | null; status: AttendanceStatus | null; @@ -32,6 +33,14 @@ interface AttendanceSummary { type AttendanceSummaryResponse = ApiResponse; +interface QRCodeData { + sessionId: number; + code: number; + expiredAt: string; +} + +type QRCodeResponse = ApiResponse; + export type { AttendanceStatus, AttendanceData, @@ -39,4 +48,6 @@ export type { AttendanceRecord, AttendanceSummary, AttendanceSummaryResponse, + QRCodeData, + QRCodeResponse, }; diff --git a/src/types/club.ts b/src/types/club.ts index 8e1c3aad..79ae52ea 100644 --- a/src/types/club.ts +++ b/src/types/club.ts @@ -10,3 +10,9 @@ export interface Club { profileImageUrl: string; backgroundImageUrl: string; } + +/** store hydration 등에 사용되는 최소 클럽 식별 정보 */ +export interface ClubIdentifier { + clubId: string; + clubName: string; +} diff --git a/src/types/home.ts b/src/types/home.ts index 3dff0a5e..c7cc3647 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -1,24 +1,17 @@ import type { ApiResponse } from '@/types/common'; -import type { FileItem } from '@/types/file'; - -type Role = 'LEAD' | 'USER'; -type NullableImage = string | null; - -interface Identifiable { - id: T; -} -interface Named { - name: string; -} - -interface WithProfileImage { - profileImageUrl: NullableImage; -} +import type { + Identifiable, + Named, + NullableImage, + Role, + UserInfo, + UserSummary, + WithProfileImage, + WithRole, +} from '@/types/user'; -interface WithRole { - role: Role; -} +import type { FileItem } from '@/types/file'; interface ClubInfo { id: string; @@ -31,10 +24,6 @@ interface ClubInfo { backgroundImageUrl: NullableImage; } -type UserSummary = Identifiable & Named & WithProfileImage & WithRole; - -type UserInfo = UserSummary; - interface MyInfo { userInfo: UserInfo; bio: string | null; diff --git a/src/types/index.ts b/src/types/index.ts index 8f053b35..16d9f700 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,17 @@ export type { RecentPost, PageData, } from './home'; -export type { Club } from './club'; + +export type { Club, ClubIdentifier } from './club'; +export type { + Role, + NullableImage, + Identifiable, + Named, + WithProfileImage, + WithRole, + UserSummary, + UserInfo, +} from './user'; export type { FileStatus, FileItem, DisplayFile, CreatePostFile } from './file'; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..9ca64848 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,22 @@ +export type Role = 'LEAD' | 'ADMIN' | 'USER'; +export type NullableImage = string | null; + +export interface Identifiable { + id: T; +} + +export interface Named { + name: string; +} + +export interface WithProfileImage { + profileImageUrl: NullableImage; +} + +export interface WithRole { + role: Role; +} + +export type UserSummary = Identifiable & Named & WithProfileImage & WithRole; + +export type UserInfo = UserSummary;