feat: 프론트엔드 더미 데이터 UI 구현

- Docker Compose, Dockerfile, Vite 프로젝트 초기 세팅
- 메인 페이지: 택배 목록, 필터 탭, 운송장 등록 폼
- 상세 페이지: 배송 타임라인, 별칭 수정, 삭제
- 택배사 로고/컬러 배지, 커스텀 드롭다운
- framer-motion 애니메이션 적용
- PC/모바일 반응형 대응
- 계획서에 carriers 테이블, RustFS 로고 저장 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-24 14:03:49 +09:00
parent fed454b8c9
commit cf515aa1ee
28 changed files with 3844 additions and 18 deletions

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Build
dist/
build/

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
traeon-frontend:
build: ./frontend
container_name: traeon-frontend
labels:
- "com.centurylinklabs.watchtower.enable=false"
volumes:
- ./frontend:/app
networks:
- app
restart: unless-stopped
networks:
app:
external: true

View file

@ -85,6 +85,15 @@ CREATE TABLE parcels (
UNIQUE KEY uq_carrier_tracking (carrier_id, tracking_number)
);
CREATE TABLE carriers (
id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: kr.cjlogistics)
name VARCHAR(100) NOT NULL, -- 택배사 이름
short_name VARCHAR(20) NOT NULL, -- 약칭 (로고 없을 때 이니셜용)
color VARCHAR(7) NOT NULL, -- 브랜드 컬러 (#hex)
logo_url VARCHAR(500), -- RustFS에 저장된 로고 이미지 URL
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tracking_events (
id INT AUTO_INCREMENT PRIMARY KEY,
parcel_id INT NOT NULL,
@ -107,7 +116,7 @@ CREATE TABLE tracking_events (
| `PUT` | `/api/parcels/:id` | 별칭 수정 |
| `DELETE` | `/api/parcels/:id` | 운송장 삭제 |
| `POST` | `/api/parcels/:id/refresh` | 수동 새로고침 |
| `GET` | `/api/carriers` | 지원 택배사 목록 |
| `GET` | `/api/carriers` | 지원 택배사 목록 (로고 URL 포함) |
## 구현 단계
@ -121,24 +130,10 @@ CREATE TABLE tracking_events (
- [ ] `.env` 파일 작성 (DB 접속정보 등)
- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
### 2단계: Backend 구현
- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
- [ ] DB 플러그인 + 테이블 자동 생성
- [ ] delivery-tracker GraphQL 클라이언트 플러그인
- [ ] API 라우트 구현 (위 API 설계 참고)
- [ ] 자동갱신 cron 서비스 (node-cron)
- 배송 중인 택배: 30분 간격 자동 조회
- 배송 완료된 택배: 조회 중단
- 상태 변경 감지 시 알림 트리거
- [ ] 알림 서비스
- Discord Webhook 또는 Telegram Bot API
- 설정 가능한 Webhook URL (.env)
### 3단계: Frontend 구현
### 2단계: Frontend 구현 (더미 데이터)
- [ ] Vite + React + Tailwind 초기 세팅
- [ ] Vite 프록시 설정 (`/api/` → backend)
- [ ] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터)
- [ ] 메인 페이지
- 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭)
- 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료)
@ -147,8 +142,26 @@ CREATE TABLE tracking_events (
- 배송 추적 타임라인 (세로 타임라인 UI)
- 수동 새로고침 버튼
- 삭제/수정 기능
- [ ] React Query로 데이터 페칭 + 자동 리페칭
- [ ] Zustand로 UI 상태 관리 (필터, 정렬)
- [ ] 디자인 확인 후 피드백 반영
### 3단계: Backend 구현 + 연동
- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
- [ ] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers)
- [ ] 택배사 로고 이미지를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장
- [ ] delivery-tracker GraphQL 클라이언트 플러그인
- [ ] API 라우트 구현 (위 API 설계 참고)
- [ ] Frontend를 더미 데이터에서 실제 API로 전환
- Vite 프록시 설정 (`/api/` → backend)
- React Query로 데이터 페칭 + 자동 리페칭
- [ ] 자동갱신 cron 서비스 (node-cron)
- 배송 중인 택배: 30분 간격 자동 조회
- 배송 완료된 택배: 조회 중단
- 상태 변경 감지 시 알림 트리거
- [ ] 알림 서비스
- Discord Webhook 또는 Telegram Bot API
- 설정 가능한 Webhook URL (.env)
### 4단계: Docker 배포

4
frontend/Dockerfile Normal file
View file

@ -0,0 +1,4 @@
# 개발 모드
FROM node:20-alpine
WORKDIR /app
CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"]

22
frontend/index.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Traeon - 택배 배송조회</title>
<link
rel="stylesheet"
as="style"
crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2752
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "traeon-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dayjs": "^1.11.19",
"framer-motion": "^11.0.8",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"zustand": "^5.0.9"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"vite": "^5.4.1"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

26
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,26 @@
import { Routes, Route } from "react-router-dom";
import MainPage from "@/pages/MainPage";
import DetailPage from "@/pages/DetailPage";
function App() {
return (
<div className="min-h-screen bg-secondary">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-2xl lg:max-w-4xl mx-auto px-4 py-3 lg:py-4 flex items-center gap-2">
<a href="/" className="flex items-center gap-2 no-underline">
<span className="text-xl lg:text-2xl font-bold text-primary">Traeon</span>
<span className="text-sm lg:text-base text-gray-400">택배 배송조회</span>
</a>
</div>
</header>
<main className="max-w-2xl lg:max-w-4xl mx-auto px-4 py-6 lg:py-8">
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/parcel/:id" element={<DetailPage />} />
</Routes>
</main>
</div>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,27 @@
import { CARRIER_MAP } from "@/data/dummy";
function CarrierBadge({ carrierId }) {
const carrier = CARRIER_MAP[carrierId];
if (!carrier) return null;
if (carrier.logo) {
return (
<img
src={carrier.logo}
alt={carrier.name}
className="w-7 h-7 lg:w-8 lg:h-8 rounded-full object-contain shrink-0"
/>
);
}
return (
<span
className="inline-flex items-center justify-center w-7 h-7 lg:w-8 lg:h-8 rounded-full text-white text-[10px] lg:text-[11px] font-bold shrink-0"
style={{ backgroundColor: carrier.color }}
>
{carrier.short.length > 2 ? carrier.short.slice(0, 2) : carrier.short}
</span>
);
}
export default CarrierBadge;

View file

@ -0,0 +1,86 @@
import { useState, useRef, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { CARRIERS } from "@/data/dummy";
import CarrierBadge from "./CarrierBadge";
function CarrierSelect({ value, onChange }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const selected = CARRIERS.find((c) => c.id === value);
useEffect(() => {
function handleClickOutside(e) {
if (ref.current && !ref.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = (carrierId) => {
onChange(carrierId);
setOpen(false);
};
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={`w-full flex items-center gap-2.5 border rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left transition-colors ${
open
? "border-primary ring-2 ring-primary/30"
: "border-gray-300 hover:border-gray-400"
}`}
>
{selected ? (
<>
<CarrierBadge carrierId={selected.id} />
<span className="text-gray-900">{selected.name}</span>
</>
) : (
<span className="text-gray-400">택배사를 선택하세요</span>
)}
<ChevronDown
size={16}
className={`ml-auto text-gray-400 transition-transform duration-200 ${
open ? "rotate-180" : ""
}`}
/>
</button>
<AnimatePresence>
{open && (
<motion.ul
initial={{ opacity: 0, y: -8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
transition={{ duration: 0.15 }}
className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto py-1 origin-top"
>
{CARRIERS.map((carrier) => (
<li key={carrier.id}>
<button
type="button"
onClick={() => handleSelect(carrier.id)}
className={`w-full flex items-center gap-2.5 px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base text-left hover:bg-gray-50 transition-colors ${
value === carrier.id
? "bg-primary/5 text-primary"
: "text-gray-700"
}`}
>
<CarrierBadge carrierId={carrier.id} />
<span>{carrier.name}</span>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
);
}
export default CarrierSelect;

View file

@ -0,0 +1,34 @@
function FilterTabs({
filter,
onFilterChange,
activeCount,
deliveredCount,
totalCount,
}) {
const tabs = [
{ key: "all", label: "전체", count: totalCount },
{ key: "active", label: "배송중", count: activeCount },
{ key: "delivered", label: "완료", count: deliveredCount },
];
return (
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => onFilterChange(tab.key)}
className={`px-3 lg:px-4 py-1.5 lg:py-2 rounded-md text-xs lg:text-sm font-medium transition-colors ${
filter === tab.key
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{tab.label}
<span className="ml-1 text-[11px] lg:text-xs opacity-60">{tab.count}</span>
</button>
))}
</div>
);
}
export default FilterTabs;

View file

@ -0,0 +1,40 @@
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import StatusBadge from "./StatusBadge";
import CarrierBadge from "./CarrierBadge";
function ParcelCard({ parcel }) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(`/parcel/${parcel.id}`)}
className="bg-white rounded-xl shadow-sm cursor-pointer hover:shadow-md transition-shadow flex items-center gap-3 lg:gap-4 p-3.5 lg:p-4"
>
<CarrierBadge carrierId={parcel.carrierId} />
<div className="flex-1 min-w-0 space-y-1.5 lg:space-y-2">
<div className="flex items-center gap-1.5 text-xs lg:text-sm text-gray-400">
<span>{parcel.carrierName}</span>
<span>|</span>
<span className="tracking-letter-spacing">
{parcel.trackingNumber}
</span>
</div>
<p className="font-semibold text-sm lg:text-base text-gray-900 truncate">
{parcel.label || parcel.trackingNumber}
</p>
<div className="flex items-center justify-between">
<span className="text-xs lg:text-sm text-gray-400">
{dayjs(parcel.createdAt).format("YYYY.MM.DD")}
</span>
<StatusBadge status={parcel.status} />
</div>
</div>
</div>
);
}
export default ParcelCard;

View file

@ -0,0 +1,88 @@
import { useState } from "react";
import { CARRIERS } from "@/data/dummy";
import useParcelStore from "@/stores/useParcelStore";
import CarrierSelect from "./CarrierSelect";
function ParcelForm({ onClose }) {
const addParcel = useParcelStore((s) => s.addParcel);
const [carrierId, setCarrierId] = useState("");
const [trackingNumber, setTrackingNumber] = useState("");
const [label, setLabel] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (!carrierId || !trackingNumber) return;
const carrier = CARRIERS.find((c) => c.id === carrierId);
addParcel({
carrierId,
carrierName: carrier.name,
trackingNumber: trackingNumber.replace(/\s/g, ""),
label: label || undefined,
});
setCarrierId("");
setTrackingNumber("");
setLabel("");
onClose();
};
return (
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl p-5 lg:p-6 shadow-sm space-y-4 lg:space-y-5"
>
<h3 className="font-semibold text-sm lg:text-base text-gray-700">운송장 등록</h3>
<div className="space-y-3 lg:space-y-4">
<div>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">택배사</label>
<CarrierSelect value={carrierId} onChange={setCarrierId} />
</div>
<div>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">운송장 번호</label>
<input
type="text"
value={trackingNumber}
onChange={(e) => setTrackingNumber(e.target.value)}
placeholder="운송장 번호를 입력하세요"
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
required
/>
</div>
<div>
<label className="block text-xs lg:text-sm text-gray-500 mb-1">
별칭 (선택)
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="예: 쿠팡 - 키보드"
className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 lg:px-5 py-2 lg:py-2.5 text-sm lg:text-base text-gray-500 hover:text-gray-700"
>
취소
</button>
<button
type="submit"
className="px-4 lg:px-5 py-2 lg:py-2.5 bg-primary text-white rounded-lg text-sm lg:text-base font-medium hover:bg-primary-dark transition-colors"
>
등록
</button>
</div>
</form>
);
}
export default ParcelForm;

View file

@ -0,0 +1,39 @@
import { AnimatePresence, motion } from "framer-motion";
import ParcelCard from "./ParcelCard";
import { Package } from "lucide-react";
function ParcelList({ parcels }) {
if (parcels.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-16 text-gray-400"
>
<Package size={48} className="mx-auto mb-3 opacity-50" />
<p className="text-sm lg:text-base">등록된 택배가 없습니다</p>
</motion.div>
);
}
return (
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{parcels.map((parcel, index) => (
<motion.div
key={parcel.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -30 }}
transition={{ duration: 0.25, delay: index * 0.05 }}
>
<ParcelCard parcel={parcel} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export default ParcelList;

View file

@ -0,0 +1,15 @@
import { STATUS_MAP } from "@/data/dummy";
function StatusBadge({ status }) {
const info = STATUS_MAP[status] || STATUS_MAP.PENDING;
return (
<span
className={`inline-flex items-center px-2 lg:px-2.5 py-0.5 lg:py-1 rounded-full text-[11px] lg:text-xs font-medium ${info.bg} ${info.color}`}
>
{info.label}
</span>
);
}
export default StatusBadge;

View file

@ -0,0 +1,78 @@
import { motion } from "framer-motion";
import dayjs from "dayjs";
import { STATUS_MAP } from "@/data/dummy";
function TrackingTimeline({ events }) {
const reversed = [...events].reverse();
return (
<div className="relative">
{reversed.map((event, index) => {
const isFirst = index === 0;
const isLast = index === reversed.length - 1;
const statusInfo = STATUS_MAP[event.status] || STATUS_MAP.PENDING;
return (
<motion.div
key={index}
initial={{ opacity: 0, x: -15 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: index * 0.08 }}
className="flex gap-3 lg:gap-4 relative"
>
<div className="flex flex-col items-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
duration: 0.3,
delay: index * 0.08 + 0.1,
type: "spring",
stiffness: 300,
}}
className={`w-3 h-3 lg:w-3.5 lg:h-3.5 rounded-full border-2 shrink-0 z-10 ${
isFirst
? "border-primary bg-primary"
: "border-gray-300 bg-white"
}`}
/>
{!isLast && (
<motion.div
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.3, delay: index * 0.08 + 0.15 }}
className="w-px flex-1 bg-gray-200 min-h-[40px] lg:min-h-[48px] origin-top"
/>
)}
</div>
<div className={`${isLast ? "pb-0" : "pb-5 lg:pb-6"} ${isFirst ? "" : "opacity-70"}`}>
<div className="flex items-center gap-2 mb-0.5 lg:mb-1">
<span
className={`text-xs lg:text-sm font-medium ${
isFirst ? statusInfo.color : "text-gray-500"
}`}
>
{event.statusName}
</span>
<span className="text-[11px] lg:text-xs text-gray-400">
{dayjs(event.time).format("MM.DD HH:mm")}
</span>
</div>
<p className="text-sm lg:text-base text-gray-700">
{event.description}
</p>
{event.location && (
<p className="text-xs lg:text-sm text-gray-400 mt-0.5">
{event.location}
</p>
)}
</div>
</motion.div>
);
})}
</div>
);
}
export default TrackingTimeline;

198
frontend/src/data/dummy.js Normal file
View file

@ -0,0 +1,198 @@
import dayjs from "dayjs";
// 로고 이미지 import
import hanjinLogo from "@/assets/carriers/hanjin.png";
import lotteLogo from "@/assets/carriers/lotte.png";
import logenLogo from "@/assets/carriers/logen.png";
export const CARRIERS = [
{ id: "kr.cjlogistics", name: "CJ대한통운", short: "CJ", color: "#E4002B" },
{ id: "kr.hanjin", name: "한진택배", short: "한진", color: "#1B3A6B", logo: hanjinLogo },
{ id: "kr.lotte", name: "롯데택배", short: "롯데", color: "#ED1C24", logo: lotteLogo },
{ id: "kr.epost", name: "우체국택배", short: "우체국", color: "#003DA5" },
{ id: "kr.logen", name: "로젠택배", short: "로젠", color: "#F5A623", logo: logenLogo },
{ id: "kr.kdexp", name: "경동택배", short: "경동", color: "#0066B3" },
{ id: "kr.cupost", name: "CU편의점택배", short: "CU", color: "#652D90" },
{ id: "kr.daesin", name: "대신택배", short: "대신", color: "#00A651" },
];
export const CARRIER_MAP = Object.fromEntries(
CARRIERS.map((c) => [c.id, c])
);
export const DUMMY_PARCELS = [
{
id: 1,
carrierId: "kr.cjlogistics",
carrierName: "CJ대한통운",
trackingNumber: "123456789012",
label: "쿠팡 - 키보드",
status: "IN_TRANSIT",
lastDetail: "경기 광주 Sub 터미널",
lastCheckedAt: dayjs().subtract(15, "minute").toISOString(),
createdAt: dayjs().subtract(2, "day").toISOString(),
events: [
{
status: "ITEM_RECEIVED",
statusName: "상품인수",
description: "보내시는 고객님으로부터 상품을 인수했습니다",
location: "서울 강남 직영",
time: dayjs().subtract(2, "day").hour(14).minute(30).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "상품이 이동중입니다",
location: "옥천 HUB",
time: dayjs().subtract(1, "day").hour(3).minute(15).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "배달 준비중입니다",
location: "경기 광주 Sub 터미널",
time: dayjs().subtract(0, "day").hour(6).minute(40).toISOString(),
},
],
},
{
id: 2,
carrierId: "kr.hanjin",
carrierName: "한진택배",
trackingNumber: "987654321098",
label: "네이버 - 모니터 거치대",
status: "OUT_FOR_DELIVERY",
lastDetail: "성남시 분당 배달 출발",
lastCheckedAt: dayjs().subtract(5, "minute").toISOString(),
createdAt: dayjs().subtract(1, "day").toISOString(),
events: [
{
status: "ITEM_RECEIVED",
statusName: "상품인수",
description: "상품을 인수했습니다",
location: "부산 사상",
time: dayjs().subtract(1, "day").hour(10).minute(0).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "상품이 이동중입니다",
location: "대전 HUB",
time: dayjs().subtract(1, "day").hour(22).minute(30).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "배송지 인근에 도착했습니다",
location: "성남시 분당",
time: dayjs().hour(7).minute(20).toISOString(),
},
{
status: "OUT_FOR_DELIVERY",
statusName: "배달출발",
description: "배달 출발했습니다",
location: "성남시 분당",
time: dayjs().hour(9).minute(10).toISOString(),
},
],
},
{
id: 3,
carrierId: "kr.lotte",
carrierName: "롯데택배",
trackingNumber: "112233445566",
label: "11번가 - 충전기",
status: "DELIVERED",
lastDetail: "배달 완료",
lastCheckedAt: dayjs().subtract(1, "day").toISOString(),
deliveredAt: dayjs().subtract(1, "day").hour(14).minute(22).toISOString(),
createdAt: dayjs().subtract(3, "day").toISOString(),
events: [
{
status: "ITEM_RECEIVED",
statusName: "상품인수",
description: "상품을 인수했습니다",
location: "서울 영등포",
time: dayjs().subtract(3, "day").hour(16).minute(0).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "상품이 이동중입니다",
location: "대전 HUB",
time: dayjs().subtract(2, "day").hour(2).minute(0).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "배송지 인근 도착",
location: "성남시 분당",
time: dayjs().subtract(1, "day").hour(7).minute(0).toISOString(),
},
{
status: "OUT_FOR_DELIVERY",
statusName: "배달출발",
description: "배달 출발했습니다",
location: "성남시 분당",
time: dayjs().subtract(1, "day").hour(10).minute(30).toISOString(),
},
{
status: "DELIVERED",
statusName: "배달완료",
description: "배달 완료되었습니다 (부재시 문앞 보관)",
location: "성남시 분당",
time: dayjs().subtract(1, "day").hour(14).minute(22).toISOString(),
},
],
},
{
id: 4,
carrierId: "kr.epost",
carrierName: "우체국택배",
trackingNumber: "665544332211",
label: "알리 - USB 허브",
status: "DELIVERED",
lastDetail: "배달 완료",
lastCheckedAt: dayjs().subtract(5, "day").toISOString(),
deliveredAt: dayjs().subtract(5, "day").hour(11).minute(5).toISOString(),
createdAt: dayjs().subtract(8, "day").toISOString(),
events: [
{
status: "ITEM_RECEIVED",
statusName: "접수",
description: "우체국에 접수되었습니다",
location: "인천국제우체국",
time: dayjs().subtract(8, "day").hour(9).minute(0).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "발송되었습니다",
location: "인천국제우체국",
time: dayjs().subtract(7, "day").hour(18).minute(0).toISOString(),
},
{
status: "IN_TRANSIT",
statusName: "이동중",
description: "도착했습니다",
location: "분당우체국",
time: dayjs().subtract(5, "day").hour(8).minute(30).toISOString(),
},
{
status: "DELIVERED",
statusName: "배달완료",
description: "배달 완료",
location: "분당우체국",
time: dayjs().subtract(5, "day").hour(11).minute(5).toISOString(),
},
],
},
];
export const STATUS_MAP = {
PENDING: { label: "대기중", color: "text-gray-500", bg: "bg-gray-100" },
ITEM_RECEIVED: { label: "상품인수", color: "text-blue-600", bg: "bg-blue-50" },
IN_TRANSIT: { label: "이동중", color: "text-yellow-600", bg: "bg-yellow-50" },
OUT_FOR_DELIVERY: { label: "배달출발", color: "text-orange-600", bg: "bg-orange-50" },
DELIVERED: { label: "배달완료", color: "text-green-600", bg: "bg-green-50" },
};

31
frontend/src/index.css Normal file
View file

@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: "Pretendard", "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
background-color: #f8fafc;
color: #1a1a1a;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.tracking-letter-spacing {
letter-spacing: 0.05em;
}

13
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View file

@ -0,0 +1,138 @@
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Trash2, RefreshCw, Pencil, Check, X } from "lucide-react";
import { useState } from "react";
import dayjs from "dayjs";
import useParcelStore from "@/stores/useParcelStore";
import TrackingTimeline from "@/components/TrackingTimeline";
import StatusBadge from "@/components/StatusBadge";
function DetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const parcel = useParcelStore((s) => s.getParcel(id));
const removeParcel = useParcelStore((s) => s.removeParcel);
const updateLabel = useParcelStore((s) => s.updateLabel);
const [editing, setEditing] = useState(false);
const [editLabel, setEditLabel] = useState("");
if (!parcel) {
return (
<div className="text-center py-12 text-gray-500">
<p className="lg:text-lg">택배를 찾을 없습니다</p>
<button
onClick={() => navigate("/")}
className="mt-4 text-primary hover:underline lg:text-lg"
>
돌아가기
</button>
</div>
);
}
const handleDelete = () => {
if (window.confirm("이 택배를 삭제할까요?")) {
removeParcel(parcel.id);
navigate("/");
}
};
const handleEditStart = () => {
setEditLabel(parcel.label || "");
setEditing(true);
};
const handleEditSave = () => {
updateLabel(parcel.id, editLabel);
setEditing(false);
};
return (
<div className="space-y-4 lg:space-y-5">
<div className="flex items-center justify-between">
<button
onClick={() => navigate("/")}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm lg:text-base"
>
<ArrowLeft size={16} className="lg:w-5 lg:h-5" />
목록으로
</button>
<button className="flex items-center gap-1 px-3 lg:px-4 py-1.5 lg:py-2 text-sm lg:text-base text-primary hover:bg-blue-50 rounded-lg transition-colors">
<RefreshCw size={14} className="lg:w-4 lg:h-4" />
새로고침
</button>
</div>
<div className="bg-white rounded-xl p-5 lg:p-6 shadow-sm space-y-4 lg:space-y-5">
<div className="flex items-start justify-between">
<div className="space-y-1 lg:space-y-2 flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-3 h-[28px] lg:h-[32px]">
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="h-full border-0 border-b-2 border-primary bg-transparent px-0 text-lg lg:text-xl font-semibold text-gray-900 flex-1 focus:outline-none focus:border-primary-dark placeholder:text-gray-300"
placeholder="별칭을 입력하세요"
autoFocus
/>
<button
onClick={handleEditSave}
className="text-green-600 hover:text-green-700 shrink-0 p-1"
>
<Check size={16} className="lg:w-[18px] lg:h-[18px]" />
</button>
<button
onClick={() => setEditing(false)}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<X size={16} className="lg:w-[18px] lg:h-[18px]" />
</button>
</div>
) : (
<div className="flex items-center gap-3 h-[28px] lg:h-[32px]">
<h2 className="font-semibold text-lg lg:text-xl truncate leading-none">
{parcel.label || parcel.trackingNumber}
</h2>
<button
onClick={handleEditStart}
className="text-gray-400 hover:text-gray-600 shrink-0 p-1"
>
<Pencil size={14} className="lg:w-4 lg:h-4" />
</button>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-500 shrink-0 p-1"
>
<Trash2 size={14} className="lg:w-4 lg:h-4" />
</button>
</div>
)}
<div className="flex items-center gap-2 text-sm lg:text-base text-gray-500">
<span>{parcel.carrierName}</span>
<span className="text-gray-300">|</span>
<span className="tracking-letter-spacing">{parcel.trackingNumber}</span>
</div>
</div>
<StatusBadge status={parcel.status} />
</div>
<div className="pt-3 lg:pt-4 border-t border-gray-100">
<h3 className="font-semibold text-sm lg:text-base text-gray-700 mb-3 lg:mb-4">배송 추적</h3>
{parcel.events.length > 0 ? (
<TrackingTimeline events={parcel.events} />
) : (
<p className="text-sm lg:text-base text-gray-400 text-center py-4">
배송 정보가 아직 없습니다
</p>
)}
</div>
</div>
<div className="text-xs lg:text-sm text-gray-400 text-center">
마지막 조회: {dayjs(parcel.lastCheckedAt).format("YYYY.MM.DD HH:mm")}
</div>
</div>
);
}
export default DetailPage;

View file

@ -0,0 +1,57 @@
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import ParcelForm from "@/components/ParcelForm";
import ParcelList from "@/components/ParcelList";
import FilterTabs from "@/components/FilterTabs";
import useParcelStore from "@/stores/useParcelStore";
function MainPage() {
const [showForm, setShowForm] = useState(false);
const filter = useParcelStore((s) => s.filter);
const setFilter = useParcelStore((s) => s.setFilter);
const parcels = useParcelStore((s) => s.filteredParcels());
const allParcels = useParcelStore((s) => s.parcels);
const activeCount = allParcels.filter((p) => p.status !== "DELIVERED").length;
const deliveredCount = allParcels.filter(
(p) => p.status === "DELIVERED"
).length;
return (
<div className="space-y-4 lg:space-y-5">
<div className="flex items-center justify-between">
<FilterTabs
filter={filter}
onFilterChange={setFilter}
activeCount={activeCount}
deliveredCount={deliveredCount}
totalCount={allParcels.length}
/>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 lg:px-5 py-2 lg:py-2.5 bg-primary text-white rounded-lg text-sm lg:text-base font-medium hover:bg-primary-dark transition-colors"
>
{showForm ? "취소" : "+ 등록"}
</button>
</div>
<AnimatePresence>
{showForm && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<ParcelForm onClose={() => setShowForm(false)} />
</motion.div>
)}
</AnimatePresence>
<ParcelList parcels={parcels} />
</div>
);
}
export default MainPage;

View file

@ -0,0 +1,50 @@
import { create } from "zustand";
import { DUMMY_PARCELS } from "@/data/dummy";
const useParcelStore = create((set, get) => ({
parcels: DUMMY_PARCELS,
filter: "all", // all, active, delivered
setFilter: (filter) => set({ filter }),
filteredParcels: () => {
const { parcels, filter } = get();
if (filter === "active") {
return parcels.filter((p) => p.status !== "DELIVERED");
}
if (filter === "delivered") {
return parcels.filter((p) => p.status === "DELIVERED");
}
return parcels;
},
addParcel: (parcel) =>
set((state) => ({
parcels: [
{
id: Date.now(),
...parcel,
status: "PENDING",
lastDetail: "조회 대기중",
lastCheckedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
events: [],
},
...state.parcels,
],
})),
removeParcel: (id) =>
set((state) => ({
parcels: state.parcels.filter((p) => p.id !== id),
})),
updateLabel: (id, label) =>
set((state) => ({
parcels: state.parcels.map((p) => (p.id === id ? { ...p, label } : p)),
})),
getParcel: (id) => get().parcels.find((p) => p.id === Number(id)),
}));
export default useParcelStore;

View file

@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#3B82F6",
dark: "#2563EB",
light: "#60A5FA",
},
secondary: "#F8FAFC",
},
fontFamily: {
sans: ["Pretendard", "Inter", "sans-serif"],
},
},
},
plugins: [],
};

26
frontend/vite.config.js Normal file
View file

@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: true,
port: 80,
allowedHosts: true,
hmr: {
overlay: false,
},
proxy: {
"/api": {
target: "http://traeon-backend:80",
changeOrigin: true,
},
},
},
});