diff --git a/TaxBaik.Application.Tests/BlogServiceTests.cs b/TaxBaik.Application.Tests/BlogServiceTests.cs index 151b94f..70ce0a0 100644 --- a/TaxBaik.Application.Tests/BlogServiceTests.cs +++ b/TaxBaik.Application.Tests/BlogServiceTests.cs @@ -61,6 +61,9 @@ public class BlogServiceTests return Task.FromResult<(IEnumerable, int)>((items, items.Count)); } + public Task> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) => + Task.FromResult>(Posts.Where(x => x.IsPublished).Take(limit).ToList()); + public Task> GetAllForAdminAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Posts); diff --git a/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs b/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs index 495311b..22fed9c 100644 --- a/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs +++ b/TaxBaik.Application/Seasonal/CurrentSeasonDto.cs @@ -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; } diff --git a/TaxBaik.Application/Seasonal/TaxSeason.cs b/TaxBaik.Application/Seasonal/TaxSeason.cs index 19b055b..99e6f53 100644 --- a/TaxBaik.Application/Seasonal/TaxSeason.cs +++ b/TaxBaik.Application/Seasonal/TaxSeason.cs @@ -15,4 +15,6 @@ public record TaxSeason public string UrgencyBadge { get; init; } = ""; public string FocusService { get; init; } = ""; public string CtaText { get; init; } = "상담 신청하기"; + /// 블로그 시즌 연동 시 우선 노출할 카테고리 slug (categories.slug 참조) + public string RelatedCategorySlug { get; init; } = ""; } diff --git a/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs b/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs index 88927eb..5c7a917 100644 --- a/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs +++ b/TaxBaik.Application/Seasonal/TaxSeasonCalendar.cs @@ -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" } ]; } diff --git a/TaxBaik.Application/Services/BlogService.cs b/TaxBaik.Application/Services/BlogService.cs index 3d25ce9..013cf5a 100644 --- a/TaxBaik.Application/Services/BlogService.cs +++ b/TaxBaik.Application/Services/BlogService.cs @@ -12,6 +12,19 @@ public class BlogService(IBlogPostRepository repository, IMemoryCache memoryCach public async Task GetBySlugAsync(string slug, CancellationToken ct = default) => await repository.GetBySlugAsync(slug, ct); + /// 카테고리 슬러그 기준 시즌 관련 글 조회. 부족 시 최신 글로 채워 total개 반환. + public async Task<(IEnumerable Seasonal, IEnumerable 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, int)> GetPublishedPagedAsync( int page, int pageSize, int? categoryId = null, CancellationToken ct = default) => await repository.GetPublishedPagedAsync(NormalizePage(page), NormalizePageSize(pageSize), categoryId, ct); diff --git a/TaxBaik.Application/Services/SeasonalMarketingService.cs b/TaxBaik.Application/Services/SeasonalMarketingService.cs index 8076f68..82ea3c4 100644 --- a/TaxBaik.Application/Services/SeasonalMarketingService.cs +++ b/TaxBaik.Application/Services/SeasonalMarketingService.cs @@ -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 diff --git a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs index e51e112..7b43091 100644 --- a/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs +++ b/TaxBaik.Domain/Interfaces/IBlogPostRepository.cs @@ -8,6 +8,7 @@ public interface IBlogPostRepository Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); Task<(IEnumerable Items, int Total)> GetPublishedPagedAsync( int page, int pageSize, int? categoryId = null, CancellationToken cancellationToken = default); + Task> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default); Task> GetAllForAdminAsync(CancellationToken cancellationToken = default); Task<(IEnumerable Items, int Total)> GetAdminPagedAsync( int page, int pageSize, CancellationToken cancellationToken = default); diff --git a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs index cfb5176..5e183b5 100644 --- a/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs +++ b/TaxBaik.Infrastructure/Repositories/BlogPostRepository.cs @@ -58,6 +58,21 @@ public class BlogPostRepository(IDbConnectionFactory connectionFactory) : BaseRe return (items, total); } + public async Task> GetByCategorySlugAsync(string categorySlug, int limit, CancellationToken cancellationToken = default) + { + using var conn = Conn(); + return await conn.QueryAsync( + @"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> GetAllForAdminAsync(CancellationToken cancellationToken = default) { using var conn = Conn(); diff --git a/TaxBaik.Web/Pages/Index.cshtml b/TaxBaik.Web/Pages/Index.cshtml index 514c4d2..c5c14ec 100644 --- a/TaxBaik.Web/Pages/Index.cshtml +++ b/TaxBaik.Web/Pages/Index.cshtml @@ -273,33 +273,79 @@ else - +
-

세무 정보

-

최신 세법 변화와 실무 팁을 공유합니다

+ @if (season != null) + { +
+ 📅 @season.Name 시즌 +
+

이번 시즌 세무 정보

+

@season.Name 관련 절세 팁과 신고 가이드를 확인하세요

+ } + else + { +

세무 정보

+

최신 세법 변화와 실무 팁을 공유합니다

+ }
- @if (Model.RecentPosts?.Count > 0) + @{ + var hasSeasonalPosts = Model.SeasonalPosts?.Count > 0; + var hasRecentPosts = Model.RecentPosts?.Count > 0; + } + + @if (hasSeasonalPosts || hasRecentPosts) {
- @foreach (var post in Model.RecentPosts.Take(3)) + @* 시즌 관련 글 (배지 강조) *@ + @if (hasSeasonalPosts) { -
-
-
📝
-
- @post.CategoryName -

@post.Title

-

@post.CreatedAt.ToString("yyyy년 MM월 dd일")

- 글 내용 보기 + @foreach (var post in Model.SeasonalPosts!) + { +
+
+
이번 시즌 추천
+
🗓️
+
+ @post.CategoryName +

@post.Title

+

@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))

+ 자세히 보기 +
-
+ } + } + @* 최신 글 (나머지 채우기) *@ + @if (hasRecentPosts) + { + @foreach (var post in Model.RecentPosts!) + { +
+
+
📝
+
+ @post.CategoryName +

@post.Title

+

@((post.PublishedAt ?? post.CreatedAt).ToString("yyyy년 MM월 dd일"))

+ 글 내용 보기 +
+
+
+ } }
-
+ +
+ @if (season != null && !string.IsNullOrEmpty(season.RelatedCategorySlug)) + { + + 📅 @season.Name 전체 글 보기 + + } 전체 블로그 보기
} diff --git a/TaxBaik.Web/Pages/Index.cshtml.cs b/TaxBaik.Web/Pages/Index.cshtml.cs index da5c72a..8131281 100644 --- a/TaxBaik.Web/Pages/Index.cshtml.cs +++ b/TaxBaik.Web/Pages/Index.cshtml.cs @@ -12,6 +12,7 @@ public class IndexModel : PageModel private readonly AnnouncementService _announcementService; public List RecentPosts { get; set; } = []; + public List SeasonalPosts { get; set; } = []; public CurrentSeasonDto? CurrentSeason { get; set; } public List 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 = []; } } } diff --git a/TaxBaik.Web/wwwroot/css/site.css b/TaxBaik.Web/wwwroot/css/site.css index 59e908d..7a35d58 100644 --- a/TaxBaik.Web/wwwroot/css/site.css +++ b/TaxBaik.Web/wwwroot/css/site.css @@ -641,3 +641,65 @@ 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; +}