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:
@@ -20,6 +20,7 @@
|
|||||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
|
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||||
@@ -138,6 +139,12 @@ proxy: {
|
|||||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||||
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
| 부동산 | 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` |
|
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||||
|
|||||||
65
src/pages/listings/Listings.css
Normal file
65
src/pages/listings/Listings.css
Normal 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; }
|
||||||
|
}
|
||||||
46
src/pages/listings/Listings.jsx
Normal file
46
src/pages/listings/Listings.jsx
Normal 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;
|
||||||
19
src/pages/listings/Listings.test.jsx
Normal file
19
src/pages/listings/Listings.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import PullToRefresh from '../../components/PullToRefresh';
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
import FAB from '../../components/FAB';
|
import FAB from '../../components/FAB';
|
||||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||||
@@ -1598,6 +1599,11 @@ function Subscription() {
|
|||||||
return (
|
return (
|
||||||
<PullToRefresh onRefresh={handleRefresh}>
|
<PullToRefresh onRefresh={handleRefresh}>
|
||||||
<div className="sub">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="sub-header">
|
<div className="sub-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const Stock = lazy(() => import('./pages/stock/Stock'));
|
|||||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||||
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||||
|
const Listings = lazy(() => import('./pages/listings/Listings'));
|
||||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||||
@@ -214,6 +215,10 @@ export const appRoutes = [
|
|||||||
path: 'realestate',
|
path: 'realestate',
|
||||||
element: <Subscription />,
|
element: <Subscription />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'realestate/listings',
|
||||||
|
element: <Listings />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'travel',
|
path: 'travel',
|
||||||
element: <Travel />,
|
element: <Travel />,
|
||||||
|
|||||||
Reference in New Issue
Block a user