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
14 changes: 14 additions & 0 deletions src/app/reviews/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";

import ReviewsShell from "@/components/review/ReviewsShell";

export const metadata: Metadata = {
title: "리뷰 관리",
description: "작성 가능한 리뷰와 내가 작성한 리뷰를 관리합니다.",
};

// 2026.07.27 정슬기 - [추가] 리뷰 관리 레이아웃
export default function ReviewsLayout({ children }: { children: ReactNode }) {
return <ReviewsShell>{children}</ReviewsShell>;
}
12 changes: 12 additions & 0 deletions src/app/reviews/me/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";

import MyReviewsPageClient from "@/components/review/MyReviewsPageClient";

export const metadata: Metadata = {
title: "내가 작성한 리뷰",
};

// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 페이지
export default function MyReviewsPage() {
return <MyReviewsPageClient />;
}
8 changes: 8 additions & 0 deletions src/app/reviews/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";

import { APP_ROUTES } from "@/lib/constants/appRoutes";

// 2026.07.27 정슬기 - [추가] /reviews → 작성 가능 리뷰로 이동
export default function ReviewsIndexPage() {
redirect(APP_ROUTES.REVIEWS.WRITABLE);
}
12 changes: 12 additions & 0 deletions src/app/reviews/writable/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";

import WritableReviewsPageClient from "@/components/review/WritableReviewsPageClient";

export const metadata: Metadata = {
title: "작성 가능한 리뷰",
};

// 2026.07.27 정슬기 - [추가] 작성 가능한 리뷰 페이지
export default function WritableReviewsPage() {
return <WritableReviewsPageClient />;
}
164 changes: 154 additions & 10 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";

import { Text } from "@/components/common/Text";
import { useClickOutside } from "@/hooks/useClickOutside";
import { AlarmIcon } from "@/icons";
import { getLoginRedirectPath, hasAuthSession, subscribeAuthSession } from "@/lib/auth/session";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { cn } from "@/lib/utils/cn";

Expand All @@ -22,10 +25,95 @@ const LOGGED_IN_LINKS = [
{ label: "내 견적 관리", href: "/estimates" },
];

const Header = ({ isLogin = false }: HeaderProps) => {
const PROFILE_MENU_ITEMS = [
{ label: "작성 가능한 리뷰", href: APP_ROUTES.REVIEWS.WRITABLE },
{ label: "내가 작성한 리뷰", href: APP_ROUTES.REVIEWS.ME },
] as const;

const Header = ({ isLogin: isLoginProp }: HeaderProps) => {
const pathname = usePathname();
const hasSession = useSyncExternalStore(subscribeAuthSession, hasAuthSession, () => false);
// 경로별로 열린 메뉴를 추적해 pathname 변경 시 별도 setState 없이 자동으로 닫힘
const [openMenuPath, setOpenMenuPath] = useState<string | null>(null);
const menuId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
const profileMenuRef = useClickOutside<HTMLDivElement>(() => setOpenMenuPath(null));

const isProfileMenuOpen = openMenuPath === pathname;
const isLogin = isLoginProp ?? hasSession;
const navLinks = isLogin ? LOGGED_IN_LINKS : LOGGED_OUT_LINKS;

const closeMenu = useCallback(() => {
setOpenMenuPath(null);
triggerRef.current?.focus();
}, []);

const openMenu = useCallback(() => {
setOpenMenuPath(pathname);
}, [pathname]);

// 2026.07.27 정슬기 - [수정] Esc는 전역, 화살표·Home·End는 메뉴 내부 포커스일 때만 처리
useEffect(() => {
if (!isProfileMenuOpen) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeMenu();
return;
}

if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Home" &&
event.key !== "End"
) {
return;
}

const menuRoot = profileMenuRef.current;
const target = event.target;
if (!(target instanceof Node) || !menuRoot?.contains(target)) {
return;
}

event.preventDefault();
const items = itemRefs.current.filter((item): item is HTMLAnchorElement => item !== null);
if (items.length === 0) return;

const activeIndex = items.findIndex((item) => item === document.activeElement);

if (event.key === "Home") {
items[0]?.focus();
return;
}

if (event.key === "End") {
items[items.length - 1]?.focus();
return;
}

if (event.key === "ArrowDown") {
const nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % items.length;
items[nextIndex]?.focus();
return;
}

const prevIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1;
items[prevIndex]?.focus();
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [closeMenu, isProfileMenuOpen, profileMenuRef]);

useEffect(() => {
if (!isProfileMenuOpen) return;
itemRefs.current[0]?.focus();
}, [isProfileMenuOpen]);

return (
<header className="border-border-subtle bg-background-surface w-full border-b">
<div className="h-gnb-height-desktop px-gnb-padding-x-desktop flex items-center justify-between py-26">
Expand Down Expand Up @@ -61,19 +149,75 @@ const Header = ({ isLogin = false }: HeaderProps) => {
{isLogin ? (
<div className="flex items-center gap-20">
<button type="button" aria-label="알림">
<AlarmIcon className="text-icon-default size-24" />
</button>
<button type="button" aria-label="프로필">
<Image src="/icons/profile-default.svg" alt="" width={36} height={36} />
</button>
{/* TODO: 프로필 기능 연동 전까지 닉네임 placeholder */}
<button type="button" className="text-text-primary">
<Text variant="md-medium">닉네임</Text>
<AlarmIcon className="text-icon-default size-24" aria-hidden="true" />
</button>

{/* 2026.07.27 정슬기 - [추가] 프로필 드롭다운 (리뷰 메뉴 진입) */}
{/* 2026.07.27 정슬기 - [수정] 경로 기반 열림 상태·키보드(Esc/화살표) 접근성 */}
<div ref={profileMenuRef} className="relative flex items-center gap-12">
<button
ref={triggerRef}
type="button"
id={`${menuId}-trigger`}
aria-label="프로필 메뉴"
aria-haspopup="menu"
aria-expanded={isProfileMenuOpen}
aria-controls={isProfileMenuOpen ? `${menuId}-menu` : undefined}
className="focus-visible:ring-border-brand rounded-8 flex items-center gap-12 focus-visible:ring-2 focus-visible:outline-none"
onClick={() => {
if (isProfileMenuOpen) {
closeMenu();
} else {
openMenu();
}
}}
>
<Image src="/icons/profile-default.svg" alt="" width={36} height={36} />
{/* TODO: auth/프로필 연동 전 임시 표기 — 세션 닉네임으로 교체 */}
<Text as="span" variant="md-medium" className="text-text-primary">
닉네임
</Text>
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{isProfileMenuOpen ? (
<div
id={`${menuId}-menu`}
role="menu"
aria-labelledby={`${menuId}-trigger`}
className="border-border-subtle bg-background-surface shadow-estimate-card rounded-12 absolute top-[calc(100%+8px)] right-0 z-50 flex min-w-[200px] flex-col overflow-hidden border py-8"
>
{PROFILE_MENU_ITEMS.map((item, index) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);

return (
<Link
key={item.href}
ref={(node) => {
itemRefs.current[index] = node;
}}
href={item.href}
role="menuitem"
tabIndex={-1}
aria-current={isActive ? "page" : undefined}
className={cn(
"hover:bg-background-hover focus-visible:bg-background-hover px-16 py-12 transition-colors focus-visible:outline-none",
isActive ? "text-text-brand" : "text-text-primary",
)}
onClick={() => setOpenMenuPath(null)}
>
<Text as="span" variant="md-medium">
{item.label}
</Text>
</Link>
);
})}
</div>
) : null}
</div>
</div>
) : (
<Link
href={APP_ROUTES.LOGIN}
href={getLoginRedirectPath()}
className="bg-background-brand text-text-inverse hover:bg-background-brand-hover rounded-8 flex h-40 items-center px-20 transition-colors"
>
<Text variant="md-semibold">로그인</Text>
Expand Down
10 changes: 8 additions & 2 deletions src/components/common/Modal/ModalClose.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { CloseIcon } from "@/icons";
import { cn } from "@/lib/utils/cn";

interface ModalCloseProps {
onClose: () => void;
disabled?: boolean;
}

const ModalClose = ({ onClose }: ModalCloseProps) => {
const ModalClose = ({ onClose, disabled = false }: ModalCloseProps) => {
return (
<button
type="button"
className="text-icon-default absolute top-30 right-30"
className={cn(
"text-icon-default absolute top-30 right-30",
disabled && "cursor-not-allowed opacity-40",
)}
onClick={onClose}
disabled={disabled}
aria-label="모달 닫기"
>
<CloseIcon className="size-18" />
Expand Down
110 changes: 110 additions & 0 deletions src/components/review/MyReviewCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import Image from "next/image";
import Link from "next/link";

import { Text } from "@/components/common/Text";
import { MoveTypeChip } from "@/components/estimate/received/MoveTypeChip";
import ReviewStarRating from "@/components/review/ReviewStarRating";
import { ProfileDefaultIcon } from "@/icons";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { formatKoreanDateLong } from "@/lib/utils/estimateFormat";
import type { MyReviewItem } from "@/types/review";

interface MyReviewCardProps {
review: MyReviewItem;
}

// 2026.07.27 정슬기 - [추가] 내가 작성한 리뷰 카드
// 2026.07.27 정슬기 - [수정] Mobile 주소 줄바꿈·타이포 / Desktop 작성일 우측 배치
// 2026.07.27 정슬기 - [수정] 카드 클릭 시 기사님 상세 페이지로 이동
export default function MyReviewCard({ review }: MyReviewCardProps) {
const { mover, estimateRequest, rating, content, createdAt } = review;
const displayName = mover.nickname?.trim() || mover.name;
const titleId = `my-review-${review.id}-title`;

return (
<Link
href={APP_ROUTES.MOVERS.DETAIL(mover.id)}
aria-label={`${displayName} 기사님 상세 보기`}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="bg-background-surface border-border-subtle shadow-estimate-card rounded-16 md:rounded-20 focus-visible:ring-border-brand flex w-full flex-col gap-12 border-[0.5px] px-16 py-20 focus-visible:ring-2 focus-visible:outline-none md:gap-16 md:px-24 md:py-28 lg:gap-20 lg:px-32 lg:py-32"
>
<div className="flex w-full flex-col gap-12 md:gap-16">
<div className="flex flex-wrap items-center gap-8">
<MoveTypeChip moveType={estimateRequest.moveType} size="sm" className="md:hidden" />
<MoveTypeChip
moveType={estimateRequest.moveType}
size="md"
className="hidden py-4 pr-7 pl-5 md:inline-flex"
/>
</div>

<div className="flex w-full items-start gap-10 md:items-center md:gap-16">
<div className="bg-background-avatar rounded-12 relative size-50 shrink-0 overflow-hidden">
{mover.imageUrl ? (
<Image
src={mover.imageUrl}
alt={`${displayName} 기사님 프로필`}
fill
sizes="50px"
className="object-cover"
/>
) : (
<ProfileDefaultIcon className="size-full" />
)}
</div>

<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex w-full items-start justify-between gap-8">
<Text
as="h3"
id={titleId}
variant={{ base: "md-semibold", md: "lg-semibold" }}
className="text-text-primary min-w-0 break-words"
>
{displayName} 기사님
</Text>
<Text
as="time"
dateTime={createdAt}
variant="md-medium"
className="text-text-muted hidden shrink-0 md:inline"
>
{formatKoreanDateLong(createdAt)}
</Text>
</div>
<Text
as="p"
variant={{ base: "sm-medium", md: "md-medium" }}
className="text-text-muted break-words"
>
{estimateRequest.fromAddress}
<span aria-hidden="true"> → </span>
<span className="sr-only">에서 </span>
{estimateRequest.toAddress}
</Text>
</div>
</div>
</div>

<ReviewStarRating value={rating} size="sm" label="내가 준 별점" />

<Text
as="p"
variant={{ base: "md-regular", md: "lg-regular" }}
className="text-text-secondary break-words whitespace-pre-wrap"
>
{content}
</Text>

<Text
as="time"
dateTime={createdAt}
variant="sm-medium"
className="text-text-muted self-end md:hidden"
>
{formatKoreanDateLong(createdAt)}
</Text>
</Link>
);
}
Loading