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
68 changes: 68 additions & 0 deletions app/account-deletion/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import { ArrowLeft, Mail, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { SERVICE_CONTACT_EMAIL } from "@/lib/constants";

export const metadata: Metadata = {
title: "계정 및 데이터 삭제 요청",
description: "로그인 없이 날씨로그 계정 또는 비회원 제보 데이터의 삭제를 요청하는 방법을 안내합니다.",
alternates: { canonical: "/account-deletion" },
};

const deletionRequestHref = `mailto:${SERVICE_CONTACT_EMAIL}?subject=${encodeURIComponent("[날씨로그] 계정 또는 데이터 삭제 요청")}&body=${encodeURIComponent("요청 유형(회원 계정/비회원 제보):\n알고 있는 가입 정보 또는 제보 링크:\n요청 내용:\n")}`;

export default function AccountDeletionPage() {
return (
<main className="min-h-screen px-5 pb-10">
<header className="safe-top compact-page-header grid grid-cols-[36px_1fr_36px] items-center pb-2">
<Link href="/" className="header-back-button" aria-label="홈으로 돌아가기">
<ArrowLeft size={18} />
</Link>
<h1 className="text-center text-lg font-extrabold">계정 및 데이터 삭제 요청</h1>
<span />
</header>

<div className="rounded-[22px] border-2 border-[#d2e3ec] p-5">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-2xl text-[#45ace4]">
<ShieldCheck size={22} />
</span>
<div className="min-w-0">
<h2 className="text-base font-extrabold">로그인하지 않아도 요청할 수 있어요</h2>
<p className="mt-2 text-sm font-semibold leading-6 text-[#526a7a]">
앱을 삭제했거나 로그인할 수 없는 경우에도 아래 공식 이메일로 회원 계정 또는 비회원 제보 데이터의 삭제를 요청할 수 있습니다.
</p>
</div>
</div>

<div className="mt-5 rounded-[18px] border-2 border-[#d2e3ec] p-4 text-sm font-semibold leading-6 text-[#526a7a]">
<p className="font-extrabold text-[#173144]">알고 있는 정보만 알려주세요</p>
<ul className="mt-2 list-disc space-y-1 pl-5 marker:text-[#45ace4]">
<li>회원: 가입한 소셜 서비스, 이메일 또는 닉네임</li>
<li>비회원: 삭제할 제보의 링크, 동네 또는 작성 시각</li>
<li>요청하려는 삭제 범위</li>
</ul>
<p className="mt-3 text-xs text-[#718594]">비밀번호, 인증번호, 소셜 토큰은 보내지 마세요.</p>
</div>

<a
href={deletionRequestHref}
className="mt-5 flex w-full items-center justify-center gap-2 rounded-2xl bg-[#45ace4] px-4 py-3.5 text-sm font-extrabold text-white transition-colors hover:bg-[#299bd8]"
>
<Mail size={17} />
이메일로 삭제 요청하기
</a>

<p className="mt-3 break-all text-center text-xs font-bold text-[#718594]">
{SERVICE_CONTACT_EMAIL}
</p>
<p className="mt-5 text-xs font-semibold leading-5 text-[#718594]">
날씨로그 팀은 전달받은 정보로 요청 대상과 소유 여부를 확인한 뒤 삭제 절차를 안내합니다. 확인에 필요한 정보가 부족하면 추가 확인을 요청할 수 있으며, 법령상 보관이 필요한 정보는 개인정보처리방침에 따릅니다.
</p>
<Link href="/privacy" className="mt-3 block text-center text-xs font-extrabold text-[#238fc9] hover:underline">
개인정보처리방침 확인하기
</Link>
</div>
</main>
);
}
13 changes: 3 additions & 10 deletions app/privacy-policy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import type { Metadata } from "next";
import { LegalDocument } from "@/components/legal-document";
import { PrivacyPolicyContent } from "@/components/legal-content";
import { permanentRedirect } from "next/navigation";

export const metadata: Metadata = {
title: "개인정보처리방침",
alternates: { canonical: "/privacy-policy" },
};

export default function PrivacyPolicyPage() {
return <LegalDocument title="개인정보처리방침" effectiveDate="2026년 7월 21일"><PrivacyPolicyContent /></LegalDocument>;
export default function LegacyPrivacyPolicyPage() {
permanentRedirect("/privacy");
}
21 changes: 21 additions & 0 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { PrivacyPolicyContent } from "@/components/legal-content";
import { LegalDocument } from "@/components/legal-document";
import { CURRENT_PRIVACY_TERMS_VERSION } from "@/lib/legal";

export const metadata: Metadata = {
title: "개인정보처리방침",
alternates: { canonical: "/privacy" },
};

export default function PrivacyPage() {
return (
<LegalDocument
title="개인정보처리방침"
version={CURRENT_PRIVACY_TERMS_VERSION}
effectiveDate="2026년 7월 21일"
>
<PrivacyPolicyContent />
</LegalDocument>
);
}
2 changes: 1 addition & 1 deletion app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { MetadataRoute } from "next";

const publicRoutes = ["", "/privacy-policy", "/terms"];
const publicRoutes = ["", "/privacy", "/terms", "/account-deletion"];

export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
Expand Down
3 changes: 2 additions & 1 deletion app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Metadata } from "next";
import { LegalDocument } from "@/components/legal-document";
import { TermsContent } from "@/components/legal-content";
import { CURRENT_SERVICE_TERMS_VERSION } from "@/lib/legal";

export const metadata: Metadata = {
title: "서비스 이용약관",
alternates: { canonical: "/terms" },
};

export default function TermsPage() {
return <LegalDocument title="서비스 이용약관" effectiveDate="2026년 7월 21일"><TermsContent /></LegalDocument>;
return <LegalDocument title="서비스 이용약관" version={CURRENT_SERVICE_TERMS_VERSION} effectiveDate="2026년 7월 21일"><TermsContent /></LegalDocument>;
}
58 changes: 41 additions & 17 deletions components/auth-callback-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,17 @@ import { authApi, type OAuthCallbackResult } from "@/lib/api/auth-api";
import { markAccountPanelReturn } from "@/lib/account-panel-return";
import { resolveApiUrl } from "@/lib/api/config";
import { logger } from "@/lib/logging";
import {
getOAuthFailureDestination,
isOAuthCallbackFailure,
} from "@/lib/oauth-callback";
import { socialProviderLabel } from "@/lib/social-providers";
import type { SocialProvider } from "@/lib/types";
import { useAuthStore } from "@/store/auth-store";
import { useToastStore } from "@/store/toast-store";

const authLogger = logger.child("auth.callback");

const providerLabel: Record<SocialProvider, string> = {
NAVER: "네이버",
KAKAO: "카카오",
GOOGLE: "구글",
};

function isOAuthCallbackResult(value: string | null): value is OAuthCallbackResult {
return ["SUCCESS", "SIGNUP_REQUIRED", "LINK_REQUIRED", "LINK_SUCCESS", "LINK_FAILED", "FAILED"].includes(value ?? "");
}
Expand Down Expand Up @@ -50,10 +49,40 @@ export function AuthCallbackScreen() {
const result: OAuthCallbackResult = isOAuthCallbackResult(callbackResult) ? callbackResult : "FAILED";

void authApi.getMe().then((session) => {
const code = searchParams.get("code");
if (isOAuthCallbackFailure(result, code)) {
const isAuthenticated =
session.result === "SUCCESS" &&
session.authenticated &&
Boolean(session.user);
const destination = getOAuthFailureDestination(isAuthenticated);

if (session.result === "SUCCESS" && session.authenticated && session.user) {
setServerUser(session.user);
} else {
setServerUser(undefined);
}
setPendingSignupProvider(undefined);
queryClient.removeQueries({ queryKey: ["auth", "me"], exact: true });
authLogger.warn("authentication_failed", { result, code: code ?? "UNKNOWN" });
showToast(
code === "OAUTH_CANCELLED" && destination === "ACCOUNT"
? "소셜 계정 연동을 취소했어요."
: getOAuthFailureMessage(code),
code === "OAUTH_CANCELLED" ? "INFO" : "ERROR",
);
if (destination === "ACCOUNT") {
markAccountPanelReturn();
router.replace("/mypage");
} else {
router.replace("/");
}
return;
}

queryClient.setQueryData(["auth", "me"], session);
if (session.result === "SUCCESS" && session.authenticated && session.user) {
setServerUser(session.user);
const code = searchParams.get("code");
if (result === "LINK_SUCCESS") {
authLogger.info("authentication_completed", { result });
queryClient.removeQueries({ queryKey: ["members", "me"], exact: true });
Expand All @@ -62,12 +91,6 @@ export function AuthCallbackScreen() {
router.replace("/mypage");
return;
}
if (result === "LINK_FAILED" || result === "FAILED") {
authLogger.warn("authentication_failed", { result, code: code ?? "UNKNOWN" });
showToast(getOAuthFailureMessage(code), code === "OAUTH_CANCELLED" ? "INFO" : "ERROR");
router.replace(result === "LINK_FAILED" ? "/mypage" : "/");
return;
}
authLogger.info("authentication_completed", { result });
showToast("로그인했어요.", "SUCCESS");
router.replace("/");
Expand All @@ -85,7 +108,6 @@ export function AuthCallbackScreen() {
return;
}

const code = searchParams.get("code");
if (code === "OAUTH_CANCELLED") {
authLogger.info("authentication_cancelled", { result });
} else {
Expand Down Expand Up @@ -127,7 +149,9 @@ export function AuthCallbackScreen() {
if (!pendingLink) {
return <main className="flex min-h-[75dvh] items-center justify-center"><div className="text-center"><LoaderCircle className="mx-auto animate-spin text-[#45ace4]" /><p className="mt-3 text-sm font-bold text-[#718594]">로그인 정보를 확인하고 있어요.</p></div></main>;
}
const existingProvider = pendingLink.existingProviders?.[0];
const existingProviderSummary = pendingLink.existingProviders
?.map((provider) => socialProviderLabel[provider])
.join(" 또는 ");

return (
<main className="flex min-h-[75dvh] items-center justify-center px-5">
Expand All @@ -136,8 +160,8 @@ export function AuthCallbackScreen() {
<div className="mt-4 rounded-[20px] border-2 border-[#d2e3ec] px-4 py-5 text-center">
<div className="mx-auto w-fit"><SocialIcon provider={pendingLink.provider} /></div>
<p className="mt-4 text-xs font-extrabold text-[#238fc9]">{pendingLink.email}</p>
<p className="mt-3 text-sm font-extrabold">{existingProvider ? `이미 ${providerLabel[existingProvider]} 계정이 있어요.` : "이미 가입된 회원 정보가 있어요."}</p>
<p className="mt-2 text-sm font-semibold text-[#526a7a]">{providerLabel[pendingLink.provider]} 계정을 연동할까요?</p>
<p className="mt-3 text-sm font-extrabold">{existingProviderSummary ? `이미 ${existingProviderSummary} 계정이 있어요.` : "이미 가입된 회원 정보가 있어요."}</p>
<p className="mt-2 text-sm font-semibold text-[#526a7a]">{socialProviderLabel[pendingLink.provider]} 계정을 연동할까요?</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-2.5">
<button type="button" disabled={isSubmitting} onClick={() => void cancel()} className="rounded-2xl border-2 border-[#d2e3ec] py-3.5 text-sm font-extrabold text-[#526a7a] disabled:opacity-50">취소</button>
Expand Down
2 changes: 2 additions & 0 deletions components/auth-session-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export function AuthSessionSync() {
setServerUser(session.data.authenticated && session.data.user ? session.data.user : undefined);
if (session.data.result === "SIGNUP_REQUIRED" && session.data.pendingAuth) {
setPendingSignupProvider(session.data.pendingAuth.provider, session.data.pendingAuth.email);
} else {
setPendingSignupProvider(undefined);
}
}, [session.data, setPendingSignupProvider, setServerUser]);

Expand Down
43 changes: 43 additions & 0 deletions components/author-block-confirm-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { Ban, LoaderCircle, X } from "lucide-react";
import { useModalNavigation } from "@/hooks/use-modal-navigation";

export function AuthorBlockConfirmModal({
isSubmitting,
onClose,
onConfirm,
}: {
isSubmitting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
const closeModal = useModalNavigation({
open: true,
onBack: () => {
if (!isSubmitting) onClose();
},
onDismiss: onClose,
});

return (
<div className="fixed inset-0 z-[70] flex items-end justify-center bg-[#173144]/35 p-4 backdrop-blur-[2px] sm:items-center" role="dialog" aria-modal="true" aria-labelledby="author-block-title">
<section className="w-full max-w-[340px] rounded-[24px] bg-[#eef9ff] p-5 shadow-2xl">
<header className="grid grid-cols-[40px_1fr_40px] items-center">
<span />
<h2 id="author-block-title" className="text-center text-lg font-extrabold">작성자 차단</h2>
<button type="button" disabled={isSubmitting} onClick={() => closeModal()} className="icon-button disabled:opacity-50" aria-label="차단 닫기"><X size={19} /></button>
</header>
<div className="mt-5 rounded-[20px] border-2 border-[#d2e3ec] px-5 py-6 text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-[#e7f6ff] text-[#268fc7]"><Ban size={22} /></span>
<p className="mt-3 text-sm font-extrabold">이 작성자를 차단하시겠어요?</p>
<p className="mt-2 text-xs font-semibold leading-5 text-[#718594]">이 작성자의 제보가 목록과 상세 화면에서 보이지 않아요.</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-2.5">
<button type="button" disabled={isSubmitting} onClick={() => closeModal()} className="rounded-2xl border-2 border-[#d2e3ec] py-3.5 text-sm font-extrabold text-[#526b7a] disabled:opacity-50">취소</button>
<button type="button" disabled={isSubmitting} onClick={onConfirm} className="flex items-center justify-center gap-2 rounded-2xl bg-[#45ace4] py-3.5 text-sm font-extrabold text-white disabled:bg-[#b9d5e4]">{isSubmitting && <LoaderCircle size={16} className="animate-spin" />}{isSubmitting ? "처리 중…" : "차단"}</button>
</div>
</section>
</div>
);
}
70 changes: 70 additions & 0 deletions components/blocked-author-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, LoaderCircle, UserRound } from "lucide-react";
import { useState } from "react";
import { moderationApi } from "@/lib/api/moderation-api";
import { resolveProfileImage } from "@/lib/constants";
import { formatReportDateTime } from "@/lib/date";
import { useToastStore } from "@/store/toast-store";

const PAGE_SIZE = 20;

export function BlockedAuthorList() {
const queryClient = useQueryClient();
const showToast = useToastStore((state) => state.showToast);
const [page, setPage] = useState(0);
const blocks = useQuery({
queryKey: ["report-blocks", page, PAGE_SIZE],
queryFn: () => moderationApi.getReportBlocks(page, PAGE_SIZE),
placeholderData: keepPreviousData,
});
const unblock = useMutation({
mutationFn: moderationApi.unblockMember,
onSuccess: () => {
showToast("차단을 해제했어요.", "SUCCESS");
if (blocks.data?.items.length === 1 && page > 0) setPage((current) => current - 1);
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["report-blocks"] }),
queryClient.invalidateQueries({ queryKey: ["weather-reports"] }),
queryClient.invalidateQueries({ queryKey: ["member-weather-reports"] }),
]);
},
onError: (error) => {
showToast(error instanceof Error ? error.message : "차단을 해제하지 못했어요.", "ERROR");
},
});

if (blocks.isPending) {
return <div className="space-y-2.5" aria-busy="true">{Array.from({ length: 3 }).map((_, index) => <div key={index} className="flex items-center gap-3 rounded-[18px] border-2 border-[#d2e3ec] p-3"><span className="skeleton size-11 rounded-full" /><span className="skeleton h-4 flex-1 rounded" /></div>)}</div>;
}

if (blocks.isError || !blocks.data) {
return <div className="rounded-[20px] border-2 border-[#d2e3ec] px-4 py-8 text-center"><p className="text-sm font-extrabold">차단 목록을 불러오지 못했어요.</p><button type="button" onClick={() => void blocks.refetch()} className="mt-3 text-xs font-extrabold text-[#238fc9]">다시 불러오기</button></div>;
}

if (blocks.data.items.length === 0) {
return <p className="py-10 text-center text-sm font-extrabold text-[#718594]">차단한 사용자가 없어요.</p>;
}

return (
<div>
<div className={`space-y-2.5 transition-opacity ${blocks.isFetching ? "opacity-60" : "opacity-100"}`} aria-busy={blocks.isFetching}>
{blocks.data.items.map((item) => {
const avatarUrl = resolveProfileImage(item.avatar?.profileImageUrl ?? item.avatar?.value);
const isUnblocking = unblock.isPending && unblock.variables === item.memberId;
return <article key={item.memberId} className="flex items-center gap-3 rounded-[18px] border-2 border-[#d2e3ec] p-3">
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-cover bg-center text-[#45ace4]" style={avatarUrl ? { backgroundImage: `url(${avatarUrl})` } : undefined} aria-hidden="true">{!avatarUrl && <UserRound size={20} />}</span>
<div className="min-w-0 flex-1"><p className="truncate text-sm font-extrabold">{item.nickname}</p><p className="mt-0.5 text-[11px] font-semibold text-[#718594]">{formatReportDateTime(item.blockedAt)} 차단</p></div>
<button type="button" disabled={unblock.isPending} onClick={() => unblock.mutate(item.memberId)} className="flex min-w-16 items-center justify-center gap-1 rounded-xl border-2 border-[#b9ddf0] px-2.5 py-2 text-xs font-extrabold text-[#268fc7] disabled:opacity-50">{isUnblocking && <LoaderCircle size={13} className="animate-spin" />}차단 해제</button>
</article>;
})}
</div>
{blocks.data.totalPages > 1 && <nav className="mt-4 flex items-center justify-center gap-4" aria-label="차단 목록 페이지">
<button type="button" disabled={page === 0 || blocks.isFetching} onClick={() => setPage((current) => Math.max(0, current - 1))} className="flex size-9 items-center justify-center rounded-xl border-2 border-[#d2e3ec] disabled:opacity-35" aria-label="이전 페이지"><ChevronLeft size={17} /></button>
<span className="min-w-14 text-center text-xs font-extrabold text-[#526a7a]">{page + 1} / {blocks.data.totalPages}</span>
<button type="button" disabled={page + 1 >= blocks.data.totalPages || blocks.isFetching} onClick={() => setPage((current) => current + 1)} className="flex size-9 items-center justify-center rounded-xl border-2 border-[#d2e3ec] disabled:opacity-35" aria-label="다음 페이지"><ChevronRight size={17} /></button>
</nav>}
</div>
);
}
Loading