11'use client' ;
2- import { useState } from 'react' ;
2+ import { useState , useEffect } from 'react' ;
33import Image from 'next/image' ;
44import { useRouter } from 'next/router' ;
55import {
@@ -15,6 +15,7 @@ import { useGetPlaceDetail } from '@/shared/main/queries/useGetPlaceDetail';
1515import { useUserStatus } from '@/shared/hooks/useUserStatus' ;
1616import { useStampAcquire } from '@/shared/api/main/node/queries/useStampAcquire' ;
1717import { savePostcard } from '@/shared/utils/storage' ;
18+ import { Skeleton } from '@/shared/components/skeleton/Skeleton' ;
1819
1920const Node = ( ) => {
2021 const router = useRouter ( ) ;
@@ -23,6 +24,12 @@ const Node = () => {
2324 const [ showErrorPopup , setShowErrorPopup ] = useState ( false ) ;
2425 const { isLoggedIn } = useUserStatus ( ) ;
2526
27+ // 이미지 로딩 상태
28+ const [ imageLoaded , setImageLoaded ] = useState ( false ) ;
29+
30+ // 스켈레톤 표시 여부 (로딩이 1초 이상일 때만 true)
31+ const [ showSkeleton , setShowSkeleton ] = useState ( false ) ;
32+
2633 // 스탬프 획득 훅
2734 const { mutate : acquireStamp } = useStampAcquire ( ) ;
2835
@@ -31,13 +38,35 @@ const Node = () => {
3138 router . isReady ? Number ( placeId ) : undefined ,
3239 ) ;
3340
34- if ( isLoading ) return < p className = 'text-center mt-10' > 로딩 중...</ p > ;
41+ useEffect ( ( ) => {
42+ let timer : NodeJS . Timeout ;
43+ if ( isLoading ) {
44+ timer = setTimeout ( ( ) => setShowSkeleton ( true ) , 1000 ) ;
45+ } else {
46+ setShowSkeleton ( false ) ;
47+ }
48+ return ( ) => clearTimeout ( timer ) ;
49+ } , [ isLoading ] ) ;
50+
51+ if ( isLoading && showSkeleton ) {
52+ return (
53+ < div className = 'flex flex-col items-center justify-center px-[2.4rem] mt-10' >
54+ < Header title = '로딩중.. ' onClick = { ( ) => router . back ( ) } />
55+ < div className = 'mt-[10rem] flex flex-col gap-[1.2rem] w-full' >
56+ < Skeleton className = 'w-full max-w-[354px] h-[300px] rounded-[16px]' />
57+ < Skeleton className = 'w-full max-w-[354px] h-[100px] rounded-[16px]' />
58+ < Skeleton className = 'w-full max-w-[354px] h-[50px] rounded-[16px]' />
59+ </ div >
60+ </ div >
61+ ) ;
62+ }
63+
3564 if ( isError || ! data )
3665 return < p className = 'text-center mt-10' > 데이터를 불러오지 못했습니다 😢</ p > ;
3766
3867 const { isCompleted, imageUrl, placeName, description, address } = data . data ;
3968
40- // 스탬프 찍기 버튼 클릭 핸들러
69+ // 🔹 스탬프 찍기 버튼
4170 const handleStampClick = ( ) => {
4271 if ( ! isLoggedIn ) {
4372 setShowLoginPopup ( true ) ;
@@ -46,21 +75,17 @@ const Node = () => {
4675
4776 if ( isCompleted ) return ;
4877
49- // 위치 가져와서 API 호출
5078 getLocation (
5179 ( pos ) => {
5280 const body = {
53- // 하드 코딩
5481 latitude : 37.48585193654532 ,
5582 longitude : 126.80355242431538 ,
56- // 실제
83+ // 실제 위치 사용 시:
5784 // latitude: pos.coords.latitude,
5885 // longitude: pos.coords.longitude,
5986 } ;
6087 const placeIdNum = Number ( placeId ) ;
6188
62- console . log ( '📍 현재 위치:' , body ) ;
63-
6489 acquireStamp (
6590 { placeId : placeIdNum , body } ,
6691 {
@@ -95,33 +120,45 @@ const Node = () => {
95120 role = 'main'
96121 aria-label = { `${ placeName } 상세 페이지` }
97122 >
98- < section className = 'relative w-full' >
99- < Image
100- src = { imageUrl || '/assets/board.svg' }
101- alt = { placeName }
102- width = { 354 }
103- height = { 436 }
104- className = { cn (
105- 'w-full h-auto object-cover block rounded-[16px] transition-all duration-300' ,
106- ! isCompleted && 'blur-xs brightness-90' ,
123+ < section className = 'relative w-full h-[256px]' >
124+ < div className = 'relative w-full h-full rounded-[16px] overflow-hidden' >
125+ { ! imageLoaded && (
126+ < Skeleton className = 'absolute inset-0 w-full h-full rounded-[16px] animate-pulse bg-gradient-to-br from-gray-200 to-gray-100' />
107127 ) }
108- />
109128
110- < button
111- aria-label = { isCompleted ? '스탬프 획득 완료' : '스탬프 찍기' }
112- className = { cn (
113- 'absolute bottom-0 right-0' ,
114- isCompleted && 'p-[2.5rem]' ,
115- ) }
116- onClick = { handleStampClick }
117- >
118- < Icon
119- name = { isCompleted ? 'Stamp' : 'PressStamp' }
120- color = { isCompleted ? 'pink-400' : 'gray-50' }
121- size = { isCompleted ? 100 : 160 }
122- aria-hidden = 'true'
129+ < Image
130+ src = { imageUrl || '/assets/board.svg' }
131+ alt = { placeName }
132+ fill
133+ sizes = '(max-width: 768px) 100vw, 354px'
134+ priority = { false }
135+ onLoadingComplete = { ( ) => setImageLoaded ( true ) }
136+ className = { cn (
137+ 'object-cover rounded-[16px] transition-opacity duration-500' ,
138+ ! isCompleted && 'blur-xs brightness-90' ,
139+ imageLoaded ? 'opacity-100' : 'opacity-0' ,
140+ ) }
123141 />
124- </ button >
142+ </ div >
143+
144+ { imageLoaded && (
145+ < button
146+ aria-label = { isCompleted ? '스탬프 획득 완료' : '스탬프 찍기' }
147+ className = { cn (
148+ 'absolute bottom-0 right-0' ,
149+ isCompleted && 'p-[2.5rem]' ,
150+ imageLoaded ? 'opacity-100' : 'opacity-0 h-0' ,
151+ ) }
152+ onClick = { handleStampClick }
153+ >
154+ < Icon
155+ name = { isCompleted ? 'Stamp' : 'PressStamp' }
156+ color = { isCompleted ? 'pink-400' : 'gray-50' }
157+ size = { isCompleted ? 100 : 160 }
158+ aria-hidden = 'true'
159+ />
160+ </ button >
161+ ) }
125162 </ section >
126163
127164 < LocationCard
@@ -131,11 +168,10 @@ const Node = () => {
131168 variant = 'mint'
132169 size = 'large'
133170 />
134-
135171 < AddressCopy variant = 'mint' value = { address } />
136172 </ main >
137173
138- { /* 로그인 필요 팝업 */ }
174+ { /* 팝업 영역 */ }
139175 { showLoginPopup && (
140176 < PopupSet
141177 text = '로그인이 필요한 서비스입니다.'
@@ -146,7 +182,6 @@ const Node = () => {
146182 />
147183 ) }
148184
149- { /* 위치 에러 팝업 */ }
150185 { showErrorPopup && (
151186 < PopupSet
152187 text = '해당 위치를 다시 확인해 주세요.'
0 commit comments