리팩토링 3단계: Symbol.jsx 분리 (717 → 346 줄)
- features/symbol/utils.js: formatKoreanDate, computeCompletion, TYPE_ORDER - features/symbol/pc/user/CharacterCard.jsx: 캐릭터 카드 (memo) - features/symbol/pc/user/SymbolCard.jsx: 심볼 카드 (memo, 계산 로직 포함) - Symbol.jsx: 검색/탭/그리드/요약 렌더링만 담당 - basicQueries/symbolQueries 배열을 useMemo로 감쌈 (매 렌더 재생성 방지) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f6f1e79b82
commit
1fe3ba0d12
4 changed files with 362 additions and 378 deletions
|
|
@ -1,363 +1,14 @@
|
|||
import { memo, useState, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import { api } from '../../../api/client'
|
||||
import { useLayout } from '../../../components/pc/Layout'
|
||||
import Select from '../../../components/common/Select'
|
||||
import Tooltip from '../../../components/common/Tooltip'
|
||||
import CharacterSuggestDropdown from '../../../components/common/CharacterSuggestDropdown'
|
||||
import { useSymbolStore } from '../store'
|
||||
import { formatMesoKorean } from '../../../utils/formatting'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
const KST = 'Asia/Seoul'
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
function formatKoreanDate(d) {
|
||||
const dj = dayjs(d).tz(KST)
|
||||
return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 심볼 완료까지 남은 일수/예상 완료일 계산
|
||||
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정
|
||||
* - extra(추가 심볼)는 즉시 적용
|
||||
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
|
||||
*/
|
||||
function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
|
||||
const need = Math.max(remainingSymbols - extra, 0)
|
||||
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
|
||||
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
|
||||
|
||||
let acc = 0
|
||||
let cursor = dayjs().tz(KST).startOf('day')
|
||||
for (let day = 0; day < 3650; day++) {
|
||||
// 오늘은 dailyDone이면 일퀘 없음, 그 외엔 daily
|
||||
if (!(day === 0 && dailyDone)) acc += daily
|
||||
// 목요일(day=4)에 주간퀘 전량 지급
|
||||
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
|
||||
if (acc >= need) return { days: day, date: cursor.toDate() }
|
||||
cursor = cursor.add(1, 'day')
|
||||
}
|
||||
return { days: null, date: null }
|
||||
}
|
||||
|
||||
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
|
||||
|
||||
const CharacterCard = memo(function CharacterCard({ char, active, onSelect, onRemove }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (e.target.closest('button')) return
|
||||
onSelect()
|
||||
}}
|
||||
className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none"
|
||||
style={{
|
||||
borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||
background: active ? 'var(--selected-bg)' : 'var(--surface-3)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove() }}
|
||||
style={{ position: 'absolute', top: 6, right: 6, zIndex: 10, color: 'var(--text-dim)' }}
|
||||
className="w-6 h-6 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] flex items-center justify-center text-base leading-none"
|
||||
aria-label="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="pt-3 px-3 pb-3 flex flex-col items-center text-center">
|
||||
<div className="w-24 h-24 overflow-hidden flex items-center justify-center">
|
||||
{char.character_image ? (
|
||||
<img
|
||||
src={char.character_image}
|
||||
alt=""
|
||||
className="w-full h-full object-contain scale-[3] origin-center pointer-events-none"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 text-base font-semibold truncate w-full"
|
||||
style={{ color: active ? 'var(--accent-bright)' : 'var(--text-emphasis)' }}
|
||||
>
|
||||
{char.character_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs tabular-nums mt-0.5 truncate w-full"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
Lv.{char.character_level} · {char.job_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SymbolCard = memo(function SymbolCard({ symbol, equipped, charId }) {
|
||||
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
|
||||
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
|
||||
|
||||
const dailyDone = progress?.dailyDone ?? false
|
||||
const weeklyCount = progress?.weeklyCount ?? 3
|
||||
const daily = progress?.daily ?? symbol.daily_default
|
||||
const extra = progress?.extra ?? 0
|
||||
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
|
||||
|
||||
const level = progress?.level ?? 0
|
||||
const growth = progress?.growth ?? 0
|
||||
const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0
|
||||
const isMax = equipped && level >= symbol.max_level
|
||||
|
||||
// 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감)
|
||||
// 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합
|
||||
// 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소
|
||||
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
|
||||
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
|
||||
let sym = 0, meso = 0, arr = 0
|
||||
// 체납: 현재 성장치로 올릴 수 있는 레벨까지 누적
|
||||
let arrLv = level, arrG = growth
|
||||
while (arrLv < symbol.max_level) {
|
||||
const req = symbol.levels.find((l) => l.level === arrLv)?.required_count
|
||||
const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost
|
||||
if (req == null || cost == null || arrG < req) break
|
||||
arr += cost
|
||||
arrG -= req
|
||||
arrLv += 1
|
||||
}
|
||||
let g = growth
|
||||
for (const l of symbol.levels) {
|
||||
if (l.level < level) continue
|
||||
sym += Math.max(l.required_count - g, 0)
|
||||
g = Math.max(g - l.required_count, 0)
|
||||
meso += l.meso_cost
|
||||
}
|
||||
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
|
||||
}, [equipped, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
|
||||
const reachableLevel = useMemo(() => {
|
||||
if (!equipped || isMax) return level
|
||||
let lv = level
|
||||
let g = growth
|
||||
while (lv < symbol.max_level) {
|
||||
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
|
||||
if (!req || g < req) break
|
||||
g -= req
|
||||
lv += 1
|
||||
}
|
||||
return lv
|
||||
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
// 성장치로 만렙까지 도달 가능하지만 레벨업은 안 한 상태
|
||||
const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level
|
||||
const interactable = equipped && !isMax && !effectivelyMax
|
||||
|
||||
// 남은 일수/예상 완료일
|
||||
const { days: daysLeft, date: completeDate } = useMemo(() => {
|
||||
if (!equipped || isMax) return { days: null, date: null }
|
||||
return computeCompletion({
|
||||
remainingSymbols,
|
||||
daily,
|
||||
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
|
||||
extra,
|
||||
dailyDone,
|
||||
})
|
||||
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
|
||||
|
||||
const inputClass = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50"
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
opacity: equipped ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-lg overflow-hidden shrink-0 flex items-center justify-center"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
{symbol.image_url && (
|
||||
<img
|
||||
src={symbol.image_url}
|
||||
alt={symbol.region}
|
||||
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold truncate">{symbol.region}</div>
|
||||
<div
|
||||
className="text-sm tabular-nums mt-0.5"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
Lv.<span className="font-bold text-base" style={{ color: 'var(--accent-bright)' }}>{level}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}> / {symbol.max_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
{equipped && !isMax && !effectivelyMax && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dailyDone: !dailyDone })}
|
||||
title="오늘 일퀘 완료 여부"
|
||||
className="shrink-0 rounded-md h-8 px-3 text-xs font-semibold border disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={dailyDone ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
background: 'var(--danger-bg-hover)',
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 진행도 바 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
||||
{isMax ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>MAX</span>
|
||||
</span>
|
||||
) : effectivelyMax ? (
|
||||
<Tooltip text={`Lv.${symbol.max_level}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>(MAX)</span> / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : reachableLevel > level ? (
|
||||
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
)}
|
||||
{!isMax && !effectivelyMax && (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="h-2 rounded-full overflow-hidden"
|
||||
style={{ background: 'var(--progress-track)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: isMax || effectivelyMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%`,
|
||||
background: isMax || effectivelyMax ? 'var(--progress-amber)' : 'var(--progress-emerald)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 획득량 입력 */}
|
||||
<div
|
||||
className="grid gap-2 mb-4"
|
||||
style={{ gridTemplateColumns: symbol.weekly_default > 0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>일퀘 획득</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(daily) : '0'}
|
||||
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={inputClass}
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{symbol.weekly_default > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>주간퀘 획득</label>
|
||||
<Select
|
||||
value={weeklyCount}
|
||||
onChange={(v) => patch({ weeklyCount: v })}
|
||||
options={[0, 1, 2, 3].map((n) => ({
|
||||
value: n,
|
||||
label: `${n * symbol.weekly_default}개`,
|
||||
}))}
|
||||
disabled={!interactable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>추가 심볼</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(extra) : '0'}
|
||||
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={inputClass}
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="text-base">
|
||||
{[
|
||||
{ label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null },
|
||||
{ label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null },
|
||||
{ label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true },
|
||||
].map((row, i) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex justify-between py-2 border-t first:border-t-0"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{row.label}</span>
|
||||
{row.tooltip ? (
|
||||
<Tooltip text={row.tooltip}>
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
import { formatKoreanDate, computeCompletion, TYPE_ORDER } from '../utils'
|
||||
import CharacterCard from './user/CharacterCard'
|
||||
import SymbolCard from './user/SymbolCard'
|
||||
|
||||
export default function Symbol() {
|
||||
const { setFullscreen } = useLayout()
|
||||
|
|
@ -395,17 +46,17 @@ export default function Symbol() {
|
|||
|
||||
const tab = storedTab || tabs[0]?.key || null
|
||||
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
|
||||
|
||||
|
||||
// 각 캐릭터 기본정보(코디 이미지) 새로고침
|
||||
const basicQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queries: useMemo(() => characters.map((c) => ({
|
||||
queryKey: ['character', 'basic', c.character_name],
|
||||
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
|
||||
enabled: !!c.character_name,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
})),
|
||||
})), [characters]),
|
||||
})
|
||||
useEffect(() => {
|
||||
characters.forEach((c, idx) => {
|
||||
|
|
@ -425,19 +76,18 @@ export default function Symbol() {
|
|||
|
||||
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
|
||||
const symbolQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queries: useMemo(() => characters.map((c) => ({
|
||||
queryKey: ['character', 'symbols', c.id],
|
||||
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
|
||||
enabled: !!c.id,
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 0,
|
||||
})),
|
||||
})), [characters]),
|
||||
})
|
||||
|
||||
// symbolQueries 결과를 store로 반영
|
||||
useEffect(() => {
|
||||
if (!allSymbols.length || !characters.length) return
|
||||
// (type, region) → symbol id 매핑
|
||||
const lookup = {}
|
||||
for (const s of allSymbols) lookup[`${s.type}|${s.region}`] = s
|
||||
characters.forEach((c, idx) => {
|
||||
|
|
@ -497,7 +147,6 @@ export default function Symbol() {
|
|||
const p = progress?.[s.id]
|
||||
if (!p?.equipped) continue
|
||||
if (p.level >= s.max_level) continue
|
||||
// 체납 성장치로 만렙 도달 가능한지 확인
|
||||
let lv = p.level, g = p.growth || 0
|
||||
while (lv < s.max_level) {
|
||||
const r = s.levels?.find((l) => l.level === lv)?.required_count
|
||||
|
|
@ -506,13 +155,12 @@ export default function Symbol() {
|
|||
}
|
||||
const effMax = lv >= s.max_level
|
||||
|
||||
// 체납 누적 (성장치 cascade)
|
||||
let arrLv = p.level, arrG = p.growth || 0
|
||||
while (arrLv < s.max_level) {
|
||||
const lv = s.levels?.find((x) => x.level === arrLv)
|
||||
if (!lv || arrG < lv.required_count) break
|
||||
arr += lv.meso_cost
|
||||
arrG -= lv.required_count
|
||||
const lv2 = s.levels?.find((x) => x.level === arrLv)
|
||||
if (!lv2 || arrG < lv2.required_count) break
|
||||
arr += lv2.meso_cost
|
||||
arrG -= lv2.required_count
|
||||
arrLv += 1
|
||||
}
|
||||
let remaining = 0
|
||||
|
|
@ -523,7 +171,7 @@ export default function Symbol() {
|
|||
gg = Math.max(gg - l.required_count, 0)
|
||||
req += l.meso_cost
|
||||
}
|
||||
if (effMax) continue // 완료 예상일 계산에서 제외
|
||||
if (effMax) continue
|
||||
const { date } = computeCompletion({
|
||||
remainingSymbols: remaining,
|
||||
daily: p.daily ?? s.daily_default ?? 0,
|
||||
|
|
@ -669,10 +317,7 @@ export default function Symbol() {
|
|||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>
|
||||
{tabInfo?.label} 전체 만렙 완료 예상일
|
||||
</div>
|
||||
<div
|
||||
className="text-3xl font-bold tabular-nums mt-1.5"
|
||||
style={{ color: 'var(--accent-bright)' }}
|
||||
>
|
||||
<div className="text-3xl font-bold tabular-nums mt-1.5" style={{ color: 'var(--accent-bright)' }}>
|
||||
{overallDate ? formatKoreanDate(overallDate) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -680,10 +325,7 @@ export default function Symbol() {
|
|||
<div className="text-right pr-10">
|
||||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>누적 체납 메소</div>
|
||||
<Tooltip text={formatMesoKorean(totalArrearMeso)}>
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums mt-1 inline-block"
|
||||
style={{ color: 'var(--danger-text)' }}
|
||||
>
|
||||
<div className="text-2xl font-bold tabular-nums mt-1 inline-block" style={{ color: 'var(--danger-text)' }}>
|
||||
{totalArrearMeso.toLocaleString()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
@ -692,10 +334,7 @@ export default function Symbol() {
|
|||
<div className="text-right pl-10">
|
||||
<div className="text-base" style={{ color: 'var(--text-muted)' }}>남은 필요 메소</div>
|
||||
<Tooltip text={formatMesoKorean(totalRequiredMeso)}>
|
||||
<div
|
||||
className="text-2xl font-bold tabular-nums mt-1 inline-block"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
>
|
||||
<div className="text-2xl font-bold tabular-nums mt-1 inline-block" style={{ color: 'var(--warning-text-bright)' }}>
|
||||
{totalRequiredMeso.toLocaleString()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
57
frontend/src/features/symbol/pc/user/CharacterCard.jsx
Normal file
57
frontend/src/features/symbol/pc/user/CharacterCard.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { memo } from 'react'
|
||||
|
||||
function CharacterCard({ char, active, onSelect, onRemove }) {
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (e.target.closest('button')) return
|
||||
onSelect()
|
||||
}}
|
||||
className="group relative shrink-0 w-36 rounded-xl border cursor-pointer select-none"
|
||||
style={{
|
||||
borderColor: active ? 'var(--selected-border)' : 'var(--panel-border)',
|
||||
background: active ? 'var(--selected-bg)' : 'var(--surface-3)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove() }}
|
||||
style={{ position: 'absolute', top: 6, right: 6, zIndex: 10, color: 'var(--text-dim)' }}
|
||||
className="w-6 h-6 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] flex items-center justify-center text-base leading-none"
|
||||
aria-label="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="pt-3 px-3 pb-3 flex flex-col items-center text-center">
|
||||
<div className="w-24 h-24 overflow-hidden flex items-center justify-center">
|
||||
{char.character_image ? (
|
||||
<img
|
||||
src={char.character_image}
|
||||
alt=""
|
||||
className="w-full h-full object-contain scale-[3] origin-center pointer-events-none"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 text-base font-semibold truncate w-full"
|
||||
style={{ color: active ? 'var(--accent-bright)' : 'var(--text-emphasis)' }}
|
||||
>
|
||||
{char.character_name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs tabular-nums mt-0.5 truncate w-full"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
Lv.{char.character_level} · {char.job_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CharacterCard)
|
||||
250
frontend/src/features/symbol/pc/user/SymbolCard.jsx
Normal file
250
frontend/src/features/symbol/pc/user/SymbolCard.jsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { memo, useMemo } from 'react'
|
||||
import Select from '../../../../components/common/Select'
|
||||
import Tooltip from '../../../../components/common/Tooltip'
|
||||
import { useSymbolStore } from '../../store'
|
||||
import { formatMesoKorean } from '../../../../utils/formatting'
|
||||
import { formatKoreanDate, computeCompletion } from '../../utils'
|
||||
|
||||
const INPUT_CLASS = "w-full h-10 rounded-md border px-3 text-base text-right tabular-nums outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] disabled:opacity-50"
|
||||
const INPUT_STYLE = {
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}
|
||||
|
||||
function SymbolCard({ symbol, equipped, charId }) {
|
||||
const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
|
||||
const updateSymbol = useSymbolStore((s) => s.updateSymbol)
|
||||
|
||||
const dailyDone = progress?.dailyDone ?? false
|
||||
const weeklyCount = progress?.weeklyCount ?? 3
|
||||
const daily = progress?.daily ?? symbol.daily_default
|
||||
const extra = progress?.extra ?? 0
|
||||
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
|
||||
|
||||
const level = progress?.level ?? 0
|
||||
const growth = progress?.growth ?? 0
|
||||
const requireGrowth = symbol.levels?.find((l) => l.level === level)?.required_count || 0
|
||||
const isMax = equipped && level >= symbol.max_level
|
||||
|
||||
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
|
||||
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
|
||||
let sym = 0, meso = 0, arr = 0
|
||||
let arrLv = level, arrG = growth
|
||||
while (arrLv < symbol.max_level) {
|
||||
const req = symbol.levels.find((l) => l.level === arrLv)?.required_count
|
||||
const cost = symbol.levels.find((l) => l.level === arrLv)?.meso_cost
|
||||
if (req == null || cost == null || arrG < req) break
|
||||
arr += cost
|
||||
arrG -= req
|
||||
arrLv += 1
|
||||
}
|
||||
let g = growth
|
||||
for (const l of symbol.levels) {
|
||||
if (l.level < level) continue
|
||||
sym += Math.max(l.required_count - g, 0)
|
||||
g = Math.max(g - l.required_count, 0)
|
||||
meso += l.meso_cost
|
||||
}
|
||||
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
|
||||
}, [equipped, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
const reachableLevel = useMemo(() => {
|
||||
if (!equipped || isMax) return level
|
||||
let lv = level
|
||||
let g = growth
|
||||
while (lv < symbol.max_level) {
|
||||
const req = symbol.levels?.find((l) => l.level === lv)?.required_count
|
||||
if (!req || g < req) break
|
||||
g -= req
|
||||
lv += 1
|
||||
}
|
||||
return lv
|
||||
}, [equipped, isMax, level, growth, symbol.levels, symbol.max_level])
|
||||
|
||||
const effectivelyMax = equipped && !isMax && reachableLevel >= symbol.max_level
|
||||
const interactable = equipped && !isMax && !effectivelyMax
|
||||
|
||||
const { days: daysLeft, date: completeDate } = useMemo(() => {
|
||||
if (!equipped || isMax) return { days: null, date: null }
|
||||
return computeCompletion({
|
||||
remainingSymbols,
|
||||
daily,
|
||||
weeklyPerWeek: (weeklyCount || 0) * (symbol.weekly_default || 0),
|
||||
extra,
|
||||
dailyDone,
|
||||
})
|
||||
}, [equipped, isMax, remainingSymbols, daily, weeklyCount, symbol.weekly_default, extra, dailyDone])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
opacity: equipped ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="w-14 h-14 rounded-lg overflow-hidden shrink-0 flex items-center justify-center"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
{symbol.image_url && (
|
||||
<img
|
||||
src={symbol.image_url}
|
||||
alt={symbol.region}
|
||||
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold truncate">{symbol.region}</div>
|
||||
<div className="text-sm tabular-nums mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
Lv.<span className="font-bold text-base" style={{ color: 'var(--accent-bright)' }}>{level}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}> / {symbol.max_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
{equipped && !isMax && !effectivelyMax && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dailyDone: !dailyDone })}
|
||||
title="오늘 일퀘 완료 여부"
|
||||
className="shrink-0 rounded-md h-8 px-3 text-xs font-semibold border disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={dailyDone ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
background: 'var(--danger-bg-hover)',
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 진행도 바 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
||||
{isMax ? (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>MAX</span>
|
||||
</span>
|
||||
) : effectivelyMax ? (
|
||||
<Tooltip text={`Lv.${symbol.max_level}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} <span className="font-bold" style={{ color: 'var(--warning-text-bright)' }}>(MAX)</span> / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : reachableLevel > level ? (
|
||||
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
성장치 {growth} / {requireGrowth}
|
||||
</span>
|
||||
)}
|
||||
{!isMax && !effectivelyMax && (
|
||||
<span style={{ color: 'var(--text-muted)' }}>
|
||||
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 rounded-full overflow-hidden" style={{ background: 'var(--progress-track)' }}>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: isMax || effectivelyMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%`,
|
||||
background: isMax || effectivelyMax ? 'var(--progress-amber)' : 'var(--progress-emerald)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 획득량 입력 */}
|
||||
<div
|
||||
className="grid gap-2 mb-4"
|
||||
style={{ gridTemplateColumns: symbol.weekly_default > 0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>일퀘 획득</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(daily) : '0'}
|
||||
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={INPUT_CLASS}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
{symbol.weekly_default > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>주간퀘 획득</label>
|
||||
<Select
|
||||
value={weeklyCount}
|
||||
onChange={(v) => patch({ weeklyCount: v })}
|
||||
options={[0, 1, 2, 3].map((n) => ({
|
||||
value: n,
|
||||
label: `${n * symbol.weekly_default}개`,
|
||||
}))}
|
||||
disabled={!interactable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>추가 심볼</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={equipped ? String(extra) : '0'}
|
||||
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
||||
disabled={!interactable}
|
||||
className={INPUT_CLASS}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="text-base">
|
||||
{[
|
||||
{ label: '남은 심볼', value: equipped && !isMax && !effectivelyMax ? `${remainingSymbols.toLocaleString()}개` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '필요 메소', value: equipped && !isMax ? remainingMeso.toLocaleString() : '-', color: 'var(--warning-text-bright)', tooltip: equipped && !isMax ? formatMesoKorean(remainingMeso) : null },
|
||||
{ label: '체납 메소', value: equipped && !isMax ? arrearMeso.toLocaleString() : '-', color: 'var(--danger-text)', tooltip: equipped && !isMax ? formatMesoKorean(arrearMeso) : null },
|
||||
{ label: '남은 일수', value: equipped && !isMax && !effectivelyMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-', color: 'var(--text-emphasis)' },
|
||||
{ label: '예상 완료일', value: equipped && !isMax && !effectivelyMax && completeDate ? formatKoreanDate(completeDate) : '-', color: equipped && !isMax && !effectivelyMax && completeDate ? 'var(--accent-bright)' : 'var(--text-dim)', strong: true },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex justify-between py-2 border-t first:border-t-0"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{row.label}</span>
|
||||
{row.tooltip ? (
|
||||
<Tooltip text={row.tooltip}>
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={`tabular-nums ${row.strong ? 'font-semibold' : 'font-medium'}`} style={{ color: row.color }}>
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SymbolCard)
|
||||
38
frontend/src/features/symbol/utils.js
Normal file
38
frontend/src/features/symbol/utils.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export const KST = 'Asia/Seoul'
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
export function formatKoreanDate(d) {
|
||||
const dj = dayjs(d).tz(KST)
|
||||
return `${dj.year()}년 ${String(dj.month() + 1).padStart(2, '0')}월 ${String(dj.date()).padStart(2, '0')}일 (${DOW[dj.day()]})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 심볼 완료까지 남은 일수/예상 완료일 계산
|
||||
* - 일퀘는 매일, 주간퀘는 매주 목요일 리셋 시 N회분을 한 번에 지급한다고 가정
|
||||
* - extra(추가 심볼)는 즉시 적용
|
||||
* - dailyDone이면 오늘 일퀘는 이미 받은 걸로 간주 (내일부터 다시 지급)
|
||||
*/
|
||||
export function computeCompletion({ remainingSymbols, daily, weeklyPerWeek, extra, dailyDone }) {
|
||||
const need = Math.max(remainingSymbols - extra, 0)
|
||||
if (need === 0) return { days: 0, date: dayjs().tz(KST).startOf('day').toDate() }
|
||||
if (daily <= 0 && weeklyPerWeek <= 0) return { days: null, date: null }
|
||||
|
||||
let acc = 0
|
||||
let cursor = dayjs().tz(KST).startOf('day')
|
||||
for (let day = 0; day < 3650; day++) {
|
||||
if (!(day === 0 && dailyDone)) acc += daily
|
||||
if (cursor.day() === 4 && weeklyPerWeek > 0) acc += weeklyPerWeek
|
||||
if (acc >= need) return { days: day, date: cursor.toDate() }
|
||||
cursor = cursor.add(1, 'day')
|
||||
}
|
||||
return { days: null, date: null }
|
||||
}
|
||||
|
||||
export const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
|
||||
Loading…
Add table
Reference in a new issue