feat(mobile): 안드로이드 뒤로가기 이중확인 + 모바일 로그인 스플래시
- src/components/back-button-guard.tsx 신규: standalone(PWA/TWA) 모드에서만 작동
· 첫 뒤로가기 → sweetalert2 toast("한 번 더 누르면 앱이 종료됩니다") 표시
· 2초 안에 두 번째 뒤로가기 → history.back() 으로 native back 위임 → 앱 종료
· 일반 브라우저(non-standalone) 사용자에게는 영향 없음
- src/app/layout.tsx 의 RootLayout 에 BackButtonGuard 마운트
- src/app/(auth)/m/login/page.tsx 에 1.5초 스플래시 overlay 추가
· 모모 로고 + "모모유통 ERP" + spinner ("로딩 중...")
· z-60 fixed inset-0, 1.5s 후 opacity fade-out
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ export default function MobileLoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [remember, setRemember] = useState(true);
|
const [remember, setRemember] = useState(true);
|
||||||
const [saveCreds, setSaveCreds] = useState(false);
|
const [saveCreds, setSaveCreds] = useState(false);
|
||||||
|
const [splash, setSplash] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +30,12 @@ export default function MobileLoginPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 스플래시: 1.5 초 후 자연스럽게 fade-out
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setSplash(false), 1500);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!userId || !password) {
|
if (!userId || !password) {
|
||||||
@@ -70,6 +77,31 @@ export default function MobileLoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* 스플래시 — 1.5초 동안 표시 후 fade-out */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-[60] flex flex-col items-center justify-center bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] transition-opacity duration-500 ${
|
||||||
|
splash ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
aria-hidden={!splash}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 pointer-events-none opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
|
<img src="/momo-icon.svg" alt="MOMO" className="w-24 h-24 mb-5 drop-shadow-2xl animate-pulse" />
|
||||||
|
<h1 className="text-white text-2xl font-bold tracking-tight">모모유통 ERP</h1>
|
||||||
|
<p className="text-emerald-100/70 text-sm mt-2">유통 업무 통합 관리</p>
|
||||||
|
<div className="mt-8 flex items-center gap-2 text-emerald-100/80 text-xs">
|
||||||
|
<span className="w-4 h-4 border-2 border-emerald-200/40 border-t-emerald-200 rounded-full animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-[100dvh] flex flex-col bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-6"
|
className="min-h-[100dvh] flex flex-col bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-6"
|
||||||
style={{ paddingTop: "max(env(safe-area-inset-top), 1rem)", paddingBottom: "max(env(safe-area-inset-bottom), 1rem)" }}
|
style={{ paddingTop: "max(env(safe-area-inset-top), 1rem)", paddingBottom: "max(env(safe-area-inset-bottom), 1rem)" }}
|
||||||
@@ -193,5 +225,6 @@ export default function MobileLoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import BackButtonGuard from "@/components/back-button-guard";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -43,6 +44,7 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="h-full">
|
<body className="h-full">
|
||||||
|
<BackButtonGuard />
|
||||||
{children}
|
{children}
|
||||||
<Script id="sw-register" strategy="afterInteractive">
|
<Script id="sw-register" strategy="afterInteractive">
|
||||||
{`if ('serviceWorker' in navigator) {
|
{`if ('serviceWorker' in navigator) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TWA/PWA standalone 환경에서 안드로이드 뒤로가기 처리.
|
||||||
|
* - 뒤로 갈 history 가 없을 때(또는 시작 entry 일 때) 첫 번째 뒤로가기는 토스트만 띄움
|
||||||
|
* - 2초 안에 한 번 더 뒤로가기 누르면 native 가 처리하도록 history.back() 호출 → 앱 종료
|
||||||
|
*
|
||||||
|
* 일반 데스크톱 브라우저 사용자에게는 영향 없음 (standalone 모드에서만 활성화).
|
||||||
|
*/
|
||||||
|
export default function BackButtonGuard() {
|
||||||
|
useEffect(() => {
|
||||||
|
// standalone 모드 (PWA 설치 또는 TWA) 인지 확인
|
||||||
|
const isStandalone =
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
// iOS Safari
|
||||||
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
|
||||||
|
if (!isStandalone) return;
|
||||||
|
|
||||||
|
let lastBackPress = 0;
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// history 에 dummy entry 1 개 추가 → 첫 뒤로가기가 이 dummy 로 pop
|
||||||
|
history.pushState({ __back_guard: true }, "", location.href);
|
||||||
|
|
||||||
|
const onPopState = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBackPress < 2000) {
|
||||||
|
// 2초 이내 두 번째 뒤로 → native 가 처리하도록 한 번 더 back (앱 종료)
|
||||||
|
if (toastTimer) {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = null;
|
||||||
|
}
|
||||||
|
history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastBackPress = now;
|
||||||
|
|
||||||
|
// dummy entry 다시 push (다음 뒤로가기를 다시 가로채려고)
|
||||||
|
history.pushState({ __back_guard: true }, "", location.href);
|
||||||
|
|
||||||
|
// 토스트 표시 — sweetalert2 의 toast mode
|
||||||
|
Swal.fire({
|
||||||
|
toast: true,
|
||||||
|
position: "bottom",
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 1800,
|
||||||
|
timerProgressBar: true,
|
||||||
|
icon: "info",
|
||||||
|
title: "한 번 더 누르면 앱이 종료됩니다",
|
||||||
|
background: "#1f2937",
|
||||||
|
color: "#ffffff",
|
||||||
|
});
|
||||||
|
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
lastBackPress = 0;
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", onPopState);
|
||||||
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user