Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@tanstack/react-query": "^5.102.8",
"@vis.gl/react-google-maps": "^1.9.0",
"axios": "^1.20.0",
"firebase": "^12.19.0",
"lucide-react": "^0.468.0",
"pretendard": "^1.3.9",
"react": "^19.0.0",
Expand Down
849 changes: 846 additions & 3 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
importScripts(
"https://www.gstatic.com/firebasejs/12.19.0/firebase-app-compat.js",
);
importScripts(
"https://www.gstatic.com/firebasejs/12.19.0/firebase-messaging-compat.js",
);

firebase.initializeApp({
apiKey: "AIzaSyD1DAdlqzDcTWzBaLK220l7CCO7SdXN6YU",
authDomain: "quespot-655f3.firebaseapp.com",
projectId: "quespot-655f3",
storageBucket: "quespot-655f3.firebasestorage.app",
messagingSenderId: "686318748064",
appId: "1:686318748064:web:e7a94717ce0cd715e5e4c5",
});

firebase.messaging();
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { RouterProvider } from "react-router-dom";
import { router } from "./routes/router";
import ForegroundNotification from "./components/ForegroundNotification";

function App() {
return (
<>
<RouterProvider router={router} />
<ForegroundNotification />
</>
);
}
Expand Down
112 changes: 112 additions & 0 deletions src/components/ForegroundNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getMessaging, isSupported, onMessage } from "firebase/messaging";
import { Bell, X } from "lucide-react";
import { getAccessToken } from "@/utils/auth";
import { firebaseApp } from "@/utils/fcm/firebase";

type ToastNotification = {
title: string;
body: string;
};

export default function ForegroundNotification() {
const queryClient = useQueryClient();
const [notification, setNotification] = useState<ToastNotification | null>(
null,
);

useEffect(() => {
let active = true;
let unsubscribe: (() => void) | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined;

const startListening = async () => {
try {
if (!(await isSupported()) || !active) return;

const messaging = getMessaging(firebaseApp);

unsubscribe = onMessage(messaging, (payload) => {
// 로그아웃 상태에서는 화면에 표시하지 않음
if (!active || !getAccessToken()) return;

void queryClient.invalidateQueries({
queryKey: ["notifications", "list"],
});

void queryClient.invalidateQueries({
queryKey: ["notifications", "unread-count"],
});

setNotification({
title:
payload.notification?.title ||
payload.data?.title ||
"새로운 알림이 도착했어요",
body:
payload.notification?.body ||
payload.data?.body ||
"알림함에서 내용을 확인해 주세요.",
});

// 새 알림이 오면 다시 5초부터 시작
if (hideTimer) clearTimeout(hideTimer);

hideTimer = setTimeout(() => {
setNotification(null);
}, 5000);
});
} catch (error) {
console.error("실시간 알림 수신 연결에 실패했습니다.", error);
}
};

void startListening();

return () => {
active = false;
unsubscribe?.();

if (hideTimer) clearTimeout(hideTimer);
};
}, [queryClient]);

if (!notification) return null;

return (
<div className="pointer-events-none fixed inset-x-0 top-[max(16px,env(safe-area-inset-top))] z-[100] flex justify-center px-4">
<section
aria-label="새 알림"
className="
pointer-events-auto flex w-full max-w-[398px] items-start gap-3
rounded-2xl border border-[#D5EAFE] bg-white p-4
shadow-[0_8px_30px_rgba(62,102,142,0.16)]
"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-[#EAF5FF] text-[#399FDF]">
<Bell size={20} aria-hidden="true" />
</span>

<div className="min-w-0 flex-1" role="status" aria-atomic="true">
<p className="break-words text-sm font-bold text-[#252743]">
{notification.title}
</p>

<p className="mt-1 break-words text-xs leading-relaxed text-[#667085]">
{notification.body}
</p>
</div>

<button
type="button"
aria-label="알림 닫기"
onClick={() => setNotification(null)}
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-[#8B939E] hover:bg-[#F4F8FF]"
>
<X size={18} aria-hidden="true" />
</button>
</section>
</div>
);
}
6 changes: 2 additions & 4 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import App from "./App";
import { router } from "@/routes/router";

import "pretendard/dist/web/variable/pretendardvariable.css";
import "./styles/global.css";
import "./index.css";
Expand All @@ -15,7 +13,7 @@ const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<App />
</QueryClientProvider>
</React.StrictMode>,
);
);
162 changes: 137 additions & 25 deletions src/pages/Auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ import { PATH } from "@/routes/paths";
import { getSocialLoginUrl, login, type SocialProvider } from "@/apis/auth";
import { saveAuth, saveLoginRedirect } from "@/utils/auth";
import { resolvePostLoginPath } from "@/utils/profile";
import { connectExistingPush } from "@/utils/fcm/connectExistingPush";

const inputClass = "type-body2 h-11 w-full rounded-2xl border border-transparent bg-[#eaf5ff] px-4 text-[#252743] outline-none transition placeholder:text-[#8b939e] focus:border-[#5bb5f8]/70 focus:bg-[#f7fbff] focus:ring-3 focus:ring-[#5bb5f8]/15";
const inputClass =
"type-body2 h-11 w-full rounded-2xl border border-transparent bg-[#eaf5ff] px-4 text-[#252743] outline-none transition placeholder:text-[#8b939e] focus:border-[#5bb5f8]/70 focus:bg-[#f7fbff] focus:ring-3 focus:ring-[#5bb5f8]/15";

export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const routeState = location.state as { email?: string; from?: string; signupComplete?: boolean } | null;
const routeState = location.state as {
email?: string;
from?: string;
signupComplete?: boolean;
} | null;
const [email, setEmail] = useState(routeState?.email ?? "");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
Expand All @@ -29,9 +35,16 @@ export default function LoginPage() {
try {
const result = await login(email.trim(), password);
saveAuth(result.accessToken, result.userId);
navigate(await resolvePostLoginPath(routeState?.from || PATH.HOME), { replace: true });
await connectExistingPush();
navigate(await resolvePostLoginPath(routeState?.from || PATH.HOME), {
replace: true,
});
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "로그인에 실패했습니다.");
setError(
loginError instanceof Error
? loginError.message
: "로그인에 실패했습니다.",
);
} finally {
setIsSubmitting(false);
}
Expand All @@ -44,45 +57,144 @@ export default function LoginPage() {

return (
<main className="grid min-h-dvh grid-rows-[minmax(330px,43%)_1fr] overflow-hidden bg-[#f4f7fd] text-[#20223d]">
<section className="flex flex-col items-center justify-center bg-[linear-gradient(160deg,#c8e8ff_8.49%,#eaf5ff_92%)] px-5 pb-7 pt-[38px] text-center" aria-labelledby="login-brand">
<img className="h-[clamp(118px,34vw,147px)] w-[clamp(118px,34vw,147px)] object-contain" src={questyLogin} alt="여행 가방을 든 Quespot 마스코트" />
<h1 className="type-brand -mt-0.5 mb-0.5 text-[#5bb5f8]" id="login-brand">Quespot</h1>
<section
className="flex flex-col items-center justify-center bg-[linear-gradient(160deg,#c8e8ff_8.49%,#eaf5ff_92%)] px-5 pb-7 pt-[38px] text-center"
aria-labelledby="login-brand"
>
<img
className="h-[clamp(118px,34vw,147px)] w-[clamp(118px,34vw,147px)] object-contain"
src={questyLogin}
alt="여행 가방을 든 Quespot 마스코트"
/>
<h1
className="type-brand -mt-0.5 mb-0.5 text-[#5bb5f8]"
id="login-brand"
>
Quespot
</h1>
<p className="type-caption2 m-0 text-[#777b81]">
최선으로 떠나는 특별한 여행<br />지금 바로 시작해보세요 <span aria-hidden="true">✈️</span>
최선으로 떠나는 특별한 여행
<br />
지금 바로 시작해보세요 <span aria-hidden="true">✈️</span>
</p>
</section>

<section className="flex flex-col bg-[#f4f7fd] px-5 pb-[max(32px,env(safe-area-inset-bottom))] pt-5" aria-label="로그인">
<section
className="flex flex-col bg-[#f4f7fd] px-5 pb-[max(32px,env(safe-area-inset-bottom))] pt-5"
aria-label="로그인"
>
<form className="grid gap-2.5" onSubmit={handleLogin}>
{routeState?.signupComplete ? <p className="px-1 text-[11px] leading-4 text-[#2ca777]" role="status">회원가입이 완료됐어요. 로그인해주세요.</p> : null}
<label className="sr-only" htmlFor="login-email">이메일 주소</label>
<input className={inputClass} autoComplete="email" id="login-email" inputMode="email" onChange={(event) => setEmail(event.target.value)} placeholder="이메일 주소" required type="email" value={email} />
<label className="sr-only" htmlFor="login-password">비밀번호</label>
<input className={inputClass} autoComplete="current-password" id="login-password" onChange={(event) => setPassword(event.target.value)} placeholder="비밀번호" required type="password" value={password} />
{error ? <p className="px-1 text-[11px] leading-4 text-[#e46f6f]" role="alert">{error}</p> : null}
<button className="type-body6 mt-0.5 h-11 w-full rounded-xl bg-[#5bb5f8] text-white shadow-[0_5px_14px_rgba(91,181,248,0.18)] transition active:translate-y-px disabled:cursor-wait disabled:opacity-60 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#5bb5f8]/30" disabled={isSubmitting} type="submit">{isSubmitting ? "로그인 중..." : "로그인"}</button>
{routeState?.signupComplete ? (
<p
className="px-1 text-[11px] leading-4 text-[#2ca777]"
role="status"
>
회원가입이 완료됐어요. 로그인해주세요.
</p>
) : null}
<label className="sr-only" htmlFor="login-email">
이메일 주소
</label>
<input
className={inputClass}
autoComplete="email"
id="login-email"
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
placeholder="이메일 주소"
required
type="email"
value={email}
/>
<label className="sr-only" htmlFor="login-password">
비밀번호
</label>
<input
className={inputClass}
autoComplete="current-password"
id="login-password"
onChange={(event) => setPassword(event.target.value)}
placeholder="비밀번호"
required
type="password"
value={password}
/>
{error ? (
<p
className="px-1 text-[11px] leading-4 text-[#e46f6f]"
role="alert"
>
{error}
</p>
) : null}
<button
className="type-body6 mt-0.5 h-11 w-full rounded-xl bg-[#5bb5f8] text-white shadow-[0_5px_14px_rgba(91,181,248,0.18)] transition active:translate-y-px disabled:cursor-wait disabled:opacity-60 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#5bb5f8]/30"
disabled={isSubmitting}
type="submit"
>
{isSubmitting ? "로그인 중..." : "로그인"}
</button>
</form>

<div className="my-3 grid grid-cols-[1fr_auto_1fr] items-center gap-3" aria-hidden="true">
<span className="h-px bg-[#e1edf7]" /><em className="text-[11px] not-italic leading-4 text-[#a2a9b2]">또는</em><span className="h-px bg-[#e1edf7]" />
<div
className="my-3 grid grid-cols-[1fr_auto_1fr] items-center gap-3"
aria-hidden="true"
>
<span className="h-px bg-[#e1edf7]" />
<em className="text-[11px] not-italic leading-4 text-[#a2a9b2]">
또는
</em>
<span className="h-px bg-[#e1edf7]" />
</div>

<div className="flex items-center justify-center gap-4" aria-label="소셜 로그인">
<button className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30" aria-label="구글 계정으로 로그인" onClick={() => handleSocialLogin("google")} type="button">
<div
className="flex items-center justify-center gap-4"
aria-label="소셜 로그인"
>
<button
className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30"
aria-label="구글 계정으로 로그인"
onClick={() => handleSocialLogin("google")}
type="button"
>
<img className="h-11 w-11" src={googleLogin} alt="" />
</button>
<button className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30" aria-label="카카오 계정으로 로그인" onClick={() => handleSocialLogin("kakao")} type="button">
<button
className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30"
aria-label="카카오 계정으로 로그인"
onClick={() => handleSocialLogin("kakao")}
type="button"
>
<img className="h-11 w-11" src={kakaoLogin} alt="" />
</button>
<button className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30" aria-label="네이버 계정으로 로그인" onClick={() => handleSocialLogin("naver")} type="button">
<button
className="h-11 w-11 rounded-full bg-transparent p-0 transition hover:-translate-y-0.5 active:scale-95 focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#5bb5f8]/30"
aria-label="네이버 계정으로 로그인"
onClick={() => handleSocialLogin("naver")}
type="button"
>
<img className="h-11 w-11" src={naverLogin} alt="" />
</button>
</div>

<nav className="mt-3 flex items-center justify-center gap-3" aria-label="계정 도움말">
<button className="bg-transparent p-0 text-[11px] font-medium leading-4 text-[#a2a9b2]" type="button">비밀번호 찾기</button>
<nav
className="mt-3 flex items-center justify-center gap-3"
aria-label="계정 도움말"
>
<button
className="bg-transparent p-0 text-[11px] font-medium leading-4 text-[#a2a9b2]"
type="button"
>
비밀번호 찾기
</button>
<i className="h-3 w-px bg-[#c8e8ff]" aria-hidden="true" />
<button className="bg-transparent p-0 text-[11px] font-medium leading-4 text-[#5bb5f8]" onClick={() => navigate(PATH.SIGNUP)} type="button">회원가입 </button>
<button
className="bg-transparent p-0 text-[11px] font-medium leading-4 text-[#5bb5f8]"
onClick={() => navigate(PATH.SIGNUP)}
type="button"
>
회원가입{" "}
</button>
</nav>
</section>
</main>
Expand Down
Loading
Loading