feat(listings): Listings shell+스타일+라우트 배선 + 청약 교차링크 + 문서

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:
2026-07-09 16:28:27 +09:00
parent 17b1348f5d
commit 690964c994
6 changed files with 148 additions and 0 deletions

View File

@@ -20,6 +20,7 @@
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
@@ -138,6 +139,12 @@ proxy: {
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
| 부동산-매물 | GET | `/api/realestate/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` — { listings: [...] } |
| 부동산-매물 | POST/GET | `/api/realestate/listings/collect`, `/collect/status` |
| 부동산-매물 | GET/PUT | `/api/realestate/listings/criteria` (부분 업데이트) |
| 부동산-매물 | GET | `/api/realestate/listings/matches` — { matches: [...] } |
| 부동산-안전마진 | POST | `/api/realestate/safety-check` — body { area, deal_type, amount(만원), dong? } → { median, ratio, tier, sample, is_toheo, disclaimer } |
| 부동산-예산 | POST | `/api/realestate/budget` — body { equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? } → { jeonse, purchase, region, disclaimer } |
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |

View File

@@ -0,0 +1,65 @@
/* 실매물 매물·안전마진 (lst-*) — Subscription 다크 토큰 재사용 */
.lst { display: grid; gap: 18px; padding: 20px; max-width: 1100px; margin: 0 auto; }
.lst-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.lst-kicker { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #f43f5e; }
.lst-header h1 { margin: 0; font-size: 24px; color: var(--text-bright); }
.lst-sub { margin: 6px 0 0; color: var(--text-muted); font-size: 13px; }
.lst-tabbar { display: flex; gap: 6px; border-bottom: 1px solid var(--line); }
.lst-tabbtn { border: none; background: none; color: var(--text-dim); padding: 10px 16px; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
.lst-tabbtn.is-active { color: var(--text-bright); border-bottom-color: #f43f5e; }
.lst-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.lst-toolbar__right { display: flex; gap: 6px; }
.lst-filter { display: flex; gap: 4px; flex-wrap: wrap; }
.lst-filter-btn { border: 1px solid var(--line); background: var(--surface); color: var(--text-dim); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; text-decoration: none; }
.lst-filter-btn.is-active { color: var(--text-bright); border-color: #f43f5e; }
.lst-filter-btn.is-primary { color: #fff; background: #f43f5e; border-color: #f43f5e; }
.lst-filter-btn:disabled { opacity: 0.5; cursor: default; }
.lst-status { font-size: 12px; color: var(--text-muted); margin: 0; }
.lst-error { color: #f9b6b1; border: 1px solid rgba(249,182,177,0.4); border-radius: 12px; padding: 10px; background: rgba(249,182,177,0.1); margin: 0; font-size: 13px; }
.lst-ok { color: #34d399; font-size: 12px; }
.lst-empty { color: var(--text-muted); text-align: center; padding: 32px 0; }
.lst-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.lst-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; display: grid; gap: 6px; }
.lst-card__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lst-tier { font-size: 12px; font-weight: 700; }
.lst-card__name { font-size: 14px; color: var(--text-bright); }
.lst-card__deal { font-size: 11px; color: #60a5fa; }
.lst-card__dong { font-size: 11px; color: var(--text-muted); }
.lst-card__body { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
.lst-card__price { color: #f59e0b; font-weight: 600; }
.lst-card__reasons { font-size: 11px; color: var(--text-muted); }
.lst-flags { display: flex; gap: 4px; flex-wrap: wrap; }
.lst-flag { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(244,63,94,0.12); color: #f87171; }
.lst-tools { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.lst-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; display: grid; gap: 10px; }
.lst-panel__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.lst-panel__actions { display: flex; gap: 8px; align-items: center; }
.lst-panel__title { margin: 0; font-size: 15px; color: var(--text-bright); }
.lst-panel__sub { margin: 0; font-size: 12px; color: var(--text-muted); }
.lst-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.lst-input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: inherit; font-size: 13px; min-width: 100px; flex: 1 1 100px; }
.lst-check { font-size: 12px; color: var(--text-dim); display: inline-flex; gap: 4px; align-items: center; }
.lst-chks { display: flex; gap: 12px; flex-wrap: wrap; }
.lst-result { display: grid; gap: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
.lst-result__row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; align-items: center; }
.lst-result__block { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; }
.lst-result__block strong { color: var(--text-bright); }
.lst-note { font-size: 11px; color: var(--text-muted); }
.lst-disclaimer { font-size: 11px; color: #cbd5e1; margin: 0; line-height: 1.45; }
.lst-criteria { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.lst-field { display: grid; gap: 4px; font-size: 12px; color: var(--text-dim); }
.lst-field > span { font-size: 11px; color: var(--text-muted); }
.lst-field--chks { grid-column: 1 / -1; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
.lst-foot-disclaimer { font-size: 11px; color: #64748b; text-align: center; margin: 8px 0 0; }
@media (max-width: 640px) {
.lst { padding: 12px; }
.lst-tools { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import ListingsTab from './components/ListingsTab';
import ToolsTab from './components/ToolsTab';
import CriteriaTab from './components/CriteriaTab';
import './Listings.css';
const TABS = [
{ id: 'listings', label: '매물' },
{ id: 'tools', label: '판정 도구' },
{ id: 'criteria', label: '조건' },
];
const Listings = () => {
const [tab, setTab] = useState('listings');
return (
<div className="lst">
<header className="lst-header">
<div>
<p className="lst-kicker">실매물 · 안전마진</p>
<h1>매물 · 안전마진</h1>
<p className="lst-sub">매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.</p>
</div>
<Link className="lst-filter-btn" to="/realestate">청약으로 </Link>
</header>
<div className="lst-tabbar">
{TABS.map((t) => (
<button key={t.id} type="button"
className={`lst-tabbtn ${tab === t.id ? 'is-active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
{tab === 'listings' && <ListingsTab />}
{tab === 'tools' && <ToolsTab />}
{tab === 'criteria' && <CriteriaTab />}
<p className="lst-foot-disclaimer">
모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요.
</p>
</div>
);
};
export default Listings;

View File

@@ -0,0 +1,19 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
vi.mock('./components/ListingsTab.jsx', () => ({ default: () => <div>LISTINGS_TAB</div> }));
vi.mock('./components/ToolsTab.jsx', () => ({ default: () => <div>TOOLS_TAB</div> }));
vi.mock('./components/CriteriaTab.jsx', () => ({ default: () => <div>CRITERIA_TAB</div> }));
vi.mock('react-router-dom', () => ({ Link: ({ children }) => <a>{children}</a> }));
import Listings from './Listings.jsx';
describe('Listings', () => {
it('기본 매물 탭 렌더 + 탭 전환', () => {
render(<Listings />);
expect(screen.getByText('LISTINGS_TAB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '판정 도구' }));
expect(screen.getByText('TOOLS_TAB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '조건' }));
expect(screen.getByText('CRITERIA_TAB')).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import { Link } from 'react-router-dom';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import DistrictTierEditor from './components/DistrictTierEditor';
@@ -1598,6 +1599,11 @@ function Subscription() {
return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="sub">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
실매물 · 안전마진
</Link>
</div>
{/* Header */}
<div className="sub-header">
<div>

View File

@@ -24,6 +24,7 @@ const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
const Listings = lazy(() => import('./pages/listings/Listings'));
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
@@ -214,6 +215,10 @@ export const appRoutes = [
path: 'realestate',
element: <Subscription />,
},
{
path: 'realestate/listings',
element: <Listings />,
},
{
path: 'travel',
element: <Travel />,