350 lines
11 KiB
React
350 lines
11 KiB
React
import { useEffect, useState } from 'react';
|
||
import { Navigate } from 'react-router-dom';
|
||
import { api, formatCredits } from '../api';
|
||
import { useAuth } from '../AuthContext';
|
||
|
||
function formatSkillValue(skill, value) {
|
||
const prefix = skill.prefix || '';
|
||
if (skill.unit === 'cr-cents') {
|
||
return `${prefix}${formatCredits(value)} cr`.replace(/^\+\+/, '+');
|
||
}
|
||
if (skill.unit === '%') return `${prefix || '+'}${value}%`;
|
||
if (skill.unit === 's') return `${prefix || '−'}${value}s`;
|
||
if (skill.unit === ' osu') return `${prefix || '+'}${value} osu`;
|
||
if (skill.prefix === '−') return `−${value}`;
|
||
if (value === 0 && !prefix) return `${value}`;
|
||
return `${prefix || '+'}${value}`;
|
||
}
|
||
|
||
export default function PrestigePage() {
|
||
const { user, loading, patchUser, refresh } = useAuth();
|
||
const [data, setData] = useState(null);
|
||
const [error, setError] = useState('');
|
||
const [message, setMessage] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const load = async () => {
|
||
const result = await api.prestige();
|
||
setData(result);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!user || user.role === 'admin') return undefined;
|
||
let cancelled = false;
|
||
(async () => {
|
||
try {
|
||
const result = await api.prestige();
|
||
if (!cancelled) setData(result);
|
||
} catch (err) {
|
||
if (!cancelled) setError(err.message);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [user?.id, user?.role]);
|
||
|
||
if (loading) return <p className="muted">Loading…</p>;
|
||
if (!user) return <Navigate to="/login" replace />;
|
||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||
if (!data && !error) return <p className="muted">Loading…</p>;
|
||
if (data && data.pageVisible === false) {
|
||
return <Navigate to="/dashboard" replace />;
|
||
}
|
||
|
||
const cost = data?.prestige?.costCents ?? 0;
|
||
const p = data?.prestige;
|
||
const prBalance = p?.prBalance ?? user.prBalance ?? 0;
|
||
const canClick = Boolean(data?.canPrestige) && !busy;
|
||
|
||
const onPrestige = async () => {
|
||
if (!canClick) return;
|
||
setBusy(true);
|
||
setError('');
|
||
setMessage('');
|
||
try {
|
||
const result = await api.doPrestige();
|
||
patchUser(result.user);
|
||
await refresh();
|
||
setMessage(
|
||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
|
||
);
|
||
await load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const onUpgrade = async (skillId) => {
|
||
setBusy(true);
|
||
setError('');
|
||
setMessage('');
|
||
try {
|
||
const result = await api.prestigeUpgrade(skillId);
|
||
patchUser(result.user);
|
||
setMessage(`Upgraded ${skillId}.`);
|
||
setData((prev) =>
|
||
prev
|
||
? {
|
||
...prev,
|
||
prestige: result.prestige,
|
||
skills: result.skills,
|
||
}
|
||
: prev
|
||
);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
|
||
const statusLabel = !data?.canAfford
|
||
? `Need ${formatCredits(shortfall)} cr more`
|
||
: data?.lockedItems > 0
|
||
? `Withdraw ${data.lockedItems} locked item${data.lockedItems === 1 ? '' : 's'} first`
|
||
: `Ready · ~${data.previewPr} Pr`;
|
||
|
||
return (
|
||
<section className="section prestige-page">
|
||
<div className="section-head prestige-head">
|
||
<div className="prestige-head-copy">
|
||
<h1>Prestige</h1>
|
||
<p className="prestige-lead">
|
||
Reset your progress for <strong className="gold">Pr</strong>, then spend Pr on permanent
|
||
skills.
|
||
</p>
|
||
</div>
|
||
<div className="prestige-pills">
|
||
{p && (
|
||
<div className="prestige-count-pill">
|
||
Prestige <strong>{p.count}</strong>
|
||
</div>
|
||
)}
|
||
<div className="prestige-pr-pill">
|
||
<strong>{prBalance}</strong> Pr
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ol className="prestige-steps">
|
||
<li>
|
||
<span className="prestige-step-num">1</span>
|
||
<div>
|
||
<strong>Pay the cost</strong>
|
||
<p className="muted">{formatCredits(cost)} cr from your balance</p>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
<span className="prestige-step-num">2</span>
|
||
<div>
|
||
<strong>Earn Pr</strong>
|
||
<p className="muted">
|
||
+{p?.prBaseReward ?? 1000} base, more if you are over the cost
|
||
</p>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
<span className="prestige-step-num">3</span>
|
||
<div>
|
||
<strong>Upgrade skills</strong>
|
||
<p className="muted">Permanent bonuses — they survive every reset</p>
|
||
</div>
|
||
</li>
|
||
</ol>
|
||
|
||
{error && <div className="error">{error}</div>}
|
||
{message && <div className="ok-msg">{message}</div>}
|
||
|
||
{p && (
|
||
<div className="prestige-totals prestige-totals-wide">
|
||
<div>
|
||
<span className="muted">Fortune</span>
|
||
<strong>+{p.chanceBonus}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Yield</span>
|
||
<strong>+{p.gainBonus}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Polish</span>
|
||
<strong>+{p.wearBonus || 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Bulk</span>
|
||
<strong>+{p.openBonus || 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Discount</span>
|
||
<strong>−{p.caseDiscount || 0}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Quality</span>
|
||
<strong>+{p.qualityBonus || 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">noTime</span>
|
||
<strong>−{p.animReduction || 0}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Refund</span>
|
||
<strong>{p.refundChance || 0}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Respin</span>
|
||
<strong>{p.respinChance || 0}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Vault slots</span>
|
||
<strong>+{p.vaultSlots || 0}</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Offline Yield</span>
|
||
<strong>+{p.offlineYield || 0}%</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Online Yield</span>
|
||
<strong>+{p.onlineYield || 0}%</strong>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{data?.lockedItems > 0 && (
|
||
<div className="error">
|
||
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
|
||
before prestiging.
|
||
</div>
|
||
)}
|
||
|
||
<div className="prestige-action admin-block">
|
||
<div className="prestige-action-head">
|
||
<h2>Reset now</h2>
|
||
<span className={`prestige-status${data?.canPrestige ? ' ready' : ''}`}>
|
||
{statusLabel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="prestige-action-stats">
|
||
<div>
|
||
<span className="muted">Cost</span>
|
||
<strong className="gold">{formatCredits(cost)} cr</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Your balance</span>
|
||
<strong>{formatCredits(user.balance)} cr</strong>
|
||
</div>
|
||
<div>
|
||
<span className="muted">Est. Pr reward</span>
|
||
<strong className="prestige-pr-est">
|
||
{data?.canAfford ? `~${data.previewPr} Pr` : '—'}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="prestige-action-split">
|
||
<div>
|
||
<h3>You keep</h3>
|
||
<ul>
|
||
<li>All Pr already earned</li>
|
||
<li>Skill-tree bonuses</li>
|
||
<li>Account & prestige count</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h3>You lose</h3>
|
||
<ul>
|
||
<li>Balance</li>
|
||
<li>Inventory & vault stakes</li>
|
||
<li>Case keys & catalog progress</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="muted prestige-pr-hint">
|
||
Reward: +{p?.prBaseReward ?? 1000} Pr base. Every{' '}
|
||
{formatCredits(p?.excessChunkCents ?? 0)} cr above the cost adds ~{p?.prPerExcessChunk ?? 1000}{' '}
|
||
Pr.
|
||
</p>
|
||
|
||
<button
|
||
type="button"
|
||
className="btn prestige-action-btn"
|
||
disabled={!canClick}
|
||
onClick={onPrestige}
|
||
title={
|
||
!data?.canAfford
|
||
? `Need ${formatCredits(cost)} cr`
|
||
: data?.lockedItems > 0
|
||
? 'Withdraw locked items first'
|
||
: 'Prestige now'
|
||
}
|
||
>
|
||
{busy ? 'Prestiging…' : 'Prestige now'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="prestige-tree-section">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Skill tree</h2>
|
||
<p className="muted">Spend Pr on permanent branches. Costs rise with each purchase.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="prestige-tree">
|
||
<div className="prestige-tree-root" aria-hidden>
|
||
<span>Core</span>
|
||
<strong>{prBalance} Pr</strong>
|
||
</div>
|
||
<div className="prestige-tree-branches">
|
||
{(data?.skills || []).map((skill) => (
|
||
<div key={skill.id} className="prestige-tree-branch">
|
||
<div className="prestige-tree-connector" aria-hidden />
|
||
<article className="prestige-skill-node">
|
||
<h3>{skill.title}</h3>
|
||
<p className="muted">{skill.description}</p>
|
||
<div className="prestige-skill-meta">
|
||
<span>
|
||
Lv {skill.level} · now{' '}
|
||
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
||
</span>
|
||
<span className="prestige-delta">
|
||
→ {formatSkillValue(skill, skill.nextValue)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn secondary"
|
||
disabled={busy || !skill.canAfford}
|
||
onClick={() => onUpgrade(skill.id)}
|
||
>
|
||
Buy · {skill.cost} Pr
|
||
</button>
|
||
</article>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{data?.history?.length > 0 && (
|
||
<div className="prestige-history">
|
||
<h2>History</h2>
|
||
<ul className="stat-list">
|
||
{data.history.map((h) => (
|
||
<li key={h.id}>
|
||
Prestige reset
|
||
{h.prGranted > 0 ? ` · +${h.prGranted} Pr` : ''}{' '}
|
||
<span className="muted">· {new Date(h.createdAt).toLocaleString()}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|