From f75a49591f4c30ff83d285a44bf85b797b399bef Mon Sep 17 00:00:00 2001 From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:55:00 +0900 Subject: [PATCH] fix: restrict hero auction UI and improve mobile navigation --- docs/hero-member-navigation-r1.md | 15 ++ package-lock.json | 4 +- package.json | 2 +- scripts/test-production-workflow.mjs | 2 +- scripts/workflow-contract.test.mjs | 2 +- src/app/(main)/hero-auctions/page.tsx | 151 ++++++++++------- .../navigation/ThreeTierNav.test.tsx | 74 +++++++-- src/components/navigation/ThreeTierNav.tsx | 154 ++++++++---------- src/components/page/hero/HeroPages.test.tsx | 101 +++++++++++- src/components/ui/dialog.tsx | 14 ++ src/components/ui/popover.tsx | 8 +- 11 files changed, 358 insertions(+), 169 deletions(-) create mode 100644 docs/hero-member-navigation-r1.md diff --git a/docs/hero-member-navigation-r1.md b/docs/hero-member-navigation-r1.md new file mode 100644 index 0000000..42e35b0 --- /dev/null +++ b/docs/hero-member-navigation-r1.md @@ -0,0 +1,15 @@ +# 광고 경매 회원 접근·모바일 메뉴·규칙 팝업 r1 + +- 기준: `main`의 `4d5c343`, 작업 브랜치 `fix/hero-member-navigation`. +- 대상은 React다. 광고 경매·XP 백엔드 정책과 메인 Hero 공개 조회는 변경하지 않는다. +- 인증 확인 완료 후 로그인한 사용자에게만 상단 광고 경매 메뉴를 표시한다. 직접 `/hero-auctions`에 접근한 비로그인 사용자에게 로그인 안내와 진입 링크를 제공한다. 인증 로딩 및 비로그인 상태에서는 경매 내용과 경매 조회 훅을 마운트하지 않는다. +- 모바일 두 번째 메뉴 열은 기본 터치 가로 스크롤을 허용한다. 데스크톱 배열을 유지하고 기존 Radix Popover portal을 사용해 경매장·도감 메뉴가 스크롤 영역에 잘리지 않도록 한다. 바깥 클릭·Escape 닫기·트리거 포커스 복귀를 유지한다. +- 인라인 경매 규칙을 기존 Dialog 기반 팝업으로 이동한다. 기존 규칙 문구를 보존하고 제목·설명·닫기·Escape·포커스 복귀를 제공한다. 작은 화면에서 팝업 최대 높이를 제한하고 내용 영역을 스크롤할 수 있게 한다. +- 완료 검증: 로그인·비로그인·인증 로딩의 메뉴와 직접 접근/조회, 로그아웃 후 내용 제거, Portal 메뉴와 키보드 닫기, 규칙 팝업 내용·Escape·포커스 회귀 테스트. 실제 모바일 브라우저에서 가로 스크롤 후 도감 메뉴 선택을 확인한다. +- Node 20 `npm run verify`, workflow 계약 테스트 2종을 실행한다. 버전은 `1.0.29`이며 package lock과 workflow 기대값을 함께 갱신한다. 구현·독립 리뷰·PR 이후 운영 병합과 배포는 별도 단계다. + +## 구현 검증 + +- Node 20.20.2 `npm run verify` 통과: 포맷·포맷 스크립트 회귀·lint·typecheck·deadcode·112개 suite의 539개 테스트·프로덕션 빌드 완료. 기존 변경 범위 밖의 lint 경고는 유지한다. +- `node --test scripts/workflow-contract.test.mjs scripts/test-production-workflow.mjs`: 9개 테스트 통과. +- `git diff --check` 통과. 실제 Chromium 모바일 스크롤·메뉴 선택·Dialog 검증과 독립 리뷰는 별도 검증 담당자에게 인계한다. diff --git a/package-lock.json b/package-lock.json index 4be0015..9d2818f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devnogi-front", - "version": "1.0.28", + "version": "1.0.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devnogi-front", - "version": "1.0.28", + "version": "1.0.29", "dependencies": { "@hookform/resolvers": "^5.1.1", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/package.json b/package.json index b6f7907..e8cd102 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devnogi-front", - "version": "1.0.28", + "version": "1.0.29", "private": true, "scripts": { "dev": "next dev", diff --git a/scripts/test-production-workflow.mjs b/scripts/test-production-workflow.mjs index dbd008e..95e92cd 100644 --- a/scripts/test-production-workflow.mjs +++ b/scripts/test-production-workflow.mjs @@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => { test("package and lockfile agree on the next release version", () => { const pkg = JSON.parse(read("package.json")); const lock = JSON.parse(read("package-lock.json")); - assert.equal(pkg.version, "1.0.28"); + assert.equal(pkg.version, "1.0.29"); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[""].version, pkg.version); }); diff --git a/scripts/workflow-contract.test.mjs b/scripts/workflow-contract.test.mjs index de17155..0ae58bb 100644 --- a/scripts/workflow-contract.test.mjs +++ b/scripts/workflow-contract.test.mjs @@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => { ); const pkg = JSON.parse(read("package.json")); const lock = JSON.parse(read("package-lock.json")); - assert.equal(pkg.version, "1.0.28"); + assert.equal(pkg.version, "1.0.29"); assert.equal(lock.version, pkg.version); assert.equal(lock.packages[""].version, pkg.version); assert.match( diff --git a/src/app/(main)/hero-auctions/page.tsx b/src/app/(main)/hero-auctions/page.tsx index d08c0fe..274b872 100644 --- a/src/app/(main)/hero-auctions/page.tsx +++ b/src/app/(main)/hero-auctions/page.tsx @@ -8,6 +8,16 @@ import { FormEvent, useEffect, useRef, useState } from "react"; import HeroCreativeForm from "@/components/page/hero/HeroCreativeForm"; import HeroTransactionHistory from "@/components/page/hero/HeroTransactionHistory"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { useAuth } from "@/contexts/AuthContext"; import { useExperience } from "@/hooks/useExperience"; import { heroRequest } from "@/lib/hero/request"; @@ -380,7 +390,22 @@ function Participation({ } export default function HeroAuctionsPage() { - const { user, isLoading } = useAuth(); + const { user, isLoading, isAuthenticated } = useAuth(); + if (isLoading) return

로그인 확인 중…

; + if (!isAuthenticated || !user) + return ( +
+

로그인이 필요합니다

+

광고 경매는 로그인 후 이용할 수 있습니다.

+ + 로그인하기 + +
+ ); + return ; +} + +function MemberHeroAuctions({ userId }: { userId: number }) { const query = useQuery({ queryKey: ["hero", "auctions"], queryFn: () => heroRequest("hero/auctions/current"), @@ -415,40 +440,67 @@ export default function HeroAuctionsPage() { 시간입니다.

-
- - 입찰 전에 경매 규칙 확인 - -
    -
  • - 인증된 정상 회원이면서 수석(45 XP) 이상이어야 합니다. 최소 10 XP, 1 - XP 단위로 현재 최고가보다 높게 입찰합니다. -
  • -
  • - 최고 입찰액은 예약되며 다른 사람이 높은 금액을 부르면 즉시 - 해제됩니다. 낙찰자만 최종 입찰액을 지불하고 등급이 내려갈 수 - 있습니다. -
  • -
  • - 두 자리 모두 낙찰받을 수 있습니다. 마감 직전 등급과 합산 잔액을 - 검사하며, 합산 잔액이 부족하면 두 입찰 모두 무효입니다. 차순위에게 - 승계하지 않습니다. -
  • -
  • - 입찰 취소·금액 인하·자동 입찰·마감 연장은 없습니다. 소재 교체는 더 - 높은 금액으로 재입찰해야 하며 낙찰 이후에는 바꿀 수 없습니다. -
  • -
  • - 정책 위반 소재는 사전 심사 없이도 관리자가 차단할 수 있으며 노출 - 중단 시 환불하지 않습니다. 운영 사유로 경매를 취소하면 전액 - 환불합니다. -
  • -
  • - 활동 삭제로 XP가 회수될 수 있습니다. 이미 소비한 XP의 부족분은 이후 - 적립에서 먼저 상쇄됩니다. -
  • -
-
+ + + + + + + 광고 경매 규칙 + + 입찰 전 참여 조건과 XP 처리 규칙을 확인해주세요. + + +
+

+ 경매는 매주 월요일 00시부터 일요일 22시까지 열립니다. 낙찰 광고는 + 다음 월요일 00시부터 7일간 노출됩니다. 모든 시각은 한국 + 시간입니다. +

+
    +
  • + 인증된 정상 회원이면서 수석(45 XP) 이상이어야 합니다. 최소 10 + XP, 1 XP 단위로 현재 최고가보다 높게 입찰합니다. +
  • +
  • + 최고 입찰액은 예약되며 다른 사람이 높은 금액을 부르면 즉시 + 해제됩니다. 낙찰자만 최종 입찰액을 지불하고 등급이 내려갈 수 + 있습니다. +
  • +
  • + 두 자리 모두 낙찰받을 수 있습니다. 마감 직전 등급과 합산 잔액을 + 검사하며, 합산 잔액이 부족하면 두 입찰 모두 무효입니다. + 차순위에게 승계하지 않습니다. +
  • +
  • + 입찰 취소·금액 인하·자동 입찰·마감 연장은 없습니다. 소재 교체는 + 더 높은 금액으로 재입찰해야 하며 낙찰 이후에는 바꿀 수 없습니다. +
  • +
  • + 정책 위반 소재는 사전 심사 없이도 관리자가 차단할 수 있으며 노출 + 중단 시 환불하지 않습니다. 운영 사유로 경매를 취소하면 전액 + 환불합니다. +
  • +
  • + 활동 삭제로 XP가 회수될 수 있습니다. 이미 소비한 XP의 부족분은 + 이후 적립에서 먼저 상쇄됩니다. +
  • +
+
+ + + + + +
+
{query.isPending ?

경매 불러오는 중…

: null} {query.isError ? (

@@ -465,29 +517,12 @@ export default function HeroAuctionsPage() { : "현재 광고 경매가 비활성화되어 있습니다."}

) : null} - {isLoading ? ( -

로그인 확인 중…

- ) : user ? ( - - ) : ( -
-

광고 소재 등록과 입찰은 로그인 후 이용할 수 있습니다.

- - 로그인하기 - - {current.map((auction) => ( -

- {auction.slot}번 · 최고가 {auction.highestBid.toLocaleString()} XP - · 마감 {heroTime(auction.closesAt)} -

- ))} -
- )} + {all.some((item) => item.weekStart !== latest) ? (

지난 경매 결과

diff --git a/src/components/navigation/ThreeTierNav.test.tsx b/src/components/navigation/ThreeTierNav.test.tsx index ebcbda8..48c834a 100644 --- a/src/components/navigation/ThreeTierNav.test.tsx +++ b/src/components/navigation/ThreeTierNav.test.tsx @@ -1,4 +1,11 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import ThreeTierNav from "./ThreeTierNav"; @@ -6,6 +13,7 @@ let pathname = "/"; const toggleTheme = jest.fn(); let mockAuthState: { isAuthenticated: boolean; + isLoading?: boolean; user: { nickname: string; role: string; @@ -127,28 +135,32 @@ it("하위 경로에서도 상위 드롭다운 메뉴를 활성 상태로 표시 ); }); -it("드롭다운을 열고 Escape로 닫으면 트리거로 포커스를 돌린다", () => { +it("드롭다운을 열고 Escape로 닫으면 트리거로 포커스를 돌린다", async () => { + const user = userEvent.setup(); render(); const trigger = screen.getByRole("button", { name: "경매장" }); expect(trigger).toHaveAttribute("aria-expanded", "false"); - fireEvent.click(trigger); + await user.click(trigger); expect(trigger).toHaveAttribute("aria-expanded", "true"); - const link = screen.getByRole("link", { name: "시세 정보" }); - link.focus(); - fireEvent.keyDown(link, { key: "Escape" }); - expect(trigger).toHaveAttribute("aria-expanded", "false"); - expect(trigger).toHaveFocus(); + await user.keyboard("{Escape}"); + await waitFor(() => + expect(trigger).toHaveAttribute("aria-expanded", "false"), + ); + await waitFor(() => expect(trigger).toHaveFocus()); expect( screen.queryByRole("group", { name: "경매장 메뉴" }), ).not.toBeInTheDocument(); }); -it("바깥 영역을 누르면 드롭다운을 닫는다", () => { +it("바깥 영역을 누르면 드롭다운을 닫는다", async () => { + const user = userEvent.setup(); render(); const trigger = screen.getByRole("button", { name: "경매장" }); - fireEvent.click(trigger); - fireEvent.pointerDown(document.body); - expect(trigger).toHaveAttribute("aria-expanded", "false"); + await user.click(trigger); + await user.click(document.body); + await waitFor(() => + expect(trigger).toHaveAttribute("aria-expanded", "false"), + ); }); it("의장 메뉴를 커뮤니티와 경매장 사이에 표시하고 하위 경로를 활성화한다", () => { @@ -158,9 +170,45 @@ it("의장 메뉴를 커뮤니티와 경매장 사이에 표시하고 하위 경 const link = within(nav).getByRole("link", { name: "의장" }); expect(link).toHaveAttribute("href", "/outfits"); expect(link).toHaveAttribute("aria-current", "page"); - expect(nav.textContent).toMatch(/커뮤니티광고 경매길드의장경매장/); + expect(nav.textContent).toMatch(/커뮤니티길드의장경매장/); expect(within(nav).getByRole("link", { name: "길드" })).toHaveAttribute( "href", "/guilds", ); }); + +it("비로그인 및 인증 로딩 중에는 광고 경매 메뉴를 숨긴다", () => { + const { rerender } = render(); + expect( + screen.queryByRole("link", { name: "광고 경매" }), + ).not.toBeInTheDocument(); + mockAuthState = { + isAuthenticated: true, + isLoading: true, + user: { nickname: "회원", role: "USER", verified: false }, + }; + rerender(); + expect( + screen.queryByRole("link", { name: "광고 경매" }), + ).not.toBeInTheDocument(); + mockAuthState = { ...mockAuthState, isLoading: false }; + rerender(); + expect(screen.getByRole("link", { name: "광고 경매" })).toHaveAttribute( + "href", + "/hero-auctions", + ); +}); + +it("모바일 메뉴는 가로 스크롤하며 드롭다운은 헤더 밖 Portal에 표시한다", async () => { + const user = userEvent.setup(); + render(); + const navigation = screen.getByRole("navigation", { name: "주요 메뉴" }); + expect(navigation).toHaveClass("overflow-x-auto", "md:overflow-visible"); + await user.click(screen.getByRole("button", { name: "도감" })); + const menu = await screen.findByRole("group", { name: "도감 메뉴" }); + expect(navigation).not.toContainElement(menu); + expect(menu.closest("header")).toBeNull(); + expect( + within(menu).getByRole("link", { name: "아이템 도감" }), + ).toHaveAttribute("href", "/item-info"); +}); diff --git a/src/components/navigation/ThreeTierNav.tsx b/src/components/navigation/ThreeTierNav.tsx index 1d5936f..135b43f 100644 --- a/src/components/navigation/ThreeTierNav.tsx +++ b/src/components/navigation/ThreeTierNav.tsx @@ -4,8 +4,13 @@ import clsx from "clsx"; import { ChevronDown, Home, Moon, Sun, User } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useEffect, useId, useRef, useState } from "react"; +import { useState } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import VerifiedBadge from "@/components/ui/VerifiedBadge"; import { useAuth } from "@/contexts/AuthContext"; import { useTheme } from "@/contexts/ThemeContext"; @@ -74,98 +79,67 @@ function NavigationDropdown({ }) { const isActive = links.some(({ href }) => isCurrentPath(pathname, href)); const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - const triggerRef = useRef(null); - const menuId = useId(); - - useEffect(() => { - if (!isOpen) return; - const closeOutside = (event: PointerEvent) => { - if (!containerRef.current?.contains(event.target as Node)) - setIsOpen(false); - }; - document.addEventListener("pointerdown", closeOutside); - return () => document.removeEventListener("pointerdown", closeOutside); - }, [isOpen]); - return ( -
{ - if (!event.currentTarget.contains(event.relatedTarget)) - setIsOpen(false); - }} - onKeyDown={(event) => { - if (event.key === "Escape" && isOpen) { - event.preventDefault(); - setIsOpen(false); - triggerRef.current?.focus(); - } - }} - > - - - {isOpen ? ( -
-
- {links.map(({ href, label: itemLabel }) => { - const isItemActive = isCurrentPath(pathname, href); + > + {label} +
- ) : null} -
+ + ); } export default function ThreeTierNav() { const pathname = usePathname(); - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated, isLoading, user } = useAuth(); const isAdminUser = isAdminRole(user?.role); const { isDarkMode, toggleTheme } = useTheme(); @@ -182,7 +156,7 @@ export default function ThreeTierNav() {