From 3b3e4a1ee13294255545b11796fd8ecd55871f3c Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 10:51:45 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(subscription):=20DEFAULT=5FPROFILE?= =?UTF-8?q?=20=EC=8B=A0=EA=B7=9C=203=ED=95=84=EB=93=9C=20+=20extractTier?= =?UTF-8?q?=20=ED=97=AC=ED=8D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/subscription/Subscription.jsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index af6febc..d41c2f0 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -30,9 +30,24 @@ const DEFAULT_PROFILE = { has_newborn: false, is_first_home: false, income_level: '', preferred_regions: '', preferred_types: '', min_area: '', max_area: '', max_price: '', + // 신규 (자치구 5티어 + 알림 설정) + preferred_districts: {}, + min_match_score: 70, + notify_enabled: true, }; // ── 유틸 ────────────────────────────────────────────────────────────────────── + +// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S") +// eslint-disable-next-line no-unused-vars +function extractTier(reasons) { + for (const r of reasons || []) { + const m = r.match(/자치구 ([SABCD])티어/); + if (m) return m[1]; + } + return null; +} + const fmt = (d) => { if (!d) return '-'; return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }); From 9e5521d784c9313102d2313d3b1db7e1af3d6932 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 10:55:54 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(subscription):=20DistrictTierEditor?= =?UTF-8?q?=20=E2=80=94=20=EC=9E=90=EC=B9=98=EA=B5=AC=205=ED=8B=B0?= =?UTF-8?q?=EC=96=B4=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C?= =?UTF-8?q?=EB=A1=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../components/DistrictTierEditor.jsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/pages/subscription/components/DistrictTierEditor.jsx diff --git a/src/pages/subscription/components/DistrictTierEditor.jsx b/src/pages/subscription/components/DistrictTierEditor.jsx new file mode 100644 index 0000000..21b5b1f --- /dev/null +++ b/src/pages/subscription/components/DistrictTierEditor.jsx @@ -0,0 +1,167 @@ +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 = () => 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 ( +
+
+

자치구 우선순위

+

지역 5티어

+
+
+ {TIERS.map(t => ( +
+ + {t.label} {t.weight} + + + {(current[t.key] || []).length === 0 + ? (없음) + : (current[t.key] || []).join(", ")} + +
+ ))} +

✏️ 자치구 분류는 PC에서 편집할 수 있어요

+
+
+ ); + } + + return ( +
+
+

자치구 우선순위

+

지역 5티어 (드래그해서 분류)

+
+
+ {/* 미할당 풀 */} +
onDragOver(e, "_unassigned")} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, null)} + > +

미할당 ({unassigned.length})

+
+ {unassigned.map(d => ( + onDragStart(e, d)} + className="sub-chip sub-chip--district dte-chip" + > + {d} + + ))} +
+
+ + {/* 5티어 그리드 */} +
+ {TIERS.map(t => ( +
onDragOver(e, t.key)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, t.key)} + > +
+ {t.label} {t.weight} +
+
+ {(current[t.key] || []).map(d => ( + onDragStart(e, d)} + className="sub-chip sub-chip--district dte-chip" + > + {d} + + + ))} +
+
+ ))} +
+
+
+ ); +} From 344caace3a2e3299357c9fc3ba55268ed16c1c62 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 10:58:45 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(subscription):=20NotificationSetting?= =?UTF-8?q?s=20=E2=80=94=20=EC=9E=84=EA=B3=84=EA=B0=92=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=8D=94=20+=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/NotificationSettings.jsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/pages/subscription/components/NotificationSettings.jsx diff --git a/src/pages/subscription/components/NotificationSettings.jsx b/src/pages/subscription/components/NotificationSettings.jsx new file mode 100644 index 0000000..d65443e --- /dev/null +++ b/src/pages/subscription/components/NotificationSettings.jsx @@ -0,0 +1,52 @@ +export default function NotificationSettings({ minScore, notifyEnabled, onChange }) { + const score = minScore ?? 70; + const enabled = notifyEnabled ?? true; + + return ( +
+
+

알림 설정

+

🔔 텔레그램 알림

+
+
+ + + + +

+ {enabled + ? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.` + : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."} +

+
+
+ ); +} From 60f17ff3e03389770d433e59029f7bb5b98ed5d4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 11:01:41 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(subscription):=20ProfileTab=EC=97=90?= =?UTF-8?q?=205=ED=8B=B0=EC=96=B4/=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/subscription/Subscription.jsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index d41c2f0..c41e732 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import PullToRefresh from '../../components/PullToRefresh'; import FAB from '../../components/FAB'; +import DistrictTierEditor from './components/DistrictTierEditor'; +import NotificationSettings from './components/NotificationSettings'; import './Subscription.css'; // ── 상수 ─────────────────────────────────────────────────────────────────────── @@ -1027,6 +1029,13 @@ function ProfileTab() { if (payload.preferred_regions.length === 0) payload.preferred_regions = 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); if (updated && Object.keys(updated).length > 0) { // Convert arrays back to comma-separated strings for display @@ -1304,6 +1313,19 @@ function ProfileTab() { + + {/* 자치구 5티어 */} + setProfile(prev => ({ ...prev, preferred_districts: next }))} + /> + + {/* 알림 설정 */} + setProfile(prev => ({ ...prev, ...patch }))} + /> From f6e78ac0ca697c97386635052b90dd9ca731f106 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 11:04:13 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(subscription):=205=ED=8B=B0=EC=96=B4?= =?UTF-8?q?=20=EB=B1=83=EC=A7=80=20+=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=EC=98=81=EC=97=AD=20+=20=ED=86=A0=EA=B8=80=20+=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=8D=94=20=EC=8A=A4=ED=83=80=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/subscription/Subscription.css | 228 ++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/src/pages/subscription/Subscription.css b/src/pages/subscription/Subscription.css index aa83260..ecb042a 100644 --- a/src/pages/subscription/Subscription.css +++ b/src/pages/subscription/Subscription.css @@ -1178,3 +1178,231 @@ 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; + } +} From 0a0ab05e41d41096b60d22f35f9bc9e29b549ac2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 11:06:32 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(subscription):=20AnnouncementCard?= =?UTF-8?q?=EC=97=90=20district=20+=205=ED=8B=B0=EC=96=B4=20=EB=B1=83?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/subscription/Subscription.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx index c41e732..5c0b8aa 100644 --- a/src/pages/subscription/Subscription.jsx +++ b/src/pages/subscription/Subscription.jsx @@ -41,7 +41,6 @@ const DEFAULT_PROFILE = { // ── 유틸 ────────────────────────────────────────────────────────────────────── // 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S") -// eslint-disable-next-line no-unused-vars function extractTier(reasons) { for (const r of reasons || []) { const m = r.match(/자치구 ([SABCD])티어/); @@ -358,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) { {item.match_score}점 )} + {item.district && ( + {item.district} + )} + {(() => { + const tier = extractTier(item.match_reasons); + return tier ? ( + + {tier}티어 + + ) : null; + })()}