Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
16 changes: 8 additions & 8 deletions frontend/src/app/(login)/login/_components/ServiceIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,34 +121,34 @@ export default function ServiceIntro() {
>
<item.icon className="w-5 h-5" />
</span>
<p className="text-base md:text-lg lg:text-xl font-bold mb-2 text-itta-point">
<p className="text-lg lg:text-xl font-bold mb-2 text-itta-point">
{item.caption}
</p>
<h3
className={cn(
'text-xl md:text-2xl lg:text-4xl font-bold leading-[1.35] whitespace-pre-line mb-4',
'text-2xl lg:text-4xl font-bold leading-[1.35] whitespace-pre-line mb-4',
isLight ? 'text-itta-black' : 'text-white',
)}
>
{item.headline}
</h3>
<p
className={cn(
'text-sm md:text-base lg:text-lg leading-relaxed whitespace-pre-line',
'text-base lg:text-lg leading-relaxed whitespace-pre-line',
isLight ? 'text-gray-500' : 'text-white/60',
)}
>
{item.description}
</p>
</div>

<div className="w-full max-w-40 md:max-w-65 lg:max-w-72 shrink-0">
<div className="w-full max-w-65 lg:max-w-72 shrink-0">
<Image
src={item.image}
alt={item.caption}
width={512}
height={1001}
sizes="(min-width: 1024px) 288px, (min-width: 768px) 260px, 160px"
sizes="(min-width: 1024px) 288px, 260px"
className="w-full h-auto object-contain"
/>
</div>
Expand All @@ -167,14 +167,14 @@ export default function ServiceIntro() {
<span className="inline-flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 text-itta-black mb-5">
<Book className="w-5 h-5" />
</span>
<p className="text-base md:text-lg lg:text-xl font-bold text-itta-point mb-2">
<p className="text-lg lg:text-xl font-bold text-itta-point mb-2">
보관함
</p>
<h3 className="text-xl md:text-2xl lg:text-4xl font-bold leading-[1.35] mb-4 text-itta-black">
<h3 className="text-2xl lg:text-4xl font-bold leading-[1.35] mb-4 text-itta-black">
그룹마다 따로,
<br />또 같이 쌓이는 우리의 기록
</h3>
<p className="text-sm md:text-base lg:text-lg leading-relaxed text-gray-500">
<p className="text-base lg:text-lg leading-relaxed text-gray-500">
가족 여행부터 친구들과의 일상까지
<br />
그룹별로 나누어 보관함에 기록해요
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/app/(main)/_components/homeCoachmarkSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ export const HOME_COACHMARK_STEPS: CoachmarkStep[] = [
{
id: 'tutorial-nav-my',
title: '개인 기록과 그룹 기록을 나눠서 관리해요',
description: '나만 보는 기록은 개인 기록함에, 함께 쓰는 기록은 그룹에 따로 쌓여요.',
description:
'나만 보는 기록은 개인 기록함에, 함께 쓰는 기록은 그룹에 따로 쌓여요.',
yOffsetMobile: 4,
isBottomNavTarget: true,
},
{
id: 'tutorial-fab-add-record',
title: '여기서 기록을 남겨보세요',
description: '사진과 함께 오늘의 순간을 기록해요.',
yOffsetMobile: 6.2,
isBottomNavTarget: true,
},
{
id: 'tutorial-nav-group',
title: '그룹으로 함께 기록해요',
description: '친구를 초대해서 함께 추억을 쌓아보세요.',
yOffsetMobile: 4,
isBottomNavTarget: true,
},
];
214 changes: 142 additions & 72 deletions frontend/src/app/(main)/profile/_components/Setting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { usePWAInstall } from '@/hooks/usePWAInstall';
import PWAInstallModal from '@/components/PWAInstallModal';
import { useAuthStore } from '@/store/useAuthStore';
Expand Down Expand Up @@ -56,6 +56,14 @@ export default function Setting() {
const [showInstructions, setShowInstructions] = useState(false);
const [pushEnabled, setPushEnabled] = useState(false);
const [pushBlocked, setPushBlocked] = useState(false);
// 연속으로 토글했을 때 먼저 시작된(느린) 요청이 나중에 끝나면서 최신 상태를
// 덮어쓰지 않도록, 매 호출마다 증가시켜 "이 완료가 아직 최신 요청인지" 판별한다.
const pushRequestIdRef = useRef(0);
Comment thread
H-sooyeon marked this conversation as resolved.
// 서버로 나가는 등록/삭제 요청을 순서대로 실행하기 위한 체인.
// isStale()은 완료 후의 "UI 반영"만 막을 뿐 네트워크 요청 자체는 막지 않아서,
// 겹쳐 실행되면 늦게 도착한 예전 요청(예: disable의 삭제)이 최신 요청(예:
// enable의 등록) 이후에 서버에 반영되며 서버 상태가 뒤집힐 수 있었다.
const pushMutationChainRef = useRef<Promise<void>>(Promise.resolve());

const { mutate: logout } = useApiPost('/api/auth/logout', {
onSuccess: async () => {
Expand Down Expand Up @@ -100,18 +108,23 @@ export default function Setting() {
}, []);

useEffect(() => {
// 하이드레이션 시작 시점의 요청 번호를 기억해뒀다가, 완료 시점에 그 사이
// 토글이 일어나 번호가 바뀌었다면(=더 최신 상태가 있다면) 이 결과는 버린다.
const requestId = pushRequestIdRef.current;
(async () => {
if (isNativePlatform()) {
try {
const { PushNotifications } =
await import('@capacitor/push-notifications');
const { receive } = await PushNotifications.checkPermissions();
if (pushRequestIdRef.current !== requestId) return;
const disabled =
localStorage.getItem('push_notifications_disabled') === 'true';
setPushEnabled(receive === 'granted' && !disabled);
setPushBlocked(receive === 'denied');
} catch {}
} else if ('Notification' in window) {
if (pushRequestIdRef.current !== requestId) return;
const disabled =
localStorage.getItem('push_notifications_disabled') === 'true';
setPushEnabled(Notification.permission === 'granted' && !disabled);
Expand Down Expand Up @@ -143,65 +156,114 @@ export default function Setting() {
};

const handleTogglePush = async () => {
const requestId = ++pushRequestIdRef.current;
const isStale = () => pushRequestIdRef.current !== requestId;
Comment thread
H-sooyeon marked this conversation as resolved.
const platform = isNativePlatform() ? 'android' : 'web';
const wasEnabled = pushEnabled;

if (pushEnabled) {
// 낙관적 업데이트: 먼저 꺼진 것으로 표시하고, 실패하면 되돌린다.
setPushEnabled(false);
try {
await removeFcmToken(platform);
localStorage.setItem('push_notifications_disabled', 'true');
} catch {
setPushEnabled(true); // 롤백
toast.error('알림 해제에 실패했습니다. 잠시 후 다시 시도해 주세요.');
}
return;
}
// 낙관적 업데이트: UI는 즉시 반영. 서버 요청은 아래에서 순서대로 실행한다.
setPushEnabled(!wasEnabled);

// 낙관적 업데이트: 먼저 켜진 것으로 표시하고, 권한 거부/실패 시 되돌린다.
setPushEnabled(true);
try {
if (isNativePlatform()) {
await registerAndroidToken();
const { PushNotifications } =
await import('@capacitor/push-notifications');
const { receive } = await PushNotifications.checkPermissions();
if (receive === 'granted') {
localStorage.removeItem('push_notifications_disabled');
setPushBlocked(false);
} else {
setPushEnabled(false); // 롤백
setPushBlocked(true);
const { NativeSettings, AndroidSettings } =
await import('capacitor-native-settings');
await NativeSettings.openAndroid({
option: AndroidSettings.AppNotification,
}).catch(() => {});
// 이전 토글의 서버 요청이 완전히 끝난 뒤에만 이번 요청을 시작한다 — 겹쳐서
// 보내면 늦게 도착하는 쪽이 순서와 무관하게 최종 서버 상태를 결정해버린다.
const previousMutation = pushMutationChainRef.current;
const thisMutation = previousMutation.catch(() => {}).then(async () => {
if (wasEnabled) {
try {
await removeFcmToken(platform);
} catch {
if (isStale()) return;
setPushEnabled(true); // 롤백
toast.error('알림 해제에 실패했습니다. 잠시 후 다시 시도해 주세요.');
return;
}
if (isStale()) return; // 그 사이 더 최신 토글이 시작됨 — 이 완료는 무시
try {
localStorage.setItem('push_notifications_disabled', 'true');
} catch (error) {
// 서버 토큰 삭제는 이미 성공했으므로 토글은 되돌리지 않는다.
Sentry.captureException(error, {
tags: {
context: 'notification',
operation: 'push-disable-persist',
},
});
}
} else {
await requestAndRegisterWebToken();
if (Notification.permission === 'granted') {
localStorage.removeItem('push_notifications_disabled');
setPushBlocked(false);
return;
}

try {
if (isNativePlatform()) {
await registerAndroidToken();
const { PushNotifications } =
await import('@capacitor/push-notifications');
const { receive } = await PushNotifications.checkPermissions();
if (isStale()) return;
if (receive === 'granted') {
// 토큰 등록은 이미 성공했으므로, 마커 제거가 실패해도 켜진 상태는 유지한다.
try {
localStorage.removeItem('push_notifications_disabled');
} catch (error) {
Sentry.captureException(error, {
tags: {
context: 'notification',
operation: 'push-enable-persist',
},
});
}
setPushBlocked(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
setPushEnabled(false); // 롤백
setPushBlocked(true);
const { NativeSettings, AndroidSettings } =
await import('capacitor-native-settings');
await NativeSettings.openAndroid({
option: AndroidSettings.AppNotification,
}).catch(() => {});
}
} else {
setPushEnabled(false); // 롤백
setPushBlocked(Notification.permission === 'denied');
if (Notification.permission === 'denied') {
toast.info('설정에서 알림 권한을 허용해 주세요.');
await requestAndRegisterWebToken();
if (isStale()) return;
if (Notification.permission === 'granted') {
// 토큰 등록은 이미 성공했으므로, 마커 제거가 실패해도 켜진 상태는 유지한다.
try {
localStorage.removeItem('push_notifications_disabled');
} catch (error) {
Sentry.captureException(error, {
tags: {
context: 'notification',
operation: 'push-enable-persist',
},
});
}
setPushBlocked(false);
} else {
setPushEnabled(false); // 롤백
setPushBlocked(Notification.permission === 'denied');
if (Notification.permission === 'denied') {
toast.info('설정에서 알림 권한을 허용해 주세요.');
}
}
}
} catch (error) {
if (isStale()) return;
setPushEnabled(false); // 롤백
try {
localStorage.setItem('push_notifications_disabled', 'true');
} catch {}
Sentry.captureException(error, {
tags: {
context: 'notification',
operation: 'push-enable',
platform: isNativePlatform() ? 'android' : 'web',
},
});
toast.error('알림 설정 변경에 실패했습니다.');
}
} catch (error) {
setPushEnabled(false); // 롤백
Sentry.captureException(error, {
tags: {
context: 'notification',
operation: 'push-enable',
platform: isNativePlatform() ? 'android' : 'web',
},
});
toast.error('알림 설정 변경에 실패했습니다.');
}
});

pushMutationChainRef.current = thisMutation;
await thisMutation;
};

const handleContact = () => {
Expand Down Expand Up @@ -267,29 +329,37 @@ export default function Setting() {
</div>

{('Notification' in window || isNativePlatform()) && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 rounded-lg transition-colors dark:bg-blue-500/10 dark:text-blue-400 bg-blue-50 text-blue-500">
<Bell className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 rounded-lg transition-colors dark:bg-blue-500/10 dark:text-blue-400 bg-blue-50 text-blue-500">
<Bell className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</div>
<span className="text-xs sm:text-sm font-bold dark:text-gray-200 text-itta-black">
푸시 알림
</span>
</div>
<span className="text-xs sm:text-sm font-bold dark:text-gray-200 text-itta-black">
푸시 알림
</span>
</div>
<button
onClick={handleTogglePush}
className={cn(
'cursor-pointer w-10 h-5 sm:w-11 sm:h-6 rounded-full relative transition-all duration-300',
pushEnabled ? 'bg-blue-500' : 'bg-gray-200',
)}
>
<div
<button
onClick={handleTogglePush}
className={cn(
'absolute top-0.5 left-0.5 sm:top-1 sm:left-1 w-4 h-4 bg-white rounded-full transition-transform duration-300 ease-in-out',
pushEnabled && 'translate-x-5',
'cursor-pointer w-10 h-5 sm:w-11 sm:h-6 rounded-full relative transition-all duration-300',
pushEnabled ? 'bg-blue-500' : 'bg-gray-200',
)}
/>
</button>
>
<div
className={cn(
'absolute top-0.5 left-0.5 sm:top-1 sm:left-1 w-4 h-4 bg-white rounded-full transition-transform duration-300 ease-in-out',
pushEnabled && 'translate-x-5',
)}
/>
</button>
</div>
{pushBlocked && (
<p className="flex items-center gap-1 text-[11px] sm:text-xs text-red-500 dark:text-red-400 pl-8 sm:pl-9">
<AlertCircle className="w-3 h-3 sm:w-3.5 sm:h-3.5 shrink-0" />
알림이 차단되어 있어요. 기기 설정에서 허용해 주세요.
</p>
)}
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,11 @@ export const GROUP_DETAIL_COACHMARK_STEPS: CoachmarkStep[] = [
title: '이 그룹에 기록을 남겨보세요',
description: '사진과 함께 그룹원들과의 순간을 기록해요.',
yOffsetMobile: 6.2,
isBottomNavTarget: true,
},
{
id: 'tutorial-group-map',
title: '지도에서 한눈에 확인해요',
description: '그룹의 기록들이 남긴 장소를 지도로 둘러보세요.',
yOffsetMobile: 4,
isBottomNavTarget: true,
},
];
Loading
Loading