From f9a8286208b26aa3c5dd7d14413068acb0ee7b7b Mon Sep 17 00:00:00 2001 From: YangSeungHyun <96910404+SHcommit@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:40:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(map-warmth):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=98=A8=EA=B8=B0=EB=AA=A8=EB=93=9C=20=EB=A7=90=ED=92=8D?= =?UTF-8?q?=EC=84=A0=20=EB=8B=A4=EC=A4=91=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90,=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EA=B9=9C=EB=B9=A1=EC=9E=84=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map-warmth): 지도 온기모드 말풍선 다중 줄바꿈 지원, 오버레이 깜빡임 제거 및 피드 스켈레톤 적용 * test: typographyRole 적용에 따른 단위 테스트 폰트 크기 기댓값 동기화 * refactor(map-warmth): CodeQL 피드백 반영 미사용 import 정리 --- .../components/VillageCard.test.tsx | 5 +- .../components/SorimaruArchiveBrowse.test.tsx | 3 +- .../components/StoryCarousel.test.tsx | 3 +- .../presentation/useVisitReviewFeed.ts | 8 +- .../core-ui/map-warmth/WarmthCardSkeleton.tsx | 217 ++++++++++ src/private/core-ui/map-warmth/WarmthFeed.tsx | 36 +- .../core-ui/map-warmth/WarmthNotesLayer.tsx | 383 ++++++++++-------- 7 files changed, 465 insertions(+), 190 deletions(-) create mode 100644 src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx diff --git a/src/features/hanok-archive/components/VillageCard.test.tsx b/src/features/hanok-archive/components/VillageCard.test.tsx index ecbad038..ba146b60 100644 --- a/src/features/hanok-archive/components/VillageCard.test.tsx +++ b/src/features/hanok-archive/components/VillageCard.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Village } from '@/features/hanok-archive/types'; +import { typographyRole } from '@/design-system/tokens'; import VillageCard from './VillageCard'; const village: Village = { @@ -33,8 +34,8 @@ describe('VillageCard', () => { const tagArea = screen.getByLabelText('한옥 태그'); expect(tagArea.textContent).toContain('한옥'); expect(tagArea.textContent).toContain('소리마루 도슨트'); - expect(getComputedStyle(tagArea.children[0]).fontSize).toBe('0.75rem'); - expect(getComputedStyle(tagArea.children[1]).fontSize).toBe('0.75rem'); + expect(getComputedStyle(tagArea.children[0]).fontSize).toBe(typographyRole.label.fontSize); + expect(getComputedStyle(tagArea.children[1]).fontSize).toBe(typographyRole.label.fontSize); }); it('keeps the complete title available while limiting it to two visual lines', () => { diff --git a/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx b/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx index 425bfb81..1b289140 100644 --- a/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx +++ b/src/features/sorimaru-audio/components/SorimaruArchiveBrowse.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SorimaruStorySummary } from '@/features/sorimaru-audio/domain/sorimaruStory'; +import { typographyRole } from '@/design-system/tokens'; import { SorimaruArchiveBrowse } from './SorimaruArchiveBrowse'; vi.mock('@/features/sorimaru-audio/hooks/useSorimaruImage', () => ({ @@ -68,7 +69,7 @@ describe('SorimaruArchiveBrowse typography', () => { const title = screen.getByRole('heading', { name: LONG_TITLE }); expect(getComputedStyle(title).webkitLineClamp).toBe('2'); - expect(getComputedStyle(title).fontSize).toBe('1rem'); + expect(getComputedStyle(title).fontSize).toBe(typographyRole.body.fontSize); const article = title.closest('article'); expect(article).not.toBeNull(); diff --git a/src/features/sorimaru-audio/components/StoryCarousel.test.tsx b/src/features/sorimaru-audio/components/StoryCarousel.test.tsx index 739f806c..63b17608 100644 --- a/src/features/sorimaru-audio/components/StoryCarousel.test.tsx +++ b/src/features/sorimaru-audio/components/StoryCarousel.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import type { SorimaruStorySummary } from '@/features/sorimaru-audio/domain/sorimaruStory'; +import { typographyRole } from '@/design-system/tokens'; import { StoryCarousel, StoryCarouselSkeleton } from './StoryCarousel'; vi.mock('@gsap/react', () => ({ useGSAP: () => undefined })); @@ -62,7 +63,7 @@ describe('StoryCarousel typography', () => { const title = screen.getByRole('heading', { name: LONG_TITLE }); expect(getComputedStyle(title).webkitLineClamp).toBe('2'); - expect(getComputedStyle(title).fontSize).toBe('1rem'); + expect(getComputedStyle(title).fontSize).toBe(typographyRole.body.fontSize); const card = title.closest('[data-story-card]'); expect(card).not.toBeNull(); diff --git a/src/features/visit-review/presentation/useVisitReviewFeed.ts b/src/features/visit-review/presentation/useVisitReviewFeed.ts index a85504b5..10e8f3e1 100644 --- a/src/features/visit-review/presentation/useVisitReviewFeed.ts +++ b/src/features/visit-review/presentation/useVisitReviewFeed.ts @@ -9,6 +9,7 @@ export function useVisitReviewFeed(regionCode?: string) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [reloadNonce, setReloadNonce] = useState(0); useEffect(() => { let cancelled = false; @@ -29,7 +30,10 @@ export function useVisitReviewFeed(regionCode?: string) { return () => { cancelled = true; }; - }, [regionCode]); + }, [regionCode, reloadNonce]); - return { data, loading, error }; + const reload = () => setReloadNonce((n) => n + 1); + + return { data, loading, error, reload }; } + diff --git a/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx b/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx new file mode 100644 index 00000000..4a5731dc --- /dev/null +++ b/src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React from 'react'; +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { surface } from '@/design-system/tokens'; + +const shimmer = keyframes` + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +`; + +const SkeletonBase = styled.div` + background: linear-gradient(100deg, #e5e5e3 20%, #f5f5f4 42%, #e5e5e3 64%); + background-size: 200% 100%; + animation: ${shimmer} 1.8s ease-in-out infinite; + + [data-theme='dark'] & { + background: linear-gradient(100deg, #30302e 20%, #454542 42%, #30302e 64%); + background-size: 200% 100%; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +`; + +const CardSkeletonWrapper = styled.article` + padding: 16px; + margin: 4px 0 10px; + border-radius: 20px; + background: ${surface.light.surface}; + border: none; + box-sizing: border-box; + + [data-theme='dark'] & { + background: ${surface.dark.surface}; + box-shadow: var(--shadow-card-inset); + } +`; + +const PlaceHeaderSkeleton = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; + +const CategoryIconSkeleton = styled(SkeletonBase)` + width: 38px; + height: 38px; + border-radius: 12px; + flex-shrink: 0; +`; + +const PlaceInfoSkeleton = styled.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const TitleRowSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const PlaceTitleSkeleton = styled(SkeletonBase)` + height: 18px; + width: 110px; + border-radius: 4px; +`; + +const BadgeSkeleton = styled(SkeletonBase)` + height: 20px; + width: 72px; + border-radius: 9999px; +`; + +const MetaSkeleton = styled(SkeletonBase)` + height: 14px; + width: 140px; + border-radius: 4px; +`; + +const MoodSkeleton = styled(SkeletonBase)` + margin: 12px 0 12px; + height: 48px; + border-radius: 16px; + width: 100%; +`; + +const ReviewBodySkeleton = styled.div` + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const TagsRowSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +`; + +const TagChipSkeleton = styled(SkeletonBase)<{ $width: number }>` + height: 26px; + width: ${({ $width }) => $width}px; + border-radius: 9999px; +`; + +const TextLineSkeleton = styled(SkeletonBase)<{ $width: string }>` + height: 15px; + width: ${({ $width }) => $width}; + border-radius: 4px; +`; + +const FooterSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 14px; + padding-top: 4px; +`; + +const AuthorSkeleton = styled.div` + display: flex; + align-items: center; + gap: 6px; +`; + +const AvatarSkeleton = styled(SkeletonBase)` + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; +`; + +const DateSkeleton = styled(SkeletonBase)` + height: 14px; + width: 90px; + border-radius: 4px; +`; + +const HelpfulBtnSkeleton = styled(SkeletonBase)` + height: 30px; + width: 80px; + border-radius: 9999px; +`; + +const RelatedPlaceSkeleton = styled(SkeletonBase)` + margin-top: 12px; + height: 56px; + border-radius: 14px; + width: 100%; +`; + +export function WarmthCardSkeleton() { + return ( + + ); +} + +export function WarmthFeedSkeletonList({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/src/private/core-ui/map-warmth/WarmthFeed.tsx b/src/private/core-ui/map-warmth/WarmthFeed.tsx index 9edb403a..ad73039e 100644 --- a/src/private/core-ui/map-warmth/WarmthFeed.tsx +++ b/src/private/core-ui/map-warmth/WarmthFeed.tsx @@ -12,6 +12,7 @@ import { useVisitReviewFeed } from '@/features/visit-review/presentation/useVisi import { useVisitReviewRegions } from '@/features/visit-review/presentation/useVisitReviewRegions'; import DateScrubber from './DateScrubber'; import WarmthCard from './WarmthCard'; +import { WarmthFeedSkeletonList } from './WarmthCardSkeleton'; import { OniSearchEmpty } from '@/shared/components/OniSearchEmpty'; import { FeedContainer, @@ -96,9 +97,12 @@ export default function WarmthFeed() { const period = useMapStore((s) => s.warmthPeriod); const [selectedRegion, setSelectedRegion] = useState('all'); const serverRegions = useVisitReviewRegions(); - const { data: serverWarmths, error: reviewsError } = useVisitReviewFeed( - selectedRegion === 'all' ? undefined : selectedRegion, - ); + const { + data: serverWarmths, + loading: reviewsLoading, + error: reviewsError, + reload: reloadReviews, + } = useVisitReviewFeed(selectedRegion === 'all' ? undefined : selectedRegion); const regionOptions = useMemo( () => [ { id: 'all', label: '전국' }, @@ -441,11 +445,33 @@ export default function WarmthFeed() {
- {reviewsError && filteredReviews.length === 0 ? ( + {reviewsLoading && allWarmths.length === 0 ? ( + + ) : reviewsError && filteredReviews.length === 0 ? ( + 다시 시도 + + } /> ) : filteredReviews.length === 0 ? (