Skip to content

Commit 3aa6cff

Browse files
authored
[25.06.07 / TASK-189] Refactor - QR 로그인 모달 UI 일부 개선 (#41)
1 parent 8ba187a commit 3aa6cff

File tree

6 files changed

+152
-36
lines changed

6 files changed

+152
-36
lines changed

src/app/(auth-required)/components/QRCode.tsx

Lines changed: 0 additions & 34 deletions
This file was deleted.

src/app/(auth-required)/components/header/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useResponsive, useModal } from '@/hooks';
1212
import { logout, me } from '@/apis';
1313
import { defaultStyle, Section, textStyle } from './Section';
1414
import { Modal } from '../notice/Modal';
15-
import { QRCode } from '../QRCode';
15+
import { QRCode } from '../qrcode';
1616

1717
const PARAMS = {
1818
MAIN: '?asc=false&sort=',
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export * from './header';
22
export * from './notice';
3-
export * from './QRCode';
3+
export * from './qrcode';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
const ROLLBACK_AFTER_CLICK_MS = 1000;
4+
5+
interface IProp {
6+
url?: string;
7+
disabled?: boolean;
8+
}
9+
10+
export const CopyButton = ({ url, disabled }: IProp) => {
11+
const [clicked, setClicked] = useState(false);
12+
const clickedRef = useRef<NodeJS.Timeout | null>(null);
13+
14+
useEffect(() => {
15+
return () => {
16+
if (clickedRef.current) clearTimeout(clickedRef.current);
17+
};
18+
}, []);
19+
20+
const handleClick = async () => {
21+
if (clicked || !url) return;
22+
23+
try {
24+
await navigator.clipboard.writeText(url);
25+
setClicked(true);
26+
27+
if (clickedRef.current) clearTimeout(clickedRef.current);
28+
29+
clickedRef.current = setTimeout(() => setClicked(false), ROLLBACK_AFTER_CLICK_MS);
30+
} catch (err) {
31+
console.error('클립보드 복사 실패:', err);
32+
}
33+
};
34+
35+
return (
36+
<button
37+
onClick={handleClick}
38+
disabled={disabled}
39+
className={`
40+
relative block p-4 rounded-lg leading-none overflow-hidden transition-all duration-200
41+
after:absolute after:inset-0 after:flex after:items-center after:justify-center truncate
42+
after:rounded-lg after:transition-all after:duration-300 after:font-medium after:pointer-events-none
43+
${
44+
disabled
45+
? 'cursor-not-allowed bg-BG-ALT text-TEXT-SUB opacity-50'
46+
: clicked
47+
? 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["복사_완료!"] after:bg-PRIMARY-SUB after:text-BG-MAIN after:opacity-100 after:scale-100'
48+
: 'cursor-pointer bg-BG-MAIN text-TEXT-MAIN hover:shadow-lg after:content-["클릭해서_복사하기"] after:bg-BG-MAIN after:text-TEXT-MAIN after:opacity-0 after:scale-95 hover:after:opacity-100 hover:after:scale-100'
49+
}
50+
`}
51+
>
52+
{url}
53+
</button>
54+
);
55+
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use client';
2+
3+
import { QRCodeSVG } from 'qrcode.react';
4+
import { useQuery } from '@tanstack/react-query';
5+
import { useState, useRef, useEffect } from 'react';
6+
import { COLORS, env, PATHS, SCREENS } from '@/constants';
7+
import { useResponsive } from '@/hooks';
8+
import { createQRToken } from '@/apis';
9+
import { Modal as Layout } from '@/components';
10+
import { formatTimeToMMSS } from '@/utils/dateUtil';
11+
import { CopyButton } from './CopyButton';
12+
13+
const TIMER_DURATION = 5 * 60; // 5분 = 300초
14+
15+
export const QRCode = () => {
16+
const width = useResponsive();
17+
const [timeLeft, setTimeLeft] = useState(TIMER_DURATION);
18+
19+
const timerRef = useRef<NodeJS.Timeout | null>(null);
20+
const isExpired = timeLeft === 0;
21+
22+
const { data, isLoading, refetch } = useQuery({
23+
queryKey: [PATHS.QRLOGIN],
24+
queryFn: createQRToken,
25+
refetchOnMount: true,
26+
staleTime: 0,
27+
refetchOnWindowFocus: false,
28+
});
29+
const url = `${env.BASE_URL}/api/qr-login?token=${data?.token}`;
30+
31+
// 타이머 시작
32+
useEffect(() => {
33+
if (!isLoading) {
34+
timerRef.current = setInterval(() => setTimeLeft((prev) => (prev <= 1 ? 0 : prev - 1)), 1000);
35+
}
36+
37+
return () => {
38+
if (timerRef.current) clearInterval(timerRef.current);
39+
};
40+
}, [isLoading]);
41+
42+
return (
43+
<Layout title="QR 로그인">
44+
<div className="flex items-center justify-center gap-10">
45+
<div
46+
className={
47+
isExpired || isLoading
48+
? `relative after:inset-0 after:absolute after:m-auto after:bg-BG-MAIN after:size-fit after:text-TEXT-MAIN after:px-3 after:py-1 after:rounded-lg after:font-medium ${isLoading ? 'after:content-["로딩중"]' : 'after:content-["만료됨"]'}`
49+
: ''
50+
}
51+
>
52+
<QRCodeSVG
53+
value={url}
54+
width={width < SCREENS.MBI ? 130 : 171}
55+
height={width < SCREENS.MBI ? 130 : 171}
56+
enableBackground={0}
57+
bgColor={COLORS.BG.SUB}
58+
fgColor={COLORS.TEXT.MAIN}
59+
className={`transition-all ${isExpired || isLoading ? 'blur-sm' : ''}`}
60+
/>
61+
</div>
62+
<div className="flex flex-col items-center gap-4">
63+
<h3 className="text-T4 text-TEXT-ALT leading-none">만료까지</h3>
64+
<h2
65+
className={`text-T2 leading-none min-w-[130px] text-center ${timeLeft <= 60 ? 'text-DESTRUCTIVE-SUB' : 'text-TEXT-MAIN'}`}
66+
>
67+
{formatTimeToMMSS(timeLeft)}
68+
</h2>
69+
{isExpired && !isLoading && (
70+
<button
71+
className="text-I1 text-BG-MAIN bg-PRIMARY-MAIN px-5 py-1 rounded-sm"
72+
onClick={async () => {
73+
await refetch();
74+
setTimeLeft(TIMER_DURATION);
75+
}}
76+
>
77+
새로고침
78+
</button>
79+
)}
80+
</div>
81+
</div>
82+
83+
<CopyButton url={url} disabled={isExpired || isLoading} />
84+
</Layout>
85+
);
86+
};

src/utils/dateUtil.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,12 @@ export const convertDateToKST = (date?: string): KSTDateFormat | undefined => {
4646
full: kstDate,
4747
};
4848
};
49+
50+
export const formatTimeToMMSS = (time: number) => {
51+
const minute = Math.floor(time / 60)
52+
.toString()
53+
.padStart(2, '0');
54+
const second = (time % 60).toString().padStart(2, '0');
55+
56+
return `${minute}${second}초`;
57+
};

0 commit comments

Comments
 (0)