refactor(subscription): 상수·순수헬퍼를 subscriptionUtils로 추출 + 테스트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
@@ -6,116 +6,10 @@ import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
import NotificationSettings from './components/NotificationSettings';
|
||||
import './Subscription.css';
|
||||
|
||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||
const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
};
|
||||
|
||||
const HOUSE_TYPE_LABELS = {
|
||||
'01': '국민주택',
|
||||
'02': '민영주택',
|
||||
'03': '도시형생활주택',
|
||||
};
|
||||
|
||||
const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
|
||||
|
||||
const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
|
||||
|
||||
const DEFAULT_PROFILE = {
|
||||
name: '', age: '', is_homeless: false, is_householder: false,
|
||||
subscription_months: '', subscription_amount: '',
|
||||
family_members: '', has_dependents: false, children_count: '',
|
||||
is_newlywed: false, marriage_months: '',
|
||||
has_newborn: false, is_first_home: false, income_level: '',
|
||||
preferred_regions: '', preferred_types: '',
|
||||
min_area: '', max_area: '', max_price: '',
|
||||
// 신규 (자치구 5티어 + 알림 설정)
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
|
||||
function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fmt = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const fmtFull = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const _diffDays = (d) => {
|
||||
if (!d) return null;
|
||||
// 로컬 타임존으로 통일하여 D-day 계산 (UTC 파싱 방지)
|
||||
const [y, m, day] = d.split('-').map(Number);
|
||||
const target = new Date(y, m - 1, day);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.round((target - today) / 86400000);
|
||||
};
|
||||
|
||||
const getDDays = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return null;
|
||||
if (diff === 0) return 'D-Day';
|
||||
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
|
||||
};
|
||||
|
||||
const getDDayColor = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return 'var(--text-dim)';
|
||||
if (diff <= 0) return '#f87171';
|
||||
if (diff <= 3) return '#f59e0b';
|
||||
if (diff <= 7) return '#00d4ff';
|
||||
return 'var(--text-dim)';
|
||||
};
|
||||
|
||||
const fmtDateTime = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
async function apiPatch(path, body) {
|
||||
const opts = {
|
||||
method: 'PATCH',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const fmtPrice = (v) => {
|
||||
if (v == null) return null;
|
||||
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
|
||||
return `${v.toLocaleString()}만`;
|
||||
};
|
||||
import {
|
||||
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
|
||||
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
|
||||
} from './subscriptionUtils';
|
||||
|
||||
// ── StatusBadge ──────────────────────────────────────────────────────────────
|
||||
function StatusBadge({ status, size }) {
|
||||
|
||||
105
src/pages/subscription/subscriptionUtils.js
Normal file
105
src/pages/subscription/subscriptionUtils.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/* 청약(Subscription) 모듈 상수·순수 헬퍼 */
|
||||
|
||||
export const STATUS_CONFIG = {
|
||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||
'청약중': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
|
||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
|
||||
'완료': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||
};
|
||||
|
||||
export const HOUSE_TYPE_LABELS = {
|
||||
'01': '국민주택',
|
||||
'02': '민영주택',
|
||||
'03': '도시형생활주택',
|
||||
};
|
||||
|
||||
export const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필'];
|
||||
|
||||
export const STATUS_FILTERS = ['전체', '청약예정', '청약중', '결과발표', '완료'];
|
||||
|
||||
export const DEFAULT_PROFILE = {
|
||||
name: '', age: '', is_homeless: false, is_householder: false,
|
||||
subscription_months: '', subscription_amount: '',
|
||||
family_members: '', has_dependents: false, children_count: '',
|
||||
is_newlywed: false, marriage_months: '',
|
||||
has_newborn: false, is_first_home: false, income_level: '',
|
||||
preferred_regions: '', preferred_types: '',
|
||||
min_area: '', max_area: '', max_price: '',
|
||||
preferred_districts: {},
|
||||
min_match_score: 70,
|
||||
notify_enabled: true,
|
||||
};
|
||||
|
||||
export function extractTier(reasons) {
|
||||
for (const r of reasons || []) {
|
||||
const m = r.match(/자치구 ([SABCD])티어/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const fmt = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
export const fmtFull = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const _diffDays = (d) => {
|
||||
if (!d) return null;
|
||||
const [y, m, day] = d.split('-').map(Number);
|
||||
const target = new Date(y, m - 1, day);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return Math.round((target - today) / 86400000);
|
||||
};
|
||||
|
||||
export const getDDays = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return null;
|
||||
if (diff === 0) return 'D-Day';
|
||||
return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
|
||||
};
|
||||
|
||||
export const getDDayColor = (d) => {
|
||||
const diff = _diffDays(d);
|
||||
if (diff === null) return 'var(--text-dim)';
|
||||
if (diff <= 0) return '#f87171';
|
||||
if (diff <= 3) return '#f59e0b';
|
||||
if (diff <= 7) return '#00d4ff';
|
||||
return 'var(--text-dim)';
|
||||
};
|
||||
|
||||
export const fmtDateTime = (d) => {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleString('ko-KR', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export async function apiPatch(path, body) {
|
||||
const opts = {
|
||||
method: 'PATCH',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const fmtPrice = (v) => {
|
||||
if (v == null) return null;
|
||||
if (v >= 10000) return `${(v / 10000).toFixed(v % 10000 === 0 ? 0 : 1)}억`;
|
||||
return `${v.toLocaleString()}만`;
|
||||
};
|
||||
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
42
src/pages/subscription/subscriptionUtils.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractTier, getDDays, getDDayColor, fmtPrice } from './subscriptionUtils.js';
|
||||
|
||||
describe('extractTier', () => {
|
||||
it('자치구 티어 문자열에서 등급 추출', () => {
|
||||
expect(extractTier(['자치구 S티어: 강남구 (+25)'])).toBe('S');
|
||||
expect(extractTier(['면적 +10', '자치구 B티어: 노원구 (+15)'])).toBe('B');
|
||||
});
|
||||
it('미매치/빈 입력 → null', () => {
|
||||
expect(extractTier(['면적 적합'])).toBe(null);
|
||||
expect(extractTier(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDDays / getDDayColor (오늘 기준 경계)', () => {
|
||||
const iso = (offsetDays) => {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + offsetDays);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||
};
|
||||
it('오늘=D-Day, 미래=D-N, 과거=D+N', () => {
|
||||
expect(getDDays(iso(0))).toBe('D-Day');
|
||||
expect(getDDays(iso(5))).toBe('D-5');
|
||||
expect(getDDays(iso(-3))).toBe('D+3');
|
||||
expect(getDDays(null)).toBe(null);
|
||||
});
|
||||
it('색 임계: 지남=빨강, ≤3=주황, ≤7=파랑, 그 외=dim', () => {
|
||||
expect(getDDayColor(iso(-1))).toBe('#f87171');
|
||||
expect(getDDayColor(iso(2))).toBe('#f59e0b');
|
||||
expect(getDDayColor(iso(6))).toBe('#00d4ff');
|
||||
expect(getDDayColor(iso(30))).toBe('var(--text-dim)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtPrice (현 구현 동작 고정)', () => {
|
||||
it('억/만 변환', () => {
|
||||
expect(fmtPrice(10000)).toBe('1억');
|
||||
expect(fmtPrice(15000)).toBe('1.5억');
|
||||
expect(fmtPrice(9999)).toBe('9,999만');
|
||||
expect(fmtPrice(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user