/* 아리 — 여행·출장 도우미 페이지
좌측 레일로 전환: 다가오는 출장 · 새 여행 계획(AI) · 찜·가격추적 */
const { useState, useEffect, useRef } = React;
const I = window.AriShell.Icon;
const Topbar = window.AriShell.Topbar;
const SubRail = window.AriShell.SubRail;
const T = window.AriTrip;
/* 페이지 전용 아이콘 (각 JSX는 독립 스코프) */
const TP_P = {
train: "M8 3h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4z|M4 11h16|M9 15h.01|M15 15h.01|M8 19l-2 3|M16 19l2 3",
bed: "M2 4v16|M2 8h18a2 2 0 0 1 2 2v10|M2 17h20|M6 8v9",
food: "M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2|M7 2v20|M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3zm0 0v7",
pin: "M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z|M12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
rain: "M16 13v6M8 13v6M12 15v6|M20 16.6A5 5 0 0 0 18 7h-1.3A8 8 0 1 0 4 14.9",
plane: "M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z",
star: "M12 2.5l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.6 9.8l6.5-.9z",
down: "M12 5v14M19 12l-7 7-7-7",
up2: "M12 19V5M5 12l7-7 7 7",
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
};
function TI({ d }) {
return (
{TP_P[d].split("|").map((s, i) => )}
);
}
const TP_KIND = {
move: { local: true, d: "train" },
meet: { local: false, d: "users" },
talk: { local: false, d: "mic" },
meal: { local: true, d: "food" },
};
function KindIcon({ kind }) {
const k = TP_KIND[kind] || TP_KIND.meet;
return {k.local ? : } ;
}
function Leg({ leg }) {
return (
{leg.mode}
{leg.from} {leg.ft}
{leg.to} {leg.tt}
{leg.seat} {leg.note}
);
}
function Spark({ vals, color }) {
const w = 100, h = 48;
const min = Math.min(...vals), max = Math.max(...vals);
const span = max - min || 1;
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * w;
const y = h - 6 - ((v - min) / span) * (h - 12);
return x.toFixed(1) + "," + y.toFixed(1);
}).join(" ");
const last = pts.split(" ").pop().split(",");
return (
);
}
/* ============ 다가오는 출장 ============ */
const TKEY = "ariTp.check";
function loadCheck() {
try { return JSON.parse(localStorage.getItem(TKEY)) || {}; } catch (e) { return {}; }
}
function UpcomingView({ day, setDay, done, flip, doneN, allN }) {
const curDay = T.days.find((d) => d.id === day);
const pct = Math.round((T.expense.planned / T.expense.budget) * 100);
return (
{/* 히어로 */}
{T.trip.dday} · 출장
{T.trip.title}
{T.trip.dates}
{T.trip.brief}
{T.trip.purpose}
{T.trip.weather.map((wx) => (
{wx.icon === "sun" ? : }{wx.d} {wx.t}
))}
{T.stay.name}
{T.stay.desc}
{T.stay.conf}
{/* 아리가 미리 해둔 일 */}
{T.prep.map((p, i) => (
{p.text}
))}
예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.
{/* 체크리스트 */}
{T.check.map((g) => (
{g.name}
{g.items.map((it) => (
flip(it.id)}>
{it.auto &&
{it.auto}}
))}
))}
{/* 일정표 */}
{T.days.map((d) => (
setDay(d.id)}>{d.tab}
))}
{curDay.items.map((ev, i) => (
))}
{/* 경비 */}
{(T.expense.planned / 10000).toFixed(1)}만 / 예산 {(T.expense.budget / 10000).toFixed(0)}만원
{pct}% 사용 예정 {((T.expense.budget - T.expense.planned) / 10000).toFixed(1)}만 여유
{T.expense.rows.map((r) => (
{r.name}
{r.amt}
{r.state === "paid" ? "결제 완료" : r.state === "hold" ? "결제 예정" : "예상"}
))}
{T.expense.note}
);
}
/* ============ 새 여행 계획 (AI 플래너) ============ */
function PlanView({ onSave }) {
const P = T.planner;
const [text, setText] = useState("");
const [phase, setPhase] = useState("input"); // input | research | result
const [step, setStep] = useState(0);
const [result, setResult] = useState(null);
const [day, setDay] = useState(0);
const [saved, setSaved] = useState(false);
const timers = useRef([]);
const pickResult = (q) => {
const idx = P.examples.findIndex((e) => e === q.trim());
const r = P.results[idx >= 0 ? idx : 0];
if (idx < 0 && q.trim()) return { ...r, title: q.trim().slice(0, 18), custom: true };
return r;
};
const run = () => {
if (!text.trim()) return;
const r = pickResult(text);
setResult(r); setDay(0); setSaved(false);
setPhase("research"); setStep(0);
timers.current.forEach(clearTimeout);
timers.current = P.research.map((_, i) =>
setTimeout(() => setStep(i + 1), 650 * (i + 1))
);
timers.current.push(setTimeout(() => setPhase("result"), 650 * (P.research.length + 1)));
};
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const reset = () => { setPhase("input"); setText(""); setResult(null); };
if (phase === "input") {
return (
새 여행 계획
어디로, 언제, 누구와 가는지 한 문장 이면 돼요. 항공·숙소·일정·예산은 아리가 다 조사해서 가져올게요.
setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") run(); }}
placeholder="예: 다음 달 말 제주 가족여행 3박 4일, 예산 150만원"
aria-label="여행 설명"
autoFocus
/>
{P.examples.map((e) => (
setText(e)}> {e}
))}
{P.research.map((r) => (
))}
);
}
if (phase === "research") {
return (
조사 중…
‘{result && result.title}’ 계획을 짜고 있어요. 잠시만요.
{P.research.map((r, i) => {
const st = i < step ? "done" : i === step ? "now" : "wait";
return (
{st === "done" ? : }
{r.label} {r.detail}
{st === "now" &&
}
{st === "done" &&
완료 }
);
})}
);
}
/* result */
const r = result;
const cur = r.days[day];
const bpct = Math.round((parseInt(r.budget.total.replace(/,/g, "")) / parseInt(r.budget.cap.replace(/,/g, ""))) * 100);
return (
아리의 추천 계획
다시 짜기
{r.title}
{r.meta}
{r.weather}
{ setSaved(true); onSave && onSave(r); }}>
{saved ? "내 여행에 추가됨" : "이 계획으로 만들기"}
계획 공유
{r.sources.join(" · ")} 를 종합했어요
{/* 이동 + 숙소 */}
이동
{r.transport.map((o) => (
{o.mode}
{o.name}{o.pick && 추천 }
{o.desc}
{o.price} {o.note && {o.note} }
))}
숙소
{r.stay.map((o) => (
{o.name}{o.pick && 추천 }
{o.desc}
{o.price} {o.unit}
))}
{/* 일정 초안 */}
{r.days.map((d, i) => (
setDay(i)}>{d.tab}
))}
{cur.items.map((ev, i) => (
))}
{/* 예상 경비 + 준비물 */}
{(parseInt(r.budget.total.replace(/,/g, "")) / 10000).toFixed(0)}만 / 예산 {(parseInt(r.budget.cap.replace(/,/g, "")) / 10000).toFixed(0)}만원
{bpct}% · 예산 안 {((parseInt(r.budget.cap.replace(/,/g, "")) - parseInt(r.budget.total.replace(/,/g, ""))) / 10000).toFixed(0)}만 여유
{r.budget.rows.map((b) => (
{b.name} {b.amt}
))}
{r.checklist.map((c, i) => (
{c}
))}
);
}
/* ============ 찜 · 가격 추적 ============ */
function SavedView() {
const [items, setItems] = useState(T.saved);
const toggle = (id) => setItems(items.map((x) => (x.id === id ? { ...x, watch: !x.watch } : x)));
return (
{items.map((s) => (
{s.note}
{s.now}
{s.delta}
{s.hint}
목표가에 닿으면 바로 알려드려요
toggle(s.id)} aria-label="가격 알림">
))}
);
}
/* ============ 셸 ============ */
const TP_RAIL = [
{ id: "upcoming", icon: "pin", label: "다가오는 출장" },
{ id: "plan", icon: "plus", label: "새 여행 계획" },
{ sep: true },
{ id: "saved", icon: "heart", label: "찜 · 가격 추적" },
];
const TP_HEAD = {
upcoming: { title: "여행", em: "준비는 아리가 해둘게요" },
plan: { title: "새 여행 계획", em: "말하면 아리가 다 조사해요" },
saved: { title: "찜한 여행", em: "가격이 내리면 알려드려요" },
};
function App() {
const [view, setView] = useState("upcoming");
const [day, setDay] = useState("d1");
const [done, setDone] = useState(loadCheck);
const [toast, setToast] = useState(null);
const flip = (id) => {
const n = { ...done, [id]: !done[id] };
setDone(n);
localStorage.setItem(TKEY, JSON.stringify(n));
};
const allItems = T.check.flatMap((g) => g.items);
const doneN = allItems.filter((it) => done[it.id]).length;
const onSave = (r) => {
setToast("‘" + r.title + "’ 계획을 저장했어요 — 찜·가격 추적에서 확인하세요.");
setTimeout(() => setToast(null), 3600);
};
const h = TP_HEAD[view];
return (
다음 일정 {T.trip.dday} · {T.trip.title}
준비물 {doneN}/{allItems.length} 챙김
{h.title} {h.em}
{view === "upcoming" &&
}
{view === "plan" &&
}
{view === "saved" &&
}
{toast &&
{toast}
}
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );