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의 부족분은 이후
- 적립에서 먼저 상쇄됩니다.
-
-
-
+
{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 ? (
-
-
- ) : 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() {