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
15 changes: 15 additions & 0 deletions docs/hero-member-navigation-r1.md
Original file line number Diff line number Diff line change
@@ -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 검증과 독립 리뷰는 별도 검증 담당자에게 인계한다.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
"version": "1.0.28",
"version": "1.0.29",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-production-workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion scripts/workflow-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
151 changes: 93 additions & 58 deletions src/app/(main)/hero-auctions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -380,7 +390,22 @@ function Participation({
}

export default function HeroAuctionsPage() {
const { user, isLoading } = useAuth();
const { user, isLoading, isAuthenticated } = useAuth();
if (isLoading) return <p role="status">로그인 확인 중…</p>;
if (!isAuthenticated || !user)
return (
<main className="mx-auto max-w-3xl space-y-4 py-8">
<h1 className="text-xl font-semibold">로그인이 필요합니다</h1>
<p>광고 경매는 로그인 후 이용할 수 있습니다.</p>
<Link href="/sign-in" className="text-primary underline">
로그인하기
</Link>
</main>
);
return <MemberHeroAuctions userId={user.userId} />;
}

function MemberHeroAuctions({ userId }: { userId: number }) {
const query = useQuery({
queryKey: ["hero", "auctions"],
queryFn: () => heroRequest<HeroAuctionsResponse>("hero/auctions/current"),
Expand Down Expand Up @@ -415,40 +440,67 @@ export default function HeroAuctionsPage() {
시간입니다.
</p>
</header>
<details className="rounded-xl border p-4">
<summary className="cursor-pointer font-medium">
입찰 전에 경매 규칙 확인
</summary>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-6">
<li>
인증된 정상 회원이면서 수석(45 XP) 이상이어야 합니다. 최소 10 XP, 1
XP 단위로 현재 최고가보다 높게 입찰합니다.
</li>
<li>
최고 입찰액은 예약되며 다른 사람이 높은 금액을 부르면 즉시
해제됩니다. 낙찰자만 최종 입찰액을 지불하고 등급이 내려갈 수
있습니다.
</li>
<li>
두 자리 모두 낙찰받을 수 있습니다. 마감 직전 등급과 합산 잔액을
검사하며, 합산 잔액이 부족하면 두 입찰 모두 무효입니다. 차순위에게
승계하지 않습니다.
</li>
<li>
입찰 취소·금액 인하·자동 입찰·마감 연장은 없습니다. 소재 교체는 더
높은 금액으로 재입찰해야 하며 낙찰 이후에는 바꿀 수 없습니다.
</li>
<li>
정책 위반 소재는 사전 심사 없이도 관리자가 차단할 수 있으며 노출
중단 시 환불하지 않습니다. 운영 사유로 경매를 취소하면 전액
환불합니다.
</li>
<li>
활동 삭제로 XP가 회수될 수 있습니다. 이미 소비한 XP의 부족분은 이후
적립에서 먼저 상쇄됩니다.
</li>
</ul>
</details>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">경매 규칙 보기</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>광고 경매 규칙</DialogTitle>
<DialogDescription>
입찰 전 참여 조건과 XP 처리 규칙을 확인해주세요.
</DialogDescription>
</DialogHeader>
<div
role="region"
aria-label="광고 경매 규칙 내용"
tabIndex={0}
className="min-h-0 overflow-y-auto pr-2 focus-visible:outline-2 focus-visible:outline-ring"
>
<p className="text-sm leading-6">
경매는 매주 월요일 00시부터 일요일 22시까지 열립니다. 낙찰 광고는
다음 월요일 00시부터 7일간 노출됩니다. 모든 시각은 한국
시간입니다.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-6">
<li>
인증된 정상 회원이면서 수석(45 XP) 이상이어야 합니다. 최소 10
XP, 1 XP 단위로 현재 최고가보다 높게 입찰합니다.
</li>
<li>
최고 입찰액은 예약되며 다른 사람이 높은 금액을 부르면 즉시
해제됩니다. 낙찰자만 최종 입찰액을 지불하고 등급이 내려갈 수
있습니다.
</li>
<li>
두 자리 모두 낙찰받을 수 있습니다. 마감 직전 등급과 합산 잔액을
검사하며, 합산 잔액이 부족하면 두 입찰 모두 무효입니다.
차순위에게 승계하지 않습니다.
</li>
<li>
입찰 취소·금액 인하·자동 입찰·마감 연장은 없습니다. 소재 교체는
더 높은 금액으로 재입찰해야 하며 낙찰 이후에는 바꿀 수 없습니다.
</li>
<li>
정책 위반 소재는 사전 심사 없이도 관리자가 차단할 수 있으며 노출
중단 시 환불하지 않습니다. 운영 사유로 경매를 취소하면 전액
환불합니다.
</li>
<li>
활동 삭제로 XP가 회수될 수 있습니다. 이미 소비한 XP의 부족분은
이후 적립에서 먼저 상쇄됩니다.
</li>
</ul>
</div>
<DialogFooter className="shrink-0">
<DialogClose asChild>
<Button variant="outline" className="min-h-11">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
{query.isPending ? <p role="status">경매 불러오는 중…</p> : null}
{query.isError ? (
<p role="alert">
Expand All @@ -465,29 +517,12 @@ export default function HeroAuctionsPage() {
: "현재 광고 경매가 비활성화되어 있습니다."}
</p>
) : null}
{isLoading ? (
<p role="status">로그인 확인 중…</p>
) : user ? (
<Participation
userId={user.userId}
auctions={current}
enabled={query.data?.enabled ?? false}
now={serverNow}
/>
) : (
<section className="space-y-3 rounded-xl border p-5">
<p>광고 소재 등록과 입찰은 로그인 후 이용할 수 있습니다.</p>
<Link className="text-primary underline" href="/sign-in">
로그인하기
</Link>
{current.map((auction) => (
<p key={auction.id}>
{auction.slot}번 · 최고가 {auction.highestBid.toLocaleString()} XP
· 마감 {heroTime(auction.closesAt)}
</p>
))}
</section>
)}
<Participation
userId={userId}
auctions={current}
enabled={query.data?.enabled ?? false}
now={serverNow}
/>
{all.some((item) => item.weekStart !== latest) ? (
<section className="space-y-3 rounded-xl border p-5">
<h2 className="text-lg font-semibold">지난 경매 결과</h2>
Expand Down
74 changes: 61 additions & 13 deletions src/components/navigation/ThreeTierNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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";

let pathname = "/";
const toggleTheme = jest.fn();
let mockAuthState: {
isAuthenticated: boolean;
isLoading?: boolean;
user: {
nickname: string;
role: string;
Expand Down Expand Up @@ -127,28 +135,32 @@ it("하위 경로에서도 상위 드롭다운 메뉴를 활성 상태로 표시
);
});

it("드롭다운을 열고 Escape로 닫으면 트리거로 포커스를 돌린다", () => {
it("드롭다운을 열고 Escape로 닫으면 트리거로 포커스를 돌린다", async () => {
const user = userEvent.setup();
render(<ThreeTierNav />);
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(<ThreeTierNav />);
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("의장 메뉴를 커뮤니티와 경매장 사이에 표시하고 하위 경로를 활성화한다", () => {
Expand All @@ -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(<ThreeTierNav />);
expect(
screen.queryByRole("link", { name: "광고 경매" }),
).not.toBeInTheDocument();
mockAuthState = {
isAuthenticated: true,
isLoading: true,
user: { nickname: "회원", role: "USER", verified: false },
};
rerender(<ThreeTierNav />);
expect(
screen.queryByRole("link", { name: "광고 경매" }),
).not.toBeInTheDocument();
mockAuthState = { ...mockAuthState, isLoading: false };
rerender(<ThreeTierNav />);
expect(screen.getByRole("link", { name: "광고 경매" })).toHaveAttribute(
"href",
"/hero-auctions",
);
});

it("모바일 메뉴는 가로 스크롤하며 드롭다운은 헤더 밖 Portal에 표시한다", async () => {
const user = userEvent.setup();
render(<ThreeTierNav />);
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");
});
Loading
Loading