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 DistrictTierEditor from './components/DistrictTierEditor';
|
||||||
import NotificationSettings from './components/NotificationSettings';
|
import NotificationSettings from './components/NotificationSettings';
|
||||||
import './Subscription.css';
|
import './Subscription.css';
|
||||||
|
import {
|
||||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
STATUS_CONFIG, HOUSE_TYPE_LABELS, TABS, STATUS_FILTERS, DEFAULT_PROFILE,
|
||||||
const STATUS_CONFIG = {
|
extractTier, fmt, fmtFull, getDDays, getDDayColor, fmtDateTime, apiPatch, fmtPrice,
|
||||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
} from './subscriptionUtils';
|
||||||
'청약중': { 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()}만`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── StatusBadge ──────────────────────────────────────────────────────────────
|
// ── StatusBadge ──────────────────────────────────────────────────────────────
|
||||||
function StatusBadge({ status, size }) {
|
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