feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성
This commit is contained in:
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { bulkPurchase } from '../../../../api';
|
||||||
|
import { MODES } from './TierModeToggle';
|
||||||
|
|
||||||
|
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await bulkPurchase({
|
||||||
|
draw_no: drawNo,
|
||||||
|
tier_mode: tierMode,
|
||||||
|
sets: mode.sets,
|
||||||
|
amount: mode.amount,
|
||||||
|
});
|
||||||
|
onSuccess?.();
|
||||||
|
alert(`${mode.sets}세트 구매 기록 완료!`);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`구매 기록 실패: ${e?.message || e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
|
||||||
|
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import RetrospectiveBox from './RetrospectiveBox';
|
||||||
|
import TierModeToggle, { MODES } from './TierModeToggle';
|
||||||
|
import TierSection from './TierSection';
|
||||||
|
import BulkPurchaseButton from './BulkPurchaseButton';
|
||||||
|
import './decision.css';
|
||||||
|
|
||||||
|
const TIER_CHAIN = {
|
||||||
|
core: ['core'],
|
||||||
|
core_bonus: ['core', 'bonus'],
|
||||||
|
core_bonus_extended: ['core', 'bonus', 'extended'],
|
||||||
|
full: ['core', 'bonus', 'extended', 'pool'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lotto.tier_mode';
|
||||||
|
|
||||||
|
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
|
||||||
|
const [tierMode, setTierMode] = useState(() =>
|
||||||
|
localStorage.getItem(STORAGE_KEY) || 'core'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, tierMode);
|
||||||
|
}, [tierMode]);
|
||||||
|
|
||||||
|
const visibleTiers = TIER_CHAIN[tierMode];
|
||||||
|
|
||||||
|
const totalSets = useMemo(
|
||||||
|
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
|
||||||
|
[briefing, visibleTiers]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 분배 칩 — 보이는 계층의 risk_tag 합산
|
||||||
|
const balance = useMemo(() => {
|
||||||
|
const acc = { '안정': 0, '균형': 0, '공격': 0 };
|
||||||
|
for (const t of visibleTiers) {
|
||||||
|
for (const p of (briefing?.picks?.[t] || [])) {
|
||||||
|
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [briefing, visibleTiers]);
|
||||||
|
|
||||||
|
if (!briefing) return null;
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lc-card">
|
||||||
|
<header className="lc-head">
|
||||||
|
<div>
|
||||||
|
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
|
||||||
|
<h3 className="lc-title">{briefing.narrative.headline}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="lc-conf">
|
||||||
|
<div className="lc-conf__num">{briefing.confidence}</div>
|
||||||
|
<div className="lc-conf__lbl">CONFIDENCE</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RetrospectiveBox briefing={briefing} review={review} />
|
||||||
|
|
||||||
|
<p className="lc-headline-3">
|
||||||
|
{(briefing.narrative.summary_3lines || []).join(' · ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="lc-balance">
|
||||||
|
<div className="lc-balance__chips">
|
||||||
|
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
|
||||||
|
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
|
||||||
|
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TierModeToggle value={tierMode} onChange={setTierMode} />
|
||||||
|
|
||||||
|
{visibleTiers.map(tier => {
|
||||||
|
const picks = briefing.picks?.[tier] || [];
|
||||||
|
const idxBase = cursor;
|
||||||
|
cursor += picks.length;
|
||||||
|
return (
|
||||||
|
<TierSection
|
||||||
|
key={tier}
|
||||||
|
tier={tier}
|
||||||
|
picks={picks}
|
||||||
|
rationale={briefing.tier_rationale?.[tier]}
|
||||||
|
indexBase={idxBase}
|
||||||
|
totalSets={totalSets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="lc-actions">
|
||||||
|
<BulkPurchaseButton
|
||||||
|
drawNo={briefing.draw_no}
|
||||||
|
tierMode={tierMode}
|
||||||
|
onSuccess={onPurchaseSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,11 @@
|
|||||||
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
||||||
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||||
|
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
|
||||||
|
font-size: 14px; min-width: 160px; }
|
||||||
|
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
|
||||||
|
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import useBriefing from '../hooks/useBriefing';
|
import useBriefing from '../hooks/useBriefing';
|
||||||
import BriefingHeader from '../components/briefing/BriefingHeader';
|
import useReview from '../hooks/useReview';
|
||||||
import BriefingSummary from '../components/briefing/BriefingSummary';
|
import DecisionCard from '../components/decision/DecisionCard';
|
||||||
import PickSetCard from '../components/briefing/PickSetCard';
|
|
||||||
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
||||||
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
|
|
||||||
|
|
||||||
export default function BriefingTab() {
|
export default function BriefingTab() {
|
||||||
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
||||||
|
const { latest: review } = useReview();
|
||||||
|
|
||||||
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
||||||
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="briefing-tab">
|
<div className="briefing-tab">
|
||||||
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
|
<DecisionCard briefing={briefing} review={review} />
|
||||||
<BriefingSummary narrative={briefing.narrative} />
|
|
||||||
<div className="briefing-picks">
|
|
||||||
<h3>이번 주 5세트</h3>
|
|
||||||
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
|
|
||||||
</div>
|
|
||||||
<CuratorUsageFooter />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user