diff --git a/app/account-deletion/page.tsx b/app/account-deletion/page.tsx new file mode 100644 index 0000000..804ab21 --- /dev/null +++ b/app/account-deletion/page.tsx @@ -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 ( +
+
+ + + +

계정 및 데이터 삭제 요청

+ +
+ +
+
+ + + +
+

로그인하지 않아도 요청할 수 있어요

+

+ 앱을 삭제했거나 로그인할 수 없는 경우에도 아래 공식 이메일로 회원 계정 또는 비회원 제보 데이터의 삭제를 요청할 수 있습니다. +

+
+
+ +
+

알고 있는 정보만 알려주세요

+
    +
  • 회원: 가입한 소셜 서비스, 이메일 또는 닉네임
  • +
  • 비회원: 삭제할 제보의 링크, 동네 또는 작성 시각
  • +
  • 요청하려는 삭제 범위
  • +
+

비밀번호, 인증번호, 소셜 토큰은 보내지 마세요.

+
+ + + + 이메일로 삭제 요청하기 + + +

+ {SERVICE_CONTACT_EMAIL} +

+

+ 날씨로그 팀은 전달받은 정보로 요청 대상과 소유 여부를 확인한 뒤 삭제 절차를 안내합니다. 확인에 필요한 정보가 부족하면 추가 확인을 요청할 수 있으며, 법령상 보관이 필요한 정보는 개인정보처리방침에 따릅니다. +

+ + 개인정보처리방침 확인하기 + +
+
+ ); +} diff --git a/app/privacy-policy/page.tsx b/app/privacy-policy/page.tsx index 16da2be..30f6b16 100644 --- a/app/privacy-policy/page.tsx +++ b/app/privacy-policy/page.tsx @@ -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 ; +export default function LegacyPrivacyPolicyPage() { + permanentRedirect("/privacy"); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..0a18dca --- /dev/null +++ b/app/privacy/page.tsx @@ -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 ( + + + + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 060725d..a899e35 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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(); diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 57118e4..9c88da8 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -1,6 +1,7 @@ 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: "서비스 이용약관", @@ -8,5 +9,5 @@ export const metadata: Metadata = { }; export default function TermsPage() { - return ; + return ; } diff --git a/components/auth-callback-screen.tsx b/components/auth-callback-screen.tsx index 7d6b182..7311575 100644 --- a/components/auth-callback-screen.tsx +++ b/components/auth-callback-screen.tsx @@ -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 = { - NAVER: "네이버", - KAKAO: "카카오", - GOOGLE: "구글", -}; - function isOAuthCallbackResult(value: string | null): value is OAuthCallbackResult { return ["SUCCESS", "SIGNUP_REQUIRED", "LINK_REQUIRED", "LINK_SUCCESS", "LINK_FAILED", "FAILED"].includes(value ?? ""); } @@ -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 }); @@ -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("/"); @@ -85,7 +108,6 @@ export function AuthCallbackScreen() { return; } - const code = searchParams.get("code"); if (code === "OAUTH_CANCELLED") { authLogger.info("authentication_cancelled", { result }); } else { @@ -127,7 +149,9 @@ export function AuthCallbackScreen() { if (!pendingLink) { return

로그인 정보를 확인하고 있어요.

; } - const existingProvider = pendingLink.existingProviders?.[0]; + const existingProviderSummary = pendingLink.existingProviders + ?.map((provider) => socialProviderLabel[provider]) + .join(" 또는 "); return (
@@ -136,8 +160,8 @@ export function AuthCallbackScreen() {

{pendingLink.email}

-

{existingProvider ? `이미 ${providerLabel[existingProvider]} 계정이 있어요.` : "이미 가입된 회원 정보가 있어요."}

-

{providerLabel[pendingLink.provider]} 계정을 연동할까요?

+

{existingProviderSummary ? `이미 ${existingProviderSummary} 계정이 있어요.` : "이미 가입된 회원 정보가 있어요."}

+

{socialProviderLabel[pendingLink.provider]} 계정을 연동할까요?

diff --git a/components/auth-session-sync.tsx b/components/auth-session-sync.tsx index b6753fc..6d5559a 100644 --- a/components/auth-session-sync.tsx +++ b/components/auth-session-sync.tsx @@ -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]); diff --git a/components/author-block-confirm-modal.tsx b/components/author-block-confirm-modal.tsx new file mode 100644 index 0000000..c77bd5f --- /dev/null +++ b/components/author-block-confirm-modal.tsx @@ -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 ( +
+
+
+ +

작성자 차단

+ +
+
+ +

이 작성자를 차단하시겠어요?

+

이 작성자의 제보가 목록과 상세 화면에서 보이지 않아요.

+
+
+ + +
+
+
+ ); +} diff --git a/components/blocked-author-list.tsx b/components/blocked-author-list.tsx new file mode 100644 index 0000000..4c7c1a1 --- /dev/null +++ b/components/blocked-author-list.tsx @@ -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
{Array.from({ length: 3 }).map((_, index) =>
)}
; + } + + if (blocks.isError || !blocks.data) { + return

차단 목록을 불러오지 못했어요.

; + } + + if (blocks.data.items.length === 0) { + return

차단한 사용자가 없어요.

; + } + + return ( +
+
+ {blocks.data.items.map((item) => { + const avatarUrl = resolveProfileImage(item.avatar?.profileImageUrl ?? item.avatar?.value); + const isUnblocking = unblock.isPending && unblock.variables === item.memberId; + return
+ +

{item.nickname}

{formatReportDateTime(item.blockedAt)} 차단

+ +
; + })} +
+ {blocks.data.totalPages > 1 && } +
+ ); +} diff --git a/components/home-screen.tsx b/components/home-screen.tsx index 56822ad..175d848 100644 --- a/components/home-screen.tsx +++ b/components/home-screen.tsx @@ -1,7 +1,7 @@ "use client"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AppHeader } from "@/components/app-header"; import { EmptyState } from "@/components/empty-state"; import { ErrorState } from "@/components/error-state"; @@ -11,17 +11,28 @@ import { ReportCard } from "@/components/report-card"; import { ReportGridSkeleton } from "@/components/report-grid-skeleton"; import { WeatherSummary, WeatherSummarySkeleton } from "@/components/weather-summary"; import { useCurrentLocation } from "@/hooks/use-current-location"; +import { useLocationFavorite } from "@/hooks/use-location-favorite"; import { weatherApi } from "@/lib/api"; import { getLocationName } from "@/lib/constants"; import { useToastStore } from "@/store/toast-store"; +import { useAuthStore } from "@/store/auth-store"; export function HomeScreen() { const { location, setLocation, isDetecting, detectionError, needsManualInput, setNeedsManualInput, detectLocation } = useCurrentLocation({ refreshOnHomeResume: true }); const showToast = useToastStore((state) => state.showToast); + const isMember = useAuthStore((state) => state.user.type === "MEMBER"); const loadMoreRef = useRef(null); const [isManualRefreshAnimating, setIsManualRefreshAnimating] = useState(false); const locationLabel = location ? getLocationName(location, "short") : ""; const locationKey = location?.id ?? locationLabel; + const showFavoriteError = useCallback(() => { + showToast("즐겨찾기를 변경하지 못했어요.", "ERROR"); + }, [showToast]); + const favorite = useLocationFavorite({ + location, + enabled: isMember, + onError: showFavoriteError, + }); const summary = useQuery({ queryKey: ["weather-summary", locationKey], queryFn: () => weatherApi.getSummary(location!), enabled: !!location, refetchInterval: 10_000, refetchIntervalInBackground: false }); const reports = useInfiniteQuery({ queryKey: ["weather-reports", locationKey], @@ -75,7 +86,10 @@ export function HomeScreen() { updatedAt={summary.dataUpdatedAt} isRefreshing={summary.isFetching || reports.isRefetching || isManualRefreshAnimating} canRefresh={Boolean(location)} + isFavorite={favorite.isFavorite} + isFavoriteDisabled={favorite.isLoading || favorite.isUpdating} onLocationClick={() => setNeedsManualInput(true)} + onFavoriteToggle={isMember && location?.id ? () => void favorite.toggleFavorite() : undefined} onRefresh={() => void refreshWeather()} /> {showLocationError ? : showFullWeatherError ?

서버 오류입니다

관리자에게 문의해 주세요

: <> diff --git a/components/home-weather-controls.tsx b/components/home-weather-controls.tsx index 54a0018..9f0b7d1 100644 --- a/components/home-weather-controls.tsx +++ b/components/home-weather-controls.tsx @@ -1,6 +1,6 @@ import { format } from "date-fns"; import { ko } from "date-fns/locale"; -import { ChevronDown, MapPin, RotateCw } from "lucide-react"; +import { ChevronDown, MapPin, RotateCw, Star } from "lucide-react"; import { useSyncExternalStore } from "react"; import { LocationDetectingIndicator } from "@/components/location-detecting-indicator"; @@ -25,7 +25,10 @@ export function HomeWeatherControls({ updatedAt, isRefreshing, canRefresh, + isFavorite, + isFavoriteDisabled, onLocationClick, + onFavoriteToggle, onRefresh, }: { location?: string; @@ -33,7 +36,10 @@ export function HomeWeatherControls({ updatedAt: number; isRefreshing: boolean; canRefresh: boolean; + isFavorite?: boolean; + isFavoriteDisabled?: boolean; onLocationClick: () => void; + onFavoriteToggle?: () => void; onRefresh: () => void; }) { const browserOpenedAt = useSyncExternalStore(subscribeToOpenedAt, getOpenedAt, getServerOpenedAt); @@ -44,13 +50,27 @@ export function HomeWeatherControls({ return (
- +
+ + {onFavoriteToggle ? ( + + ) : null} +
diff --git a/components/legal-modal.tsx b/components/legal-modal.tsx index 35a58ec..8d2f445 100644 --- a/components/legal-modal.tsx +++ b/components/legal-modal.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { ArrowLeft, X } from "lucide-react"; import { PrivacyPolicyContent, TermsContent } from "@/components/legal-content"; import { useModalNavigation } from "@/hooks/use-modal-navigation"; +import { CURRENT_PRIVACY_TERMS_VERSION, CURRENT_SERVICE_TERMS_VERSION } from "@/lib/legal"; import { useLegalModalStore } from "@/store/legal-modal-store"; export function LegalModal() { @@ -32,6 +33,7 @@ export function LegalModal() { if (!document) return null; const isPrivacy = document === "PRIVACY"; const title = isPrivacy ? "개인정보처리방침" : "서비스 이용약관"; + const version = isPrivacy ? CURRENT_PRIVACY_TERMS_VERSION : CURRENT_SERVICE_TERMS_VERSION; return (
{ if (event.target === event.currentTarget) closeModal(); }}> @@ -42,7 +44,7 @@ export function LegalModal() {
-

시행일 2026년 7월 21일

+

버전 {version} / 시행일 2026년 7월 21일

{isPrivacy ? : }
diff --git a/components/member-page.tsx b/components/member-page.tsx index c51014c..b5fb411 100644 --- a/components/member-page.tsx +++ b/components/member-page.tsx @@ -1,18 +1,50 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Grid3X3, UserRound } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Ellipsis, Grid3X3, UserRound } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { AuthorBlockConfirmModal } from "@/components/author-block-confirm-modal"; import { ErrorState } from "@/components/error-state"; import { MyReportList } from "@/components/my-report-list"; import { ProfilePreviewModal } from "@/components/profile-preview-modal"; +import { ReportActionsModal } from "@/components/report-actions-modal"; import { weatherApi } from "@/lib/api"; +import { moderationApi } from "@/lib/api/moderation-api"; +import { useAuthStore } from "@/store/auth-store"; +import { useToastStore } from "@/store/toast-store"; export function MemberPage({ memberId }: { memberId: string }) { const router = useRouter(); + const queryClient = useQueryClient(); + const currentUser = useAuthStore((state) => state.user); + const showToast = useToastStore((state) => state.showToast); const [isProfilePreviewOpen, setIsProfilePreviewOpen] = useState(false); + const [isActionsOpen, setIsActionsOpen] = useState(false); + const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const profile = useQuery({ queryKey: ["member-profile", memberId], queryFn: () => weatherApi.getMemberProfile(memberId) }); + const canBlock = currentUser.type === "MEMBER" && currentUser.id !== memberId; + const blockAuthor = useMutation({ + mutationFn: () => { + if (!canBlock) { + throw new Error("로그인이 필요한 기능이에요."); + } + return moderationApi.blockMember(memberId); + }, + onSuccess: () => { + setIsBlockModalOpen(false); + void Promise.all([ + queryClient.invalidateQueries({ queryKey: ["report-blocks"] }), + queryClient.invalidateQueries({ queryKey: ["weather-reports"] }), + queryClient.invalidateQueries({ queryKey: ["member-weather-reports"] }), + ]); + showToast("작성자를 차단했어요.", "SUCCESS"); + router.back(); + }, + onError: (error) => { + showToast(error instanceof Error ? error.message : "작성자를 차단하지 못했어요.", "ERROR"); + }, + }); if (profile.isLoading) { return router.back()} />; @@ -27,7 +59,7 @@ export function MemberPage({ memberId }: { memberId: string }) {

프로필

- + {canBlock ? : }
{isProfilePreviewOpen && setIsProfilePreviewOpen(false)} />} + {isActionsOpen && setIsActionsOpen(false)} onBlock={() => setIsBlockModalOpen(true)} />} + {isBlockModalOpen && setIsBlockModalOpen(false)} onConfirm={() => blockAuthor.mutate()} />} ); } diff --git a/components/my-report-list.tsx b/components/my-report-list.tsx index 48a03f3..09cb606 100644 --- a/components/my-report-list.tsx +++ b/components/my-report-list.tsx @@ -19,7 +19,6 @@ export function MyReportList({ memberId, columns = 2, publicProfile = false }: { }); const { fetchNextPage, hasNextPage, isFetchingNextPage } = reports; const reportItems = reports.data?.pages.flatMap((page) => page.reports) ?? []; - useEffect(() => { const target = loadMoreRef.current; if (!target || !hasNextPage) return; diff --git a/components/report-actions-modal.tsx b/components/report-actions-modal.tsx new file mode 100644 index 0000000..0e4cc63 --- /dev/null +++ b/components/report-actions-modal.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Ban, Flag, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; + +export function ReportActionsModal({ + onBlock, + onClose, + onFlag, +}: { + onBlock?: () => void; + onClose: () => void; + onFlag?: () => void; +}) { + const closeModal = useModalNavigation({ + open: true, + onBack: onClose, + onDismiss: onClose, + }); + + return ( +
{ if (event.target === event.currentTarget) closeModal(); }}> +
+
+ +

작업 메뉴

+ +
+
+ {onFlag && } + {onBlock && } +
+
+
+ ); +} diff --git a/components/report-detail.tsx b/components/report-detail.tsx index 5d7209d..ceb046e 100644 --- a/components/report-detail.tsx +++ b/components/report-detail.tsx @@ -1,13 +1,15 @@ "use client"; import { useMutation, useQuery, useQueryClient, type InfiniteData, type QueryKey } from "@tanstack/react-query"; -import { ArrowLeft, CloudRain, Heart, Sun, Thermometer, Trash2, UserRound } from "lucide-react"; +import { ArrowLeft, CloudRain, Ellipsis, Heart, Sun, Thermometer, Trash2, UserRound } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useState, type ReactNode, type UIEvent } from "react"; import { ErrorState } from "@/components/error-state"; +import { ReportActionsModal } from "@/components/report-actions-modal"; import { ReportDeleteConfirmModal, type ModalDismiss } from "@/components/report-delete-confirm-modal"; +import { ReportFlagModal } from "@/components/report-flag-modal"; import { weatherApi } from "@/lib/api"; import { ApiError } from "@/lib/api/http-client"; import { PRECIPITATION_OPTIONS, SUNLIGHT_OPTIONS, TEMPERATURE_OPTIONS, formatThanksCount, getLocationName, statusLabel } from "@/lib/constants"; @@ -72,6 +74,8 @@ export function ReportDetail() { const currentUser = useAuthStore((state) => state.user); const setLocation = useLocationStore((state) => state.setLocation); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isActionsOpen, setIsActionsOpen] = useState(false); + const [isFlagModalOpen, setIsFlagModalOpen] = useState(false); const report = useQuery({ queryKey: ["weather-report", id], queryFn: () => weatherApi.getReport(id) }); const thanks = useMutation({ mutationFn: () => weatherApi.toggleThanks(id, Boolean(report.data?.isThanked)), @@ -149,7 +153,6 @@ export function ReportDetail() { showToast(error instanceof Error ? error.message : "제보를 삭제하지 못했어요.", "ERROR"); }, }); - if (report.isLoading) return router.back()} />; if (report.isError || !report.data) return
router.back()} title="제보 상세" /> report.refetch()} />
; const item = report.data; @@ -179,6 +182,7 @@ export function ReportDetail() { + {item.images.length > 0 && } @@ -193,6 +197,8 @@ export function ReportDetail() { {canDelete &&
}
{isDeleteModalOpen && setIsDeleteModalOpen(false)} onConfirm={(dismissModal) => deleteReport.mutate(dismissModal)} />} + {isActionsOpen && setIsActionsOpen(false)} onFlag={() => setIsFlagModalOpen(true)} />} + {isFlagModalOpen && setIsFlagModalOpen(false)} />} ); } diff --git a/components/report-flag-modal.tsx b/components/report-flag-modal.tsx new file mode 100644 index 0000000..7351890 --- /dev/null +++ b/components/report-flag-modal.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { LoaderCircle, X } from "lucide-react"; +import { useModalNavigation } from "@/hooks/use-modal-navigation"; +import { moderationApi } from "@/lib/api/moderation-api"; +import { getTextLength, truncateText } from "@/lib/text"; +import type { ReportFlagReason } from "@/lib/types"; +import { useToastStore } from "@/store/toast-store"; + +const FLAG_REASONS: ReadonlyArray<{ value: ReportFlagReason; label: string }> = [ + { value: "SPAM", label: "스팸/도배" }, + { value: "ABUSE", label: "욕설/괴롭힘" }, + { value: "HATE", label: "혐오 표현" }, + { value: "SEXUAL", label: "선정적 콘텐츠" }, + { value: "PRIVACY", label: "개인정보 노출" }, + { value: "FALSE_INFORMATION", label: "거짓 정보" }, + { value: "OTHER", label: "기타" }, +]; + +export function ReportFlagModal({ reportId, onClose }: { reportId: string; onClose: () => void }) { + const showToast = useToastStore((state) => state.showToast); + const [reason, setReason] = useState(null); + const [detail, setDetail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const closeModal = useModalNavigation({ + open: true, + onBack: () => { + if (!isSubmitting) onClose(); + }, + onDismiss: onClose, + }); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!reason || isSubmitting) return; + setIsSubmitting(true); + try { + await moderationApi.flagReport(reportId, { + reason, + detail: detail.trim() || undefined, + }); + showToast("제보를 신고했어요.", "SUCCESS"); + closeModal(); + } catch (error) { + showToast(error instanceof Error ? error.message : "제보를 신고하지 못했어요.", "ERROR"); + setIsSubmitting(false); + } + }; + + return ( +
+
void submit(event)} className="flex max-h-[calc(100dvh-32px)] w-full max-w-[380px] flex-col overflow-hidden rounded-[24px] bg-[#eef9ff] shadow-2xl"> +
+ +

제보 신고

+ +
+
+

신고 사유를 선택해 주세요.

+
+ {FLAG_REASONS.map((option, index) => )} +
+
+
{getTextLength(detail)}/500
+