Skip to content
Closed
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "onmaru-frontend",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
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