Merge branch 'feat/realestate-frontend-targeting'
청약 페이지 자치구 5티어 + 알림 설정 UI — 9 task TDD 구현 - DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only - NotificationSettings: 임계값 슬라이더 + 알림 토글 - AnnouncementCard / MatchesTab: district + 5티어 뱃지 - AnnouncementDetail: 매칭 분석 섹션 (점수 + 사유 + 자격) - 백엔드 스펙: web-backend 2026-04-28-realestate-targeting-enhancement - 빌드 clean, 린트 baseline 유지 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• 백엔드 스펙: `web-backend/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md` |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
@@ -113,7 +113,8 @@ proxy: {
|
|||||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||||
| 부동산 | PUT | `/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, ... }` |
|
||||||
| 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 서비스 |
|
||||||
|
|||||||
@@ -1178,3 +1178,231 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 신규: 자치구 5티어 + district 뱃지 ============================== */
|
||||||
|
.sub-chip--district {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.sub-chip--tier {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sub-chip--tier-S { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.sub-chip--tier-A { background: #fef3c7; color: #d97706; border-color: #fcd34d; }
|
||||||
|
.sub-chip--tier-B { background: #d1fae5; color: #059669; border-color: #6ee7b7; }
|
||||||
|
.sub-chip--tier-C { background: #dbeafe; color: #2563eb; border-color: #93c5fd; }
|
||||||
|
.sub-chip--tier-D { background: #ede9fe; color: #7c3aed; border-color: #c4b5fd; }
|
||||||
|
|
||||||
|
/* === 신규: DistrictTierEditor ====================================== */
|
||||||
|
.dte-pool {
|
||||||
|
border: 1px dashed var(--border-soft, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.dte-pool--over {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.dte-pool__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.dte-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dte-chip {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dte-chip:active { cursor: grabbing; }
|
||||||
|
.dte-chip__remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.dte-chip__remove:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.dte-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dte-zone {
|
||||||
|
border: 1px solid var(--border-soft, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.dte-zone--over {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.dte-zone__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.dte-zone__weight {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.dte-zone__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 read-only 뷰 */
|
||||||
|
.dte-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft, #e5e7eb);
|
||||||
|
}
|
||||||
|
.dte-row:last-of-type { border-bottom: 0; }
|
||||||
|
.dte-row__list {
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.dte-empty {
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dte-mobile-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 신규: NotificationSettings ==================================== */
|
||||||
|
.ns-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ns-row--column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ns-row__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
}
|
||||||
|
.ns-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sub-toggle {
|
||||||
|
appearance: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 11px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sub-toggle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.sub-toggle:checked {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
.sub-toggle:checked::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.sub-toggle__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
.ns-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.ns-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ns-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
.ns-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 신규: 매칭 분석 섹션 ========================================== */
|
||||||
|
.sub-match-analysis {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-soft, #f9fafb);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__score {
|
||||||
|
font-family: var(--font-display, system-ui);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__elig {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 dte-grid → 1칼럼 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dte-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||||
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 NotificationSettings from './components/NotificationSettings';
|
||||||
import './Subscription.css';
|
import './Subscription.css';
|
||||||
|
|
||||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||||
@@ -30,9 +32,23 @@ const DEFAULT_PROFILE = {
|
|||||||
has_newborn: false, is_first_home: false, income_level: '',
|
has_newborn: false, is_first_home: false, income_level: '',
|
||||||
preferred_regions: '', preferred_types: '',
|
preferred_regions: '', preferred_types: '',
|
||||||
min_area: '', max_area: '', max_price: '',
|
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) => {
|
const fmt = (d) => {
|
||||||
if (!d) return '-';
|
if (!d) return '-';
|
||||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||||
@@ -341,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
|||||||
{item.match_score}점
|
{item.match_score}점
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||||
@@ -588,6 +615,39 @@ function AnnouncementDetail({ item, onBookmark }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.match_score !== undefined && item.match_score !== null && (
|
||||||
|
<div className="sub-match-analysis">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||||
|
<span className="sub-match-analysis__score">
|
||||||
|
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
|
||||||
|
<ul className="sub-match-analysis__reasons">
|
||||||
|
{item.match_reasons.map((r, idx) => (
|
||||||
|
<li key={idx}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||||
|
<div className="sub-match-analysis__elig">
|
||||||
|
{item.eligible_types.map(t => (
|
||||||
|
<span key={t} className="sub-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -869,6 +929,17 @@ function MatchesTab() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
||||||
|
{match.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(match.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<p className="sub-card__address" style={{ margin: 0 }}>
|
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||||
{match.region_name || '-'}
|
{match.region_name || '-'}
|
||||||
@@ -1012,6 +1083,13 @@ function ProfileTab() {
|
|||||||
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
||||||
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
||||||
|
|
||||||
|
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||||
|
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||||
|
? profile.preferred_districts
|
||||||
|
: {};
|
||||||
|
payload.min_match_score = profile.min_match_score ?? null;
|
||||||
|
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||||
|
|
||||||
const updated = await apiPut('/api/realestate/profile', payload);
|
const updated = await apiPut('/api/realestate/profile', payload);
|
||||||
if (updated && Object.keys(updated).length > 0) {
|
if (updated && Object.keys(updated).length > 0) {
|
||||||
// Convert arrays back to comma-separated strings for display
|
// Convert arrays back to comma-separated strings for display
|
||||||
@@ -1289,6 +1367,19 @@ function ProfileTab() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 자치구 5티어 */}
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SEOUL_DISTRICTS = [
|
||||||
|
"강남구","강동구","강북구","강서구","관악구",
|
||||||
|
"광진구","구로구","금천구","노원구","도봉구",
|
||||||
|
"동대문구","동작구","마포구","서대문구","서초구",
|
||||||
|
"성동구","성북구","송파구","양천구","영등포구",
|
||||||
|
"용산구","은평구","종로구","중구","중랑구",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ key: "S", label: "S", weight: "100%" },
|
||||||
|
{ key: "A", label: "A", weight: "80%" },
|
||||||
|
{ key: "B", label: "B", weight: "60%" },
|
||||||
|
{ key: "C", label: "C", weight: "40%" },
|
||||||
|
{ key: "D", label: "D", weight: "20%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
|
||||||
|
function useIsDesktop() {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const mq = window.matchMedia("(min-width: 768px)");
|
||||||
|
const handler = (e) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DistrictTierEditor({ value, onChange }) {
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||||
|
|
||||||
|
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||||
|
|
||||||
|
const unassigned = SEOUL_DISTRICTS.filter(
|
||||||
|
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||||
|
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
for (const t of Object.keys(next)) {
|
||||||
|
next[t] = (current[t] || []).filter(d => d !== district);
|
||||||
|
}
|
||||||
|
if (targetTier) {
|
||||||
|
next[targetTier] = [...next[targetTier], district];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e, district) => {
|
||||||
|
e.dataTransfer.setData("text/district", district);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
const onDragOver = (e, key) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragOver !== key) setDragOver(key);
|
||||||
|
};
|
||||||
|
const onDragLeave = (e) => {
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
setDragOver(null);
|
||||||
|
};
|
||||||
|
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const district = e.dataTransfer.getData("text/district");
|
||||||
|
setDragOver(null);
|
||||||
|
if (district) moveDistrict(district, targetTier);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDesktop) {
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div key={t.key} className="dte-row dte-row--readonly">
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} {t.weight}
|
||||||
|
</span>
|
||||||
|
<span className="dte-row__list">
|
||||||
|
{(current[t.key] || []).length === 0
|
||||||
|
? <span className="dte-empty">(없음)</span>
|
||||||
|
: (current[t.key] || []).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||||
|
{/* 미할당 풀 */}
|
||||||
|
<div
|
||||||
|
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, null)}
|
||||||
|
>
|
||||||
|
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||||
|
<div className="dte-chips">
|
||||||
|
{unassigned.map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5티어 그리드 */}
|
||||||
|
<div className="dte-grid">
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.key}
|
||||||
|
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, t.key)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, t.key)}
|
||||||
|
>
|
||||||
|
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dte-zone__chips">
|
||||||
|
{(current[t.key] || []).map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dte-chip__remove"
|
||||||
|
onClick={() => moveDistrict(d, null)}
|
||||||
|
aria-label={`${d} 미할당으로`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/subscription/components/NotificationSettings.jsx
Normal file
52
src/pages/subscription/components/NotificationSettings.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
|
||||||
|
const score = minScore ?? 70;
|
||||||
|
const enabled = notifyEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">알림 설정</p>
|
||||||
|
<h3>🔔 텔레그램 알림</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
|
||||||
|
<label className="ns-row">
|
||||||
|
<span className="ns-row__label">텔레그램 알림</span>
|
||||||
|
<span className="ns-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sub-toggle"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ns-row ns-row--column">
|
||||||
|
<span className="ns-row__label">매칭 임계값 — {score}점</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={score}
|
||||||
|
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
|
||||||
|
className="ns-slider"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<div className="ns-scale">
|
||||||
|
<span>0</span>
|
||||||
|
<span>50</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="ns-hint">
|
||||||
|
{enabled
|
||||||
|
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
|
||||||
|
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user