Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/features/hanok-archive/components/VillageCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions src/features/visit-review/presentation/useVisitReviewFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function useVisitReviewFeed(regionCode?: string) {
const [data, setData] = useState<Warmth[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
const [reloadNonce, setReloadNonce] = useState(0);

useEffect(() => {
let cancelled = false;
Expand All @@ -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 };
}

217 changes: 217 additions & 0 deletions src/private/core-ui/map-warmth/WarmthCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CardSkeletonWrapper aria-hidden="true" data-testid="warmth-card-skeleton">
<PlaceHeaderSkeleton>
<CategoryIconSkeleton />
<PlaceInfoSkeleton>
<TitleRowSkeleton>
<PlaceTitleSkeleton />
<BadgeSkeleton />
</TitleRowSkeleton>
<MetaSkeleton />
</PlaceInfoSkeleton>
</PlaceHeaderSkeleton>

<MoodSkeleton />

<ReviewBodySkeleton>
<TagsRowSkeleton>
<TagChipSkeleton $width={58} />
<TagChipSkeleton $width={68} />
<TagChipSkeleton $width={52} />
</TagsRowSkeleton>
<TextLineSkeleton $width="94%" />
<TextLineSkeleton $width="82%" />
<TextLineSkeleton $width="60%" />
</ReviewBodySkeleton>

<FooterSkeleton>
<AuthorSkeleton>
<AvatarSkeleton />
<DateSkeleton />
</AuthorSkeleton>
<HelpfulBtnSkeleton />
</FooterSkeleton>

<RelatedPlaceSkeleton />
</CardSkeletonWrapper>
);
}

export function WarmthFeedSkeletonList({ count = 3 }: { count?: number }) {
return (
<div
role="status"
aria-busy="true"
aria-label="온기 이야기를 불러오는 중입니다"
data-testid="warmth-feed-skeleton-list"
>
{Array.from({ length: count }).map((_, index) => (
<WarmthCardSkeleton key={`warmth-skeleton-${index}`} />
))}
</div>
);
}
36 changes: 31 additions & 5 deletions src/private/core-ui/map-warmth/WarmthFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: '전국' },
Expand Down Expand Up @@ -441,11 +445,33 @@ export default function WarmthFeed() {

<FeedScroll>
<div ref={feedTopRef} />
{reviewsError && filteredReviews.length === 0 ? (
{reviewsLoading && allWarmths.length === 0 ? (
<WarmthFeedSkeletonList count={3} />
) : reviewsError && filteredReviews.length === 0 ? (
<OniSearchEmpty
size="md"
title="온기 이야기를 불러오지 못했어요"
description="잠시 후 다시 시도해주세요."
description="네트워크 연결을 확인하거나 다시 시도해 주세요."
action={
<button
type="button"
onClick={reloadReviews}
style={{
marginTop: 12,
padding: '8px 18px',
borderRadius: 9999,
border: 'none',
background: 'rgba(78, 89, 104, 0.08)',
color: meok[800],
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
다시 시도
</button>
}
/>
) : filteredReviews.length === 0 ? (
<OniSearchEmpty
Expand Down
Loading
Loading