2026-04-15 13:43:52 +09:00
|
|
|
|
import { useState, useEffect, useMemo } from 'react'
|
2026-04-15 14:27:01 +09:00
|
|
|
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
2026-04-15 15:06:45 +09:00
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import utc from 'dayjs/plugin/utc'
|
|
|
|
|
|
import timezone from 'dayjs/plugin/timezone'
|
2026-04-15 12:07:07 +09:00
|
|
|
|
import { api } from '../../api/client'
|
|
|
|
|
|
import { useLayout } from '../../components/Layout'
|
2026-04-15 13:43:52 +09:00
|
|
|
|
import Select from '../../components/Select'
|
2026-04-15 14:27:01 +09:00
|
|
|
|
import Tooltip from '../../components/Tooltip'
|
2026-04-15 14:06:01 +09:00
|
|
|
|
import { useSymbolStore } from './store'
|
2026-04-15 13:43:52 +09:00
|
|
|
|
|
2026-04-15 15:06:45 +09:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:27:01 +09:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 13:43:52 +09:00
|
|
|
|
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
|
2026-04-15 12:07:07 +09:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:06:01 +09:00
|
|
|
|
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
|
2026-04-15 14:27:01 +09:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-15 15:06:45 +09:00
|
|
|
|
// 현재 성장치로 도달 가능한 최대 레벨 (연속 체납 반영)
|
|
|
|
|
|
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])
|
2026-04-15 12:07:07 +09:00
|
|
|
|
|
|
|
|
|
|
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">
|
2026-04-15 13:43:52 +09:00
|
|
|
|
{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' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
2026-04-15 13:43:52 +09:00
|
|
|
|
<div className="text-base font-semibold text-gray-100 truncate">{symbol.region}</div>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
<div className="text-sm text-gray-400 tabular-nums mt-0.5">
|
|
|
|
|
|
Lv.<span className="text-emerald-300 font-bold text-base">{level}</span>
|
2026-04-15 13:43:52 +09:00
|
|
|
|
<span className="text-gray-600"> / {symbol.max_level}</span>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
{!isMax && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!equipped}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 진행도 바 */}
|
|
|
|
|
|
<div className="mb-4">
|
2026-04-15 14:27:01 +09:00
|
|
|
|
<div className="flex justify-between text-sm tabular-nums mb-1.5">
|
2026-04-15 15:06:45 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-04-15 14:27:01 +09:00
|
|
|
|
{!isMax && (
|
|
|
|
|
|
<span className="text-gray-400">
|
|
|
|
|
|
{requireGrowth ? Math.min(Math.floor((growth / requireGrowth) * 100), 100) : 0}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="h-2 rounded-full bg-gray-950 overflow-hidden">
|
|
|
|
|
|
<div
|
2026-04-15 14:27:01 +09:00
|
|
|
|
className={`h-full transition-all ${isMax ? 'bg-amber-400' : 'bg-emerald-500/80'}`}
|
|
|
|
|
|
style={{ width: isMax ? '100%' : `${Math.min((growth / requireGrowth) * 100, 100)}%` }}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 획득량 입력 */}
|
2026-04-15 13:59:44 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="grid gap-2 mb-4"
|
|
|
|
|
|
style={{ gridTemplateColumns: symbol.weekly_default > 0 ? '0.7fr 1.3fr 1fr' : '1fr 1fr' }}
|
|
|
|
|
|
>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<label className="block text-xs text-gray-400">일퀘 획득</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputMode="numeric"
|
2026-04-15 14:06:01 +09:00
|
|
|
|
value={equipped ? String(daily) : '0'}
|
|
|
|
|
|
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
2026-04-15 14:27:01 +09:00
|
|
|
|
disabled={!interactable}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
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>
|
2026-04-15 13:59:44 +09:00
|
|
|
|
{symbol.weekly_default > 0 && (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<label className="block text-xs text-gray-400">주간퀘 획득</label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={weeklyCount}
|
2026-04-15 14:06:01 +09:00
|
|
|
|
onChange={(v) => patch({ weeklyCount: v })}
|
2026-04-15 15:06:45 +09:00
|
|
|
|
options={[0, 1, 2, 3].map((n) => ({
|
2026-04-15 13:59:44 +09:00
|
|
|
|
value: n,
|
|
|
|
|
|
label: `${n * symbol.weekly_default}개`,
|
|
|
|
|
|
}))}
|
2026-04-15 14:27:01 +09:00
|
|
|
|
disabled={!interactable}
|
2026-04-15 13:59:44 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-15 13:43:52 +09:00
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<label className="block text-xs text-gray-400">추가 심볼</label>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputMode="numeric"
|
2026-04-15 14:06:01 +09:00
|
|
|
|
value={equipped ? String(extra) : '0'}
|
|
|
|
|
|
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
|
2026-04-15 14:27:01 +09:00
|
|
|
|
disabled={!interactable}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
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>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
<span className="tabular-nums text-gray-200 font-medium">
|
|
|
|
|
|
{equipped && !isMax ? `${remainingSymbols.toLocaleString()}개` : '-'}
|
|
|
|
|
|
</span>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-2">
|
|
|
|
|
|
<span className="text-gray-400">필요 메소</span>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-2">
|
|
|
|
|
|
<span className="text-gray-400">체납 메소</span>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-2">
|
|
|
|
|
|
<span className="text-gray-400">남은 일수</span>
|
2026-04-15 15:06:45 +09:00
|
|
|
|
<span className="tabular-nums text-gray-200 font-medium">
|
|
|
|
|
|
{equipped && !isMax && daysLeft != null ? `${daysLeft.toLocaleString()}일` : '-'}
|
|
|
|
|
|
</span>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-2">
|
|
|
|
|
|
<span className="text-gray-400">예상 완료일</span>
|
2026-04-15 15:06:45 +09:00
|
|
|
|
<span className={`tabular-nums font-semibold ${equipped && !isMax && completeDate ? 'text-emerald-300' : 'text-gray-600'}`}>
|
|
|
|
|
|
{equipped && !isMax && completeDate ? formatKoreanDate(completeDate) : '-'}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function Symbol() {
|
|
|
|
|
|
const { setFullscreen } = useLayout()
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setFullscreen(true)
|
|
|
|
|
|
return () => setFullscreen(false)
|
|
|
|
|
|
}, [setFullscreen])
|
|
|
|
|
|
|
2026-04-15 13:43:52 +09:00
|
|
|
|
// 심볼 목록 (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])
|
|
|
|
|
|
|
2026-04-15 14:06:01 +09:00
|
|
|
|
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)
|
2026-04-15 14:27:01 +09:00
|
|
|
|
const syncCharacterSymbols = useSymbolStore((s) => s.syncCharacterSymbols)
|
2026-04-15 15:06:45 +09:00
|
|
|
|
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) }
|
2026-04-15 14:27:01 +09:00
|
|
|
|
|
|
|
|
|
|
// 각 캐릭터의 장착 심볼 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(',')])
|
2026-04-15 14:06:01 +09:00
|
|
|
|
|
2026-04-15 12:07:07 +09:00
|
|
|
|
const [addName, setAddName] = useState('')
|
|
|
|
|
|
const [addError, setAddError] = useState('')
|
|
|
|
|
|
|
2026-04-15 13:43:52 +09:00
|
|
|
|
const symbols = allSymbols.filter((s) => s.type === tab)
|
|
|
|
|
|
const tabInfo = tabs.find((t) => t.key === tab)
|
2026-04-15 12:07:07 +09:00
|
|
|
|
|
|
|
|
|
|
const searchMutation = useMutation({
|
|
|
|
|
|
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
|
|
|
|
|
|
onSuccess: (data) => {
|
2026-04-15 14:06:01 +09:00
|
|
|
|
if (characters.find((c) => c.character_name === data.character_name)) {
|
|
|
|
|
|
setAddError('이미 추가된 캐릭터입니다')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setAddError('')
|
|
|
|
|
|
setAddName('')
|
|
|
|
|
|
addCharacter(data)
|
2026-04-15 12:07:07 +09:00
|
|
|
|
},
|
|
|
|
|
|
onError: (err) => setAddError(err.message || '조회 실패'),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearch = (e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
const n = addName.trim()
|
|
|
|
|
|
if (!n) return
|
|
|
|
|
|
setAddError('')
|
|
|
|
|
|
searchMutation.mutate(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 14:27:01 +09:00
|
|
|
|
const progress = useSymbolStore((s) => s.progress[selectedCharId])
|
|
|
|
|
|
const isEquipped = (symbolId) => !!progress?.[symbolId]?.equipped
|
|
|
|
|
|
|
2026-04-15 15:06:45 +09:00
|
|
|
|
// 현재 탭의 누적 메소 + 최종 완료일 계산
|
|
|
|
|
|
const { totalRequiredMeso, totalArrearMeso, overallDate } = useMemo(() => {
|
|
|
|
|
|
let req = 0, arr = 0, latest = null
|
2026-04-15 14:27:01 +09:00
|
|
|
|
for (const s of symbols) {
|
|
|
|
|
|
const p = progress?.[s.id]
|
|
|
|
|
|
if (!p?.equipped) continue
|
|
|
|
|
|
if (p.level >= s.max_level) continue
|
2026-04-15 15:06:45 +09:00
|
|
|
|
let remaining = 0
|
2026-04-15 14:27:01 +09:00
|
|
|
|
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
|
2026-04-15 15:06:45 +09:00
|
|
|
|
remaining += Math.max(l.required_count - (p.growth || 0), 0)
|
2026-04-15 14:27:01 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
req += l.meso_cost
|
2026-04-15 15:06:45 +09:00
|
|
|
|
remaining += l.required_count
|
2026-04-15 14:27:01 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 15:06:45 +09:00
|
|
|
|
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
|
2026-04-15 14:27:01 +09:00
|
|
|
|
}
|
2026-04-15 15:06:45 +09:00
|
|
|
|
return { totalRequiredMeso: req, totalArrearMeso: arr, overallDate: latest }
|
2026-04-15 14:27:01 +09:00
|
|
|
|
}, [symbols, progress])
|
2026-04-15 12:07:07 +09:00
|
|
|
|
|
|
|
|
|
|
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="캐릭터 닉네임으로 장착 심볼 불러오기"
|
2026-04-15 14:06:01 +09:00
|
|
|
|
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"
|
2026-04-15 12:07:07 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</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}
|
2026-04-15 14:06:01 +09:00
|
|
|
|
onSelect={() => selectCharacter(c.id)}
|
|
|
|
|
|
onRemove={() => removeCharacter(c.id)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 심볼 타입 탭 */}
|
|
|
|
|
|
<div className="flex gap-2">
|
2026-04-15 13:43:52 +09:00
|
|
|
|
{tabs.map((t) => (
|
2026-04-15 12:07:07 +09:00
|
|
|
|
<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'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-04-15 13:43:52 +09:00
|
|
|
|
{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" />
|
|
|
|
|
|
)}
|
2026-04-15 12:07:07 +09:00
|
|
|
|
<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">
|
2026-04-15 14:27:01 +09:00
|
|
|
|
{symbols.map((s) => (
|
|
|
|
|
|
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(s.id)} charId={selectedCharId} />
|
2026-04-15 12:07:07 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
<div className="text-base text-gray-400">{tabInfo?.label} 전체 만렙 완료 예상일</div>
|
2026-04-15 15:06:45 +09:00
|
|
|
|
<div className="text-3xl font-bold text-emerald-300 tabular-nums mt-1.5">
|
|
|
|
|
|
{overallDate ? formatKoreanDate(overallDate) : '-'}
|
|
|
|
|
|
</div>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
|
<div className="text-right pr-10">
|
|
|
|
|
|
<div className="text-base text-gray-400">누적 체납 메소</div>
|
2026-04-15 14:27:01 +09:00
|
|
|
|
<Tooltip text={formatMesoKorean(totalArrearMeso)}>
|
|
|
|
|
|
<div className="text-2xl font-bold text-red-400 tabular-nums mt-1 inline-block">
|
|
|
|
|
|
{totalArrearMeso.toLocaleString()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Tooltip>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="w-px h-12 bg-white/10" />
|
|
|
|
|
|
<div className="text-right pl-10">
|
2026-04-15 14:27:01 +09:00
|
|
|
|
<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>
|
2026-04-15 12:07:07 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|