feat(mypage): 내 의뢰 타임라인 + 추적 링크

This commit is contained in:
2026-06-12 05:47:12 +09:00
parent 5e90295d26
commit dbd4bbf21b

View File

@@ -7,6 +7,13 @@ import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
import {
REQUEST_STATUS,
TIMELINE_STEPS,
timelineIndex,
isRequestStatus,
type RequestStatus,
} from '@/lib/request-status';
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
@@ -53,6 +60,12 @@ interface Order {
service: string;
message: string;
status: string;
// 2026-06-12-client-portal 마이그레이션 신규 컬럼 — 미적용 환경에선 undefined
public_token?: string | null;
project_type?: string | null;
budget?: string | null;
timeline?: string | null;
updated_at?: string | null;
}
// 구매 제품 자료 그룹 (/api/packs/list-mine 응답)
@@ -86,6 +99,8 @@ function MyPageContent() {
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
// 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
@@ -195,6 +210,15 @@ function MyPageContent() {
setTelegramLinkState('idle');
};
function toggleRequest(id: string) {
setExpandedRequests((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleDownload(fileId: string) {
setDownloading(fileId);
try {
@@ -503,23 +527,12 @@ function MyPageContent() {
) : (
<div className="space-y-3">
{orders.map((o) => (
<Card key={o.id} compact>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{o.service}
</div>
<StatusBadge status={o.status} />
</div>
<p
className="text-sm line-clamp-2 break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{o.message}
</p>
<div className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(o.created_at).toLocaleDateString('ko-KR')}
</div>
</Card>
<RequestCard
key={o.id}
order={o}
expanded={expandedRequests.has(o.id)}
onToggle={() => toggleRequest(o.id)}
/>
))}
</div>
)}
@@ -792,27 +805,261 @@ function QuickLink({ href, title, sub }: { href: string; title: string; sub: str
);
}
// 상태 뱃지 — pending=surface-alt / in_progress=accent-soft / completed=성공 그린(예외 허용)
// 상태 뱃지 — REQUEST_STATUS 8종.
// completed=성공 그린(예외 허용) / accepted·quoted·in_progress=accent / pending·reviewing=surface-alt
// on_hold·cancelled=faint. 알 수 없는 값(다른 도메인 status 등)은 원문 라벨+기본 스타일 폴백.
const STATUS_BADGE_STYLE: Record<RequestStatus, React.CSSProperties> = {
completed: { background: '#dcfce7', color: '#166534' },
accepted: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
in_progress: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
quoted: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
pending: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
reviewing: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
on_hold: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' },
cancelled: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' },
};
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; style: React.CSSProperties }> = {
completed: { label: '완료', style: { background: '#dcfce7', color: '#166534' } },
in_progress: { label: '진행중', style: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' } },
pending: { label: '대기중', style: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' } },
};
const conf = map[status] ?? {
label: status,
style: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
};
const known = isRequestStatus(status);
const label = known ? REQUEST_STATUS[status].label : status;
const style = known
? STATUS_BADGE_STYLE[status]
: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' };
return (
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap flex-shrink-0"
style={conf.style}
style={style}
>
{conf.label}
{label}
</span>
);
}
// 펼침 토글 셰브론
function Chevron({ open }: { open: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
style={{
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function TimelineCheck() {
return (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
// 컴팩트 가로 미니 타임라인 — track 페이지 타임라인의 축소판.
// 모바일에서는 라벨을 숨기고 도트만 노출(라벨 축약 허용).
function MiniTimeline({ current }: { current: number }) {
return (
<ol className="flex items-start">
{TIMELINE_STEPS.map((step, i) => {
const isDone = i < current;
const isCurrent = i === current;
const isLast = i === TIMELINE_STEPS.length - 1;
const label = REQUEST_STATUS[step].label;
return (
<li key={step} className="flex-1 flex flex-col items-center min-w-0">
<div className="flex items-center w-full">
{/* 좌측 연결선 */}
<span
className="h-0.5 flex-1"
style={{
background: i === 0 ? 'transparent' : i <= current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
}}
aria-hidden
/>
{/* 마커 */}
<span
className="relative z-10 flex items-center justify-center rounded-full shrink-0"
style={{
width: 20,
height: 20,
background: isDone ? 'var(--jsm-accent)' : 'var(--jsm-surface)',
border: isDone || isCurrent ? '2px solid var(--jsm-accent)' : '2px solid var(--jsm-line)',
color: isDone ? '#ffffff' : 'transparent',
boxShadow: isCurrent ? '0 0 0 3px var(--jsm-accent-soft)' : 'none',
}}
aria-hidden
>
{isDone ? (
<TimelineCheck />
) : (
<span
className="rounded-full"
style={{
width: 6,
height: 6,
background: isCurrent ? 'var(--jsm-accent)' : 'var(--jsm-line)',
}}
/>
)}
</span>
{/* 우측 연결선 */}
<span
className="h-0.5 flex-1"
style={{
background: isLast ? 'transparent' : i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
}}
aria-hidden
/>
</div>
{/* 라벨 — 모바일 숨김 */}
<span
className="hidden sm:block mt-1.5 text-[11px] text-center break-keep"
style={{
color: isDone || isCurrent ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
fontWeight: isCurrent ? 700 : 500,
...KOR_BODY,
}}
>
{label}
</span>
</li>
);
})}
</ol>
);
}
// 내 의뢰 카드 — 접힘 기본, 펼치면 타임라인 + 의뢰 정보 + 추적 링크
function RequestCard({
order,
expanded,
onToggle,
}: {
order: Order;
expanded: boolean;
onToggle: () => void;
}) {
const status: RequestStatus = isRequestStatus(order.status) ? order.status : 'pending';
const current = timelineIndex(status);
const info: { label: string; value: string }[] = [];
if (order.project_type) info.push({ label: '프로젝트 유형', value: order.project_type });
if (order.budget) info.push({ label: '예산', value: order.budget });
if (order.timeline) info.push({ label: '희망 일정', value: order.timeline });
return (
<Card compact>
{/* 헤더 — 클릭 토글 */}
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="w-full text-left"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{order.service}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusBadge status={order.status} />
<span style={{ color: 'var(--jsm-ink-faint)' }}>
<Chevron open={expanded} />
</span>
</div>
</div>
<p
className={`text-sm break-keep ${expanded ? '' : 'line-clamp-2'}`}
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{order.message}
</p>
<div className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(order.created_at).toLocaleDateString('ko-KR')}
</div>
</button>
{/* 펼침 영역 */}
{expanded && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: 'var(--jsm-line)' }}>
{status === 'cancelled' ? (
<p className="text-sm break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
.
</p>
) : (
<>
{status === 'on_hold' && (
<div
className="mb-4 rounded-lg px-3 py-2.5"
style={{ background: 'var(--jsm-surface-alt)' }}
>
<p className="text-xs break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
.
</p>
</div>
)}
<div className="px-1 py-1">
<MiniTimeline current={current} />
</div>
</>
)}
{/* 의뢰 정보 */}
{info.length > 0 && (
<dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3">
{info.map((item) => (
<div key={item.label}>
<dt className="text-xs mb-0.5" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{item.label}
</dt>
<dd
className="text-sm font-medium break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
{item.value}
</dd>
</div>
))}
</dl>
)}
{/* 상세 추적 페이지 링크 */}
{order.public_token && (
<Link
href={`/track/${order.public_token}`}
className="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors hover:underline"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<span aria-hidden></span>
</Link>
)}
</div>
)}
</Card>
);
}
function EmptyState({
title,
desc,