Skip to content

Commit bb65eeb

Browse files
authored
Merge pull request #108 from Team-Senifit/release-1.0.1
QA 수정 후 배포 진행
2 parents 0f6e3f7 + 30d16c5 commit bb65eeb

File tree

20 files changed

+423
-217
lines changed

20 files changed

+423
-217
lines changed

src/apis/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export class AuthError extends Error {
44
}
55
}
66
export const isAuthError = (e: unknown): e is AuthError =>
7-
e instanceof AuthError;
7+
e instanceof AuthError ||
8+
(e instanceof Error && e.message === "AUTH_REQUIRED");

src/app/(plain)/exercise/class/[id]/page.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import useProgramStore from "@/states/useProgramStore";
55
import WorkoutVideoPlaylist from "./panel/WorkoutVideoPlayer";
66
import { useRouter } from "next/navigation";
77
import { useClientReady } from "@/hooks/useClientReady";
8-
import { useDeadlineTrigger } from "@/hooks/useDeadlineTrigger";
9-
import dayjs from "dayjs";
10-
import { useToastStore } from "@/states/useToastStore";
8+
// import { useDeadlineTrigger } from "@/hooks/useDeadlineTrigger";
9+
// import dayjs from "dayjs";
10+
// import { useToastStore } from "@/states/useToastStore";
1111

1212
const Page = () => {
1313
const isClientReady = useClientReady();
1414
const router = useRouter();
1515
const { selectedProgram } = useProgramStore();
1616

17-
const { setToastOpen } = useToastStore();
17+
// const { setToastOpen } = useToastStore();
1818

1919
useEffect(() => {
2020
if (!isClientReady) return;
@@ -24,16 +24,17 @@ const Page = () => {
2424
return () => {};
2525
}, [selectedProgram, router, isClientReady]);
2626

27-
useDeadlineTrigger({
28-
at: dayjs().add(selectedProgram?.duration || 0, "minute"),
29-
onFire: () =>
30-
setToastOpen({
31-
message: `목표수업시간 ${selectedProgram?.duration ? Math.floor(selectedProgram?.duration) : 0}분이 되었어요!`,
32-
}),
33-
enabled: !!selectedProgram,
34-
});
3527
if (!selectedProgram) return null;
3628

29+
// useDeadlineTrigger({
30+
// at: dayjs().add(selectedProgram?.duration || 0, "minute"),
31+
// onFire: () =>
32+
// setToastOpen({
33+
// message: `목표수업시간 ${selectedProgram?.duration ? Math.floor(selectedProgram?.duration) : 0}분이 되었어요!`,
34+
// }),
35+
// enabled: !!selectedProgram,
36+
// });
37+
3738
return (
3839
<WorkoutVideoPlaylist
3940
duration={selectedProgram?.duration}

src/app/(plain)/exercise/class/[id]/panel/WorkoutVideoPlayer.tsx

Lines changed: 148 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { Box, Button, Stack, Typography } from "@mui/material";
55
import VideoPlayer, { IVideoHandle } from "@/components/VideoPlayer";
66
import useMedia from "@/hooks/useMedia";
77
import Header from "./Header";
8-
import { useParams } from "next/navigation";
8+
import { useParams, useRouter } from "next/navigation";
99
import { useTimer } from "@/hooks/useTimer";
10-
import { notifyClassDone } from "@/utils/broadcast";
11-
import { useToastStore } from "@/states/useToastStore";
10+
import { axiosClient } from "@/apis/axiosClient";
11+
import { isAuthError } from "@/apis/errors";
12+
// import { useToastStore } from "@/states/useToastStore";
13+
import SenifitDialog from "@/components/SenifitDialog";
1214

1315
export interface IWorkoutVideo {
1416
id: number;
@@ -68,19 +70,126 @@ export default function WorkoutVideoPlaylist({
6870
duration,
6971
}: IWorkoutVideoPlaylistProps) {
7072
const { isPhone } = useMedia();
73+
const router = useRouter();
7174

7275
const { seconds } = useTimer();
7376

74-
const { id: programId } = useParams();
77+
const { id } = useParams();
78+
const recordId = useMemo(() => (Array.isArray(id) ? id[0] : id), [id]);
7579

7680
const notifyDone = useCallback((): void => {
77-
const pid = Array.isArray(programId) ? programId[0] : programId;
78-
notifyClassDone({ programId: pid, seconds });
79-
// 브로드캐스트가 부모 탭에 전달될 시간 확보
80-
window.setTimeout(() => {
81-
window.close();
82-
}, 50);
83-
}, [programId, seconds]);
81+
if (!recordId) return;
82+
shouldBypassUnload.current = true;
83+
// 단일 탭 흐름: 종료 시 완료 화면으로 이동
84+
router.replace(`/exercise/done/${recordId}?seconds=${seconds}`);
85+
}, [recordId, router, seconds]);
86+
87+
useEffect(() => {
88+
if (!recordId) return;
89+
90+
let cancelled = false;
91+
let timeoutId: number | null = null;
92+
let inFlight = false;
93+
let redirected = false;
94+
95+
const pulse = async () => {
96+
if (cancelled || inFlight) return;
97+
inFlight = true;
98+
try {
99+
await axiosClient.put(`/records/${recordId}`);
100+
} catch (err) {
101+
// axios interceptor에서 401/403 -> AuthError로 throw 되지만
102+
// 여기서 catch로 삼키면 전역 error boundary 리디렉션이 동작하지 않음.
103+
// heartbeat에서는 즉시 로그인으로 전환한다.
104+
if (!redirected && isAuthError(err)) {
105+
redirected = true;
106+
cancelled = true;
107+
if (timeoutId != null) window.clearTimeout(timeoutId);
108+
const currentPath = window.location.pathname + window.location.search;
109+
const loginUrl = `/login?next=${encodeURIComponent(currentPath)}`;
110+
window.location.href = loginUrl;
111+
return;
112+
}
113+
console.error("Heartbeat failed:", err);
114+
} finally {
115+
inFlight = false;
116+
}
117+
};
118+
119+
const scheduleNext = (delayMs: number) => {
120+
if (cancelled) return;
121+
timeoutId = window.setTimeout(async () => {
122+
await pulse();
123+
scheduleNext(30000);
124+
}, delayMs);
125+
};
126+
127+
// 운동 시작 시 즉시 첫 하트비트 전송 + 이후 30초 루프
128+
void pulse();
129+
scheduleNext(30000);
130+
131+
// 백그라운드 타이머 throttling 대비: 다시 포커스/표시되면 즉시 1회 전송
132+
const onVisible = () => {
133+
if (document.visibilityState === "visible") void pulse();
134+
};
135+
window.addEventListener("focus", onVisible);
136+
window.addEventListener("visibilitychange", onVisible);
137+
window.addEventListener("pageshow", onVisible);
138+
139+
// 페이지가 닫히거나 전환될 때 마지막 1회 시도(keepalive)
140+
const onPageHide = () => {
141+
try {
142+
void fetch(`/api/records/${recordId}`, {
143+
method: "PUT",
144+
credentials: "include",
145+
keepalive: true,
146+
});
147+
} catch {
148+
// ignore
149+
}
150+
};
151+
window.addEventListener("pagehide", onPageHide);
152+
153+
return () => {
154+
cancelled = true;
155+
if (timeoutId != null) window.clearTimeout(timeoutId);
156+
window.removeEventListener("focus", onVisible);
157+
window.removeEventListener("visibilitychange", onVisible);
158+
window.removeEventListener("pageshow", onVisible);
159+
window.removeEventListener("pagehide", onPageHide);
160+
};
161+
}, [recordId]);
162+
163+
// 이탈 방지 bypass 플래그 (앱 내부 이동 시 사용)
164+
const shouldBypassUnload = useRef(false);
165+
166+
// 이탈 방지 로직 (브라우저 종료/새로고침)
167+
useEffect(() => {
168+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
169+
if (shouldBypassUnload.current) return;
170+
e.preventDefault();
171+
e.returnValue = "";
172+
};
173+
174+
window.addEventListener("beforeunload", handleBeforeUnload);
175+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
176+
}, []);
177+
178+
// 이탈 방지 로직 (뒤로 가기)
179+
useEffect(() => {
180+
// 현재 상태를 push하여 뒤로 가기 시 popstate가 트리거되게 함
181+
window.history.pushState(null, "", window.location.href);
182+
183+
const handlePopState = () => {
184+
// 뒤로 가기 버튼을 눌렀을 때 다이얼로그를 띄움
185+
setOpenExitDialog(true);
186+
// 다시 pushState를 해서 현재 페이지를 유지 (사용자가 '나가기'를 누를 때까지)
187+
window.history.pushState(null, "", window.location.href);
188+
};
189+
190+
window.addEventListener("popstate", handlePopState);
191+
return () => window.removeEventListener("popstate", handlePopState);
192+
}, []);
84193

85194
const initialIndex = useMemo(() => {
86195
if (initialId == null) return 0;
@@ -89,6 +198,7 @@ export default function WorkoutVideoPlaylist({
89198
}, [videos, initialId]);
90199

91200
const [index, setIndex] = useState<number>(initialIndex);
201+
const [openExitDialog, setOpenExitDialog] = useState(false);
92202
const handleRef = useRef<IVideoHandle>(null);
93203

94204
const current = videos[index];
@@ -108,7 +218,11 @@ export default function WorkoutVideoPlaylist({
108218
let target = next;
109219

110220
if (next >= len) {
111-
if (!loop) return notifyDone();
221+
if (!loop) {
222+
// 마지막 영상까지 끝나면 자동으로 완료 처리
223+
notifyDone();
224+
return;
225+
}
112226
target = 0;
113227
} else if (next < 0) {
114228
target = 0;
@@ -117,24 +231,24 @@ export default function WorkoutVideoPlaylist({
117231
setIndex(target);
118232
onIndexChange?.(target, videos[target]);
119233
},
120-
[videos, loop, notifyDone, onIndexChange],
234+
[videos, loop, onIndexChange, notifyDone],
121235
);
122-
const { setToastOpen } = useToastStore();
236+
// const { setToastOpen } = useToastStore();
123237

124238
const prev = useCallback(() => {
125-
setToastOpen({ message: "이전 영상을 재생합니다.", autoHide: "short" });
239+
// setToastOpen({ message: "이전 영상을 재생합니다.", autoHide: "short" });
126240
go(index - 1);
127-
}, [go, index, setToastOpen]);
241+
}, [go, index]);
128242

129243
const next = useCallback(() => {
130244
const isLast = index === videos.length - 1;
131245
if (isLast && !loop) {
132246
notifyDone();
133247
return;
134248
}
135-
setToastOpen({ message: "다음 영상을 재생합니다.", autoHide: "short" });
249+
// setToastOpen({ message: "다음 영상을 재생합니다.", autoHide: "short" });
136250
go(index + 1);
137-
}, [go, index, loop, notifyDone, setToastOpen, videos.length]);
251+
}, [go, index, loop, notifyDone, videos.length]);
138252

139253
// src 바뀌면 자동 재생 시도(사용자 제스처 이후 연속 재생 안정화)
140254
useEffect(() => {
@@ -261,6 +375,22 @@ export default function WorkoutVideoPlaylist({
261375
</Button>
262376
</Stack>
263377
</Stack>
378+
379+
{/* 이탈 확인 다이얼로그 */}
380+
<SenifitDialog
381+
isOpen={openExitDialog}
382+
onClose={() => setOpenExitDialog(false)}
383+
dialogType={"error"}
384+
title={"사이트에서 나가시겠습니까?"}
385+
body={"변경사항이 저장되지 않을 수 있습니다"}
386+
primaryText={"나가기"}
387+
onPrimaryClick={() => {
388+
shouldBypassUnload.current = true;
389+
notifyDone();
390+
}}
391+
secondaryText={"취소"}
392+
onSecondaryClick={() => setOpenExitDialog(false)}
393+
/>
264394
</Box>
265395
);
266396
}

src/app/(plain)/exercise/start/page.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,9 @@ const Page = () => {
3434
router.push(`/exercise/class/${data.data.id}`);
3535
},
3636
onError: () => {
37-
// LOGIN_NEEDED broadcast하기
37+
// 단일 탭 흐름: 로그인 페이지로 이동
3838
notifyLogout();
39-
setTimeout(() => {
40-
try {
41-
window.close();
42-
} catch {}
43-
}, 150);
39+
router.replace("/login?next=" + encodeURIComponent("/exercise/start"));
4440
},
4541
});
4642

src/app/(plain)/login/panel/LoginForm.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import SenifitTextField from "../../../../components/SenifitTextField";
1515
import { useSearchParams } from "next/navigation";
1616
import { useEffect, useState } from "react";
1717
import useMedia from "@/hooks/useMedia";
18-
import Logo from "@/assets/logo/senifit-logo.svg";
18+
import Logo from "@/assets/logo/Logo.svg";
1919
import SenifitDialog from "@/components/SenifitDialog";
2020
import Link from "next/link";
2121
import { kakaoChannelLink } from "@/constants/kakaoCh";
@@ -24,12 +24,11 @@ import EyeIcon from "@/components/icons/EyeIcon";
2424
import EyeOffIcon from "@/components/icons/EyeOffIcon";
2525
import InquiryButton from "@/components/InquiryButton";
2626
import BackgroundImage from "@/assets/images/login-background.png";
27+
import { signupGoogleForm } from "@/constants/signupGF";
2728

2829
type LoginFormValues = { id: string; password: string };
2930

3031
export default function LoginForm() {
31-
const signUpLink = "https://forms.gle/7zWgYjysswZ8ZjwE8";
32-
3332
const { isPhone } = useMedia();
3433

3534
const searchParams = useSearchParams();
@@ -195,7 +194,7 @@ export default function LoginForm() {
195194
/>
196195

197196
<InquiryButton
198-
onClick={() => window.open(signUpLink, "_blank")}
197+
onClick={() => window.open(signupGoogleForm, "_blank")}
199198
mt={1.5}
200199
width={200}
201200
height={32}

src/app/(with-container)/(exercise)/exercise/check-selected/page.tsx

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import { ButtonProps, Stack } from "@mui/material";
3+
// import { ButtonProps } from "@mui/material";
4+
import { Stack } from "@mui/material";
45
import React, { useEffect, useRef, useState } from "react";
56
import GradationPageInfoCard from "../../../../../components/GradationPageInfoCard";
67
import useProgramStore from "@/states/useProgramStore";
@@ -12,7 +13,7 @@ import { createBroadcastListener } from "@/utils/broadcast";
1213
import Members from "./panel/Members";
1314
import Routine from "./panel/Routine";
1415
import SenifitDialog from "@/components/SenifitDialog";
15-
import Link from "next/link";
16+
// import Link from "next/link";
1617

1718
// broadcast handled via utils/broadcast
1819

@@ -117,21 +118,12 @@ const Page = () => {
117118
dialogType={"success"}
118119
title={"이제 수업을 시작할까요?"}
119120
primaryText={"네, 시작할게요"}
120-
primaryButtonProps={
121-
{
122-
component: Link,
123-
href: "/exercise/start",
124-
target: "_blank",
125-
rel: "noopener noreferrer",
126-
} as ButtonProps
127-
}
121+
onPrimaryClick={() => {
122+
setOpenModal(false);
123+
router.push("/exercise/start");
124+
}}
128125
secondaryText={type === "customized" ? "수정하기" : "돌아가기"}
129-
secondaryButtonProps={
130-
{
131-
component: Link,
132-
href: routineUrl,
133-
} as ButtonProps
134-
}
126+
onSecondaryClick={() => setOpenModal(false)}
135127
/>
136128
</>
137129
);

src/app/(with-container)/(exercise)/exercise/done/[id]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const Page = () => {
107107
}}
108108
>
109109
<Box component={"span"}>{`${formatTime(seconds ?? 0)}`}</Box>
110-
{` / ${formatTime(selectedProgram?.duration ?? 0)}`}
110+
{` / ${formatTime((selectedProgram?.duration ?? 0) * 60)}`}
111111
</Typography>
112112
</Stack>
113113
</Stack>

0 commit comments

Comments
 (0)