maplestory/frontend/src/features/symbol/Symbol.jsx
caadiq 64411b6a38 미장착 심볼 카드에서 금일 일퀘 완료 버튼 숨김
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:13:54 +09:00

574 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, 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/Layout'
import Select from '../../components/Select'
import Tooltip from '../../components/Tooltip'
import { useSymbolStore } from './store'
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 }
}
function formatMesoKorean(n) {
const v = Number(n) || 0
if (v <= 0) return '0'
const eok = Math.floor(v / 100_000_000)
const man = Math.floor((v % 100_000_000) / 10_000)
const parts = []
if (eok) parts.push(`${eok.toLocaleString()}`)
if (man) parts.push(`${man.toLocaleString()}`)
return parts.length ? parts.join(' ') : v.toLocaleString()
}
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
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 transition ${
active
? 'border-emerald-500/40 bg-emerald-500/[0.08]'
: 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
}`}
>
{/* 삭제 (우상단) */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove() }}
style={{ position: 'absolute', top: 6, right: 6, zIndex: 10 }}
className="w-6 h-6 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition 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-gray-600 text-3xl">?</span>
)}
</div>
<div className={`mt-2 text-base font-semibold truncate w-full ${active ? 'text-emerald-200' : 'text-gray-200'}`}>
{char.character_name}
</div>
<div className="text-xs text-gray-500 tabular-nums mt-0.5 truncate w-full">
Lv.{char.character_level} · {char.job_name}
</div>
</div>
</div>
)
}
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 interactable = equipped && !isMax
// 남은 심볼: 현재 레벨→만렙 까지 필요한 심볼 총합 (현재 성장치 차감)
// 필요 메소: 현재 레벨→만렙 까지 필요한 메소 총합
// 체납 메소: 이미 성장치가 현재 레벨 요구치 이상이면 바로 올릴 수 있는 레벨의 메소
const { remainingSymbols, remainingMeso, arrearMeso } = useMemo(() => {
if (!equipped || !symbol.levels?.length) return { remainingSymbols: 0, remainingMeso: 0, arrearMeso: 0 }
let sym = 0, meso = 0, arr = 0
for (const l of symbol.levels) {
if (l.level < level) continue
if (l.level === level) {
sym += Math.max(l.required_count - growth, 0)
meso += l.meso_cost
if (growth >= l.required_count) arr += l.meso_cost
} else {
sym += l.required_count
meso += l.meso_cost
}
}
return { remainingSymbols: sym, remainingMeso: meso, arrearMeso: arr }
}, [equipped, level, growth, symbol.levels])
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
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 { 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 transition ${
equipped
? 'border-white/10 bg-gray-900/60 hover:border-white/20'
: 'border-white/5 bg-gray-950/40 opacity-60'
}`}>
<div className="flex items-center gap-3 mb-4">
<div className="w-14 h-14 rounded-lg bg-gray-950 overflow-hidden shrink-0 flex items-center justify-center">
{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 text-gray-100 truncate">{symbol.region}</div>
<div className="text-sm text-gray-400 tabular-nums mt-0.5">
Lv.<span className="text-emerald-300 font-bold text-base">{level}</span>
<span className="text-gray-600"> / {symbol.max_level}</span>
</div>
</div>
{equipped && !isMax && (
<button
type="button"
onClick={() => patch({ dailyDone: !dailyDone })}
title="오늘 일퀘 완료 여부"
className={`shrink-0 rounded-md h-8 px-3 text-xs font-semibold border transition disabled:opacity-40 disabled:cursor-not-allowed ${
dailyDone
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
: 'bg-red-500/10 border-red-500/40 text-red-300 hover:bg-red-500/20'
}`}
>
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
</button>
)}
</div>
{/* 진행도 바 */}
<div className="mb-4">
<div className="flex justify-between text-sm tabular-nums mb-1.5">
{reachableLevel > level ? (
<Tooltip text={`Lv.${reachableLevel}까지 상승 가능`}>
<span className="text-gray-400">
성장치 {growth} / {requireGrowth}
</span>
</Tooltip>
) : (
<span className="text-gray-400">
성장치 {isMax ? (
<span className="text-amber-300 font-bold">MAX</span>
) : (
<>{growth} / {requireGrowth}</>
)}
</span>
)}
{!isMax && (
<span className="text-gray-400">
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
</span>
)}
</div>
<div className="h-2 rounded-full bg-gray-950 overflow-hidden">
<div
className={`h-full transition-all ${isMax ? 'bg-amber-400' : 'bg-emerald-500/80'}`}
style={{ width: isMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%` }}
/>
</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 text-gray-400">일퀘 획득</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="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
</div>
{symbol.weekly_default > 0 && (
<div className="space-y-1">
<label className="block text-xs text-gray-400">주간퀘 획득</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 text-gray-400">추가 심볼</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="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
</div>
</div>
{/* 정보 */}
<div className="divide-y divide-white/5 text-base">
<div className="flex justify-between py-2">
<span className="text-gray-400">남은 심볼</span>
<span className="tabular-nums text-gray-200 font-medium">
{equipped && !isMax ? `${remainingSymbols.toLocaleString()}` : '-'}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">필요 메소</span>
{equipped && !isMax ? (
<Tooltip text={formatMesoKorean(remainingMeso)}>
<span className="tabular-nums text-amber-300 font-medium">
{remainingMeso.toLocaleString()}
</span>
</Tooltip>
) : (
<span className="tabular-nums text-amber-300 font-medium">-</span>
)}
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">체납 메소</span>
{equipped && !isMax ? (
<Tooltip text={formatMesoKorean(arrearMeso)}>
<span className="tabular-nums text-red-400 font-medium">
{arrearMeso.toLocaleString()}
</span>
</Tooltip>
) : (
<span className="tabular-nums text-red-400 font-medium">-</span>
)}
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">남은 일수</span>
<span className="tabular-nums text-gray-200 font-medium">
{equipped && !isMax && daysLeft != null ? `${daysLeft.toLocaleString()}` : '-'}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">예상 완료일</span>
<span className={`tabular-nums font-semibold ${equipped && !isMax && completeDate ? 'text-emerald-300' : 'text-gray-600'}`}>
{equipped && !isMax && completeDate ? formatKoreanDate(completeDate) : '-'}
</span>
</div>
</div>
</div>
)
}
export default function Symbol() {
const { setFullscreen } = useLayout()
useEffect(() => {
setFullscreen(true)
return () => setFullscreen(false)
}, [setFullscreen])
// 심볼 목록 (DB에서 로드)
const { data: allSymbols = [] } = useQuery({
queryKey: ['symbol', 'symbols'],
queryFn: () => api('/api/symbols').catch(() => []),
staleTime: 5 * 60 * 1000,
})
const tabs = useMemo(() => {
const groups = {}
for (const s of allSymbols) {
if (!groups[s.type]) groups[s.type] = s
}
return TYPE_ORDER
.filter((t) => groups[t])
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
}, [allSymbols])
const characters = useSymbolStore((s) => s.characters)
const selectedCharId = useSymbolStore((s) => s.selectedCharId)
const addCharacter = useSymbolStore((s) => s.addCharacter)
const removeCharacter = useSymbolStore((s) => s.removeCharacter)
const selectCharacter = useSymbolStore((s) => s.selectCharacter)
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
const storedTab = useSymbolStore((s) => s.selectedTabs?.[selectedCharId])
const setTabStore = useSymbolStore((s) => s.setTab)
const tab = storedTab || tabs[0]?.key || null
const setTab = (t) => { if (selectedCharId) setTabStore(selectedCharId, t) }
// 각 캐릭터의 장착 심볼 fetch (새로고침마다 갱신)
const symbolQueries = useQueries({
queries: characters.map((c) => ({
queryKey: ['character', 'symbols', c.id],
queryFn: () => api(`/api/character/symbols?ocid=${c.id}`),
enabled: !!c.id,
refetchOnMount: 'always',
staleTime: 0,
})),
})
// 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) => {
const q = symbolQueries[idx]
if (!q?.data?.symbols) return
const equippedMap = {}
for (const es of q.data.symbols) {
const match = lookup[`${es.type}|${es.region}`]
if (!match) continue
equippedMap[match.id] = {
level: es.level,
growth: es.growth_count,
require_growth: es.require_growth_count,
}
}
syncCharacterSymbols(c.id, equippedMap)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSymbols, symbolQueries.map((q) => q.dataUpdatedAt).join(',')])
const [addName, setAddName] = useState('')
const [addError, setAddError] = useState('')
const symbols = allSymbols.filter((s) => s.type === tab)
const tabInfo = tabs.find((t) => t.key === tab)
const searchMutation = useMutation({
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
onSuccess: (data) => {
if (characters.find((c) => c.character_name === data.character_name)) {
setAddError('이미 추가된 캐릭터입니다')
return
}
setAddError('')
setAddName('')
addCharacter(data)
},
onError: (err) => setAddError(err.message || '조회 실패'),
})
const handleSearch = (e) => {
e.preventDefault()
const n = addName.trim()
if (!n) return
setAddError('')
searchMutation.mutate(n)
}
const progress = useSymbolStore((s) => s.progress[selectedCharId])
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
// 현재 탭의 누적 메소 + 최종 완료일 계산
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
let req = 0, arr = 0, latest = null
for (const s of symbols) {
const p = progress?.[s.id]
if (!p?.equipped) continue
if (p.level >= s.max_level) continue
let remaining = 0
for (const l of s.levels || []) {
if (l.level < p.level) continue
if (l.level === p.level) {
req += l.meso_cost
if ((p.growth || 0) >= l.required_count) arr += l.meso_cost
remaining += Math.max(l.required_count - (p.growth || 0), 0)
} else {
req += l.meso_cost
remaining += l.required_count
}
}
const { date } = computeCompletion({
remainingSymbols: remaining,
daily: p.daily ?? s.daily_default ?? 0,
weeklyPerWeek: (p.weeklyCount ?? 3) * (s.weekly_default || 0),
extra: p.extra || 0,
dailyDone: !!p.dailyDone,
})
if (date && (!latest || date > latest)) latest = date
}
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
}, [symbols, progress])
return (
<div className="space-y-6 pb-10 max-w-5xl mx-auto">
{/* 캐릭터 조회 */}
<div className="rounded-2xl border border-white/10 bg-gray-900/60 p-5 space-y-4">
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="8" cy="8" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M12 12L16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
type="text"
value={addName}
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
className="w-full h-12 box-border rounded-lg border border-white/10 bg-gray-950 pl-10 pr-4 text-base outline-none focus:border-emerald-500/60 hover:border-white/20 transition"
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending}
className="shrink-0 rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white px-6 h-12 text-base font-semibold shadow-lg shadow-emerald-500/20 transition"
>
{searchMutation.isPending ? '...' : '조회'}
</button>
</form>
{addError && <p className="text-sm text-red-400">{addError}</p>}
{/* 캐릭터 목록 */}
{characters.length > 0 && (
<div className="flex items-start gap-3 overflow-x-auto pt-1">
{characters.map((c) => (
<CharacterCard
key={c.id}
char={c}
active={c.id === selectedCharId}
onSelect={() => selectCharacter(c.id)}
onRemove={() => removeCharacter(c.id)}
/>
))}
</div>
)}
</div>
{/* 심볼 타입 탭 */}
<div className="flex gap-2">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`flex-1 flex items-center justify-center gap-2.5 rounded-2xl border px-4 py-3 transition ${
tab === t.key
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-lg shadow-emerald-500/10'
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
}`}
>
{t.image_url ? (
<img src={t.image_url} alt="" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-8 h-8 bg-gray-800 rounded" />
)}
<span className="text-base font-semibold">{t.label}</span>
</button>
))}
</div>
{/* 심볼 카드 그리드 */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{symbols.map((s) => (
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(s.id)} charId={selectedCharId} />
))}
</div>
{/* 전체 요약 */}
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-emerald-500/10 to-emerald-500/[0.02] p-6 flex items-center justify-between gap-6 flex-wrap">
<div>
<div className="text-base text-gray-400">{tabInfo?.label} 전체 만렙 완료 예상일</div>
<div className="text-3xl font-bold text-emerald-300 tabular-nums mt-1.5">
{overallDate ? formatKoreanDate(overallDate) : '-'}
</div>
</div>
<div className="flex items-center">
<div className="text-right pr-10">
<div className="text-base text-gray-400">누적 체납 메소</div>
<Tooltip text={formatMesoKorean(totalArrearMeso)}>
<div className="text-2xl font-bold text-red-400 tabular-nums mt-1 inline-block">
{totalArrearMeso.toLocaleString()}
</div>
</Tooltip>
</div>
<div className="w-px h-12 bg-white/10" />
<div className="text-right pl-10">
<div className="text-base text-gray-400">남은 필요 메소</div>
<Tooltip text={formatMesoKorean(totalRequiredMeso)}>
<div className="text-2xl font-bold text-amber-300 tabular-nums mt-1 inline-block">
{totalRequiredMeso.toLocaleString()}
</div>
</Tooltip>
</div>
</div>
</div>
</div>
)
}