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
2 changes: 1 addition & 1 deletion src/app/favorites/movers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function FavoriteMoversPage() {
</>
}
buttonLabel="기사님 찾기로 돌아가기"
href={APP_ROUTES.MOVERS}
href={APP_ROUTES.MOVERS.ROOT}
/>
</div>
</div>
Expand Down
28 changes: 28 additions & 0 deletions src/app/movers/[moverId]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { useEffect } from "react";

import MoversErrorPanel from "@/components/mover/MoversErrorPanel";

interface MoverDetailErrorProps {
error: Error & { digest?: string };
reset: () => void;
}

export default function MoverDetailError({ error, reset }: MoverDetailErrorProps) {
useEffect(() => {
console.error(error);
}, [error]);

return (
<div className="bg-background-default flex w-full flex-1 flex-col items-center justify-center">
<MoversErrorPanel
title="불러오지 못했어요"
description="기사님 상세 페이지를 표시하는 중 문제가 발생했습니다."
actionLabel="다시 시도"
isRetrying={false}
onRetry={reset}
/>
</div>
);
}
5 changes: 5 additions & 0 deletions src/app/movers/[moverId]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import MoverDetailNotFoundStatus from "@/components/mover/detail/MoverDetailNotFoundStatus";

export default function MoverDetailNotFound() {
return <MoverDetailNotFoundStatus />;
}
21 changes: 10 additions & 11 deletions src/app/movers/[moverId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";

import MoverDetailView from "@/components/mover/detail/MoverDetailView";
import { getMockMoverDetail } from "@/components/mover/detail/moverDetailMock";
import { isMoverDetailId } from "@/lib/utils/isMoverDetailId";

interface MoverDetailPageProps {
params: Promise<{ moverId: string }>;
}

export async function generateMetadata({ params }: MoverDetailPageProps): Promise<Metadata> {
const { moverId } = await params;
const detail = getMockMoverDetail(moverId);
const name = detail?.name ?? "기사님";

return {
title: `${name} 기사님 상세`,
description: detail?.title ?? "이사 기사님 상세 정보를 확인하세요.",
};
}
export const metadata: Metadata = {
title: "기사님 상세",
description: "이사 기사님 상세 정보를 확인하세요.",
};

export default async function MoverDetailPage({ params }: MoverDetailPageProps) {
const { moverId } = await params;

if (!isMoverDetailId(moverId)) {
notFound();
}

return <MoverDetailView key={moverId} moverId={moverId} />;
}
41 changes: 30 additions & 11 deletions src/components/mover/MoverCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"use client";

import Link from "next/link";

import { Text } from "@/components/common/Text";
import { MoverMeta } from "@/components/mover/MoverMeta";
import { MoverProfileImage } from "@/components/mover/MoverProfileImage";
import { MoverServiceTypeChips } from "@/components/mover/MoverServiceTypeChips";
import { useFavoriteMover } from "@/hooks/useFavoriteMover";
import { DriverBadgeIcon, LikeIcon } from "@/icons";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { cn } from "@/lib/utils/cn";
import type { Mover } from "@/types/mover";

import { MoverMeta } from "@/components/mover/MoverMeta";
import { MoverProfileImage } from "@/components/mover/MoverProfileImage";
import { MoverServiceTypeChips } from "@/components/mover/MoverServiceTypeChips";

interface MoverCardProps {
mover: Mover;
variant?: "full" | "compact";
Expand All @@ -35,13 +37,17 @@ function FavoriteButton({
onToggle,
}: FavoriteButtonProps) {
return (
<div className="flex shrink-0 items-center justify-center gap-2">
<div className="pointer-events-auto flex shrink-0 items-center justify-center gap-2">
<button
type="button"
className="focus-visible:ring-border-brand rounded-8 cursor-pointer focus-visible:ring-2 focus-visible:outline-none"
aria-label={`${moverName} 기사님 찜하기`}
aria-pressed={isFavorite}
onClick={() => onToggle(!isFavorite)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggle(!isFavorite);
}}
>
<LikeIcon
isFavorite={isFavorite}
Expand Down Expand Up @@ -87,16 +93,24 @@ export default function MoverCard({
onToggle: toggleFavorite,
};

const detailHref = APP_ROUTES.MOVERS.DETAIL(mover.id);
const detailLabel = `${mover.name} 기사님 상세 보기`;

if (variant === "compact") {
return (
<article
className={cn(
"border-border-subtle bg-background-surface rounded-16 flex w-full flex-col gap-20 border-[0.5px] p-20",
"border-border-subtle bg-background-surface rounded-16 relative flex w-full flex-col gap-20 border-[0.5px] p-20",
"shadow-[-2px_-2px_10px_0px_rgba(220,220,220,0.2),2px_2px_10px_0px_rgba(220,220,220,0.2)]",
className,
)}
>
<div className="flex flex-col gap-12">
<Link
href={detailHref}
aria-label={detailLabel}
className="focus-visible:ring-border-brand rounded-16 absolute inset-0 z-0 focus-visible:ring-2 focus-visible:outline-none"
/>
<div className="pointer-events-none relative z-10 flex flex-col gap-12">
<MoverServiceTypeChips serviceTypes={mover.serviceTypes} size="sm" />
<div className="flex flex-col gap-16">
<Text as="h3" variant="lg-semibold" className="text-text-secondary">
Expand Down Expand Up @@ -139,14 +153,19 @@ export default function MoverCard({
return (
<article
className={cn(
"border-border-subtle bg-background-surface flex w-full flex-col border-[0.5px]",
"border-border-subtle bg-background-surface relative flex w-full flex-col border-[0.5px]",
"rounded-16 gap-8 p-20",
"min-[744px]:rounded-20 min-[744px]:gap-20 min-[744px]:px-28 min-[744px]:py-24",
"shadow-[-2px_-2px_10px_0px_rgba(220,220,220,0.2),2px_2px_10px_0px_rgba(220,220,220,0.2)]",
className,
)}
>
<div className="flex flex-col gap-8 min-[744px]:hidden">
<Link
href={detailHref}
aria-label={detailLabel}
className="focus-visible:ring-border-brand rounded-16 min-[744px]:rounded-20 absolute inset-0 z-0 focus-visible:ring-2 focus-visible:outline-none"
/>
<div className="pointer-events-none relative z-10 flex flex-col gap-8 min-[744px]:hidden">
<MoverServiceTypeChips serviceTypes={mover.serviceTypes} size="sm" />

<div className="flex w-full flex-col gap-16">
Expand Down Expand Up @@ -201,7 +220,7 @@ export default function MoverCard({
</div>
</div>

<div className="hidden min-[744px]:contents">
<div className="pointer-events-none relative z-10 hidden min-[744px]:flex min-[744px]:flex-col min-[744px]:gap-20">
<div className="flex min-h-32 items-center">
<MoverServiceTypeChips serviceTypes={mover.serviceTypes} size="md" />
</div>
Expand Down
44 changes: 44 additions & 0 deletions src/components/mover/MoversErrorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import Button from "@/components/common/Button/Button";
import { Text } from "@/components/common/Text";

export interface MoversErrorPanelProps {
title: string;
description: string;
actionLabel: string;
isRetrying: boolean;
onRetry: () => void;
}

/** 기사님 찾기 / 상세 공통 네트워크·조회 실패 패널 */
export default function MoversErrorPanel({
title,
description,
actionLabel,
isRetrying,
onRetry,
}: MoversErrorPanelProps) {
return (
<div role="alert" className="flex w-full flex-col items-center gap-16 py-40 text-center">
<div className="flex flex-col gap-8">
<Text as="p" variant="lg-semibold" className="text-text-secondary">
{title}
</Text>
<Text as="p" variant="md-regular" className="text-text-muted">
{description}
</Text>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Button
type="button"
variant="outline"
size="cta"
disabled={isRetrying}
onClick={onRetry}
className="min-w-[160px]"
>
{isRetrying ? "다시 시도 중..." : actionLabel}
</Button>
</div>
);
}
46 changes: 3 additions & 43 deletions src/components/mover/MoversList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import { useEffect, useRef, useState } from "react";

import Button from "@/components/common/Button/Button";
import EmptyState from "@/components/common/EmptyState/EmptyState";
import Toast from "@/components/common/Toast/Toast";
import { Text } from "@/components/common/Text";
import MoverCard from "@/components/mover/MoverCard";
import { MoverCardSkeletonList } from "@/components/mover/MoverCardSkeleton";
import MoversErrorPanel from "@/components/mover/MoversErrorPanel";
import { useMovers } from "@/hooks/useMovers";
import { mapMoverListItemToMover } from "@/lib/utils/mapMover";
import type { MoversSearchParamsState } from "@/lib/utils/moversSearchParams";
Expand All @@ -16,14 +15,6 @@ interface MoversListProps {
filters: MoversSearchParamsState;
}

interface MoversListErrorPanelProps {
title: string;
description: string;
actionLabel: string;
isRetrying: boolean;
onRetry: () => void;
}

const MOVERS_EMPTY_DESCRIPTION = (
<>
검색 결과가 없어요.
Expand All @@ -37,37 +28,6 @@ const MOVERS_LIST_SKELETON_COUNT = 3;
/** 다음 페이지 fetch 중 하단 스켈레톤 카드 수 */
const MOVERS_NEXT_PAGE_SKELETON_COUNT = 2;

function MoversListErrorPanel({
title,
description,
actionLabel,
isRetrying,
onRetry,
}: MoversListErrorPanelProps) {
return (
<div className="flex w-full flex-col items-center gap-16 py-40 text-center">
<div className="flex flex-col gap-8">
<Text as="p" variant="lg-semibold" className="text-text-secondary">
{title}
</Text>
<Text as="p" variant="md-regular" className="text-text-muted">
{description}
</Text>
</div>
<Button
type="button"
variant="outline"
size="cta"
disabled={isRetrying}
onClick={onRetry}
className="min-w-[160px]"
>
{isRetrying ? "다시 시도 중..." : actionLabel}
</Button>
</div>
);
}

export function MoversList({ filters }: MoversListProps) {
const query = useMovers(filters);
const sentinelRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -109,7 +69,7 @@ export function MoversList({ filters }: MoversListProps) {

if (query.isError) {
return (
<MoversListErrorPanel
<MoversErrorPanel
title="불러오지 못했어요"
description="기사님 목록을 가져오는 중 문제가 발생했습니다."
actionLabel="다시 시도"
Expand Down Expand Up @@ -153,7 +113,7 @@ export function MoversList({ filters }: MoversListProps) {

{/* 재시도 중에는 스켈레톤만 보여 패널·스켈레톤이 겹치지 않게 함 */}
{isFetchNextPageError && !isFetchingNextPage ? (
<MoversListErrorPanel
<MoversErrorPanel
title="더 불러오지 못했어요"
description="다음 기사님 목록을 가져오는 중 문제가 발생했습니다."
actionLabel="다시 시도"
Expand Down
2 changes: 1 addition & 1 deletion src/components/mover/detail/MoverDetailActions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import Button from "@/components/common/Button/Button";
import { LikeOutlineButton } from "@/components/mover/detail/LikeOutlineButton";
import { Text } from "@/components/common/Text";
import { LikeOutlineButton } from "@/components/mover/detail/LikeOutlineButton";

interface MoverDetailActionsProps {
moverName: string;
Expand Down
22 changes: 22 additions & 0 deletions src/components/mover/detail/MoverDetailNotFoundStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import EmptyState from "@/components/common/EmptyState/EmptyState";

/** 잘못된 URL / 존재하지 않는 기사 공통 안내 UI */
export default function MoverDetailNotFoundStatus() {
return (
<div className="bg-background-default flex w-full flex-1 flex-col items-center justify-center">
<EmptyState
size="sm"
imageSrc="/images/empty/character.png"
description={
<>
기사님을 찾을 수 없습니다.
<br />
주소가 잘못되었거나, 존재하지 않는 기사님입니다.
</>
}
buttonLabel="기사님 찾기로 돌아가기"
href="/movers"
/>
</div>
);
}
Loading