Merge feature/lotto-curator-evolution: Lotto Curator Evolution (frontend)
7 commits for Phase F + G:
- api.js: getLatestReview / getReviewHistory / bulkPurchase 헬퍼
- useReview 훅 + useBriefing 4계층 normalize
- DecisionCard + RetrospectiveBox + TierModeToggle + TierSection + PickCard + BulkPurchaseButton
- BriefingTab 단일 화면 재구성
- 분석탭 → 자료실 라벨 + 9개 패널 details 접힘
- PurchasePanel 자동 채점 일치수 배지 + 4등↑ 플래그
- 4주 추세 차트(너 vs 큐레이터 평균 일치)
자세한 컨셉/계획: docs/superpowers/{specs,plans}/2026-05-11-*.md
This commit is contained in:
15
src/api.js
15
src/api.js
@@ -680,3 +680,18 @@ export const getBatchJob = (id) => apiGet(`/api/music/generate-b
|
|||||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||||
export const listGenres = () => apiGet('/api/music/genres');
|
export const listGenres = () => apiGet('/api/music/genres');
|
||||||
|
|
||||||
|
// === 주간 회고 (weekly_review) ===
|
||||||
|
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
|
||||||
|
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
|
||||||
|
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
|
||||||
|
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getReviewHistory = (limit = 4) =>
|
||||||
|
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
|
||||||
|
|
||||||
|
// === 큐레이터 4계층 원클릭 구매 ===
|
||||||
|
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||||
|
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||||
{ id: 'analysis', label: '📊 분석·통계' },
|
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||||
{ id: 'purchase', label: '💰 구매·성과' },
|
{ id: 'purchase', label: '💰 구매·성과' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-list__head {
|
.lotto-purchase-list__head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1033,7 +1033,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-row {
|
.lotto-purchase-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -1068,6 +1068,21 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-purchase-row__hits {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
|
||||||
|
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
|
||||||
|
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
|
||||||
|
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
|
||||||
|
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
|
||||||
|
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
|
||||||
|
|
||||||
.is-pos { color: #97c9aa; }
|
.is-pos { color: #97c9aa; }
|
||||||
.is-neg { color: #f7a8a5; }
|
.is-neg { color: #f7a8a5; }
|
||||||
.is-prize { color: #fdd4b1; }
|
.is-prize { color: #fdd4b1; }
|
||||||
@@ -1098,8 +1113,8 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
|
||||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,7 +1158,7 @@
|
|||||||
|
|
||||||
.lotto-purchase-list__head,
|
.lotto-purchase-list__head,
|
||||||
.lotto-purchase-row {
|
.lotto-purchase-row {
|
||||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotto-purchase-list__head span:nth-child(4),
|
.lotto-purchase-list__head span:nth-child(4),
|
||||||
@@ -1526,3 +1541,14 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lotto-section-fold { margin-bottom: 14px; }
|
||||||
|
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
|
||||||
|
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
|
||||||
|
|
||||||
|
.trend-chart { display: block; margin: 0 auto; }
|
||||||
|
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.dot--curator { background: #b8a8ff; }
|
||||||
|
.dot--user { background: #76e09a; }
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const PurchasePanel = ({
|
|||||||
<span>투자금</span>
|
<span>투자금</span>
|
||||||
<span>당첨금</span>
|
<span>당첨금</span>
|
||||||
<span>손익</span>
|
<span>손익</span>
|
||||||
|
<span>채점</span>
|
||||||
<span>메모</span>
|
<span>메모</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +153,14 @@ const PurchasePanel = ({
|
|||||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="lotto-purchase-row__hits">
|
||||||
|
{(rec.results || []).map((r, i) => (
|
||||||
|
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
|
||||||
|
))}
|
||||||
|
{(rec.results || []).some((r) => r.correct >= 4) && (
|
||||||
|
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||||
<div className="lotto-purchase-row__actions">
|
<div className="lotto-purchase-row__actions">
|
||||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||||
|
|||||||
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getReviewHistory } from '../../../api';
|
||||||
|
|
||||||
|
export default function PurchaseTrendChart() {
|
||||||
|
const [reviews, setReviews] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (reviews.length === 0) return null;
|
||||||
|
|
||||||
|
const maxAvg = Math.max(
|
||||||
|
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
|
||||||
|
2.5
|
||||||
|
);
|
||||||
|
const w = 320, h = 80, pad = 16;
|
||||||
|
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
|
||||||
|
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
|
||||||
|
|
||||||
|
const line = (key) => reviews
|
||||||
|
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
|
||||||
|
.filter(p => p.y != null)
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lotto-panel">
|
||||||
|
<div className="lotto-panel__head">
|
||||||
|
<div>
|
||||||
|
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
|
||||||
|
<h3>너 vs 큐레이터 평균 일치 수</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width={w} height={h} className="trend-chart">
|
||||||
|
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
|
||||||
|
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<div className="trend-legend">
|
||||||
|
<span><span className="dot dot--curator" /> 큐레이터</span>
|
||||||
|
<span><span className="dot dot--user" /> 너</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
|
||||||
|
|
||||||
|
export default function PickCard({ pick, index, total }) {
|
||||||
|
const role = pick.risk_tag;
|
||||||
|
return (
|
||||||
|
<div className="lc-set">
|
||||||
|
<div className="lc-set__head">
|
||||||
|
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
|
||||||
|
<span className="lc-set__idx">Set {index + 1} / {total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lc-balls">
|
||||||
|
{pick.numbers.map(n => (
|
||||||
|
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="lc-set__reason">{pick.reason}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function RetrospectiveBox({ briefing, review }) {
|
||||||
|
const retro = briefing?.narrative?.retrospective;
|
||||||
|
if (!retro) return null;
|
||||||
|
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
|
||||||
|
return (
|
||||||
|
<aside className="lc-retro">
|
||||||
|
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
|
||||||
|
<p className="lc-retro__body">{retro}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const MODES = [
|
||||||
|
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
|
||||||
|
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
|
||||||
|
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
|
||||||
|
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TierModeToggle({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="lc-toggle" role="tablist">
|
||||||
|
{MODES.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === m.key}
|
||||||
|
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => onChange(m.key)}
|
||||||
|
>
|
||||||
|
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
|
||||||
|
<span className="lc-toggle__lbl">{m.label}</span>
|
||||||
|
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MODES };
|
||||||
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import PickCard from './PickCard';
|
||||||
|
|
||||||
|
const TIER_TITLE = {
|
||||||
|
core: '코어 (필수, 5세트)',
|
||||||
|
bonus: '보너스 (+5)',
|
||||||
|
extended: '확장 (+5)',
|
||||||
|
pool: '풀 (+5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
|
||||||
|
if (!picks?.length) return null;
|
||||||
|
return (
|
||||||
|
<section className={`lc-tier lc-tier--${tier}`}>
|
||||||
|
<header className="lc-tier__head">
|
||||||
|
<h4>{TIER_TITLE[tier]}</h4>
|
||||||
|
{rationale && tier !== 'core' && (
|
||||||
|
<p className="lc-tier__rationale">{rationale}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{picks.map((p, i) => (
|
||||||
|
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/lotto/components/decision/decision.css
Normal file
52
src/pages/lotto/components/decision/decision.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
|
||||||
|
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||||
|
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
|
||||||
|
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
|
||||||
|
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
|
||||||
|
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
|
||||||
|
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
|
||||||
|
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
|
||||||
|
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
|
||||||
|
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
|
||||||
|
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
|
||||||
|
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
|
||||||
|
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
|
||||||
|
.lc-balance__chips { display: flex; gap: 8px; }
|
||||||
|
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
|
||||||
|
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
|
||||||
|
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
|
||||||
|
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
|
||||||
|
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
|
||||||
|
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||||
|
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
|
||||||
|
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
|
||||||
|
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
|
||||||
|
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
|
||||||
|
.lc-tier { margin-bottom: 14px; }
|
||||||
|
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
|
||||||
|
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
|
||||||
|
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
|
||||||
|
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
|
||||||
|
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
|
||||||
|
padding: 14px; margin-bottom: 10px; }
|
||||||
|
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
|
||||||
|
.lc-set__role--stable { color: #76e09a; }
|
||||||
|
.lc-set__role--balance { color: #ffce6e; }
|
||||||
|
.lc-set__role--aggro { color: #ff8aa0; }
|
||||||
|
.lc-set__idx { font-size: 10px; opacity: 0.4; }
|
||||||
|
.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-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) {
|
||||||
|
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
||||||
|
|
||||||
|
const normalizePicks = (picks) => {
|
||||||
|
if (Array.isArray(picks)) {
|
||||||
|
return { core: picks, bonus: [], extended: [], pool: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
core: picks?.core || [],
|
||||||
|
bonus: picks?.bonus || [],
|
||||||
|
extended: picks?.extended || [],
|
||||||
|
pool: picks?.pool || [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function useBriefing() {
|
export default function useBriefing() {
|
||||||
const [briefing, setBriefing] = useState(null);
|
const [briefing, setBriefing] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -12,7 +24,7 @@ export default function useBriefing() {
|
|||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const data = await getLatestBriefing();
|
const data = await getLatestBriefing();
|
||||||
setBriefing(data);
|
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,7 +45,7 @@ export default function useBriefing() {
|
|||||||
try {
|
try {
|
||||||
const data = await getLatestBriefing();
|
const data = await getLatestBriefing();
|
||||||
if (data && data.generated_at !== prevGen) {
|
if (data && data.generated_at !== prevGen) {
|
||||||
setBriefing(data);
|
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||||
setRegenerating(false);
|
setRegenerating(false);
|
||||||
clearInterval(pollingRef.current);
|
clearInterval(pollingRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||||
|
bulkPurchase as apiBulkPurchase,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import { emptyPurchaseForm } from '../lottoUtils';
|
import { emptyPurchaseForm } from '../lottoUtils';
|
||||||
|
|
||||||
@@ -94,6 +95,12 @@ export default function usePurchases() {
|
|||||||
} catch { refreshPurchases(); }
|
} catch { refreshPurchases(); }
|
||||||
}, [refreshPurchases]);
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
|
const handleBulkPurchase = useCallback(async (params) => {
|
||||||
|
const result = await apiBulkPurchase(params);
|
||||||
|
await refreshPurchases();
|
||||||
|
return result;
|
||||||
|
}, [refreshPurchases]);
|
||||||
|
|
||||||
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,5 +108,6 @@ export default function usePurchases() {
|
|||||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||||
|
handleBulkPurchase,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/pages/lotto/hooks/useReview.js
Normal file
23
src/pages/lotto/hooks/useReview.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getLatestReview, getReviewHistory } from '../../../api';
|
||||||
|
|
||||||
|
export default function useReview() {
|
||||||
|
const [latest, setLatest] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancel = false;
|
||||||
|
Promise.all([getLatestReview(), getReviewHistory(4)])
|
||||||
|
.then(([l, h]) => {
|
||||||
|
if (cancel) return;
|
||||||
|
setLatest(l);
|
||||||
|
setHistory(h);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => !cancel && setLoading(false));
|
||||||
|
return () => { cancel = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { latest, history, loading };
|
||||||
|
}
|
||||||
@@ -40,18 +40,21 @@ export default function AnalysisTab() {
|
|||||||
<PerformanceBanner perf={ld.perfStats} />
|
<PerformanceBanner perf={ld.perfStats} />
|
||||||
|
|
||||||
{/* 종합 추론 번호 추천 */}
|
{/* 종합 추론 번호 추천 */}
|
||||||
<CombinedRecommendPanel
|
<details className="lotto-section-fold">
|
||||||
combined={ld.combined}
|
<summary>종합 추론 추천</summary>
|
||||||
history={ld.combinedHistory}
|
<CombinedRecommendPanel
|
||||||
loading={ld.combinedLoading}
|
combined={ld.combined}
|
||||||
histLoading={ld.combinedHistLoading}
|
history={ld.combinedHistory}
|
||||||
onRun={ld.runCombinedRecommend}
|
loading={ld.combinedLoading}
|
||||||
onCopy={copyNumbers}
|
histLoading={ld.combinedHistLoading}
|
||||||
/>
|
onRun={ld.runCombinedRecommend}
|
||||||
|
onCopy={copyNumbers}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 최신 회차 + 시뮬레이션 추천 */}
|
{/* 최신 회차 */}
|
||||||
<div className="lotto-grid">
|
<details className="lotto-section-fold">
|
||||||
{/* Latest Draw */}
|
<summary>최신 회차</summary>
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* Simulation Picks */}
|
{/* Simulation Picks */}
|
||||||
|
<details className="lotto-section-fold">
|
||||||
|
<summary>시뮬레이션 추천</summary>
|
||||||
<section className="lotto-panel">
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
@@ -163,19 +169,24 @@ export default function AnalysisTab() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
{/* 이번 주 공략 리포트 */}
|
{/* 이번 주 공략 리포트 */}
|
||||||
<ReportPanel
|
<details className="lotto-section-fold">
|
||||||
report={ld.report}
|
<summary>이번 주 공략 리포트</summary>
|
||||||
history={ld.reportHistory}
|
<ReportPanel
|
||||||
loading={ld.reportLoading}
|
report={ld.report}
|
||||||
onRefresh={ld.refreshReport}
|
history={ld.reportHistory}
|
||||||
onSelectDrw={ld.loadSpecificReport}
|
loading={ld.reportLoading}
|
||||||
/>
|
onRefresh={ld.refreshReport}
|
||||||
|
onSelectDrw={ld.loadSpecificReport}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 통계 분석 */}
|
{/* 통계 분석 */}
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>통계 분석</summary>
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
<p className="lotto-panel__eyebrow">Analysis</p>
|
||||||
@@ -237,9 +248,12 @@ export default function AnalysisTab() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 전체 번호 분포 */}
|
{/* 전체 번호 분포 */}
|
||||||
<section className="lotto-panel lotto-panel--wide">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>전체 회차 번호 분포</summary>
|
||||||
|
<section className="lotto-panel lotto-panel--wide">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||||
@@ -263,12 +277,18 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 내 번호 패턴 */}
|
{/* 내 번호 패턴 */}
|
||||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
<details className="lotto-section-fold">
|
||||||
|
<summary>내 번호 패턴</summary>
|
||||||
|
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 수동 추천 */}
|
{/* 수동 추천 */}
|
||||||
<section className="lotto-panel">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>수동 추천</summary>
|
||||||
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
||||||
@@ -365,9 +385,12 @@ export default function AnalysisTab() {
|
|||||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* 추천 히스토리 */}
|
{/* 추천 히스토리 */}
|
||||||
<section className="lotto-panel">
|
<details className="lotto-section-fold">
|
||||||
|
<summary>추천 히스토리</summary>
|
||||||
|
<section className="lotto-panel">
|
||||||
<div className="lotto-panel__head">
|
<div className="lotto-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<p className="lotto-panel__eyebrow">History</p>
|
<p className="lotto-panel__eyebrow">History</p>
|
||||||
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</details>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import usePurchases from '../hooks/usePurchases';
|
import usePurchases from '../hooks/usePurchases';
|
||||||
import PurchasePanel from '../components/PurchasePanel';
|
import PurchasePanel from '../components/PurchasePanel';
|
||||||
|
import PurchaseTrendChart from '../components/PurchaseTrendChart';
|
||||||
|
|
||||||
export default function PurchaseTab() {
|
export default function PurchaseTab() {
|
||||||
const pur = usePurchases();
|
const pur = usePurchases();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PurchasePanel
|
<>
|
||||||
records={pur.purchases}
|
<PurchaseTrendChart />
|
||||||
stats={pur.purchaseStats}
|
<PurchasePanel
|
||||||
loading={pur.purchaseLoading}
|
records={pur.purchases}
|
||||||
formOpen={pur.purchaseFormOpen}
|
stats={pur.purchaseStats}
|
||||||
form={pur.purchaseForm}
|
loading={pur.purchaseLoading}
|
||||||
formSaving={pur.purchaseFormSaving}
|
formOpen={pur.purchaseFormOpen}
|
||||||
formError={pur.purchaseFormError}
|
form={pur.purchaseForm}
|
||||||
editId={pur.purchaseEditId}
|
formSaving={pur.purchaseFormSaving}
|
||||||
onFormOpen={pur.handlePurchaseFormOpen}
|
formError={pur.purchaseFormError}
|
||||||
onFormClose={pur.handlePurchaseFormClose}
|
editId={pur.purchaseEditId}
|
||||||
onFormChange={pur.handlePurchaseFormChange}
|
onFormOpen={pur.handlePurchaseFormOpen}
|
||||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
onFormClose={pur.handlePurchaseFormClose}
|
||||||
onEditStart={pur.handlePurchaseEditStart}
|
onFormChange={pur.handlePurchaseFormChange}
|
||||||
onDelete={pur.handlePurchaseDelete}
|
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||||
/>
|
onEditStart={pur.handlePurchaseEditStart}
|
||||||
|
onDelete={pur.handlePurchaseDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user