From 690964c994ab1ed9599366c4f8261f31577fd6db Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 16:28:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(listings):=20Listings=20shell+=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC+=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=20+=20=EC=B2=AD=EC=95=BD=20=EA=B5=90=EC=B0=A8?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20+=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- CLAUDE.md | 7 +++ src/pages/listings/Listings.css | 65 +++++++++++++++++++++++++ src/pages/listings/Listings.jsx | 46 +++++++++++++++++ src/pages/listings/Listings.test.jsx | 19 ++++++++ src/pages/subscription/Subscription.jsx | 6 +++ src/routes.jsx | 5 ++ 6 files changed, 148 insertions(+) create mode 100644 src/pages/listings/Listings.css create mode 100644 src/pages/listings/Listings.jsx create mode 100644 src/pages/listings/Listings.test.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 3828027..1061497 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ | `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) | | `/realestate` | `Subscription` | 청약 자격·일정 관리
• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글
• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시
• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) | | `/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 서비스 | diff --git a/src/pages/listings/Listings.css b/src/pages/listings/Listings.css new file mode 100644 index 0000000..39cda8a --- /dev/null +++ b/src/pages/listings/Listings.css @@ -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; } +} diff --git a/src/pages/listings/Listings.jsx b/src/pages/listings/Listings.jsx new file mode 100644 index 0000000..9d815a2 --- /dev/null +++ b/src/pages/listings/Listings.jsx @@ -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 ( +
+
+
+

실매물 · 안전마진

+

매물 · 안전마진

+

매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.

+
+ 청약으로 → +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'listings' && } + {tab === 'tools' && } + {tab === 'criteria' && } + +

+ ※ 모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요. +

+
+ ); +}; + +export default Listings; diff --git a/src/pages/listings/Listings.test.jsx b/src/pages/listings/Listings.test.jsx new file mode 100644 index 0000000..4565199 --- /dev/null +++ b/src/pages/listings/Listings.test.jsx @@ -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: () =>
LISTINGS_TAB
})); +vi.mock('./components/ToolsTab.jsx', () => ({ default: () =>
TOOLS_TAB
})); +vi.mock('./components/CriteriaTab.jsx', () => ({ default: () =>
CRITERIA_TAB
})); +vi.mock('react-router-dom', () => ({ Link: ({ children }) => {children} })); +import Listings from './Listings.jsx'; + +describe('Listings', () => { + it('기본 매물 탭 렌더 + 탭 전환', () => { + render(); + 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(); + }); +}); diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index d8a0ae1..08630bc 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -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 (
+
+ + 실매물 · 안전마진 → + +
{/* Header */}
diff --git a/src/routes.jsx b/src/routes.jsx index 9532f3d..8cb5845 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -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: , }, + { + path: 'realestate/listings', + element: , + }, { path: 'travel', element: ,