정비사업 매칭 (광고주 제품)
정비사업 집행부와 검증된 시공사·신탁사·설계사를 잇는 1:1 매칭입니다. 광고주가 단지를 검색해 관심(찜)을 보내고, 집행부가 수락하면 양쪽 연락처가 상호 공개됩니다.
매칭의 종착점은 연락처 교환입니다. PRD의 인앱 1:1 DM은 아직 구현돼 있지 않습니다. “DM 매칭”이라는 표현이 기획 문서에 남아 있어도 현재 코드에는 채팅 화면이 없습니다.
전체 흐름
[광고주] 입점 신청 → (운영팀 수동 승인) → 로그인 → 단지 검색 → 찜 발송(월 한도 차감)
↓
[집행부] 받은 제안 확인 → 수락 / 거절(사유 필수)
↓
수락 시에만 양쪽 연락처 상호 공개승인 UI는 이 레포에 없습니다 — 운영팀이 백오피스에서 AdvertiserStatus를 REQUESTED → APPROVED로 바꿉니다.
화면
전부 apps/dashboard 안에 있습니다. apps/app·native-app에는 매칭 코드가 없습니다.
광고주 포털 — /advertiser (조합 [unionId] 밖 독립 트리)
| 경로 | 화면 | 인증 |
|---|---|---|
/advertiser/apply | 입점 신청 폼 | 공개 |
/advertiser/pending | 승인 대기 안내 | 공개 |
/advertiser | 단지 검색 | 게이트 안 |
/advertiser/interests | 내 찜 목록 | 게이트 안 |
/advertiser/me | 내 정보 | 게이트 안 |
셸은 대시보드 사이드바가 아니라 자체 top-nav(AdvertiserHeader)입니다. 외부 사용자용 별도 제품 표면이라 의도적으로 다릅니다.
페이지 폭은 AdvertiserPageContainer의 wide(960) / narrow(640) 두 단계만 씁니다.
집행부 — /[unionId]/matching-proposals
받은 제안 카드 목록 + 수락·거절·연락처 다이얼로그. 셸은 다른 대시보드 화면과 동일한 규칙
(h-12 헤더 + px-3 pb-3 안의 rounded-2xl 카드, 스크롤은 카드 내부).
사이드메뉴 노출은 아직 하드코딩된 조합 ID 하나로만 게이팅됩니다
(SideMenuItems.tsx의 AD_MATCHING_ALLOWED_UNION_ID). 다른 조합에서 테스트하려면 이 값을 봐야 합니다.
데이터 경계 — 가장 중요한 계약
packages/domains/types/matching.ts에 타입 주석으로 강제돼 있습니다. 어기면 개인정보 유출입니다.
| 타입 | 노출 시점 | 담는 것 |
|---|---|---|
RegionMatchingProposal | 수락 전에도 상시 | 회사명·유형·주소·회사 실적·메시지 |
MatchedAdvertiserContact | 수락 후에만 | 담당자 실명·부서·직책·연락처·개인 실적 |
담당자 정보를 RegionMatchingProposal에 절대 추가하지 마세요. 서버도 미수락 건에는
MATCH_NOT_ACCEPTED(403)로 차단합니다.
API
packages/api/lib/matching/{index.ts,schema.ts} — 두 클래스로 갈립니다.
MatchingPartnerAPI(광고주,v1/partner/*) — 9개 메서드DashboardMatchingAPI(집행부,v1/dashboard/region/{regionId}/matching-proposals*) — 4개
DB 스키마는 이 레포에 없습니다. 영속화는 백엔드(~/workspace/howmuchhome, FastAPI) 소유이고
프론트는 타입 + REST 클라이언트만 가집니다.
공유 컴포넌트
apps/dashboard/src/features/advertiser/ — 두 라우트 트리가 갈라져 있어 같은 값을 각자 정의하던 것을 모았습니다.
| 파일 | 용도 |
|---|---|
constants.ts | 광고주 유형·제안 상태·사업유형 라벨의 단일 소스 |
MatchingStatusTag / AdvertiserTypeBadge | 상태·유형 배지 |
MatchingEmptyState | 빈 상태 |
MatchingLoadingSkeleton | 로딩 스켈레톤 (aria-busy 밖에 role="status") |
DescriptionList | 연락처·프로필 <dl> |
ReferenceList | 정비사업 실적 목록 |
formatMatchingDate | KST 기준 YYYY.MM.DD |
matchingErrorMessage | 에러코드 → 한국어 문구 |
함정
전부 실제로 밟았거나 리뷰에서 잡힌 것입니다.
1. 에러 메시지를 그대로 토스트에 띄우지 말 것
ApiServerError.message는 `${errorCode} - ${detail}` 형식입니다(packages/api/error.ts).
서버 detail이 비면 사용자에게 MATCHING_PROPOSAL_QUOTA_EXCEEDED가 그대로 보입니다.
getMatchingErrorMessage(error, fallback)을 쓰세요.
2. 날짜를 createdAt.slice(0, 10)으로 자르지 말 것
UTC 날짜가 그대로 나와서 한국시간 자정 직후(UTC 전날 15시 이후)에 보낸 제안이 하루 전날로 보입니다.
formatMatchingDate를 쓰세요.
3. 상태 필터는 백엔드 지원이 없음
GetRegionProposalsRequest는 regionId/limit/offset만 받습니다. status 파라미터가 없어서
클라이언트에서 거르면 현재 페이지 안에서만 걸러지고 탭 건수도 틀리게 나옵니다.
상태 필터가 필요하면 백엔드에 쿼리 지원을 먼저 요청하세요.
4. 페이지네이션은 범위 이탈을 보정할 것
제안이 철회되면 total이 줄어 보고 있던 마지막 페이지가 범위를 벗어납니다.
빈 상태를 items.length === 0으로 판정하면 페이지네이션 바까지 사라져 빠져나올 수 없습니다.
total === 0일 때만 빈 상태를 띄우고, 범위를 벗어나면 마지막 유효 페이지로 되돌리세요.
5. RSC prefetch와 클라이언트 초기 상태의 limit/offset을 맞출 것
어긋나면 하이드레이션 직후 같은 데이터를 한 번 더 요청합니다.
기본 페이지 크기를 _lib/pagination.ts로 양쪽이 공유합니다.
6. 광고주 로그인은 공유 /sign-in 폴백
별도 로그인 페이지가 없습니다. dashboardSignIn 먼저 → 4xx면 MatchingPartnerAPI.login 폴백
(app/(auth)/sign-in/actions.tsx). getMe의 403은 “미승인 광고주”와 “비광고주”를 구분하지 못하므로,
어디로 보낼지는 어느 로그인이 성공했는지(loginType)로 판별합니다 (resolvePostLoginPath).
영업용 데모 — /demo
로그인 없이 광고주 시점 ↔ 집행부 시점을 오가며 전 시나리오를 시연하는 공개 라우트입니다.
apps/dashboard/src/app/demo/.
매칭 화면 컴포넌트의 로직을 복제하지 않고 재사용합니다. configureApiFetcher
(packages/api/lib/HowmuchhomeAPI.ts)로 브라우저 fetcher만 인메모리 스토어로 교체하는 방식입니다.
@MutationOptions의 invalidate 체인이 살아 있어 찜을 보내면 quota 배지가 실제로 줄어듭니다.
데모가 프로덕션과 다르게 보이는 부분 (HMH-9294)
고객이 “이렇게 바뀌면 좋겠다”는 안을 보고용으로 요청하면, 프로덕션을 먼저 바꾸지 않고 데모에서만 그 모습을 보여줍니다. 지금 어긋나 있는 건 둘입니다.
| 데모 | 프로덕션 | 어떻게 |
|---|---|---|
| 사이드바·헤더·탭이 “내 명함첩" | "매칭 제안” 그대로 | _client/labels.ts의 DEMO_INBOX_MENU_TITLE 한 곳에서만 정의 |
제안 카드가 명함 모양(DemoBizCard) | MatchingProposalCard 그대로 | MatchingProposalListSection의 presentation prop |
전문가 브라우징 메뉴(“전문가 명함첩”, DEMO_EXPERT_DIRECTORY_MENU_TITLE)는 프로덕션에 아직
없는 신규 기능이라 위 표의 “어긋남”과는 다른 종류입니다. 두 이름은 남의 명함을 모아 둔 곳 ↔
나에게 온 명함으로 짝지어 읽히도록 함께 정합니다 (HMH-9382) — 한쪽만 바꾸면 대비가 깨집니다.
메뉴를 누르면 카테고리를 고르는 화면 없이 전문가 전체 목록이 바로 열리고, 카테고리는 목록
상단 칩(전문가 수 포함)으로 좁힙니다. 광고 배너는 그 목록 상단에 있고 칩 선택을 따라갑니다.
presentation은 문구와 카드 컴포넌트만 주입받는 선택적 prop입니다. 페이지네이션·다이얼로그·
범위 보정 로직은 프로덕션 섹션에 그대로 두고, 넘기지 않으면 프로덕션 기본값이 쓰입니다 —
집행부 화면의 동작·외형은 이 prop이 생기기 전과 같습니다. 데모가 목록 섹션을 통째로 복제하면
안 되는 이유가 이겁니다: 복제본은 프로덕션이 고쳐질 때 조용히 뒤처집니다.
담기는 정보는 두 카드가 같아야 합니다. 명함 카드에서 항목을 빼면 “수락 전에는 회사 정보만 보인다”는 데모의 핵심 설명이 화면으로 증명되지 않습니다.
광고 배너 크리에이티브 — ?ad=
집행부 화면의 광고 지면은 목업입니다(노출·클릭 집계나 소재 입고 경로가 없음).
소재는 ?ad= 쿼리로 갈아끼웁니다 — _mock/fixtures.ts의 DEMO_AD_CREATIVES.
| URL | 소재 |
|---|---|
/demo | 기본. 가상 광고주(얼마집종합건설), 브랜드 로고 없음 |
/demo?ad=imsec | iM증권 로고·키컬러·카피 |
특정 광고주에게 자사 로고로 된 지면 그림을 보여줘야 할 때 쓰는 장치입니다. 기본 /demo는
가상 회사로 남겨두세요 — 다른 광고주 상담 자리에서 “저 회사는 이미 쓰고 있네”라는 오해가
생깁니다. 실존 업체명은 fixture 규칙상 금지이고, imsec이 그 유일한 예외입니다(사유는
DEMO_AD_CREATIVES.imsec 주석).
브랜드 소재를 추가할 때: 카피는 광고주가 보낸 문구를 그대로 씁니다(보고서에 그대로 들어감).
광고 표기와 지면 규격 캡션은 컴포넌트에 박혀 있어 뺄 수 없습니다 — 표시광고법상 필요하고,
목업에서 빼두면 실제 구현 때 “원래 없었잖아요”가 되기 쉽습니다.
로고는 ImSecLogo처럼 컴포넌트 하나로 분리해 두세요. 공식 파일을 나중에 받으면 거기만
갈아끼우면 됩니다.
데모를 만질 때 반드시 지킬 것
| 장치 | 이유 |
|---|---|
dynamic(ssr: false) | RSC가 렌더하면 서버엔 데모 fetcher가 없어 Next 서버가 실제 API를 호출한다 |
| 첫 렌더 전 설치 + effect에도 멱등 설치 | StrictMode가 mount→unmount→remount 하며 cleanup으로 fetcher를 지운다. 재설치가 없으면 이후 요청이 전부 실제 API로 샌다 (실제로 밟음) |
| fail-closed 핸들러 | 등록되지 않은 경로는 throw. 네트워크로 나가는 경로가 구조적으로 없게 만든다 |
| 격리 CookieJar | AdvertiserSignOutButton이 실제 accesstoken을 지운다. 안 막으면 데모 중 로그아웃한 사람이 실제 세션을 잃는다 |
handlers.test.ts | 핸들러 경로가 packages/api와 중복이라, __apiMetadata.pathFn으로 실제 경로를 만들어 대조한다. 엔드포인트가 바뀌면 조용히 죽지 않고 CI에서 깨진다 |
데모를 수정했으면 프로덕션 빌드에서 전 시나리오를 돌린 뒤 DevTools Network에
v1/partner·v1/dashboard 요청이 0건인지 확인하세요.
관련 티켓
기반: HMH-7738(Phase A~C) · HMH-7740(Phase D) · HMH-7756(집행부 화면) · HMH-7838/7847/7852(로그인) · HMH-8516(등록정보 확장) · HMH-8723(유형 5종) 디자인 정리: HMH-8900(공유 프리미티브) · HMH-8907(집행부) · HMH-8909(광고주 포털) · HMH-8913(데모) 데모 보고용 변형: HMH-9294(iM증권 배너 크리에이티브 · 명함 카드 · 매칭 제안 → “명함첩” 이름 변경안) 전문가 브라우징: HMH-9341(카테고리 브라우징 · 역방향 명함교환) · HMH-9382(“전문가 명함첩”/“내 명함첩” 이름 조합 · 목록 직접 진입)