diff --git a/getcloser/frontend/package-lock.json b/getcloser/frontend/package-lock.json index e328b8e..de11454 100644 --- a/getcloser/frontend/package-lock.json +++ b/getcloser/frontend/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", + "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "js-cookie": "^3.0.5", @@ -2296,6 +2297,16 @@ "dev": true, "license": "MIT" }, + "node_modules/boring-avatars": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/boring-avatars/-/boring-avatars-2.0.4.tgz", + "integrity": "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/getcloser/frontend/package.json b/getcloser/frontend/package.json index 6eafefe..0ea7119 100644 --- a/getcloser/frontend/package.json +++ b/getcloser/frontend/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", + "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "js-cookie": "^3.0.5", diff --git a/getcloser/frontend/src/app/globals.css b/getcloser/frontend/src/app/globals.css index 6cd2925..05ebf1e 100644 --- a/getcloser/frontend/src/app/globals.css +++ b/getcloser/frontend/src/app/globals.css @@ -51,3 +51,7 @@ html { --font-sans: var(--font-dongle); --font-mono: var(--font-geist-mono); } + +.material-symbols-outlined.filled { + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} diff --git a/getcloser/frontend/src/app/layout.tsx b/getcloser/frontend/src/app/layout.tsx index 24fea63..e402442 100644 --- a/getcloser/frontend/src/app/layout.tsx +++ b/getcloser/frontend/src/app/layout.tsx @@ -39,6 +39,9 @@ export default function RootLayout({ return ( + + + diff --git a/getcloser/frontend/src/app/page2/page.tsx b/getcloser/frontend/src/app/page2/page.tsx index 74697e0..55b24c6 100644 --- a/getcloser/frontend/src/app/page2/page.tsx +++ b/getcloser/frontend/src/app/page2/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import Avatar from 'boring-avatars'; import Cookies from 'js-cookie'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import Link from 'next/link'; @@ -11,50 +12,84 @@ import Modal from '@/components/Modal'; import { authenticatedFetch } from '../../lib/api'; import { useFormStore } from '../../store/formStore'; -export default function Page2() { - const { id, setTeamId, setMemberIds } = useFormStore(); - const router = useRouter(); - const [inputs, setInputs] = useState>(() => { - const initialInputs = Array(5).fill({ id: '', displayName: '' }); - return initialInputs; - }); - const [showModal, setShowModal] = useState(false); - const timeoutRef = useRef(null); // Add timeoutRef - - const fetchUserById = useCallback(async (index: number, userId: string) => { - if (!userId) return; // Don't fetch if ID is empty +type View = 'loading' | 'create' | 'waiting'; +type TeamMember = { + user_id: number; + is_ready: boolean; + displayName: string; +}; +type InputState = { id: string; displayName: string }; - // Prevent adding self as a team member - if (index !== 0 && id && Number(userId) === Number(id)) { // Check only for other team members, not self - alert(`자기 자신(${userId})을 팀원으로 추가할 수 없습니다.`); - const newInputs = [...inputs]; - newInputs[index] = { id: '', displayName: '' }; // Clear the input field - setInputs(newInputs); - return; - } +const WaitingView = ({ teamMembers, myId, teamId }: { teamMembers: TeamMember[], myId: number, teamId: number }) => { + const router = useRouter(); + const handleLeaveTeam = async () => { try { - const response = await authenticatedFetch(`/api/v1/users/${userId}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const userData = await response.json(); - // Assuming the API returns an object with a 'data' field which is the display name - const newInputs = [...inputs]; - newInputs[index] = { id: userId, displayName: userData.data || userId }; // Store both id and display name - setInputs(newInputs); - } catch (error: unknown) { - let errorMessage = '알 수 없는 오류가 발생했습니다.'; - if (error instanceof Error) { - errorMessage = error.message; - } - console.error(`Error fetching user ${userId}:`, errorMessage); - const newInputs = [...inputs]; - newInputs[index] = { id: userId, displayName: userId }; // Fallback to just ID if fetch fails - setInputs(newInputs); - // Optionally, display an error message to the user + await authenticatedFetch(`/api/v1/teams/${teamId}/cancel`, { + method: 'POST', + }); + } catch (error) { + console.error('Error canceling team:', error); + alert('팀을 나가는데 실패했습니다.'); } - }, [id, inputs]); + router.back(); + }; + + const me = teamMembers.find(m => m.user_id === myId); + + return ( +
+
+
+
+
+
+
+
+
+

팀원 기다리는 중...

+

모든 팀원이 준비되면 퀴즈가 시작됩니다.

+
+
+ {teamMembers.map(member => ( +
+
+ +

+ {member.displayName} {member.user_id === myId ? '(나)' : ''} +

+
+
+ {member.is_ready ? ( + check_circle + ) : ( +
+ )} +
+
+ ))} +
+
+ +
+
+
+ ); +}; + +const CreateTeamView = ({ + inputs, + handleInputChange, + handleCreateTeam, + fetchUserById +}: { + inputs: InputState[], + handleInputChange: (index: number, value: string) => void, + handleCreateTeam: () => void, + fetchUserById: (index: number, userId: string) => void +}) => { + const [showModal, setShowModal] = useState(false); + const timeoutRef = useRef(null); useEffect(() => { const hasSeenModal = Cookies.get('doNotShowModalPage2'); @@ -63,133 +98,203 @@ export default function Page2() { } }, []); - // Effect to pre-fill the first input if ID is available - useEffect(() => { - if (id && inputs[0].id === '') { // Only fetch if ID exists and input is empty - fetchUserById(0, id); - } - }, [id, fetchUserById, inputs]); // Rerun when id changes - - const handleConfirm = () => { - setShowModal(false); - }; - + const handleConfirm = () => setShowModal(false); const handleDoNotShowAgain = () => { - Cookies.set('doNotShowModalPage2', 'true', { expires: 365 }); // Cookie expires in 365 days + Cookies.set('doNotShowModalPage2', 'true', { expires: 365 }); setShowModal(false); }; - const handleInputChange = (index: number, value: string) => { + return ( +
+ 코드는 위에 개인별 다른 코드가 있습니다.
2. 5명이 함께 문제 풀기에 도전하세요!
(팁! 문제는 팀원들과 관련된 문제가 나옵니다.)
3. 성공 시 부스 방문해주세요.
성공 선물을 드립니다.')} + onConfirm={handleConfirm} + onDoNotShowAgain={handleDoNotShowAgain} + isOpen={showModal} + /> +
+ {[...Array(5)].map((_, index) => ( +
+ + handleInputChange(index, e.target.value)} + onBlur={() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + fetchUserById(index, inputs[index].id); + }} + disabled={index === 0} + /> +
+ ))} + +
+ +
+ ); +}; + +export default function Page2() { + const { id: myId, teamId, setTeamId, setMemberIds } = useFormStore(); + const router = useRouter(); + + const [view, setView] = useState('loading'); + const [teamMembers, setTeamMembers] = useState([]); + const [inputs, setInputs] = useState(() => Array(5).fill({ id: '', displayName: '' })); + + const hasCheckedTeamStatus = useRef(false); + const timeoutRef = useRef(null); + + const fetchUserDisplayName = useCallback(async (userId: number | string): Promise => { + try { + const response = await authenticatedFetch(`/api/v1/users/${userId}`); + if (!response.ok) return `User ${userId}`; + const data = await response.json(); + return data.data || `User ${userId}`; + } catch (error) { + console.error(error); + return `User ${userId}`; + } + }, []); + + const fetchUserById = useCallback(async (index: number, userId: string) => { + if (!userId) return; + if (index !== 0 && myId && Number(userId) === Number(myId)) { + alert(`자기 자신(${userId})을 팀원으로 추가할 수 없습니다.`); + const newInputs = [...inputs]; + newInputs[index] = { id: '', displayName: '' }; + setInputs(newInputs); + return; + } + const displayName = await fetchUserDisplayName(userId); const newInputs = [...inputs]; - newInputs[index] = { id: value, displayName: value }; // Temporarily set display name to ID + newInputs[index] = { id: userId, displayName: displayName }; setInputs(newInputs); + }, [myId, inputs, fetchUserDisplayName]); + + useEffect(() => { + if (myId && inputs[0].id === '') { + fetchUserById(0, String(myId)); + } + }, [myId, fetchUserById, inputs]); - // Clear any existing timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); + useEffect(() => { + if (!myId || hasCheckedTeamStatus.current) return; + + const checkUserTeam = async () => { + hasCheckedTeamStatus.current = true; + try { + const response = await authenticatedFetch('/api/v1/teams/create'); + if (response.ok) { + const teamData = await response.json(); + if (teamData && teamData.status === 'PENDING') { + setTeamId(teamData.team_id); + setView('waiting'); + return; + } + } + setView('create'); + } catch (error) { + console.error('Error checking user\'s team status:', error); + setView('create'); + } + }; + checkUserTeam(); + }, [myId, setTeamId]); + + useEffect(() => { + if (view !== 'waiting' || !teamId) return; + + const interval = setInterval(async () => { + try { + const response = await authenticatedFetch(`/api/v1/teams/${teamId}/status`); + if (!response.ok) throw new Error('Failed to fetch team status'); + const memberStatuses: { user_id: number, is_ready: boolean }[] = await response.json(); + const membersWithNames = await Promise.all(memberStatuses.map(async (member) => { + const existingMember = teamMembers.find(m => m.user_id === member.user_id); + const displayName = existingMember?.displayName ?? await fetchUserDisplayName(member.user_id); + return { ...member, displayName }; + })); + setTeamMembers(membersWithNames); + } catch (error) { + console.error('Error polling team status:', error); + } + }, 2000); + + return () => clearInterval(interval); + }, [view, teamId, fetchUserDisplayName, teamMembers]); + + useEffect(() => { + if (view === 'waiting' && teamMembers.length > 0 && teamMembers.every(m => m.is_ready)) { + setMemberIds(teamMembers.map(m => m.user_id)); + router.push('/page3'); } + }, [view, teamMembers, router, setMemberIds]); - // Set a new timeout to fetch user data after 3 seconds + const handleInputChange = (index: number, value: string) => { + const newInputs = [...inputs]; + newInputs[index] = { id: value, displayName: value }; + setInputs(newInputs); + if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { fetchUserById(index, value); - }, 3000); + }, 1500); }; - const handleSolveProblem = async () => { - // Step 1: Check if all inputs are filled - const allInputsFilled = inputs.every(input => input.id !== ''); - if (!allInputsFilled) { + const handleCreateTeam = async () => { + if (inputs.some(input => input.id === '')) { alert('모든 팀원 ID를 채워주세요.'); return; } + const memberIds = inputs.slice(1).map(input => Number(input.id)); + const requestBody = { my_id: myId, member_ids: memberIds }; - // Step 2: Construct the JSON body - const myId = id; // id from useFormStore() - const memberIds = inputs.slice(1).map(input => input.id); // Exclude the first input (my_id) - - const requestBody = { - my_id: myId, - member_ids: memberIds, - }; - - console.log('Request Body:', requestBody); - - // Step 3: Make the POST request try { const response = await authenticatedFetch('/api/v1/teams/create', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorData = await response.json(); - throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.detail || response.statusText}`); + throw new Error(errorData.detail || 'Team creation failed'); } - const responseData = await response.json(); - console.log('Team creation successful:', responseData); - setTeamId(responseData.team_id); // Save team_id to store - setMemberIds(responseData.members_ids); // Save members_ids to store - router.push('/page3'); // Navigate to page3 on success - } catch (error: unknown) { + setTeamId(responseData.team_id); + setView('waiting'); + } catch (error) { console.error('Error creating team:', error); - let errorMessage = '알 수 없는 오류가 발생했습니다.'; - if (error instanceof Error) { - errorMessage = error.message; - } - alert(`팀 생성에 실패했습니다: ${errorMessage}`); + alert(`팀 생성에 실패했습니다: ${error}`); } }; - return ( -
- ' + - ' 코드는 위에 개인별 다른 코드가 있습니다.
' + - '2. 5명이 함께 문제 풀기에 도전하세요!
' + - ' (팁! 문제는 팀원들과 관련된 문제가 나옵니다.)
' + - '3. 성공 시 부스 방문해주세요.
' + - ' 성공 선물을 드립니다.' - )} - onConfirm={handleConfirm} - onDoNotShowAgain={handleDoNotShowAgain} - isOpen={showModal} - /> - -
- {[...Array(5)].map((_, index) => ( -
- - {index === 0 && } - handleInputChange(index, e.target.value)} - onBlur={() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); // Clear any pending debounce - } - fetchUserById(index, inputs[index].id); // Fetch immediately on blur using the stored ID - }} - disabled={index === 0} - /> -
- ))} - + if (view === 'loading') { + return ( +
+
+ ); + } - -
+ if (view === 'waiting') { + return ; + } + + return ( + ); }