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:
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 { 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>
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user