Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 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.
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