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
+ {!avatarUrl && }
+ {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/signup-form.tsx b/components/signup-form.tsx
index 1bbe7d1..5eaa0c7 100644
--- a/components/signup-form.tsx
+++ b/components/signup-form.tsx
@@ -7,17 +7,12 @@ import { useCallback, useEffect, useState } from "react";
import { SocialIcon } from "@/components/social-icon";
import { authApi } from "@/lib/api/auth-api";
import { ApiError } from "@/lib/api/http-client";
-import type { SocialProvider } from "@/lib/types";
+import { CURRENT_PRIVACY_TERMS_VERSION, CURRENT_SERVICE_TERMS_VERSION } from "@/lib/legal";
+import { socialProviderLabel } from "@/lib/social-providers";
import { useAuthStore } from "@/store/auth-store";
import { useLegalModalStore } from "@/store/legal-modal-store";
import { useToastStore } from "@/store/toast-store";
-const providerLabel: Record = {
- NAVER: "네이버",
- KAKAO: "카카오",
- GOOGLE: "구글",
-};
-
export function SignupForm() {
const router = useRouter();
const queryClient = useQueryClient();
@@ -84,8 +79,8 @@ export function SignupForm() {
try {
await authApi.signup({
agreedTerms: [
- { type: "SERVICE", version: "1.0" },
- { type: "PRIVACY", version: "1.0" },
+ { type: "SERVICE", version: CURRENT_SERVICE_TERMS_VERSION },
+ { type: "PRIVACY", version: CURRENT_PRIVACY_TERMS_VERSION },
],
});
const session = await authApi.getMe();
@@ -123,7 +118,7 @@ export function SignupForm() {
-
{providerLabel[provider]} 계정
+
{socialProviderLabel[provider]} 계정
{pendingEmail ?? "이메일 정보 없음"}
diff --git a/components/social-icon.tsx b/components/social-icon.tsx
index 534dc30..9d48d5f 100644
--- a/components/social-icon.tsx
+++ b/components/social-icon.tsx
@@ -1,20 +1,23 @@
import Image from "next/image";
import type { SocialProvider } from "@/lib/types";
+const providerAsset: Readonly> = {
+ APPLE: "/login/애플_로그인.svg",
+ GOOGLE: "/login/구글_로그인_원형.svg",
+ KAKAO: "/login/카카오_로그인_원형.png",
+ NAVER: "/login/네이버_로그인_원형.png",
+};
+
export function SocialIcon({ provider, className = "size-9" }: { provider: SocialProvider; className?: string }) {
- if (provider === "NAVER") {
- return (
-
-
-
- );
- }
- if (provider === "KAKAO") {
- return (
-
-
-
- );
- }
- return G;
+ return (
+
+
+
+ );
}
diff --git a/components/user-panel.tsx b/components/user-panel.tsx
index 6401105..725f102 100644
--- a/components/user-panel.tsx
+++ b/components/user-panel.tsx
@@ -6,6 +6,7 @@ import { flushSync } from "react-dom";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
+ Ban,
ChevronRight,
FileText,
Link2,
@@ -20,6 +21,7 @@ import {
X,
} from "lucide-react";
import { NameEditModal } from "@/components/name-edit-modal";
+import { BlockedAuthorList } from "@/components/blocked-author-list";
import { WithdrawalConfirmModal } from "@/components/withdrawal-confirm-modal";
import { SocialIcon } from "@/components/social-icon";
import { useModalNavigation } from "@/hooks/use-modal-navigation";
@@ -27,21 +29,14 @@ import { authApi } from "@/lib/api/auth-api";
import { resolveApiUrl } from "@/lib/api/config";
import { memberApi } from "@/lib/api/member-api";
import { SERVICE_CONTACT_EMAIL } from "@/lib/constants";
+import { socialProviderLabel, webSocialProviders } from "@/lib/social-providers";
import { getTextLength, truncateText } from "@/lib/text";
import type { SocialProvider } from "@/lib/types";
import { useAuthStore } from "@/store/auth-store";
import { useLegalModalStore, type LegalDocumentType } from "@/store/legal-modal-store";
import { useToastStore } from "@/store/toast-store";
-const providerLabel: Record = {
- NAVER: "네이버",
- KAKAO: "카카오",
- GOOGLE: "구글",
-};
-
-const socialProviders: SocialProvider[] = ["NAVER", "KAKAO"];
-
-export type UserPanelView = "MAIN" | "ACCOUNT" | "FEEDBACK";
+export type UserPanelView = "MAIN" | "ACCOUNT" | "BLOCKS" | "FEEDBACK";
export function UserPanel({
open,
@@ -80,7 +75,7 @@ export function UserPanel({
open,
onBack: () => {
if (isSubmittingFeedback || isWithdrawing) return;
- if ((member && view === "ACCOUNT") || view === "FEEDBACK") {
+ if (view !== "MAIN") {
setView("MAIN");
return;
}
@@ -186,7 +181,7 @@ export function UserPanel({
if (linked) {
await memberApi.disconnectSocialAccount(provider);
await queryClient.invalidateQueries({ queryKey: ["members", "me"] });
- showToast(`${providerLabel[provider]} 계정 연동을 해제했어요.`, "SUCCESS");
+ showToast(`${socialProviderLabel[provider]} 계정 연동을 해제했어요.`, "SUCCESS");
} else {
const { authorizationUrl } = await authApi.linkSocial(provider);
navigateToSocialAuth(resolveApiUrl(authorizationUrl), requestId);
@@ -230,8 +225,9 @@ export function UserPanel({
};
const isAccountView = Boolean(member && view === "ACCOUNT");
+ const isBlocksView = view === "BLOCKS";
const isFeedbackView = view === "FEEDBACK";
- const isSubView = isAccountView || isFeedbackView;
+ const isSubView = isAccountView || isBlocksView || isFeedbackView;
const memberAccount = account.data;
return (
@@ -239,7 +235,7 @@ export function UserPanel({
{isSubView ?
setView("MAIN")} className="header-back-button" aria-label="설정으로 돌아가기"> :
}
-
{isAccountView ? "계정 정보" : isFeedbackView ? "서비스 피드백" : member ? "설정" : "로그인"}
+
{isAccountView ? "계정 정보" : isBlocksView ? "차단 목록" : isFeedbackView ? "서비스 피드백" : member ? "설정" : "로그인"}
closePanel()} className="icon-button" aria-label="닫기">
@@ -261,20 +257,22 @@ export function UserPanel({
소셜 연동
- {socialProviders.map((provider, index) => {
+ {webSocialProviders.map((provider, index) => {
const isCurrent = memberAccount?.currentProvider === provider;
const isLinked = memberAccount?.connectedProviders.includes(provider) ?? false;
const isLastLinked = isLinked && memberAccount?.connectedProviders.length === 1;
- return
+ return
-
{providerLabel[provider]}
{isCurrent ? "현재 로그인" : isLinked ? "연동됨" : "연동 안 됨"}
-
void handleSocialToggle(provider)} className={`ml-auto flex h-7 w-12 items-center rounded-full p-0.5 transition-colors ${isLinked ? "bg-[#45ace4]" : "bg-[#cbd6dc]"} disabled:cursor-wait disabled:opacity-60`}>
+
{socialProviderLabel[provider]}
{isCurrent ? "현재 로그인" : isLinked ? "연동됨" : "연동 안 됨"}
+
void handleSocialToggle(provider)} className={`ml-auto flex h-7 w-12 items-center rounded-full p-0.5 transition-colors ${isLinked ? "bg-[#45ace4]" : "bg-[#cbd6dc]"} disabled:cursor-wait disabled:opacity-60`}>
;
})}
setIsWithdrawalOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-extrabold text-[#c95e5e]"> 회원 탈퇴
+ ) : isBlocksView ? (
+
) : isFeedbackView ? (
@@ -287,6 +285,7 @@ export function UserPanel({
) : member ? <>
setView("ACCOUNT")} className="flex w-full items-center gap-3 border-b-2 border-[#d2e3ec] px-4 py-4 text-left text-sm font-extrabold"> 계정 정보
+ setView("BLOCKS")} className="flex w-full items-center gap-3 border-b-2 border-[#d2e3ec] px-4 py-4 text-left text-sm font-extrabold"> 차단 목록
setView("FEEDBACK")} className="flex w-full items-center gap-3 px-4 py-4 text-left text-sm font-extrabold"> 서비스 피드백
@@ -297,8 +296,9 @@ export function UserPanel({
© 2026 날씨로그. All rights reserved.
> : <>
- void openServerLogin("NAVER")} className="flex size-11 items-center justify-center rounded-full shadow-sm transition hover:-translate-y-0.5 disabled:cursor-wait disabled:opacity-60" aria-label="네이버 로그인">
- void openServerLogin("KAKAO")} className="flex size-11 items-center justify-center rounded-full shadow-sm transition hover:-translate-y-0.5 disabled:cursor-wait disabled:opacity-60" aria-label="카카오 로그인">
+ {webSocialProviders.map((provider) => (
+ void openServerLogin(provider)} className="flex size-11 items-center justify-center rounded-full shadow-sm transition hover:-translate-y-0.5 disabled:cursor-wait disabled:opacity-60" aria-label={`${socialProviderLabel[provider]} 로그인`}>
+ ))}
setView("FEEDBACK")} className="flex w-full items-center gap-3 border-b-2 border-[#d2e3ec] px-4 py-4 text-left text-sm font-extrabold"> 서비스 피드백
diff --git a/hooks/use-location-favorite.ts b/hooks/use-location-favorite.ts
new file mode 100644
index 0000000..2ebdae1
--- /dev/null
+++ b/hooks/use-location-favorite.ts
@@ -0,0 +1,94 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useMemo } from "react";
+import { locationApi } from "@/lib/api/location-api";
+import type { Location } from "@/lib/types";
+
+const FAVORITES_QUERY_KEY = ["locations", "favorites"] as const;
+const FAVORITE_CATALOG_QUERY_KEY = [...FAVORITES_QUERY_KEY, "catalog"] as const;
+
+interface UseLocationFavoriteOptions {
+ location: Location | null;
+ enabled: boolean;
+ onError: () => void;
+}
+
+interface FavoriteMutationInput {
+ location: Location;
+ favorite: boolean;
+}
+
+export function useLocationFavorite({
+ location,
+ enabled,
+ onError,
+}: UseLocationFavoriteOptions) {
+ const queryClient = useQueryClient();
+ const catalog = useQuery({
+ queryKey: FAVORITE_CATALOG_QUERY_KEY,
+ queryFn: locationApi.favoriteCatalog,
+ enabled: enabled && Boolean(location?.id),
+ staleTime: 60_000,
+ retry: 1,
+ });
+ const locationId = location?.id;
+
+ const isFavorite = useMemo(
+ () => Boolean(locationId && catalog.data?.some((item) => item.id === locationId)),
+ [catalog.data, locationId],
+ );
+
+ const mutation = useMutation({
+ mutationFn: async ({ location: target, favorite }: FavoriteMutationInput) => {
+ if (!target.id) return;
+ if (favorite) {
+ await locationApi.removeFavorite(target.id);
+ return;
+ }
+ await locationApi.addFavorite(target.id);
+ },
+ onMutate: async ({ location: target, favorite }) => {
+ await queryClient.cancelQueries({ queryKey: FAVORITE_CATALOG_QUERY_KEY });
+ const previous = queryClient.getQueryData
(FAVORITE_CATALOG_QUERY_KEY);
+ queryClient.setQueryData(FAVORITE_CATALOG_QUERY_KEY, (current = []) => {
+ if (favorite) return current.filter((item) => item.id !== target.id);
+ return current.some((item) => item.id === target.id) ? current : [...current, target];
+ });
+ return { previous };
+ },
+ onError: (_error, _input, context) => {
+ queryClient.setQueryData(FAVORITE_CATALOG_QUERY_KEY, context?.previous);
+ onError();
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: FAVORITES_QUERY_KEY });
+ },
+ });
+
+ const toggleFavorite = useCallback(async () => {
+ if (!enabled || !location?.id || mutation.isPending) return;
+
+ let favorites = catalog.data;
+ if (!favorites) {
+ const refreshed = await catalog.refetch();
+ favorites = refreshed.data;
+ if (!favorites) {
+ onError();
+ return;
+ }
+ }
+
+ mutation.mutate({
+ location,
+ favorite: favorites.some((item) => item.id === location.id),
+ });
+ }, [catalog, enabled, location, mutation, onError]);
+
+ return {
+ isFavorite,
+ isLoading: enabled && !catalog.data && catalog.isFetching,
+ isUpdating: mutation.isPending,
+ toggleFavorite,
+ };
+}
diff --git a/lib/api/auth-api.ts b/lib/api/auth-api.ts
index 30f3767..9c2d20f 100644
--- a/lib/api/auth-api.ts
+++ b/lib/api/auth-api.ts
@@ -1,6 +1,6 @@
import { getApiUrl } from "@/lib/api/config";
import { apiRequest, jsonRequest } from "@/lib/api/http-client";
-import type { AvatarType, SocialProvider } from "@/lib/types";
+import type { AgreedTerm, AvatarType, SocialProvider, UserRole } from "@/lib/types";
export type SessionAuthResult =
| "SUCCESS"
@@ -21,6 +21,7 @@ export interface AuthUserResponse {
nickname: string;
profileImageUrl?: string | null;
avatar?: { type: AvatarType; value: string | null };
+ role: UserRole;
}
export interface PendingAuthResponse {
@@ -44,10 +45,11 @@ export interface AuthMeResponse {
}
export interface SignupRequest {
- agreedTerms: Array<{ type: "SERVICE" | "PRIVACY"; version: string }>;
+ agreedTerms: AgreedTerm[];
}
const providerPath: Record = {
+ APPLE: "apple",
NAVER: "naver",
KAKAO: "kakao",
GOOGLE: "google",
@@ -55,7 +57,14 @@ const providerPath: Record = {
function normalizeProvider(provider: string): SocialProvider {
const normalized = provider.toUpperCase();
- if (normalized === "NAVER" || normalized === "KAKAO" || normalized === "GOOGLE") return normalized;
+ if (
+ normalized === "NAVER" ||
+ normalized === "KAKAO" ||
+ normalized === "GOOGLE" ||
+ normalized === "APPLE"
+ ) {
+ return normalized;
+ }
throw new Error("지원하지 않는 소셜 로그인 제공자예요.");
}
diff --git a/lib/api/http-weather-api.test.ts b/lib/api/http-weather-api.test.ts
new file mode 100644
index 0000000..b815ba1
--- /dev/null
+++ b/lib/api/http-weather-api.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { httpWeatherApi } from "@/lib/api/http-weather-api";
+import { jsonRequest } from "@/lib/api/http-client";
+import { createRequiredReportAgreements } from "@/lib/legal";
+import type { CreateReportInput } from "@/lib/types";
+
+vi.mock("@/lib/api/http-client", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ jsonRequest: vi.fn(),
+ };
+});
+
+const jsonRequestMock = vi.mocked(jsonRequest);
+const backendReport = {
+ id: "987654321098765432",
+ isMine: true,
+ location: {
+ id: "123456789012345678",
+ sido: "서울특별시",
+ sigungu: "강서구",
+ dong: "가양동",
+ label: "서울특별시 강서구 가양동",
+ shortLabel: "강서구 가양동",
+ },
+ author: {
+ type: "ANONYMOUS" as const,
+ id: null,
+ nickname: "익명의 이웃",
+ },
+ temperature: "FRESH" as const,
+ precipitation: "NONE" as const,
+ sunlight: "MODERATE" as const,
+ comment: "바람이 조금 불어요",
+ imageUrls: [],
+ thanksCount: 0,
+ isThanked: false,
+ createdAt: "2026-08-02T08:00:00Z",
+};
+
+function createInput(overrides: Partial = {}): CreateReportInput {
+ return {
+ location: {
+ id: backendReport.location.id,
+ label: backendReport.location.label,
+ },
+ images: [],
+ content: backendReport.comment,
+ temperature: "FRESH",
+ precipitation: "NONE",
+ sunlight: "MODERATE",
+ ...overrides,
+ };
+}
+
+describe("httpWeatherApi.createReport", () => {
+ beforeEach(() => {
+ jsonRequestMock.mockReset();
+ jsonRequestMock.mockResolvedValue(backendReport);
+ });
+
+ it("includes each required agreement in an anonymous report", async () => {
+ const agreedTerms = createRequiredReportAgreements();
+
+ await httpWeatherApi.createReport(createInput({ agreedTerms }));
+
+ expect(jsonRequestMock).toHaveBeenCalledWith(
+ "/api/reports",
+ "POST",
+ expect.objectContaining({ agreedTerms }),
+ { signal: undefined },
+ );
+ });
+
+ it("omits agreements from a member report", async () => {
+ await httpWeatherApi.createReport(createInput());
+
+ const body = jsonRequestMock.mock.calls[0]?.[2];
+ expect(body).not.toHaveProperty("agreedTerms");
+ });
+});
diff --git a/lib/api/http-weather-api.ts b/lib/api/http-weather-api.ts
index 64fb3c9..98b26f1 100644
--- a/lib/api/http-weather-api.ts
+++ b/lib/api/http-weather-api.ts
@@ -270,6 +270,7 @@ export const httpWeatherApi: WeatherApi = {
sunlight: input.sunlight,
comment: input.content,
imageKeys,
+ ...(input.agreedTerms ? { agreedTerms: input.agreedTerms } : {}),
}, { signal: options.signal });
options.onProgress?.({ stage: "CREATING", percent: 100 });
return normalizeReport(report);
diff --git a/lib/api/moderation-api.test.ts b/lib/api/moderation-api.test.ts
new file mode 100644
index 0000000..43c335a
--- /dev/null
+++ b/lib/api/moderation-api.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { apiRequest, jsonRequest } from "@/lib/api/http-client";
+import { moderationApi } from "@/lib/api/moderation-api";
+
+vi.mock("@/lib/api/http-client", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ apiRequest: vi.fn(),
+ jsonRequest: vi.fn(),
+ };
+});
+
+const apiRequestMock = vi.mocked(apiRequest);
+const jsonRequestMock = vi.mocked(jsonRequest);
+
+describe("moderationApi", () => {
+ beforeEach(() => {
+ apiRequestMock.mockReset();
+ jsonRequestMock.mockReset();
+ });
+
+ it("submits a trimmed report flag", async () => {
+ jsonRequestMock.mockResolvedValueOnce({
+ id: "101",
+ reportId: "5001",
+ reason: "SPAM",
+ status: "PENDING",
+ createdAt: "2026-08-02T09:10:11.123Z",
+ });
+
+ await moderationApi.flagReport("5001", {
+ reason: "SPAM",
+ detail: " 반복 게시물입니다. ",
+ });
+
+ expect(jsonRequestMock).toHaveBeenCalledWith(
+ "/api/reports/5001/flags",
+ "POST",
+ { reason: "SPAM", detail: "반복 게시물입니다." },
+ );
+ });
+
+ it("uses member identifiers for block management", async () => {
+ jsonRequestMock.mockResolvedValue(undefined);
+
+ await moderationApi.blockMember("123456789");
+ await moderationApi.unblockMember("123456789");
+
+ expect(jsonRequestMock).toHaveBeenNthCalledWith(
+ 1,
+ "/api/report-blocks/members/123456789",
+ "POST",
+ );
+ expect(jsonRequestMock).toHaveBeenNthCalledWith(
+ 2,
+ "/api/report-blocks/members/123456789",
+ "DELETE",
+ );
+ });
+
+ it("requests a bounded block list page", async () => {
+ apiRequestMock.mockResolvedValueOnce({
+ items: [],
+ page: 0,
+ size: 50,
+ totalElements: 0,
+ totalPages: 0,
+ });
+
+ await moderationApi.getReportBlocks(-1, 100);
+
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ "/api/report-blocks?page=0&size=50",
+ { cache: "no-store" },
+ );
+ });
+});
diff --git a/lib/api/moderation-api.ts b/lib/api/moderation-api.ts
new file mode 100644
index 0000000..47f7e3c
--- /dev/null
+++ b/lib/api/moderation-api.ts
@@ -0,0 +1,43 @@
+import { apiRequest, jsonRequest } from "@/lib/api/http-client";
+import type {
+ ReportBlockPage,
+ ReportFlagInput,
+ ReportFlagResponse,
+} from "@/lib/types";
+
+function requireId(value: string, label: string) {
+ const normalized = value.trim();
+ if (!normalized) throw new Error(`${label}를 확인해 주세요.`);
+ return normalized;
+}
+
+export const moderationApi = {
+ flagReport: (reportId: string, input: ReportFlagInput) =>
+ jsonRequest(
+ `/api/reports/${encodeURIComponent(requireId(reportId, "제보 ID"))}/flags`,
+ "POST",
+ {
+ reason: input.reason,
+ ...(input.detail?.trim() ? { detail: input.detail.trim() } : {}),
+ },
+ ),
+
+ blockMember: (memberId: string) =>
+ jsonRequest(
+ `/api/report-blocks/members/${encodeURIComponent(requireId(memberId, "회원 ID"))}`,
+ "POST",
+ ),
+
+ unblockMember: (memberId: string) =>
+ jsonRequest(
+ `/api/report-blocks/members/${encodeURIComponent(requireId(memberId, "회원 ID"))}`,
+ "DELETE",
+ ),
+
+ getReportBlocks: (page = 0, size = 20) =>
+ apiRequest(
+ `/api/report-blocks?page=${Math.max(0, Math.trunc(page))}&size=${Math.min(50, Math.max(1, Math.trunc(size)))}`,
+ { cache: "no-store" },
+ ),
+
+};
diff --git a/lib/legal.test.ts b/lib/legal.test.ts
new file mode 100644
index 0000000..995d584
--- /dev/null
+++ b/lib/legal.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import {
+ CURRENT_PRIVACY_TERMS_VERSION,
+ CURRENT_SERVICE_TERMS_VERSION,
+ createRequiredReportAgreements,
+} from "@/lib/legal";
+
+describe("required report agreements", () => {
+ it("creates each required agreement exactly once with the displayed versions", () => {
+ const agreements = createRequiredReportAgreements();
+
+ expect(agreements).toEqual([
+ { type: "SERVICE", version: CURRENT_SERVICE_TERMS_VERSION },
+ { type: "PRIVACY", version: CURRENT_PRIVACY_TERMS_VERSION },
+ ]);
+ expect(new Set(agreements.map(({ type }) => type)).size).toBe(2);
+ expect(
+ agreements.every(
+ ({ version }) => version.trim().length > 0 && version.length <= 20,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/lib/legal.ts b/lib/legal.ts
new file mode 100644
index 0000000..7c3c107
--- /dev/null
+++ b/lib/legal.ts
@@ -0,0 +1,11 @@
+import type { AgreedTerm } from "@/lib/types";
+
+export const CURRENT_SERVICE_TERMS_VERSION = "1.0";
+export const CURRENT_PRIVACY_TERMS_VERSION = "1.0";
+
+export function createRequiredReportAgreements(): AgreedTerm[] {
+ return [
+ { type: "SERVICE", version: CURRENT_SERVICE_TERMS_VERSION },
+ { type: "PRIVACY", version: CURRENT_PRIVACY_TERMS_VERSION },
+ ];
+}
diff --git a/lib/oauth-callback.test.ts b/lib/oauth-callback.test.ts
new file mode 100644
index 0000000..146b4f0
--- /dev/null
+++ b/lib/oauth-callback.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import {
+ getOAuthFailureDestination,
+ isOAuthCallbackFailure,
+} from "@/lib/oauth-callback";
+
+describe("isOAuthCallbackFailure", () => {
+ it("treats provider cancellation as failure even with a stale signup hint", () => {
+ expect(isOAuthCallbackFailure("SIGNUP_REQUIRED", "OAUTH_CANCELLED")).toBe(
+ true,
+ );
+ });
+
+ it("accepts an actual signup-required callback", () => {
+ expect(isOAuthCallbackFailure("SIGNUP_REQUIRED", null)).toBe(false);
+ });
+
+ it("rejects explicit login and link failures", () => {
+ expect(isOAuthCallbackFailure("FAILED", null)).toBe(true);
+ expect(isOAuthCallbackFailure("LINK_FAILED", null)).toBe(true);
+ });
+
+ it("returns an authenticated member to the account panel after failure", () => {
+ expect(getOAuthFailureDestination(true)).toBe("ACCOUNT");
+ expect(getOAuthFailureDestination(false)).toBe("HOME");
+ });
+});
diff --git a/lib/oauth-callback.ts b/lib/oauth-callback.ts
new file mode 100644
index 0000000..453d093
--- /dev/null
+++ b/lib/oauth-callback.ts
@@ -0,0 +1,16 @@
+import type { OAuthCallbackResult } from "@/lib/api/auth-api";
+
+export function isOAuthCallbackFailure(
+ result: OAuthCallbackResult,
+ code: string | null,
+) {
+ return (
+ result === "FAILED" ||
+ result === "LINK_FAILED" ||
+ code === "OAUTH_CANCELLED"
+ );
+}
+
+export function getOAuthFailureDestination(authenticated: boolean) {
+ return authenticated ? "ACCOUNT" : "HOME";
+}
diff --git a/lib/social-providers.ts b/lib/social-providers.ts
new file mode 100644
index 0000000..08691ed
--- /dev/null
+++ b/lib/social-providers.ts
@@ -0,0 +1,14 @@
+import type { SocialProvider } from "@/lib/types";
+
+export const socialProviderLabel: Readonly> = {
+ APPLE: "애플",
+ GOOGLE: "구글",
+ KAKAO: "카카오",
+ NAVER: "네이버",
+};
+
+export const webSocialProviders = [
+ "NAVER",
+ "KAKAO",
+ "GOOGLE",
+] as const satisfies readonly SocialProvider[];
diff --git a/lib/types.ts b/lib/types.ts
index 5bd33f7..3783054 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -2,6 +2,7 @@ export type TemperatureStatus = "COLD" | "FRESH" | "HOT";
export type PrecipitationStatus = "NONE" | "LIGHT" | "HEAVY";
export type SunlightStatus = "LOW" | "MODERATE" | "STRONG";
export type WeatherStatus = TemperatureStatus | PrecipitationStatus | SunlightStatus;
+export type UserRole = "MEMBER" | "MODERATOR" | "ADMIN";
export type WeatherAuthor =
| { type: "ANONYMOUS"; nickname?: string }
@@ -18,9 +19,10 @@ export type CurrentUser =
provider?: SocialProvider;
avatarUrl?: string;
linkedProviders?: SocialProvider[];
+ role: UserRole;
};
-export type SocialProvider = "NAVER" | "KAKAO" | "GOOGLE";
+export type SocialProvider = "NAVER" | "KAKAO" | "GOOGLE" | "APPLE";
export type AvatarType = "DEFAULT" | "PRESET" | "CUSTOM";
@@ -38,6 +40,7 @@ export interface MemberAccount {
avatar: MemberAvatar;
connectedProviders: SocialProvider[];
currentProvider: SocialProvider;
+ role: UserRole;
}
export interface MemberProfile {
@@ -87,6 +90,50 @@ export interface ThanksState {
isThanked: boolean;
}
+export type LegalAgreementType = "SERVICE" | "PRIVACY";
+
+export interface AgreedTerm {
+ type: LegalAgreementType;
+ version: string;
+}
+
+export type ReportFlagReason =
+ | "SPAM"
+ | "ABUSE"
+ | "HATE"
+ | "SEXUAL"
+ | "PRIVACY"
+ | "FALSE_INFORMATION"
+ | "OTHER";
+
+export interface ReportFlagInput {
+ reason: ReportFlagReason;
+ detail?: string | null;
+}
+
+export interface ReportFlagResponse {
+ id: string;
+ reportId: string;
+ reason: ReportFlagReason;
+ status: "PENDING";
+ createdAt: string;
+}
+
+export interface ReportBlockItem {
+ memberId: string;
+ nickname: string;
+ avatar: MemberAvatar | null;
+ blockedAt: string;
+}
+
+export interface ReportBlockPage {
+ items: ReportBlockItem[];
+ page: number;
+ size: number;
+ totalElements: number;
+ totalPages: number;
+}
+
export interface CreateReportInput {
location: Location;
images: File[];
@@ -94,6 +141,7 @@ export interface CreateReportInput {
temperature: TemperatureStatus;
precipitation: PrecipitationStatus;
sunlight: SunlightStatus;
+ agreedTerms?: AgreedTerm[];
}
export type ReportUploadStage = "PREPARING" | "UPLOADING" | "CREATING";
diff --git a/package-lock.json b/package-lock.json
index f5f27ea..aaa1bdd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nalssilog-web",
- "version": "0.1.13",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nalssilog-web",
- "version": "0.1.13",
+ "version": "0.2.0",
"dependencies": {
"@hookform/resolvers": "latest",
"@sentry/nextjs": "^10.67.0",
diff --git a/package.json b/package.json
index 739be8d..58d23b7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nalssilog-web",
- "version": "0.1.13",
+ "version": "0.2.0",
"private": true,
"scripts": {
"config:init": "git -c submodule.env-config.update=checkout submodule update --init --checkout env-config && git -C env-config switch main && git -C env-config pull --ff-only origin main && npm run config:sync",
diff --git "a/public/login/\354\225\240\355\224\214_\353\241\234\352\267\270\354\235\270.svg" "b/public/login/\354\225\240\355\224\214_\353\241\234\352\267\270\354\235\270.svg"
new file mode 100644
index 0000000..389c544
--- /dev/null
+++ "b/public/login/\354\225\240\355\224\214_\353\241\234\352\267\270\354\235\270.svg"
@@ -0,0 +1,10 @@
+
+
diff --git a/store/auth-store.ts b/store/auth-store.ts
index f76ebb7..459eaf8 100644
--- a/store/auth-store.ts
+++ b/store/auth-store.ts
@@ -2,7 +2,7 @@
import { create } from "zustand";
import { resolveProfileImage } from "@/lib/constants";
-import type { AvatarType, CurrentUser, SocialProvider } from "@/lib/types";
+import type { AvatarType, CurrentUser, SocialProvider, UserRole } from "@/lib/types";
const AUTH_SESSION_HINT_KEY = "nalssilog-auth-session";
@@ -33,6 +33,7 @@ interface ServerUser {
nickname: string;
profileImageUrl?: string | null;
avatar?: { type: AvatarType; value: string | null };
+ role: UserRole;
}
interface AuthState {
@@ -65,6 +66,7 @@ export const useAuthStore = create((set) => ({
id: serverUser.id,
nickname: serverUser.nickname,
avatarUrl: resolveProfileImage(serverUser.profileImageUrl ?? serverUser.avatar?.value),
+ role: serverUser.role,
}
: anonymousUser,
hasCheckedServerSession: true,