diff --git a/src/components/character/CharacterPage.tsx b/src/components/character/CharacterPage.tsx deleted file mode 100644 index 66e8d89..0000000 --- a/src/components/character/CharacterPage.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useState } from 'react' -import { AbilitryContainer } from './AbilitryContainer' -import { CharacterInfoContainer } from './CharacterInfoContainer' -import { HyperStatContainer } from './HyperStatContainer' -import { InventoryContainer } from './inventory/InventoryContainer' -import { StatContainer } from './StatContainer' -import { useCharacterData } from '../../hooks/character/useCharacterData' -import { useInventory } from '../../hooks/character/useInventory' - -import { useUserStore } from '../../store/userStore' -import { searchCharacterOcid } from '../../apis/character/characterController' - -import { FiAlertTriangle, FiSearch } from 'react-icons/fi' -import Button from '../common/Button' - -interface CharacterPageProps { - type: 'character' | 'search' -} - -export const CharacterPage = ({ type }: CharacterPageProps) => { - const { characterStats, ability, hyperStat, basic, isLoading, error } = - useCharacterData() - const [characterName, setCharacterName] = useState('') - - const { inventory } = useInventory() - - const [showStats, setShowStats] = useState(true) - - const { setCharacterOcid } = useUserStore() - const [searchLoading, setSearchLoading] = useState(false) - - const searchCharacterHandler = async () => { - if (characterName.trim() === '') { - alert('캐릭터 이름을 입력해주세요.') - return - } - setSearchLoading(true) - - try { - const { ocid } = await searchCharacterOcid(characterName.trim()) - if (!ocid) { - alert('캐릭터를 찾을 수 없습니다.') - return - } - setCharacterOcid(ocid) - } catch { - alert('캐릭터 검색에 실패했습니다.') - } finally { - setSearchLoading(false) - } - } - - if (error) { - return ( -
-
-
-
- {type === 'character' && ( -
-
- {/* */} -
-
-
-
-

- 본캐릭터는 넥슨 OpenAPI에서 레벨이 가장 높은 - 캐릭터를 기준으로 자동 설정됩니다. -

-

- - 정보가 정확하지 않다면 - - 동기화 - - 버튼을 눌러주세요 - -

-
-
-
-
-
- )} -
- -
-
-

- 캐릭터 정보를 불러올 수 없습니다 -

-

- 2023년 12월 21일 이후의 데이터만 조회할 수 있습니다. -

-
-
-
-
-
- -
-

- 캐릭터 검색 -

-
-
- setCharacterName(e.target.value)} - className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm bg-white" - /> - -
-
-
-
-
- ) - } - - return ( -
- {/* 탭 버튼 */} -
- {type === 'character' ? ( -
-
- {/* */} -
-
-
-
-

- 본캐릭터는 넥슨 OpenAPI에서 레벨이 가장 높은 캐릭터를 - 기준으로 자동 설정됩니다. -

-

- - 정보가 정확하지 않다면 - - 동기화 - 버튼을 눌러주세요 -

-
-
-
-
-
- ) : ( -
- )} -
- - -
- -
- setCharacterName?.(e.target.value)} - /> - -
-
- - {/* 콘텐츠 영역 */} - {isLoading ? ( -
- {showStats ? ( -
- {/* 캐릭터 정보와 어빌리티 스켈레톤 */} -
-
-
-
- - {/* 기본 스탯 스켈레톤 */} -
-
-
- - {/* 하이퍼 스탯 스켈레톤 */} -
-
-
-
- ) : ( -
- {/* 캐릭터 이미지 스켈레톤 */} -
- {/* 장비 정보 스켈레톤 */} -
- {Array.from({ length: 15 }).map((_, index) => ( -
- ))} -
-
- )} -
- ) : ( -
- {showStats ? ( -
- {/* 캐릭터 정보와 어빌리티 (큰 화면에서 세로로 배치) */} -
- {/* 캐릭터 정보 (작은 화면에서 맨 위) */} -
{basic && }
- - {/* 어빌리티 (캐릭터 정보 아래에 배치) */} -
{ability && }
-
- - {/* 기본 스탯 (큰 화면에서 중앙에 위치) */} -
- {characterStats && } -
- - {/* 하이퍼 스탯 (큰 화면에서 오른쪽에 위치) */} -
- {hyperStat && } -
-
- ) : ( -
- {inventory && basic?.character_image && ( - - )} -
- )} -
- )} -
- ) -} diff --git a/src/components/character/containers/CharacterDesktopHeader.tsx b/src/components/character/containers/CharacterDesktopHeader.tsx new file mode 100644 index 0000000..80031c4 --- /dev/null +++ b/src/components/character/containers/CharacterDesktopHeader.tsx @@ -0,0 +1,93 @@ +interface CharacterDesktopHeaderProps { + type: 'character' | 'search' + characterName: string + setCharacterName: (name: string) => void + searchLoading: boolean + onSearch: () => void + showStats: boolean + setShowStats: (show: boolean) => void +} + +export const CharacterDesktopHeader = ({ + type, + characterName, + setCharacterName, + searchLoading, + onSearch, + showStats, + setShowStats +}: CharacterDesktopHeaderProps) => { + return ( +
+ {type === 'character' ? ( +
+
+
+
+
+
+

+ 본캐릭터는 넥슨 OpenAPI에서 레벨이 가장 높은 캐릭터를 + 기준으로 자동 설정됩니다. +

+

+ + 정보가 정확하지 않다면 + + 동기화 + 버튼을 눌러주세요 +

+
+
+
+
+
+ ) : ( +
+ )} + + {/* 탭 버튼 */} +
+ + +
+ + {/* 검색 입력 */} +
+ setCharacterName(e.target.value)} + /> + +
+
+ ) +} diff --git a/src/components/character/CharacterInfoContainer.tsx b/src/components/character/containers/CharacterInfoContainer.tsx similarity index 93% rename from src/components/character/CharacterInfoContainer.tsx rename to src/components/character/containers/CharacterInfoContainer.tsx index 340e284..2fd6cfb 100644 --- a/src/components/character/CharacterInfoContainer.tsx +++ b/src/components/character/containers/CharacterInfoContainer.tsx @@ -1,5 +1,5 @@ -import { CharacterBasic } from '../../types/character' -import { formatDate } from '../../utils/format' +import { CharacterBasic } from '../../../types/character' +import { formatDate } from '../../../utils/format' interface Props { basic: CharacterBasic diff --git a/src/components/character/containers/CharacterMobileHeader.tsx b/src/components/character/containers/CharacterMobileHeader.tsx new file mode 100644 index 0000000..c527a5e --- /dev/null +++ b/src/components/character/containers/CharacterMobileHeader.tsx @@ -0,0 +1,70 @@ +interface CharacterMobileHeaderProps { + type: 'character' | 'search' + characterName: string + setCharacterName: (name: string) => void + searchLoading: boolean + onSearch: () => void + showStats: boolean + setShowStats: (show: boolean) => void +} + +export const CharacterMobileHeader = ({ + type, + characterName, + setCharacterName, + searchLoading, + onSearch, + showStats, + setShowStats +}: CharacterMobileHeaderProps) => { + return ( +
+
+ {/* 캐릭터 검색 */} +
+ setCharacterName(e.target.value)} + /> + +
+ + {/* 탭 네비게이션 */} +
+ + +
+ + {/* 도움말 (캐릭터 타입일 때만) */} + {type === 'character' && ( +
+

+ 💡 본캐는 최고레벨 캐릭터로 자동설정됩니다. 정확하지 않다면 동기화 + 버튼을 눌러주세요. +

+
+ )} +
+
+ ) +} diff --git a/src/components/character/inventory/ItemInventory.tsx b/src/components/character/inventory/ItemInventory.tsx index 9a279db..9071ffa 100644 --- a/src/components/character/inventory/ItemInventory.tsx +++ b/src/components/character/inventory/ItemInventory.tsx @@ -40,10 +40,10 @@ const ItemInventory = ({ inventory, characterImg, onSelected }: Props) => { const extraSlots = ['포켓 아이템', '뱃지'] return ( -
-
+
+
{/* 왼쪽 5행 2열 */} -
+
{leftSlots.map(slot => ( {
{/* 가운데 캐릭터 + 아래 슬롯 3개 */} -
-
+
+
-
+
{bottomSlots.map(slot => ( {
{/* 오른쪽 5행 2열 */} -
+
{rightSlots.map(slot => ( {
{/* 아래 포켓, 벳지 */} -
+
{extraSlots.map(slot => ( { return (
{ + return ( +
+ {showStats ? ( + <> + {/* 데스크톱 레이아웃 (lg 이상) */} +
+
+
{basic && }
+
{ability && }
+
+
+ {characterStats && } +
+
+ {hyperStat && } +
+
+ + {/* 모바일 레이아웃 (lg 미만) */} +
+ {basic && ( +
+ +
+ )} + {ability && ( +
+ +
+ )} + {characterStats && ( +
+ +
+ )} + {hyperStat && ( +
+ +
+ )} +
+ + ) : ( +
+ {inventory && basic?.character_image && ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/character/pages/CharacterErrorState.tsx b/src/components/character/pages/CharacterErrorState.tsx new file mode 100644 index 0000000..d440f4e --- /dev/null +++ b/src/components/character/pages/CharacterErrorState.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { FiAlertTriangle, FiSearch, FiInfo } from 'react-icons/fi' +import Button from '../../common/Button' +import { searchCharacterOcid } from '../../../apis/character/characterController' +import { useUserStore } from '../../../store/userStore' + +interface CharacterErrorStateProps { + type: 'character' | 'search' +} + +export const CharacterErrorState = ({ type }: CharacterErrorStateProps) => { + const [characterName, setCharacterName] = useState('') + const [searchLoading, setSearchLoading] = useState(false) + const { setCharacterOcid } = useUserStore() + + const searchCharacterHandler = async () => { + if (characterName.trim() === '') { + alert('캐릭터 이름을 입력해주세요.') + return + } + setSearchLoading(true) + + try { + const { ocid } = await searchCharacterOcid(characterName.trim()) + if (!ocid) { + alert('캐릭터를 찾을 수 없습니다.') + return + } + setCharacterOcid(ocid) + } catch { + alert('캐릭터 검색에 실패했습니다.') + } finally { + setSearchLoading(false) + } + } + + return ( +
+
+
+
+ {type === 'character' && ( +
+
+
+
+
+
+

+ 본캐릭터는 넥슨 OpenAPI에서 레벨이 가장 높은 캐릭터를 + 기준으로 자동 설정됩니다. +

+

+ + 정보가 정확하지 않다면 + + 동기화 + + 버튼을 눌러주세요 + +

+
+
+
+
+
+ )} +
+ +
+
+ +

+ 캐릭터 정보를 불러올 수 없습니다 +

+

+ 2023년 12월 21일 이후의 데이터만 조회할 수 있습니다. +

+ + {/* 모바일용 도움말 */} + {type === 'character' && ( +
+
+ +
+

+ 본캐릭터는 넥슨 OpenAPI에서 레벨이 가장 높은 캐릭터를 + 기준으로 자동 설정됩니다. +

+

+ 정보가 정확하지 않다면{' '} + 동기화 버튼을 + 눌러주세요. +

+
+
+
+ )} +
+ + {/* 검색 폼 */} +
+
+
+
+ +
+

+ 캐릭터 검색 +

+
+
+ setCharacterName(e.target.value)} + className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm bg-white" + /> + +
+
+
+
+
+ ) +} diff --git a/src/components/character/pages/CharacterLoadingSkeleton.tsx b/src/components/character/pages/CharacterLoadingSkeleton.tsx new file mode 100644 index 0000000..907b84c --- /dev/null +++ b/src/components/character/pages/CharacterLoadingSkeleton.tsx @@ -0,0 +1,63 @@ +interface CharacterLoadingSkeletonProps { + showStats: boolean +} + +export const CharacterLoadingSkeleton = ({ + showStats +}: CharacterLoadingSkeletonProps) => { + return ( +
+ {showStats ? ( + <> + {/* 데스크톱 스켈레톤 (lg 이상) */} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* 모바일 스켈레톤 (lg 미만) */} +
+
+
+
+
+
+ + ) : ( + <> + {/* 데스크톱 장비 스켈레톤 */} +
+
+
+ {Array.from({ length: 15 }).map((_, index) => ( +
+ ))} +
+
+ + {/* 모바일 장비 스켈레톤 */} +
+
+
+ {Array.from({ length: 15 }).map((_, index) => ( +
+ ))} +
+
+ + )} +
+ ) +} diff --git a/src/components/character/pages/CharacterPage.tsx b/src/components/character/pages/CharacterPage.tsx new file mode 100644 index 0000000..3129eee --- /dev/null +++ b/src/components/character/pages/CharacterPage.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { useCharacterData } from '../../../hooks/character/useCharacterData' +import { useInventory } from '../../../hooks/character/useInventory' +import { useUserStore } from '../../../store/userStore' +import { searchCharacterOcid } from '../../../apis/character/characterController' + +import { CharacterErrorState } from '../pages/CharacterErrorState' + +import { CharacterLoadingSkeleton } from '../pages/CharacterLoadingSkeleton' +import { CharacterContent } from '../pages/CharacterContent' +import { CharacterDesktopHeader } from '../containers/CharacterDesktopHeader' +import { CharacterMobileHeader } from '../containers/CharacterMobileHeader' + +interface CharacterPageProps { + type: 'character' | 'search' +} + +export const CharacterPage = ({ type }: CharacterPageProps) => { + const { characterStats, ability, hyperStat, basic, isLoading, error } = + useCharacterData() + const { inventory } = useInventory() + const [characterName, setCharacterName] = useState('') + const [showStats, setShowStats] = useState(true) + const [searchLoading, setSearchLoading] = useState(false) + const { setCharacterOcid } = useUserStore() + + const searchCharacterHandler = async () => { + if (characterName.trim() === '') { + alert('캐릭터 이름을 입력해주세요.') + return + } + setSearchLoading(true) + + try { + const { ocid } = await searchCharacterOcid(characterName.trim()) + if (!ocid) { + alert('캐릭터를 찾을 수 없습니다.') + return + } + setCharacterOcid(ocid) + } catch { + alert('캐릭터 검색에 실패했습니다.') + } finally { + setSearchLoading(false) + } + } + + // 에러 상태 + if (error) { + return + } + + return ( +
+ {/* 모바일 헤더 */} + + + {/* 데스크톱 헤더 */} + + + {/* 컨텐츠 */} + {isLoading ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/character/AbilitryContainer.tsx b/src/components/character/stats/AbilitryContainer.tsx similarity index 96% rename from src/components/character/AbilitryContainer.tsx rename to src/components/character/stats/AbilitryContainer.tsx index 98c911d..db18c1c 100644 --- a/src/components/character/AbilitryContainer.tsx +++ b/src/components/character/stats/AbilitryContainer.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { CharacterAbility } from '../../types/character' +import { CharacterAbility } from '../../../types/character' interface Props { ability: CharacterAbility diff --git a/src/components/character/DoubleStatRow.tsx b/src/components/character/stats/DoubleStatRow.tsx similarity index 100% rename from src/components/character/DoubleStatRow.tsx rename to src/components/character/stats/DoubleStatRow.tsx diff --git a/src/components/character/HyperStatContainer.tsx b/src/components/character/stats/HyperStatContainer.tsx similarity index 96% rename from src/components/character/HyperStatContainer.tsx rename to src/components/character/stats/HyperStatContainer.tsx index 11a8d6d..5e7bd65 100644 --- a/src/components/character/HyperStatContainer.tsx +++ b/src/components/character/stats/HyperStatContainer.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { HyperStat } from '../../types/character' + import { HyperStatTable } from './HyperStatTable' +import { HyperStat } from '../../../types/character' interface Props { hyperStat: HyperStat diff --git a/src/components/character/HyperStatTable.tsx b/src/components/character/stats/HyperStatTable.tsx similarity index 93% rename from src/components/character/HyperStatTable.tsx rename to src/components/character/stats/HyperStatTable.tsx index 00408b3..0d1e8f6 100644 --- a/src/components/character/HyperStatTable.tsx +++ b/src/components/character/stats/HyperStatTable.tsx @@ -1,4 +1,4 @@ -import { HyperStatInfo } from '../../types/character' +import { HyperStatInfo } from '../../../types/character' interface Props { hyperStat: HyperStatInfo diff --git a/src/components/character/StatContainer.tsx b/src/components/character/stats/StatContainer.tsx similarity index 97% rename from src/components/character/StatContainer.tsx rename to src/components/character/stats/StatContainer.tsx index 0d26f61..3f777a5 100644 --- a/src/components/character/StatContainer.tsx +++ b/src/components/character/stats/StatContainer.tsx @@ -1,6 +1,6 @@ -import { CharacterStats } from '../../types/character' -import { formatKoreanNumber } from '../../utils/format' -import { getStatValue } from '../../utils/getStatValue' +import { CharacterStats } from '../../../types/character' +import { formatKoreanNumber } from '../../../utils/format' +import { getStatValue } from '../../../utils/getStatValue' import StatTable from './StatTable' interface Props { diff --git a/src/components/character/StatTable.tsx b/src/components/character/stats/StatTable.tsx similarity index 100% rename from src/components/character/StatTable.tsx rename to src/components/character/stats/StatTable.tsx diff --git a/src/pages/Character.tsx b/src/pages/Character.tsx index 1ec2480..7ede4c2 100644 --- a/src/pages/Character.tsx +++ b/src/pages/Character.tsx @@ -1,4 +1,4 @@ -import { CharacterPage } from '../components/character/CharacterPage' +import { CharacterPage } from '../components/character/pages/CharacterPage' const Character = () => { return diff --git a/src/pages/SearchCharacter.tsx b/src/pages/SearchCharacter.tsx index b0a49b9..2118fe9 100644 --- a/src/pages/SearchCharacter.tsx +++ b/src/pages/SearchCharacter.tsx @@ -1,4 +1,4 @@ -import { CharacterPage } from '../components/character/CharacterPage' +import { CharacterPage } from '../components/character/pages/CharacterPage' export const SearchCharacter = () => { return diff --git a/src/pages/SearchGuild.tsx b/src/pages/SearchGuild.tsx index 027dd9e..5a07d31 100644 --- a/src/pages/SearchGuild.tsx +++ b/src/pages/SearchGuild.tsx @@ -1,7 +1,6 @@ -import { FiUsers } from 'react-icons/fi' +import { FiUsers, FiArrowLeft } from 'react-icons/fi' import Button from '../components/common/Button' import { useGuildSearch } from '../hooks/search/useGuildSearch' - import { servers } from '../data/worlds' import { MemberContainer } from '../components/guild/MemberContainer' import { Empty } from '../components/common/Empty' @@ -40,6 +39,7 @@ export const SearchGuild = () => { type: '', member: null }) + const handleSearchCharacter = (value: string) => { setSearchCharacter(value) } @@ -52,168 +52,227 @@ export const SearchGuild = () => { } return ( -
- {guildsInfo?.length === 0 ? ( -
-
-
-
- +
+ {guildsInfo === undefined ? ( +
+
+
+ {/* 헤더 */} +
+
+ +
+
+

+ 길드 검색 +

+

+ 최대 4개까지 검색 가능 +

+
-

- 길드 검색 (최대 4개 검색 가능) -

-
-
-
- - setGuildName(e.target.value)} - onKeyPress={handleGuildKeyPress} - className="flex-1 px-4 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white" - /> + +
+ {/* 서버 선택 */} +
+ + +
+ + {/* 길드 이름 입력 */} +
+ +
+ setGuildName(e.target.value)} + onKeyPress={handleGuildKeyPress} + className="flex-1 px-4 py-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white" + /> + +
+
+ + {/* 추가된 길드 목록 */} + {guildList.length > 0 && ( +
+ +
+ {guildList.map(guild => ( +
+ + {guild} + + +
+ ))} +
+
+ )} + + {/* 검색 버튼 */}
- -
-

검색할 길드 목록

-
- {guildList.map(guild => ( -
- {guild} - -
- ))} -
-
- -
) : ( -
-
-
-

길드 관리

-

길드원 정보 관리

+
+ {/* 모바일 헤더 */} +
+
+
+
+ +
+

+ 길드 관리 +

+

+ 길드원 정보 관리 +

+
+
+ +
-
-
-
-
- ({ - worldName: v.worldName, - guildName: v.guildName - })) as Guild[]) || [] - } - mainCharacterInfoSearchHandler={searchMemberInfo} - isUpdating={isUpdatingMembers} - /> -
-
-
-
- {(isLoading || isUpdatingMembers) && ( -
-
-

- 캐릭터 정보를 불러오는 중... -

-
- )} - {guildsInfo && - guildsInfo.length > 0 && - !isLoading && - !isUpdatingMembers && ( - ({ - guildName: v.guildName, - guildMasterName: v.guildMasterName, - memberDetailResponse: v.guildMember - }))} - masterName={selectedGuildMember?.guildMasterName} - guildName={selectedGuildMember?.guildName} - onSelect={handleMemberSelect} - searchCharacter={searchCharacter} - setSearchCharacter={handleSearchCharacter} + {/* 컨텐츠 */} +
+
+
+ {/* 액션 버튼 영역 */} +
+
+ ({ + worldName: v.worldName, + guildName: v.guildName + })) as Guild[]) || [] + } + mainCharacterInfoSearchHandler={searchMemberInfo} + isUpdating={isUpdatingMembers} /> - )} - {guildsInfo && guildsInfo.length === 0 && ( - - )} +
+
+ + {/* 멤버 컨테이너 */} +
+
+ {/* 로딩 상태 */} + {(isLoading || isUpdatingMembers) && ( +
+
+

+ 캐릭터 정보를 불러오는 중... +

+
+ )} + + {/* 멤버 정보 */} + {guildsInfo && + guildsInfo.length > 0 && + !isLoading && + !isUpdatingMembers && ( + ({ + guildName: v.guildName, + guildMasterName: v.guildMasterName, + memberDetailResponse: v.guildMember + }))} + masterName={selectedGuildMember?.guildMasterName} + guildName={selectedGuildMember?.guildName} + onSelect={handleMemberSelect} + searchCharacter={searchCharacter} + setSearchCharacter={handleSearchCharacter} + /> + )} + + {/* 빈 상태 */} + {guildsInfo && guildsInfo.length === 0 && ( + + )} +
+
)} + + {/* 모달 */} {activeModal === 'detailMember' && selectedMember && selectedMember.member &&