Compare commits
16 Commits
a50c6c8be2
...
573c0364bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 573c0364bb | |||
| 7f42ff3594 | |||
| 1c331f209a | |||
| c87e764063 | |||
| 80fcb07fc0 | |||
| a9a6808005 | |||
| 0a0ab05e41 | |||
| f6e78ac0ca | |||
| 60f17ff3e0 | |||
| 344caace3a | |||
| 9e5521d784 | |||
| 3b3e4a1ee1 | |||
| a9d9540f61 | |||
| c68cee502a | |||
| 1bd680e47f | |||
| 60655f8ba9 |
@@ -17,7 +17,7 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/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` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
@@ -113,7 +113,8 @@ proxy: {
|
||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||
| 부동산 | 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` |
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
|
||||
147
src/components/LogoLoop.css
Normal file
147
src/components/LogoLoop.css
Normal file
@@ -0,0 +1,147 @@
|
||||
.logoloop {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logoloop:not(.logoloop--vertical) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.logoloop--vertical {
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logoloop__track {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: max-content;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logoloop__track--vertical {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logoloop__track {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.logoloop__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logoloop__list--vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logoloop__item {
|
||||
flex: none;
|
||||
font-size: var(--logoloop-logoHeight, 36px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logoloop__list:not(.logoloop__list--vertical) .logoloop__item {
|
||||
margin-right: var(--logoloop-gap, 32px);
|
||||
}
|
||||
|
||||
.logoloop__list--vertical .logoloop__item {
|
||||
margin-bottom: var(--logoloop-gap, 32px);
|
||||
}
|
||||
|
||||
.logoloop__item--scalable {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.logoloop__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s linear;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.logoloop__link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logoloop__link:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.logoloop__node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logoloop__node--scale,
|
||||
.logoloop__img--scale {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.logoloop__item--scalable:hover .logoloop__node--scale,
|
||||
.logoloop__item--scalable:hover .logoloop__img--scale {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.logoloop__img {
|
||||
height: var(--logoloop-logoHeight, 36px);
|
||||
width: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
|
||||
.logoloop__fade {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logoloop__fade--left {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--right {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--top {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.logoloop__fade--bottom {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: clamp(24px, 8%, 120px);
|
||||
background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%);
|
||||
}
|
||||
322
src/components/LogoLoop.jsx
Normal file
322
src/components/LogoLoop.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import './LogoLoop.css';
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
SMOOTH_TAU: 0.25,
|
||||
MIN_COPIES: 2,
|
||||
COPY_HEADROOM: 2,
|
||||
};
|
||||
|
||||
const toCssLength = (value) =>
|
||||
typeof value === 'number' ? `${value}px` : value ?? undefined;
|
||||
|
||||
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
||||
|
||||
function useResizeObserver(callback, elements, deps) {
|
||||
useEffect(() => {
|
||||
if (!window.ResizeObserver) {
|
||||
const handler = () => callback();
|
||||
window.addEventListener('resize', handler);
|
||||
callback();
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}
|
||||
const observers = elements.map((ref) => {
|
||||
if (!ref.current) return null;
|
||||
const observer = new ResizeObserver(callback);
|
||||
observer.observe(ref.current);
|
||||
return observer;
|
||||
});
|
||||
callback();
|
||||
return () => {
|
||||
observers.forEach((o) => o?.disconnect());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
function useImageLoader(seqRef, onLoad, deps) {
|
||||
useEffect(() => {
|
||||
const images = seqRef.current?.querySelectorAll('img') ?? [];
|
||||
if (images.length === 0) {
|
||||
onLoad();
|
||||
return;
|
||||
}
|
||||
let remaining = images.length;
|
||||
const handleLoad = () => {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) onLoad();
|
||||
};
|
||||
images.forEach((img) => {
|
||||
if (img.complete) {
|
||||
handleLoad();
|
||||
} else {
|
||||
img.addEventListener('load', handleLoad, { once: true });
|
||||
img.addEventListener('error', handleLoad, { once: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
images.forEach((img) => {
|
||||
img.removeEventListener('load', handleLoad);
|
||||
img.removeEventListener('error', handleLoad);
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
|
||||
const rafRef = useRef(null);
|
||||
const lastTsRef = useRef(null);
|
||||
const offsetRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const seqSize = isVertical ? seqHeight : seqWidth;
|
||||
|
||||
if (seqSize > 0) {
|
||||
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
|
||||
track.style.transform = isVertical
|
||||
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
|
||||
if (prefersReduced) {
|
||||
track.style.transform = 'translate3d(0, 0, 0)';
|
||||
return () => {
|
||||
lastTsRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
const animate = (ts) => {
|
||||
if (lastTsRef.current === null) lastTsRef.current = ts;
|
||||
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
|
||||
lastTsRef.current = ts;
|
||||
|
||||
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
|
||||
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
|
||||
velocityRef.current += (target - velocityRef.current) * easing;
|
||||
|
||||
if (seqSize > 0) {
|
||||
let next = offsetRef.current + velocityRef.current * dt;
|
||||
next = ((next % seqSize) + seqSize) % seqSize;
|
||||
offsetRef.current = next;
|
||||
track.style.transform = isVertical
|
||||
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
||||
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
lastTsRef.current = null;
|
||||
};
|
||||
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
|
||||
}
|
||||
|
||||
export default function LogoLoop({
|
||||
logos,
|
||||
speed = 60,
|
||||
direction = 'left',
|
||||
width = '100%',
|
||||
logoHeight = 36,
|
||||
gap = 32,
|
||||
pauseOnHover = true,
|
||||
hoverSpeed,
|
||||
fadeOut = true,
|
||||
fadeOutColor,
|
||||
scaleOnHover = true,
|
||||
ariaLabel = 'Skill logos',
|
||||
className,
|
||||
style,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const seqRef = useRef(null);
|
||||
|
||||
const [seqWidth, setSeqWidth] = useState(0);
|
||||
const [seqHeight, setSeqHeight] = useState(0);
|
||||
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const effectiveHoverSpeed = useMemo(() => {
|
||||
if (hoverSpeed !== undefined) return hoverSpeed;
|
||||
if (pauseOnHover === true) return 0;
|
||||
if (pauseOnHover === false) return undefined;
|
||||
return 0;
|
||||
}, [hoverSpeed, pauseOnHover]);
|
||||
|
||||
const isVertical = direction === 'up' || direction === 'down';
|
||||
|
||||
const targetVelocity = useMemo(() => {
|
||||
const magnitude = Math.abs(speed);
|
||||
const dirMul = isVertical
|
||||
? direction === 'up'
|
||||
? 1
|
||||
: -1
|
||||
: direction === 'left'
|
||||
? 1
|
||||
: -1;
|
||||
const speedMul = speed < 0 ? -1 : 1;
|
||||
return magnitude * dirMul * speedMul;
|
||||
}, [speed, direction, isVertical]);
|
||||
|
||||
const updateDimensions = useCallback(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
||||
const seqRect = seqRef.current?.getBoundingClientRect?.();
|
||||
const sw = seqRect?.width ?? 0;
|
||||
const sh = seqRect?.height ?? 0;
|
||||
if (isVertical) {
|
||||
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
|
||||
if (containerRef.current && parentH > 0) {
|
||||
const h = Math.ceil(parentH);
|
||||
if (containerRef.current.style.height !== `${h}px`)
|
||||
containerRef.current.style.height = `${h}px`;
|
||||
}
|
||||
if (sh > 0) {
|
||||
setSeqHeight(Math.ceil(sh));
|
||||
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
|
||||
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||
}
|
||||
} else if (sw > 0) {
|
||||
setSeqWidth(Math.ceil(sw));
|
||||
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
|
||||
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
||||
}
|
||||
}, [isVertical]);
|
||||
|
||||
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
|
||||
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
|
||||
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
|
||||
|
||||
const cssVars = useMemo(() => ({
|
||||
'--logoloop-gap': `${gap}px`,
|
||||
'--logoloop-logoHeight': `${logoHeight}px`,
|
||||
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
|
||||
}), [gap, logoHeight, fadeOutColor]);
|
||||
|
||||
const containerStyle = useMemo(() => ({
|
||||
width: isVertical
|
||||
? toCssLength(width) === '100%'
|
||||
? undefined
|
||||
: toCssLength(width)
|
||||
: toCssLength(width) ?? '100%',
|
||||
...cssVars,
|
||||
...style,
|
||||
}), [width, cssVars, style, isVertical]);
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
|
||||
}, [effectiveHoverSpeed]);
|
||||
const handleLeave = useCallback(() => {
|
||||
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
|
||||
}, [effectiveHoverSpeed]);
|
||||
|
||||
const renderItem = (item, key) => {
|
||||
const isNode = 'node' in item;
|
||||
const inner = isNode ? (
|
||||
<span
|
||||
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
|
||||
aria-hidden={!!item.href && !item.ariaLabel}
|
||||
>
|
||||
{item.node}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
|
||||
src={item.src}
|
||||
srcSet={item.srcSet}
|
||||
sizes={item.sizes}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
alt={item.alt ?? ''}
|
||||
title={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
|
||||
const wrapper = item.href ? (
|
||||
<a
|
||||
className="logoloop__link"
|
||||
href={item.href}
|
||||
aria-label={itemAriaLabel || 'logo link'}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
|
||||
key={key}
|
||||
role="listitem"
|
||||
>
|
||||
{wrapper}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const lists = useMemo(
|
||||
() =>
|
||||
Array.from({ length: copyCount }, (_, i) => (
|
||||
<ul
|
||||
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
|
||||
key={`copy-${i}`}
|
||||
role="list"
|
||||
aria-hidden={i > 0}
|
||||
ref={i === 0 ? seqRef : undefined}
|
||||
>
|
||||
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
|
||||
</ul>
|
||||
)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[copyCount, logos, isVertical, scaleOnHover],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
|
||||
style={containerStyle}
|
||||
role="region"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{fadeOut && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
|
||||
ref={trackRef}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{lists}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -378,6 +378,42 @@
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
/* ── Skill Logo Loop ─────────────────────────────────────────────────── */
|
||||
|
||||
.pf-skill-loop {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px 28px;
|
||||
}
|
||||
|
||||
.pf-skill-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pf-skill-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-bright);
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
border: 1px solid rgba(6, 182, 212, 0.35);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Filter Bar ──────────────────────────────────────────────────────── */
|
||||
|
||||
.pf-filter-bar {
|
||||
|
||||
@@ -6,6 +6,45 @@ const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', inf
|
||||
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
|
||||
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
|
||||
|
||||
const SKILL_LOGO_SLUGS = {
|
||||
Python: 'python',
|
||||
JavaScript: 'javascript',
|
||||
SQL: 'mysql',
|
||||
'HTML/CSS': 'html5',
|
||||
FastAPI: 'fastapi',
|
||||
React: 'react',
|
||||
Vite: 'vite',
|
||||
Docker: 'docker',
|
||||
'Synology NAS': 'synology',
|
||||
Nginx: 'nginx',
|
||||
Gitea: 'gitea',
|
||||
SQLite: 'sqlite',
|
||||
Linux: 'linux',
|
||||
Git: 'git',
|
||||
'Claude API': 'anthropic',
|
||||
Ollama: 'ollama',
|
||||
'Suno API': 'suno',
|
||||
};
|
||||
|
||||
function SkillLogoNode({ name, slug }) {
|
||||
const [error, setError] = useState(false);
|
||||
if (!slug || error) {
|
||||
return <span className="pf-skill-fallback">{name}</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
className="pf-skill-logo"
|
||||
src={`https://cdn.simpleicons.org/${slug}`}
|
||||
alt={name}
|
||||
title={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
const { profile, careers, skills } = data;
|
||||
const [editingProfile, setEditingProfile] = useState(null);
|
||||
@@ -180,19 +219,29 @@ export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
items.length > 0 && (
|
||||
<div key={cat} className="pf-skill-group">
|
||||
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
||||
<div className="pf-skill-group__tags">
|
||||
{items.map((s) => (
|
||||
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
||||
{s.name}
|
||||
{editing && (
|
||||
{editing ? (
|
||||
<div className="pf-skill-group__tags">
|
||||
{items.map((s) => (
|
||||
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
||||
{s.name}
|
||||
<span className="pf-skill-tag__actions">
|
||||
<button onClick={() => setSkillForm({...s})}>✎</button>
|
||||
<button onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pf-skill-loop" aria-label={`${SKILL_CATEGORIES[cat]} 기술 스택`}>
|
||||
{items.map((s) => (
|
||||
<SkillLogoNode
|
||||
key={s.id}
|
||||
name={s.name}
|
||||
slug={SKILL_LOGO_SLUGS[s.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,8 @@ const BASE = '/api/profile';
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
|
||||
@@ -1178,3 +1178,359 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 신규: 자치구 5티어 + district 뱃지 (다크/네온 테마) =========== */
|
||||
.sub-chip--district {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sub-chip--tier {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
/* 페이지 accent 컬러 팔레트와 정렬: rose / orange / mint / cyan / purple */
|
||||
.sub-chip--tier-S {
|
||||
background: rgba(244, 63, 94, 0.14);
|
||||
color: #fda4af;
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||
}
|
||||
.sub-chip--tier-A {
|
||||
background: rgba(251, 146, 60, 0.14);
|
||||
color: #fdba74;
|
||||
border: 1px solid rgba(251, 146, 60, 0.4);
|
||||
box-shadow: 0 0 12px rgba(251, 146, 60, 0.16);
|
||||
}
|
||||
.sub-chip--tier-B {
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||
box-shadow: 0 0 12px rgba(52, 211, 153, 0.16);
|
||||
}
|
||||
.sub-chip--tier-C {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
box-shadow: 0 0 12px rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
.sub-chip--tier-D {
|
||||
background: rgba(192, 132, 252, 0.14);
|
||||
color: #d8b4fe;
|
||||
border: 1px solid rgba(192, 132, 252, 0.4);
|
||||
box-shadow: 0 0 12px rgba(192, 132, 252, 0.16);
|
||||
}
|
||||
|
||||
/* === 신규: DistrictTierEditor (다크/glass + rose accent) =========== */
|
||||
.dte-pool {
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
background: var(--surface-card);
|
||||
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||
}
|
||||
.dte-pool--over {
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
border-style: solid;
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
}
|
||||
.dte-pool__title {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--accent-subscription);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
.dte-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-chip {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
transition: background 0.15s var(--ease-out), border-color 0.15s var(--ease-out), transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
.dte-chip:hover {
|
||||
border-color: rgba(244, 63, 94, 0.4);
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||
}
|
||||
.dte-chip:active { cursor: grabbing; transform: translateY(0); }
|
||||
.dte-chip__remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
margin-left: 6px;
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.dte-chip__remove:hover { color: var(--accent-subscription); opacity: 1; }
|
||||
|
||||
.dte-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.dte-zone {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px;
|
||||
min-height: 140px;
|
||||
background: var(--surface-card);
|
||||
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||
}
|
||||
.dte-zone--over {
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
}
|
||||
.dte-zone__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dte-zone__weight {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.dte-zone__chips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 모바일 read-only 뷰 */
|
||||
.dte-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line-subtle);
|
||||
}
|
||||
.dte-row:last-of-type { border-bottom: 0; }
|
||||
.dte-row__list {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dte-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dte-mobile-hint {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--line-subtle);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === 신규: NotificationSettings (다크/rose glow) =================== */
|
||||
.ns-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.ns-row--column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.ns-row__label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ns-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sub-toggle {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s var(--ease-out), border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
|
||||
margin: 0;
|
||||
}
|
||||
.sub-toggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text-dim);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s var(--ease-spring), background 0.25s var(--ease-out);
|
||||
}
|
||||
.sub-toggle:checked {
|
||||
background: rgba(244, 63, 94, 0.25);
|
||||
border-color: rgba(244, 63, 94, 0.5);
|
||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
.sub-toggle:checked::before {
|
||||
transform: translateX(20px);
|
||||
background: var(--accent-subscription);
|
||||
box-shadow: 0 0 8px rgba(244, 63, 94, 0.6);
|
||||
}
|
||||
.sub-toggle__label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription); }
|
||||
|
||||
.ns-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
margin: 6px 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ns-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent-subscription);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||
}
|
||||
.ns-slider::-webkit-slider-thumb:hover { transform: scale(1.15); box-shadow: 0 0 18px rgba(244, 63, 94, 0.7); }
|
||||
.ns-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent-subscription);
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||
}
|
||||
.ns-slider:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ns-slider:disabled::-webkit-slider-thumb { background: var(--text-muted); box-shadow: none; }
|
||||
.ns-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.ns-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--accent-subscription);
|
||||
}
|
||||
|
||||
/* === 신규: 매칭 분석 섹션 (다크/glass) ============================== */
|
||||
.sub-match-analysis {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sub-match-analysis::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--accent-subscription), transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sub-match-analysis__score {
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-subscription);
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 0 24px rgba(244, 63, 94, 0.35);
|
||||
}
|
||||
.sub-match-analysis__reasons {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.sub-match-analysis__reasons li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.sub-match-analysis__reasons li::marker {
|
||||
color: var(--accent-subscription);
|
||||
}
|
||||
.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 PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
import NotificationSettings from './components/NotificationSettings';
|
||||
import './Subscription.css';
|
||||
|
||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||
@@ -30,9 +32,23 @@ 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")
|
||||
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' });
|
||||
@@ -341,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
||||
{item.match_score}점
|
||||
</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>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||
@@ -588,6 +615,39 @@ function AnnouncementDetail({ item, onBookmark }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -869,6 +929,17 @@ function MatchesTab() {
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||
{match.region_name || '-'}
|
||||
@@ -1012,6 +1083,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
|
||||
@@ -1289,6 +1367,19 @@ function ProfileTab() {
|
||||
</label>
|
||||
</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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,15 @@ export const navLinks = [
|
||||
icon: <IconHome />,
|
||||
accent: '#f7a8a5',
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
label: 'Portfolio',
|
||||
path: '/portfolio',
|
||||
subtitle: 'RESUME',
|
||||
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
||||
icon: <IconPortfolio />,
|
||||
accent: '#06b6d4',
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
label: 'Blog',
|
||||
@@ -47,6 +56,15 @@ export const navLinks = [
|
||||
icon: <IconBlog />,
|
||||
accent: '#c084fc',
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
label: 'Travel',
|
||||
path: '/travel',
|
||||
subtitle: 'VISUAL DIARY',
|
||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||
icon: <IconTravel />,
|
||||
accent: '#fb923c',
|
||||
},
|
||||
{
|
||||
id: 'lotto',
|
||||
label: 'Lotto',
|
||||
@@ -65,24 +83,6 @@ export const navLinks = [
|
||||
icon: <IconStock />,
|
||||
accent: '#60a5fa',
|
||||
},
|
||||
{
|
||||
id: 'realestate',
|
||||
label: 'Realestate',
|
||||
path: '/realestate',
|
||||
subtitle: '부동산',
|
||||
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||
icon: <IconBuilding />,
|
||||
accent: '#f43f5e',
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
label: 'Travel',
|
||||
path: '/travel',
|
||||
subtitle: 'VISUAL DIARY',
|
||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||
icon: <IconTravel />,
|
||||
accent: '#fb923c',
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
label: 'Music',
|
||||
@@ -92,6 +92,15 @@ export const navLinks = [
|
||||
icon: <IconMusic />,
|
||||
accent: '#f5a623',
|
||||
},
|
||||
{
|
||||
id: 'realestate',
|
||||
label: 'Realestate',
|
||||
path: '/realestate',
|
||||
subtitle: '부동산',
|
||||
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||
icon: <IconBuilding />,
|
||||
accent: '#f43f5e',
|
||||
},
|
||||
{
|
||||
id: 'blog-lab',
|
||||
label: 'Blog Lab',
|
||||
@@ -101,15 +110,6 @@ export const navLinks = [
|
||||
icon: <IconBlogMarketing />,
|
||||
accent: '#10b981',
|
||||
},
|
||||
{
|
||||
id: 'lab',
|
||||
label: 'Lab',
|
||||
path: '/lab',
|
||||
subtitle: 'STREAM',
|
||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||
icon: <IconLab />,
|
||||
accent: '#fbbf24',
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
label: 'Todo',
|
||||
@@ -119,15 +119,6 @@ export const navLinks = [
|
||||
icon: <IconTodo />,
|
||||
accent: '#f472b6',
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
label: 'Portfolio',
|
||||
path: '/portfolio',
|
||||
subtitle: 'RESUME',
|
||||
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
||||
icon: <IconPortfolio />,
|
||||
accent: '#06b6d4',
|
||||
},
|
||||
{
|
||||
id: 'agent-office',
|
||||
label: 'Agent Office',
|
||||
@@ -137,6 +128,15 @@ export const navLinks = [
|
||||
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||
accent: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'lab',
|
||||
label: 'Lab',
|
||||
path: '/lab',
|
||||
subtitle: 'STREAM',
|
||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||
icon: <IconLab />,
|
||||
accent: '#fbbf24',
|
||||
},
|
||||
];
|
||||
|
||||
export const appRoutes = [
|
||||
|
||||
Reference in New Issue
Block a user