Compare commits
4 Commits
1ad720afe6
...
624156361a
| Author | SHA1 | Date | |
|---|---|---|---|
| 624156361a | |||
| 278126fd92 | |||
| 77a5c44cb5 | |||
| 46951d871a |
+386
-64
@@ -2,67 +2,19 @@
|
||||
|
||||
이 문서는 "완료 보고"가 아니라 검증 가능한 작업 목록이다. 각 WBS는 성공 기준을 통과해야 완료로 본다.
|
||||
|
||||
---
|
||||
|
||||
## 완료 판정 원칙
|
||||
|
||||
- 코드 변경만으로 완료 처리하지 않는다.
|
||||
- 서버 배포 대상 기능은 CI/CD 성공과 Playwright 브라우저 테스트 통과를 모두 요구한다.
|
||||
- 서버 배포 대상 기능은 CI/CD 성공과 실제 동작 확인을 요구한다.
|
||||
- API 기능은 단위 테스트 또는 통합 테스트와 함께 실제 HTTP 호출 결과를 확인한다.
|
||||
- DB 변경은 마이그레이션과 롤백 위험을 문서화한다.
|
||||
- 비밀값은 Gitea Secrets 또는 서버 환경변수로만 관리한다.
|
||||
|
||||
## WBS-OPS-01 배포 검증 게이트 고도화
|
||||
---
|
||||
|
||||
목표: curl/API만이 아니라 실제 브라우저 검증까지 통과해야 배포를 성공으로 본다.
|
||||
|
||||
성공 기준:
|
||||
- `dotnet build TaxBaik.sln -c Release` 경고 0, 오류 0
|
||||
- `dotnet test TaxBaik.sln -c Release --no-build` 전체 통과
|
||||
- CI 배포 후 Playwright가 `/taxbaik/admin/login`에서 실제 로그인 수행
|
||||
- 로그인 후 `/taxbaik/admin/dashboard` 도달
|
||||
- 브라우저 console error 및 page error 0개
|
||||
- `blog-seo`, `contact-submit`, `admin-password-change`가 배포본 기준으로 통과
|
||||
|
||||
Todo:
|
||||
- [x] Playwright Test 프로젝트 추가
|
||||
- [x] 관리자 로그인 E2E 추가
|
||||
- [x] CI 배포 후 Playwright 실행 단계 추가
|
||||
- [x] Playwright가 발견한 Blazor DI 결함 수정
|
||||
- [ ] CI run에서 Playwright 전체 통과 확인
|
||||
- [ ] 배포 검증에 블로그 상세/문의/비밀번호 변경 성공 기준 반영 확인
|
||||
|
||||
## WBS-AUTH-01 인증/비밀번호 운영 안정화
|
||||
|
||||
목표: DB 직접 수정 대신 API로 관리자 인증 운영 작업을 수행한다.
|
||||
|
||||
성공 기준:
|
||||
- 비밀번호 변경 API가 현재 비밀번호를 요구한다.
|
||||
- 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다.
|
||||
- 실패 응답은 민감 정보를 노출하지 않는다.
|
||||
- Playwright 로그인 테스트와 비밀번호 변경 테스트가 변경 후에도 통과한다.
|
||||
|
||||
Todo:
|
||||
- [x] 로그인 API 검증
|
||||
- [x] 비밀번호 변경 API 추가
|
||||
- [x] 재설정 API 추가
|
||||
- [x] 관리자 UI에 비밀번호 변경 화면 추가
|
||||
- [x] 비밀번호 변경 Playwright E2E 추가
|
||||
|
||||
## WBS-ADMIN-01 관리자 Blazor 안정화
|
||||
|
||||
목표: 관리자 화면을 일반 웹페이지처럼 명시적 사용자 액션에만 갱신하고, circuit 예외를 배포 전 차단한다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 주요 메뉴 대시보드/블로그/문의/설정 접근 시 circuit error 0개
|
||||
- 잘못된 DI 타입 주입 0건
|
||||
- 저장/삭제/상태 변경 액션은 성공/실패 메시지를 표시한다.
|
||||
- Playwright가 주요 메뉴 smoke test를 수행한다.
|
||||
|
||||
Todo:
|
||||
- [x] 중복 `/admin` 라우트 제거
|
||||
- [x] MudBlazor DI 타입 오류 수정
|
||||
- [x] 관리자 메뉴 smoke E2E 추가
|
||||
- [x] 설정 저장 TODO를 실제 DB 기반 기능으로 전환
|
||||
- [x] 대시보드/목록 읽기 성능 개선
|
||||
## ── 홈페이지 · SEO · UX ───────────────────────────
|
||||
|
||||
## WBS-UX-01 공개 홈페이지 UX/SEO 검증
|
||||
|
||||
@@ -86,24 +38,394 @@ Todo:
|
||||
- `tests/e2e/contact-submit.spec.ts`
|
||||
- `tests/e2e/inquiry-detail.spec.ts`
|
||||
|
||||
## WBS-UX-02 홈페이지 FAQ 섹션
|
||||
|
||||
목표: 방문자가 상담 전 자주 묻는 질문에서 직접 답을 얻고 전환율을 높인다.
|
||||
|
||||
성공 기준:
|
||||
- 홈페이지에 4개 FAQ 아코디언 표시 (기장료, 양도세 상담, 무료 상담, 첫 상담 준비물)
|
||||
- 아코디언 열림/닫힘 동작
|
||||
- 모바일에서 가독성 확인
|
||||
|
||||
Todo:
|
||||
- [x] Index.cshtml에 FAQ 아코디언 섹션 추가 (최종 CTA 앞)
|
||||
- [x] site.css faq-accordion / faq-item / faq-question / faq-answer 스타일
|
||||
- [ ] 배포 후 브라우저 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## ── 시즌별 마케팅 ───────────────────────────────
|
||||
|
||||
## WBS-MKT-01 시즌별 홈페이지 자동 전환
|
||||
|
||||
목표: 세무 신고 시즌마다 홈페이지 Hero·CTA·서비스 카드 순서가 자동 변경된다.
|
||||
|
||||
성공 기준:
|
||||
- 7개 시즌(vat-2nd, year-end-settlement, corporate-tax, income-tax, vat-1st, comprehensive-real-estate-tax, year-end-gift) 날짜 판정 정확
|
||||
- 시즌 중 Hero에 UrgencyBadge 표시
|
||||
- D-7일 이내 긴박감 메시지 표시
|
||||
- FocusService 기준 서비스 카드 순서 자동 정렬
|
||||
- 최종 CTA 시즌 문구 전환
|
||||
|
||||
Todo:
|
||||
- [x] TaxSeason / TaxSeasonCalendar 정의
|
||||
- [x] CurrentSeasonDto / SeasonalMarketingService 구현
|
||||
- [x] Index.cshtml Hero 시즌 분기 렌더링
|
||||
- [x] Index.cshtml 서비스 카드 cardOrder 정렬 로직
|
||||
- [x] Index.cshtml 최종 CTA 시즌 전환
|
||||
- [x] CLAUDE.md 섹션 13 세무 캘린더 하네스
|
||||
- [ ] 배포 후 시즌 날짜 경계값 수동 확인
|
||||
|
||||
## WBS-MKT-02 관리자 공지사항 (Announcement)
|
||||
|
||||
목표: 운영자가 홈페이지 최상단 배너를 등록·수정·삭제할 수 있다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 `/taxbaik/admin/announcements` 목록/생성/수정/삭제 동작
|
||||
- is_active=TRUE + 기간 조건(starts_at~ends_at)에 해당하는 공지만 홈페이지에 노출
|
||||
- 유형(info/banner/urgent) 별 색상 배지 표시
|
||||
- 홈페이지 최상단 announcement-bar 노출
|
||||
|
||||
Todo:
|
||||
- [x] V005__CreateAnnouncements.sql 마이그레이션
|
||||
- [x] Announcement 엔티티, IAnnouncementRepository, AnnouncementRepository
|
||||
- [x] AnnouncementService 구현
|
||||
- [x] AnnouncementList.razor, AnnouncementEdit.razor 관리자 화면
|
||||
- [x] Index.cshtml 공지사항 배너 렌더링
|
||||
- [x] MainLayout.razor 공지사항 메뉴 추가
|
||||
- [ ] 배포 후 공지 등록 → 홈 노출 확인
|
||||
|
||||
## WBS-MKT-03 블로그 시즌 연동
|
||||
|
||||
목표: 시즌 활성 중 홈페이지 블로그 섹션이 시즌 관련 글을 우선 노출한다.
|
||||
|
||||
배경: 세무 시즌에 맞는 콘텐츠를 전면에 배치해 상담 전환율과 SEO 체류시간을 높인다.
|
||||
|
||||
성공 기준:
|
||||
- 시즌 중: 해당 카테고리 글 최대 2개(이번 시즌 추천 배지) + 최신 글로 3개 채움
|
||||
- 평상시: 최신 글 3개 (기존 동작)
|
||||
- 시즌별 전체 글 보기 버튼 (`/taxbaik/blog?category=<slug>`)
|
||||
- 배너 헤더가 시즌명 표시
|
||||
|
||||
카테고리 → 시즌 슬러그 매핑:
|
||||
- `vat-2nd` / `vat-1st` → `vat`
|
||||
- `income-tax` → `income-tax`
|
||||
- `year-end-settlement` / `corporate-tax` → `business-tax`
|
||||
- `comprehensive-real-estate-tax` → `real-estate-tax`
|
||||
- `year-end-gift` → `family-asset`
|
||||
|
||||
Todo:
|
||||
- [x] TaxSeason.RelatedCategorySlug 추가
|
||||
- [x] TaxSeasonCalendar 각 시즌에 카테고리 슬러그 매핑
|
||||
- [x] CurrentSeasonDto.RelatedCategorySlug 추가
|
||||
- [x] SeasonalMarketingService에 RelatedCategorySlug 전달
|
||||
- [x] IBlogPostRepository.GetByCategorySlugAsync 추가
|
||||
- [x] BlogPostRepository.GetByCategorySlugAsync 구현
|
||||
- [x] BlogService.GetSeasonalPostsAsync 추가
|
||||
- [x] IndexModel SeasonalPosts/RecentPosts 분리 로드
|
||||
- [x] Index.cshtml 블로그 섹션 시즌 분기 렌더링
|
||||
- [x] site.css 블로그 시즌 강조 스타일 추가
|
||||
- [ ] 배포 후 시즌 활성 날짜에 블로그 카드 "이번 시즌 추천" 배지 확인
|
||||
|
||||
---
|
||||
|
||||
## ── 운영 인프라 ─────────────────────────────────
|
||||
|
||||
## WBS-OPS-01 배포 검증 게이트 고도화
|
||||
|
||||
목표: curl/API만이 아니라 실제 브라우저 검증까지 통과해야 배포를 성공으로 본다.
|
||||
|
||||
성공 기준:
|
||||
- `dotnet build TaxBaik.sln -c Release` 경고 0, 오류 0
|
||||
- `dotnet test TaxBaik.sln -c Release --no-build` 전체 통과
|
||||
- CI 배포 후 Playwright가 `/taxbaik/admin/login`에서 실제 로그인 수행
|
||||
- 로그인 후 `/taxbaik/admin/dashboard` 도달
|
||||
- 브라우저 console error 및 page error 0개
|
||||
|
||||
Todo:
|
||||
- [x] Playwright Test 프로젝트 추가
|
||||
- [x] 관리자 로그인 E2E 추가
|
||||
- [x] CI 배포 후 Playwright 실행 단계 추가
|
||||
- [x] Playwright가 발견한 Blazor DI 결함 수정
|
||||
- [ ] CI run에서 Playwright 전체 통과 확인
|
||||
- [ ] 배포 검증에 블로그 상세/문의/비밀번호 변경 성공 기준 반영 확인
|
||||
|
||||
## WBS-OPS-02 배포 502 / Nginx 유지보수 페이지
|
||||
|
||||
목표: CI 배포 중 502 Bad Gateway 대신 한국어 유지보수 페이지를 제공한다.
|
||||
|
||||
성공 기준:
|
||||
- Nginx error_page 502/503 → maintenance.html 직접 서빙
|
||||
- 배포 중 방문자는 유지보수 페이지(15초 자동 새로고침)를 본다.
|
||||
- 배포 완료 후 정상 서비스 복구
|
||||
|
||||
Todo:
|
||||
- [x] maintenance.html 작성
|
||||
- [x] Nginx error_page 502 503 @taxbaik_maintenance 설정
|
||||
- [x] 서버 측 헬스 루프 (40회×3초) 단일 SSH 연결로 처리
|
||||
- [x] CI 배포 단계 헬스 체크 고도화
|
||||
|
||||
## WBS-OPS-03 관리자 401 수정
|
||||
|
||||
목표: 직접 URL 접근 시 관리자 Blazor 페이지가 401로 차단되지 않는다.
|
||||
|
||||
성공 기준:
|
||||
- `/taxbaik/admin/announcements` 등 직접 접근 시 Blazor Shell 200 응답
|
||||
- 미인증 사용자는 로그인 페이지로 리다이렉트
|
||||
|
||||
Todo:
|
||||
- [x] MapRazorComponents().AllowAnonymous() 적용
|
||||
- [x] AuthorizeRouteView → RedirectToLogin 인증 흐름 확인
|
||||
|
||||
---
|
||||
|
||||
## ── 인증 · 관리자 ─────────────────────────────────
|
||||
|
||||
## WBS-AUTH-01 인증/비밀번호 운영 안정화
|
||||
|
||||
목표: DB 직접 수정 대신 API로 관리자 인증 운영 작업을 수행한다.
|
||||
|
||||
성공 기준:
|
||||
- 비밀번호 변경 API가 현재 비밀번호를 요구한다.
|
||||
- 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다.
|
||||
- 실패 응답은 민감 정보를 노출하지 않는다.
|
||||
|
||||
Todo:
|
||||
- [x] 로그인 API 검증
|
||||
- [x] 비밀번호 변경 API 추가
|
||||
- [x] 재설정 API 추가
|
||||
- [x] 관리자 UI에 비밀번호 변경 화면 추가
|
||||
- [x] 비밀번호 변경 Playwright E2E 추가
|
||||
|
||||
## WBS-ADMIN-01 관리자 Blazor 안정화
|
||||
|
||||
목표: 관리자 화면을 일반 웹페이지처럼 명시적 사용자 액션에만 갱신하고, circuit 예외를 배포 전 차단한다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 주요 메뉴 대시보드/블로그/문의/설정/공지사항 circuit error 0개
|
||||
- 저장/삭제/상태 변경 액션은 성공/실패 메시지를 표시한다.
|
||||
|
||||
Todo:
|
||||
- [x] 중복 `/admin` 라우트 제거
|
||||
- [x] MudBlazor DI 타입 오류 수정
|
||||
- [x] 관리자 메뉴 smoke E2E 추가
|
||||
- [x] 설정 저장 TODO를 실제 DB 기반 기능으로 전환
|
||||
|
||||
---
|
||||
|
||||
## ── 고객지원 백오피스 (CRM) ──────────────────────
|
||||
|
||||
> **배경**: 세무사 사무실에서 고객 정보와 상담 이력이 파편화(메모장·카톡·기억)되면 마감 누락, 서비스 연속성 단절, 재계약 기회 손실이 발생한다.
|
||||
> 30년 경력 세무사가 혼자 또는 소수 인원으로 운영할 때 가장 먼저 필요한 것은 고객 카드와 상담 이력이다.
|
||||
|
||||
## WBS-CRM-01 고객 카드 (Client Card) — Phase 1
|
||||
|
||||
목표: 고객별 기본 정보·서비스 유형·상태를 한 화면에서 관리한다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자 `/taxbaik/admin/clients` 목록/검색/생성/수정/삭제 동작
|
||||
- 고객 카드: 이름, 회사명, 연락처, 이메일, 서비스 유형, 세금 유형, 상태, 유입 경로, 메모
|
||||
- 상태 필터(활성/비활성)로 목록 조회
|
||||
- 고객 저장 시 updated_at 자동 갱신
|
||||
|
||||
DB 스키마:
|
||||
- `clients` 테이블 (V006 마이그레이션)
|
||||
- 컬럼: id, name, company_name, phone, email, service_type, tax_type, status, source, memo, created_at, updated_at
|
||||
|
||||
Todo:
|
||||
- [x] V006__CreateClients.sql 마이그레이션
|
||||
- [x] Client 엔티티 (Domain)
|
||||
- [x] IClientRepository 인터페이스 (Domain)
|
||||
- [x] ClientRepository 구현 (Infrastructure)
|
||||
- [x] ClientService 구현 (Application)
|
||||
- [x] ClientList.razor 관리자 목록 화면
|
||||
- [x] ClientEdit.razor 관리자 등록/수정 화면
|
||||
- [x] MainLayout.razor 고객 메뉴 추가
|
||||
- [ ] 배포 후 고객 등록 → 목록 조회 확인
|
||||
|
||||
## WBS-CRM-02 상담 이력 (Consultation Log) — Phase 1
|
||||
|
||||
목표: 고객별 상담 일자·내용·결과·수수료를 기록해 "이 고객 지난번에 뭐 상담했더라?"를 해결한다.
|
||||
|
||||
성공 기준:
|
||||
- 고객 상세에서 상담 이력 목록/추가/삭제 동작
|
||||
- 상담 이력 필드: 날짜, 서비스 유형, 상담 요약, 결과(계약/보류/거절/완료), 수수료
|
||||
- 이력 없는 고객은 빈 목록 표시
|
||||
|
||||
DB 스키마:
|
||||
- `consultations` 테이블 (V007 마이그레이션)
|
||||
- 컬럼: id, client_id(FK), consultation_date, service_type, summary, result, fee, created_at
|
||||
|
||||
Todo:
|
||||
- [ ] V007__CreateConsultations.sql 마이그레이션
|
||||
- [ ] Consultation 엔티티 (Domain)
|
||||
- [ ] IConsultationRepository 인터페이스 (Domain)
|
||||
- [ ] ConsultationRepository 구현 (Infrastructure)
|
||||
- [ ] ConsultationService 구현 (Application)
|
||||
- [ ] ClientDetail.razor (고객 상세 + 상담 이력 탭)
|
||||
- [ ] 배포 후 고객 상세에서 상담 이력 추가 확인
|
||||
|
||||
## WBS-CRM-03 문의 → 고객 전환 — Phase 1
|
||||
|
||||
목표: 홈페이지 문의 접수 건을 클릭 한 번으로 고객 카드로 등록한다.
|
||||
|
||||
성공 기준:
|
||||
- 문의 상세에 "고객으로 등록" 버튼 표시
|
||||
- 버튼 클릭 시 이름·연락처 자동 채워진 고객 생성 폼으로 이동
|
||||
- 이미 연결된 고객이 있으면 버튼 대신 고객 카드 링크 표시
|
||||
- inquiries 테이블에 client_id 컬럼 추가
|
||||
|
||||
Todo:
|
||||
- [ ] inquiries 테이블에 client_id FK 컬럼 추가 (V008 마이그레이션)
|
||||
- [ ] InquiryDetail.razor에 "고객으로 등록" 버튼 추가
|
||||
- [ ] ClientEdit.razor에 inquiry_id 파라미터 지원 (자동 채우기)
|
||||
- [ ] 배포 후 문의 → 고객 전환 흐름 확인
|
||||
|
||||
---
|
||||
|
||||
## ── 고객지원 백오피스 Phase 2 ──────────────────────
|
||||
|
||||
## WBS-CRM-04 신고 일정 캘린더 — Phase 2
|
||||
|
||||
목표: 고객별 신고 예정일과 마감일을 추적해 가산세 리스크를 방지한다.
|
||||
|
||||
성공 기준:
|
||||
- 관리자에서 고객별 세금 신고 일정 등록/수정/완료 처리
|
||||
- D-Day 표시 (D-7일 이내 강조)
|
||||
- 이번 달 마감 목록을 대시보드 위젯으로 표시
|
||||
|
||||
DB 스키마:
|
||||
- `tax_filings` 테이블 (V009 마이그레이션)
|
||||
- 컬럼: id, client_id(FK), filing_type, due_date, status(pending/filed/overdue), memo
|
||||
|
||||
Todo:
|
||||
- [ ] V009__CreateTaxFilings.sql
|
||||
- [ ] TaxFiling 엔티티, Repository, Service
|
||||
- [ ] TaxFilingList.razor (관리자 신고 일정 화면)
|
||||
- [ ] Dashboard.razor에 이번 달 마감 위젯 추가
|
||||
- [ ] 배포 후 신고 일정 등록 → D-Day 표시 확인
|
||||
|
||||
## WBS-CRM-05 문의 접수 현황 강화 — Phase 2
|
||||
|
||||
목표: 문의 상태를 세분화하고 담당자 메모를 기록해 처리 흐름을 추적한다.
|
||||
|
||||
성공 기준:
|
||||
- 문의 상태: 신규/상담중/계약완료/거절/종결 5단계
|
||||
- 목록에서 상태 칩 필터로 빠른 분류
|
||||
- 상태 변경 시 변경 일시 자동 기록
|
||||
|
||||
Todo:
|
||||
- [ ] inquiries.status 컬럼 확장 (V010 마이그레이션)
|
||||
- [ ] InquiryList.razor 상태 필터 추가
|
||||
- [ ] InquiryDetail.razor 상태 변경 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
## ── 고객지원 백오피스 Phase 3 ──────────────────────
|
||||
|
||||
## WBS-CRM-06 텔레그램 자동 리포트 — Phase 3
|
||||
|
||||
목표: 세무사에게 일/주 단위 신규 문의·처리 현황·마감 임박 건을 텔레그램으로 전송한다.
|
||||
|
||||
성공 기준:
|
||||
- 매일 오전 9시 신규 문의 수, 처리 대기 수 자동 전송
|
||||
- 매주 월요일 주간 리포트 (신규 고객, 이번 주 마감 신고 건)
|
||||
- 텔레그램 전송 실패 시 로그만 남기고 앱 정상 운영 유지
|
||||
|
||||
Todo:
|
||||
- [ ] BackgroundService 또는 Hangfire 기반 스케줄러 추가
|
||||
- [ ] 일간/주간 리포트 메시지 템플릿
|
||||
- [ ] TelegramNotificationService에 리포트 메서드 추가
|
||||
|
||||
## WBS-CRM-07 고객 포털 (읽기 전용) — Phase 3
|
||||
|
||||
목표: 기장 고객이 본인 신고 현황과 중요 알림을 직접 확인한다.
|
||||
|
||||
성공 기준:
|
||||
- 고객 전용 URL + 인증(소셜 로그인 또는 링크 토큰)
|
||||
- 본인 신고 일정, 상담 요약(세무사 허용 항목만) 조회
|
||||
- 개인정보 열람 범위는 세무사가 허용한 항목만
|
||||
|
||||
Todo:
|
||||
- [ ] 고객 포털 설계 (인증 방식 결정 — WBS-CRM-08 선행)
|
||||
- [ ] 고객 전용 Razor Pages 추가
|
||||
- [ ] 세무사 허용 권한 설정 UI
|
||||
|
||||
## WBS-CRM-08 고객 회원가입 · 소셜 로그인 — Phase 3
|
||||
|
||||
목표: 고객 포털 접근을 위한 회원가입과 소셜 로그인을 제공한다.
|
||||
가입 마찰을 최소화해 상담 접수 → 고객 포털 전환율을 높인다.
|
||||
|
||||
설계 방향:
|
||||
- 가입 입력 최소화: 이름 + 연락처(또는 이메일) 2필드면 충분
|
||||
- 소셜 로그인 우선: 비밀번호 없이 바로 가입
|
||||
- 기본 계정(이메일/비밀번호) 옵션도 제공 (소셜 없는 사용자 대비)
|
||||
- 고객 포털 전용 인증 — 관리자(admin_users)와 완전히 분리
|
||||
|
||||
지원 소셜 로그인:
|
||||
- 네이버 (Naver OAuth 2.0) — 국내 주요 채널
|
||||
- 카카오 (Kakao Login) — 기존 카카오 채널 연계
|
||||
- 구글 (Google OAuth 2.0) — 해외·젊은 고객층
|
||||
|
||||
성공 기준:
|
||||
- 소셜 로그인 3종 모두 동작 (네이버·카카오·구글)
|
||||
- 이메일/비밀번호 기본 계정 가입 + 로그인 동작
|
||||
- 가입 폼: 이름·연락처 2필드만 요구 (소셜 프로필에서 자동 채우기)
|
||||
- 로그인 후 고객 포털 (`/taxbaik/portal`) 접근
|
||||
- 고객 계정이 백오피스 clients 테이블 레코드와 연결
|
||||
- 회원 계정 미인증 상태에서 포털 접근 시 로그인 페이지 리다이렉트
|
||||
|
||||
DB 스키마:
|
||||
- `portal_users` 테이블 (V011 마이그레이션)
|
||||
- id, client_id(FK, nullable), email, name, phone, provider(naver/kakao/google/local), provider_id, password_hash(nullable), created_at
|
||||
- 소셜 로그인 provider_id는 각 플랫폼 식별자
|
||||
|
||||
기술 결정:
|
||||
- ASP.NET Core OAuth Middleware (Microsoft.AspNetCore.Authentication.OAuth)
|
||||
- 네이버: 커스텀 OAuth handler (공식 패키지 없음, 직접 구현)
|
||||
- 카카오: AspNet.Security.OAuth.Kakao 패키지
|
||||
- 구글: Microsoft.AspNetCore.Authentication.Google 패키지
|
||||
- 고객 포털 세션: HttpOnly Cookie 기반 (JWT localStorage와 분리)
|
||||
|
||||
환경 변수 필요 (Gitea Secrets 추가):
|
||||
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`
|
||||
- `KAKAO_CLIENT_ID` / `KAKAO_CLIENT_SECRET`
|
||||
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
Todo:
|
||||
- [ ] WBS-CRM-07 고객 포털 기본 구조 완성 (선행)
|
||||
- [ ] OAuth 앱 등록 (네이버·카카오·구글 개발자 콘솔)
|
||||
- [ ] V011__CreatePortalUsers.sql 마이그레이션
|
||||
- [ ] PortalUser 엔티티 / IPortalUserRepository / PortalUserRepository
|
||||
- [ ] 네이버 OAuth Handler 구현
|
||||
- [ ] 카카오·구글 패키지 추가 및 설정
|
||||
- [ ] 기본 계정 회원가입 폼 (`/taxbaik/portal/register`)
|
||||
- [ ] 소셜 로그인 콜백 처리 → portal_users 자동 생성
|
||||
- [ ] 신규 가입 시 clients 테이블 연결 또는 신규 생성
|
||||
- [ ] 포털 로그인 페이지 (`/taxbaik/portal/login`) — 소셜 버튼 + 이메일 폼
|
||||
- [ ] Gitea Secrets에 OAuth 키 추가
|
||||
- [ ] 배포 후 소셜 로그인 3종 E2E 테스트
|
||||
|
||||
---
|
||||
|
||||
## ── 유지보수성 ─────────────────────────────────
|
||||
|
||||
## WBS-MAINT-01 유지보수성/파편화 축소
|
||||
|
||||
목표: 문서와 실제 구조의 불일치를 줄이고 단일 앱 운영 기준을 유지한다.
|
||||
|
||||
성공 기준:
|
||||
- README/CLAUDE/DEPLOYMENT_GUIDE의 .NET 버전, 앱 구조, 테스트 기준이 실제 코드와 일치
|
||||
- 배포 문서에 Playwright 검증 절차 포함
|
||||
- 오래된 분리 Admin 서비스 문서 제거 또는 명확히 deprecated 처리
|
||||
|
||||
Todo:
|
||||
- [x] README 테스트/배포 섹션 갱신
|
||||
- [x] CLAUDE.md E2E 기준 갱신
|
||||
- [x] 오래된 최종 보고 문서의 허위 완료 표현 정정
|
||||
- [x] CLAUDE.md 섹션 13 시즌별 마케팅 하네스 추가
|
||||
|
||||
---
|
||||
|
||||
### 현재 검증 메모
|
||||
- `dotnet build TaxBaik.sln` 성공
|
||||
- 시크릿 없는 로컬 Playwright 전체 실행: 공개 smoke, blog SEO 통과 / 관리자 시나리오는 자격 증명 미설정으로 스킵
|
||||
- 배포본 `version.txt`: `8f0cb69`
|
||||
- 배포본 블로그 상세: HTTP 200
|
||||
- CI Playwright 전체 통과는 최신 커밋 배포 후 재확인 필요
|
||||
- 비밀번호 변경 E2E는 배포 환경 자격 증명 확인 대기
|
||||
|
||||
- `dotnet build TaxBaik.sln` 성공 (2026-06-27 기준, 경고 0 오류 0)
|
||||
- 배포 커밋: `77a5c44` (FAQ 섹션 추가, 푸시 대기 중)
|
||||
- WBS-MKT-01/02/03 구현 완료, 배포 후 시각 검증 필요
|
||||
- WBS-CRM-01 구현 중 (Phase 1 고객 카드)
|
||||
- WBS-CRM-02/03 Phase 1 구현 예정 (고객 카드 완료 후 순차 진행)
|
||||
|
||||
@@ -61,6 +61,9 @@ public class BlogServiceTests
|
||||
return Task.FromResult<(IEnumerable<BlogPost>, int)>((items, items.Count));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts.Where(x => x.IsPublished).Take(limit).ToList());
|
||||
|
||||
public Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IEnumerable<BlogPost>>(Posts);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public record CurrentSeasonDto
|
||||
public string HeroSubtext { get; init; } = "";
|
||||
public string UrgencyBadge { get; init; } = "";
|
||||
public string FocusService { get; init; } = "";
|
||||
public string RelatedCategorySlug { get; init; } = "";
|
||||
public string CtaText { get; init; } = "상담 신청하기";
|
||||
public int DaysUntilDeadline { get; init; }
|
||||
public DateTime Deadline { get; init; }
|
||||
|
||||
@@ -15,4 +15,6 @@ public record TaxSeason
|
||||
public string UrgencyBadge { get; init; } = "";
|
||||
public string FocusService { get; init; } = "";
|
||||
public string CtaText { get; init; } = "상담 신청하기";
|
||||
/// <summary>블로그 시즌 연동 시 우선 노출할 카테고리 slug (categories.slug 참조)</summary>
|
||||
public string RelatedCategorySlug { get; init; } = "";
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "일반과세 사업자 확정신고 · 기한 내 신고로 가산세 방지",
|
||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "부가세 신고 상담"
|
||||
CtaText = "부가세 신고 상담",
|
||||
RelatedCategorySlug = "vat"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -30,7 +31,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "직원이 있는 사업자 원천징수 신고 · 환급 최대화",
|
||||
UrgencyBadge = "연말정산 진행 중",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "연말정산 상담"
|
||||
CtaText = "연말정산 상담",
|
||||
RelatedCategorySlug = "business-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -42,7 +44,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "법인사업자 결산 · 세무조정 · 절세 전략 수립",
|
||||
UrgencyBadge = "D-{n}일 | 법인세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "법인세 신고 상담"
|
||||
CtaText = "법인세 신고 상담",
|
||||
RelatedCategorySlug = "business-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -54,7 +57,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "개인사업자 · 임대소득 · 프리랜서 · 기타소득 모두 해당",
|
||||
UrgencyBadge = "D-{n}일 | 종합소득세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "종합소득세 상담"
|
||||
CtaText = "종합소득세 상담",
|
||||
RelatedCategorySlug = "income-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -66,7 +70,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "일반과세 사업자 1기 확정신고 · 매입세액 공제 점검",
|
||||
UrgencyBadge = "D-{n}일 | 부가세 마감",
|
||||
FocusService = "business-tax",
|
||||
CtaText = "부가세 신고 상담"
|
||||
CtaText = "부가세 신고 상담",
|
||||
RelatedCategorySlug = "vat"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -78,7 +83,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "다주택자 · 임대사업자 세부담 분석 · 분납·합산배제 검토",
|
||||
UrgencyBadge = "D-{n}일 | 종부세 납부",
|
||||
FocusService = "real-estate-tax",
|
||||
CtaText = "종부세 절세 상담"
|
||||
CtaText = "종부세 절세 상담",
|
||||
RelatedCategorySlug = "real-estate-tax"
|
||||
},
|
||||
new TaxSeason
|
||||
{
|
||||
@@ -90,7 +96,8 @@ public static class TaxSeasonCalendar
|
||||
HeroSubtext = "증여 공제 한도 · 자산 이전 · 법인전환 연간 마감",
|
||||
UrgencyBadge = "D-{n}일 | 연간 증여 한도 마감",
|
||||
FocusService = "family-asset",
|
||||
CtaText = "연말 절세 상담"
|
||||
CtaText = "연말 절세 상담",
|
||||
RelatedCategorySlug = "family-asset"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,19 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach
|
||||
public async Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken ct = default) =>
|
||||
await repository.GetBySlugAsync(slug, ct);
|
||||
|
||||
/// <summary>카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환.</summary>
|
||||
public async Task<(IEnumerable<BlogPost> Seasonal, IEnumerable<BlogPost> Latest)> GetSeasonalPostsAsync(
|
||||
string categorySlug, int seasonalCount, int totalCount, CancellationToken ct = default)
|
||||
{
|
||||
var seasonal = (await repository.GetByCategorySlugAsync(categorySlug, seasonalCount, ct)).ToList();
|
||||
var seasonalIds = seasonal.Select(p => p.Id).ToHashSet();
|
||||
|
||||
var (latestAll, _) = await repository.GetPublishedPagedAsync(1, totalCount + seasonalCount, null, ct);
|
||||
var latest = latestAll.Where(p => !seasonalIds.Contains(p.Id)).Take(totalCount - seasonal.Count).ToList();
|
||||
|
||||
return (seasonal, latest);
|
||||
}
|
||||
|
||||
public async Task<(IEnumerable<BlogPost>, int)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken ct = default) =>
|
||||
await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct);
|
||||
|
||||
@@ -24,6 +24,7 @@ public class SeasonalMarketingService
|
||||
HeroSubtext = season.HeroSubtext,
|
||||
UrgencyBadge = season.UrgencyBadge.Replace("{n}", days.ToString()),
|
||||
FocusService = season.FocusService,
|
||||
RelatedCategorySlug = season.RelatedCategorySlug,
|
||||
CtaText = season.CtaText,
|
||||
DaysUntilDeadline = days,
|
||||
Deadline = end
|
||||
|
||||
@@ -8,6 +8,7 @@ public interface IBlogPostRepository
|
||||
Task<BlogPost?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetPublishedPagedAsync(
|
||||
int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IEnumerable<BlogPost> Items, int Total)> GetAdminPagedAsync(
|
||||
int page, int pageSize, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -58,6 +58,21 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
return await conn.QueryAsync<BlogPost>(
|
||||
@"SELECT bp.id, bp.title, bp.slug, bp.category_id, bp.tags,
|
||||
bp.published_at, bp.view_count, bp.seo_description, bp.thumbnail_url,
|
||||
bp.is_published, bp.created_at, bp.updated_at, c.name AS category_name
|
||||
FROM blog_posts bp
|
||||
LEFT JOIN categories c ON bp.category_id = c.id
|
||||
WHERE bp.is_published = TRUE AND c.slug = @CategorySlug
|
||||
ORDER BY bp.published_at DESC
|
||||
LIMIT @Limit",
|
||||
new { CategorySlug = categorySlug, Limit = limit });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BlogPost>> GetAllForAdminAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var conn = Conn();
|
||||
|
||||
+143
-15
@@ -273,39 +273,167 @@ else
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 블로그 -->
|
||||
<!-- 세무 정보 블로그 -->
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="section-title">세무 정보</h2>
|
||||
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
|
||||
@if (season != null)
|
||||
{
|
||||
<div class="seasonal-blog-header mb-2">
|
||||
<span class="seasonal-blog-tag">📅 @season.Name 시즌</span>
|
||||
</div>
|
||||
<h2 class="section-title">이번 시즌 세무 정보</h2>
|
||||
<p class="text-muted">@season.Name 관련 절세 팁과 신고 가이드를 확인하세요</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="section-title">세무 정보</h2>
|
||||
<p class="text-muted">최신 세법 변화와 실무 팁을 공유합니다</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Model.RecentPosts?.Count > 0)
|
||||
@{
|
||||
var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0;
|
||||
var hasRecentPosts = Model.RecentPosts?.Count > 0;
|
||||
}
|
||||
|
||||
@if (hasSeasonalPosts || hasRecentPosts)
|
||||
{
|
||||
<div class="row g-4">
|
||||
@foreach (var post in Model.RecentPosts.Take(3))
|
||||
@* 시즌 관련 글 (배지 강조) *@
|
||||
@if (hasSeasonalPosts)
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card blog-card h-100">
|
||||
<div class="blog-placeholder">📝</div>
|
||||
<div class="card-body">
|
||||
<small class="badge bg-primary-badge">@post.CategoryName</small>
|
||||
<h4 class="card-title mt-3">@post.Title</h4>
|
||||
<p class="text-muted small">@post.CreatedAt.ToString("yyyy년 MM월 dd일")</p>
|
||||
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
|
||||
@foreach (var post in Model.SeasonalPosts!)
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card blog-card h-100 blog-card--seasonal">
|
||||
<div class="blog-seasonal-ribbon">이번 시즌 추천</div>
|
||||
<div class="blog-placeholder">🗓️</div>
|
||||
<div class="card-body">
|
||||
<small class="badge bg-season-badge">@post.CategoryName</small>
|
||||
<h4 class="card-title mt-3">@post.Title</h4>
|
||||
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
|
||||
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-seasonal">자세히 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@* 최신 글 (나머지 채우기) *@
|
||||
@if (hasRecentPosts)
|
||||
{
|
||||
@foreach (var post in Model.RecentPosts!)
|
||||
{
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card blog-card h-100">
|
||||
<div class="blog-placeholder">📝</div>
|
||||
<div class="card-body">
|
||||
<small class="badge bg-primary-badge">@post.CategoryName</small>
|
||||
<h4 class="card-title mt-3">@post.Title</h4>
|
||||
<p class="text-muted small">@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))</p>
|
||||
<a href="/taxbaik/blog/@post.Slug" class="btn btn-sm btn-primary">글 내용 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="text-center mt-5">
|
||||
|
||||
<div class="text-center mt-5 d-flex justify-content-center gap-3 flex-wrap">
|
||||
@if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug))
|
||||
{
|
||||
<a href="/taxbaik/blog?category=@season.RelatedCategorySlug" class="btn btn-outline-seasonal btn-lg">
|
||||
📅 @season.Name 전체 글 보기
|
||||
</a>
|
||||
}
|
||||
<a href="/taxbaik/blog" class="btn btn-outline-primary btn-lg">전체 블로그 보기</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 자주 묻는 질문 -->
|
||||
<section class="py-5" style="background: #F9F7F3;">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="section-title">자주 묻는 질문</h2>
|
||||
<p class="text-muted">상담 전 궁금하신 사항을 먼저 확인해 보세요</p>
|
||||
</div>
|
||||
|
||||
<div class="accordion faq-accordion" id="faqAccordion">
|
||||
<div class="accordion-item faq-item">
|
||||
<h3 class="accordion-header">
|
||||
<button class="accordion-button collapsed faq-question" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#faq1">
|
||||
기장료가 얼마인지 미리 알 수 있나요?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
업종과 매출 규모에 따라 다르지만, 무료 상담 후 정확한 견적을 안내드립니다.
|
||||
일반적으로 소규모 사업자는 월 10만 원대부터 시작하며, 부가가치세·소득세 신고 시기에는 별도 수수료 없이 포함 처리합니다.
|
||||
<strong>먼저 상담해 보시면 구체적인 금액을 바로 말씀드릴 수 있습니다.</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item faq-item">
|
||||
<h3 class="accordion-header">
|
||||
<button class="accordion-button collapsed faq-question" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#faq2">
|
||||
양도세 상담은 어떻게 진행되나요?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
등기부등본, 취득·양도 계약서, 보유 기간 확인 서류 등을 카카오 채널 또는 문의폼으로 전달해 주시면
|
||||
예상 세액과 절세 방법을 검토해 드립니다.
|
||||
<strong>매도 전에 상담하시면 취득세·비과세 요건 등을 사전에 확인할 수 있어 훨씬 유리합니다.</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item faq-item">
|
||||
<h3 class="accordion-header">
|
||||
<button class="accordion-button collapsed faq-question" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#faq3">
|
||||
무료 상담도 가능한가요?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
네, 초기 현황 파악과 방향성 검토까지는 무료로 진행합니다.
|
||||
카카오 채널 또는 문의폼으로 연락 주시면 빠르게 확인해 드립니다.
|
||||
<strong>실질적인 세무 처리·신고 대행이 시작되는 시점부터 수수료가 발생합니다.</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item faq-item">
|
||||
<h3 class="accordion-header">
|
||||
<button class="accordion-button collapsed faq-question" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#faq4">
|
||||
처음 상담 시 어떤 자료를 준비해야 하나요?
|
||||
</button>
|
||||
</h3>
|
||||
<div id="faq4" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||
<div class="accordion-body faq-answer">
|
||||
상담 목적에 따라 다르지만 일반적으로 아래 자료가 있으면 더 정확한 안내가 가능합니다:
|
||||
<ul class="mt-2 mb-0">
|
||||
<li><strong>사업자 세무:</strong> 사업자등록증, 최근 3개월 매출·매입 자료</li>
|
||||
<li><strong>부동산:</strong> 등기부등본, 취득·매도 계약서, 보유 기간 확인 자료</li>
|
||||
<li><strong>증여·상속:</strong> 재산 목록, 증여 예정 자산 내역</li>
|
||||
</ul>
|
||||
<span class="d-block mt-2"><strong>자료가 없어도 상담은 가능합니다. 먼저 연락 주세요.</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<p class="text-muted mb-3">더 궁금한 점이 있으시면 바로 문의해 주세요</p>
|
||||
<a href="/taxbaik/contact" class="btn btn-primary btn-lg">상담 문의하기</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최종 CTA -->
|
||||
<section class="py-5" style="background: linear-gradient(135deg, #2E5C4E 0%, #1F3A30 100%); color: white;">
|
||||
<div class="container text-center">
|
||||
|
||||
@@ -12,6 +12,7 @@ public class IndexModel : PageModel
|
||||
private readonly AnnouncementService _announcementService;
|
||||
|
||||
public List<BlogPost> RecentPosts { get; set; } = [];
|
||||
public List<BlogPost> SeasonalPosts { get; set; } = [];
|
||||
public CurrentSeasonDto? CurrentSeason { get; set; }
|
||||
public List<Announcement> ActiveAnnouncements { get; set; } = [];
|
||||
|
||||
@@ -40,12 +41,23 @@ public class IndexModel : PageModel
|
||||
|
||||
try
|
||||
{
|
||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
|
||||
RecentPosts = posts.ToList();
|
||||
if (CurrentSeason is not null && !string.IsNullOrEmpty(CurrentSeason.RelatedCategorySlug))
|
||||
{
|
||||
var (seasonal, latest) = await _blogService.GetSeasonalPostsAsync(
|
||||
CurrentSeason.RelatedCategorySlug, seasonalCount: 2, totalCount: 3);
|
||||
SeasonalPosts = seasonal.ToList();
|
||||
RecentPosts = latest.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var (posts, _) = await _blogService.GetPublishedPagedAsync(1, 3);
|
||||
RecentPosts = posts.ToList();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
RecentPosts = [];
|
||||
SeasonalPosts = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,3 +641,108 @@ img {
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ===== 블로그 시즌 연동 ===== */
|
||||
.seasonal-blog-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.seasonal-blog-tag {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #C62828 0%, #B71C1C 100%);
|
||||
color: white;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 16px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.blog-card--seasonal {
|
||||
border: 2px solid var(--color-primary) !important;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.blog-seasonal-ribbon {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 14px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bg-season-badge {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.btn-seasonal {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
.btn-seasonal:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-outline-seasonal {
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
.btn-outline-seasonal:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ===== FAQ 아코디언 ===== */
|
||||
.faq-accordion {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.faq-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md) !important;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.faq-question {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
background: white;
|
||||
font-size: 1rem;
|
||||
padding: 1.1rem 1.5rem;
|
||||
}
|
||||
.faq-question:not(.collapsed) {
|
||||
color: var(--color-secondary);
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
}
|
||||
.faq-question::after {
|
||||
filter: none;
|
||||
}
|
||||
.faq-question:focus {
|
||||
box-shadow: 0 0 0 3px rgba(200, 157, 110, 0.2);
|
||||
}
|
||||
.faq-answer {
|
||||
background: #fdfcfa;
|
||||
color: var(--color-text-light);
|
||||
line-height: 1.85;
|
||||
padding: 1rem 1.5rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.faq-answer ul {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.faq-answer ul li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user