feat(mypage): 내 의뢰 타임라인 + 추적 링크
This commit is contained in:
@@ -7,6 +7,13 @@ import { createClient } from '@/lib/supabase/client';
|
|||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
||||||
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
||||||
|
import {
|
||||||
|
REQUEST_STATUS,
|
||||||
|
TIMELINE_STEPS,
|
||||||
|
timelineIndex,
|
||||||
|
isRequestStatus,
|
||||||
|
type RequestStatus,
|
||||||
|
} from '@/lib/request-status';
|
||||||
|
|
||||||
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
|
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
|
||||||
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
|
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
|
||||||
@@ -53,6 +60,12 @@ interface Order {
|
|||||||
service: string;
|
service: string;
|
||||||
message: string;
|
message: string;
|
||||||
status: 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 응답)
|
// 구매 제품 자료 그룹 (/api/packs/list-mine 응답)
|
||||||
@@ -86,6 +99,8 @@ function MyPageContent() {
|
|||||||
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
||||||
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
|
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
|
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
|
||||||
|
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 텔레그램 연동 상태
|
// 텔레그램 연동 상태
|
||||||
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
||||||
@@ -195,6 +210,15 @@ function MyPageContent() {
|
|||||||
setTelegramLinkState('idle');
|
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) {
|
async function handleDownload(fileId: string) {
|
||||||
setDownloading(fileId);
|
setDownloading(fileId);
|
||||||
try {
|
try {
|
||||||
@@ -503,23 +527,12 @@ function MyPageContent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{orders.map((o) => (
|
{orders.map((o) => (
|
||||||
<Card key={o.id} compact>
|
<RequestCard
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
key={o.id}
|
||||||
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
order={o}
|
||||||
{o.service}
|
expanded={expandedRequests.has(o.id)}
|
||||||
</div>
|
onToggle={() => toggleRequest(o.id)}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { label: string; style: React.CSSProperties }> = {
|
const known = isRequestStatus(status);
|
||||||
completed: { label: '완료', style: { background: '#dcfce7', color: '#166534' } },
|
const label = known ? REQUEST_STATUS[status].label : status;
|
||||||
in_progress: { label: '진행중', style: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' } },
|
const style = known
|
||||||
pending: { label: '대기중', style: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' } },
|
? STATUS_BADGE_STYLE[status]
|
||||||
};
|
: { 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)' },
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap flex-shrink-0"
|
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>
|
</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({
|
function EmptyState({
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
|||||||
Reference in New Issue
Block a user