feat(subscription): 5축 점수 breakdown 시각화 + 알림 대상 카운트
- AnnouncementDetail: 5축(지역/유형/면적/가격/자격) progress bar 추가 - MatchesTab: 카드마다 미니 5축 비례 바 추가 (색상 구분) - ProfileTab: 마운트 시 dashboard도 함께 fetch → pass_count 취득 - NotificationSettings: passCount prop → "현재 N건 대상" 인라인 표시 - Subscription.css: .ns-pass-count 스타일 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1534,3 +1534,18 @@ input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 신규: 알림 대상 카운트 뱃지 ===================================== */
|
||||
.ns-pass-count {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
color: #00d4ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ns-pass-count strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -618,16 +618,52 @@ function AnnouncementDetail({ item, onBookmark }) {
|
||||
|
||||
{item.match_score !== undefined && item.match_score !== null && (
|
||||
<div className="sub-match-analysis">
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||
<span className="sub-match-analysis__score">
|
||||
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||
<span className="sub-match-analysis__score">
|
||||
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.score_breakdown && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{[
|
||||
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
|
||||
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
|
||||
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
|
||||
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
|
||||
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
|
||||
].map(({ key, label, max, color }) => {
|
||||
const v = item.score_breakdown[key] ?? 0;
|
||||
return (
|
||||
<div key={key} style={{ display: 'grid', gap: 3 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
|
||||
<span>
|
||||
<span style={{ fontWeight: 700, color }}>{v}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3, background: color,
|
||||
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||
<div>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
|
||||
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
|
||||
<ul className="sub-match-analysis__reasons">
|
||||
{item.match_reasons.map((r, idx) => (
|
||||
<li key={idx}>{r}</li>
|
||||
@@ -973,6 +1009,24 @@ function MatchesTab() {
|
||||
).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{match.score_breakdown && (
|
||||
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
|
||||
{[
|
||||
{ key: 'region', max: 35, color: '#00d4ff' },
|
||||
{ key: 'type', max: 10, color: '#8b5cf6' },
|
||||
{ key: 'area', max: 15, color: '#f59e0b' },
|
||||
{ key: 'price', max: 15, color: '#f43f5e' },
|
||||
{ key: 'eligibility', max: 25, color: '#34d399' },
|
||||
].map(({ key, max, color }) => {
|
||||
const v = match.score_breakdown[key] ?? 0;
|
||||
return (
|
||||
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
|
||||
<div>
|
||||
@@ -1026,6 +1080,7 @@ function MatchesTab() {
|
||||
// ── ProfileTab ────────────────────────────────────────────────────────────────
|
||||
function ProfileTab() {
|
||||
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
|
||||
const [passCount, setPassCount] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -1034,13 +1089,17 @@ function ProfileTab() {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiGet('/api/realestate/profile');
|
||||
const [data, dash] = await Promise.all([
|
||||
apiGet('/api/realestate/profile'),
|
||||
apiGet('/api/realestate/dashboard').catch(() => null),
|
||||
]);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const display = { ...DEFAULT_PROFILE, ...data };
|
||||
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
|
||||
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
|
||||
setProfile(display);
|
||||
}
|
||||
if (dash?.pass_count != null) setPassCount(dash.pass_count);
|
||||
} catch (e) {
|
||||
console.error('Profile load error:', e);
|
||||
} finally {
|
||||
@@ -1379,6 +1438,7 @@ function ProfileTab() {
|
||||
minScore={profile.min_match_score ?? 70}
|
||||
notifyEnabled={profile.notify_enabled ?? true}
|
||||
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||
passCount={passCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
|
||||
export default function NotificationSettings({ minScore, notifyEnabled, onChange, passCount }) {
|
||||
const score = minScore ?? 70;
|
||||
const enabled = notifyEnabled ?? true;
|
||||
|
||||
@@ -42,9 +42,16 @@ export default function NotificationSettings({ minScore, notifyEnabled, onChange
|
||||
</label>
|
||||
|
||||
<p className="ns-hint">
|
||||
{enabled
|
||||
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
|
||||
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||
{enabled ? (
|
||||
<>
|
||||
💡 {score}점 이상 매치 시 텔레그램에 자동 알림합니다.
|
||||
{passCount != null && (
|
||||
<span className="ns-pass-count">
|
||||
현재 <strong>{passCount}건</strong> 대상
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user