You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

510 lines
21 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 아리 — 여행·출장 도우미 페이지
좌측 레일로 전환: 다가오는 출장 · 새 여행 계획(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 (
<svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
{TP_P[d].split("|").map((s, i) => <path key={i} d={s} />)}
</svg>
);
}
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 <span className="tp-ev-k">{k.local ? <TI d={k.d} /> : <I d={k.d} />}</span>;
}
function Leg({ leg }) {
return (
<div className="tp-leg">
<span className="tp-mode"><TI d="train" />{leg.mode}</span>
<div className="tp-st"><b>{leg.from}</b><span>{leg.ft}</span></div>
<div className="tp-line"></div>
<div className="tp-st to"><b>{leg.to}</b><span>{leg.tt}</span></div>
<div className="tp-seat"><b>{leg.seat}</b>{leg.note}</div>
</div>
);
}
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 (
<svg viewBox={"0 0 " + w + " " + h} preserveAspectRatio="none" aria-hidden="true">
<polyline points={pts} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx={last[0]} cy={last[1]} r="3" fill={color} />
</svg>
);
}
/* ============ 다가오는 출장 ============ */
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 (
<div className="board">
{/* 히어로 */}
<section className="card sp2 hero">
<div className="ch"><span className="hero-spark"><TI d="pin" />{T.trip.dday} · 출장</span></div>
<div className="tp-head">
<span className="tp-title">{T.trip.title}</span>
<span className="tp-dates">{T.trip.dates}</span>
</div>
<p className="brief">{T.trip.brief}</p>
<div className="tp-tags">
<span className="tp-tag"><TI d="pin" />{T.trip.purpose}</span>
{T.trip.weather.map((wx) => (
<span className="tp-tag wx" key={wx.d}>
{wx.icon === "sun" ? <I d="sun" /> : <TI d="rain" />}{wx.d} {wx.t}
</span>
))}
</div>
<div className="tp-route">
<Leg leg={T.route.out} />
<Leg leg={T.route.back} />
</div>
<div className="tp-stay">
<span className="st-ic"><TI d="bed" /></span>
<div className="st-body">
<div className="st-name">{T.stay.name}</div>
<div className="st-desc">{T.stay.desc}</div>
</div>
<span className="st-conf">{T.stay.conf}</span>
</div>
</section>
{/* 아리가 미리 해둔 일 */}
<section className="card">
<div className="ch">
<div className="ico"><I d="spark" /></div>
<div className="htext"><h3>아리가 미리 해둔 </h3></div>
</div>
<div className="pp-list">
{T.prep.map((p, i) => (
<div className={"pp-row" + (p.state === "doing" ? " doing" : "")} key={i}>
<span className="pp-ic"><I d={p.state === "doing" ? "clock" : "tick"} /></span>
<span>{p.text}</span>
</div>
))}
</div>
<div className="pp-note">예매·예약 같은 결제 처리는 모두 결재함에서 승인받았어요.</div>
</section>
{/* 체크리스트 */}
<section className="card">
<div className="ch">
<div className="ico"><I d="check" /></div>
<div className="htext"><h3>체크리스트</h3><div className="sub"> </div></div>
<span className="count">{doneN}/{allN}</span>
</div>
<div>
{T.check.map((g) => (
<React.Fragment key={g.name}>
<div className="tp-gp">{g.name}</div>
<div className="tasks">
{g.items.map((it) => (
<div className={"task" + (done[it.id] ? " done" : "")} key={it.id} onClick={() => flip(it.id)}>
<span className="box"><I d="tick" /></span>
<div className="task-body"><div className="task-title">{it.text}</div></div>
{it.auto && <span className="tp-auto"><I d="spark" />{it.auto}</span>}
</div>
))}
</div>
</React.Fragment>
))}
</div>
</section>
{/* 일정표 */}
<section className="card sp2">
<div className="ch">
<div className="ico"><I d="route" /></div>
<div className="htext"><h3>일정표</h3><div className="sub">·· </div></div>
</div>
<div className="tp-tabs">
{T.days.map((d) => (
<button key={d.id} className={"tp-tab" + (day === d.id ? " on" : "")} onClick={() => setDay(d.id)}>{d.tab}</button>
))}
</div>
<div className="sched">
{curDay.items.map((ev, i) => (
<div className="ev" key={i}>
<div className="ev-time">{ev.t}</div>
<KindIcon kind={ev.kind} />
<div className="ev-body" style={{ "--accent": "var(--" + ev.tone + ")" }}>
<div className="ev-title">{ev.title}</div>
<div className="ev-meta">{ev.meta}</div>
</div>
{ev.hot && <span className="soon">핵심</span>}
</div>
))}
</div>
</section>
{/* 경비 */}
<section className="card">
<div className="ch">
<div className="ico"><I d="wallet" /></div>
<div className="htext"><h3>경비</h3><div className="sub"> · </div></div>
</div>
<div className="fin-big"><b>{(T.expense.planned / 10000).toFixed(1)}</b><span className="u">/ 예산 {(T.expense.budget / 10000).toFixed(0)}만원</span></div>
<div className="fin-bar"><i style={{ width: pct + "%" }}></i></div>
<div className="fin-range"><span>{pct}% 사용 예정</span><span>{((T.expense.budget - T.expense.planned) / 10000).toFixed(1)} 여유</span></div>
<div className="xp-rows">
{T.expense.rows.map((r) => (
<div className="xp-row" key={r.name}>
<span className="xp-name">{r.name}</span>
<span className="xp-amt">{r.amt}</span>
<span className={"xp-state " + r.state}>{r.state === "paid" ? "결제 완료" : r.state === "hold" ? "결제 예정" : "예상"}</span>
</div>
))}
</div>
<div className="sched-note"><I d="spark" />{T.expense.note}</div>
</section>
</div>
);
}
/* ============ 새 여행 계획 (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 (
<div className="board">
<section className="card sp2 hero">
<span className="hero-spark"><TI d="plane" /> 여행 계획</span>
<p className="brief">어디로, 언제, 누구와 가는지 <b> 문장</b> . ··· .</p>
<div className="cmd">
<I d="pen" />
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") run(); }}
placeholder="예: 다음 달 말 제주 가족여행 3박 4일, 예산 150만원"
aria-label="여행 설명"
autoFocus
/>
<button className="send" disabled={!text.trim()} onClick={run} aria-label="계획 만들기"><I d="arrow" /></button>
</div>
<div className="chips">
{P.examples.map((e) => (
<button key={e} className="chip" onClick={() => setText(e)}><I d="spark" />{e}</button>
))}
</div>
</section>
<section className="card sp2">
<div className="ch">
<div className="ico"><I d="brain" /></div>
<div className="htext"><h3>아리가 조사하는 것들</h3><div className="sub"> </div></div>
</div>
<div className="pl-grid">
{P.research.map((r) => (
<div className="pl-cap" key={r.label}>
<span className="pl-cap-ic"><TI d={r.icon} /></span>
<div><b>{r.label}</b><span>{r.detail}</span></div>
</div>
))}
</div>
</section>
</div>
);
}
if (phase === "research") {
return (
<div className="board">
<section className="card sp2 hero">
<span className="hero-spark"><I d="spark" />조사 </span>
<p className="brief">{result && result.title} 계획을 짜고 있어요. 잠시만요.</p>
<div className="pl-prog">
{P.research.map((r, i) => {
const st = i < step ? "done" : i === step ? "now" : "wait";
return (
<div className={"pl-step " + st} key={r.label}>
<span className="pl-step-ic">{st === "done" ? <I d="tick" /> : <TI d={r.icon} />}</span>
<div className="pl-step-body"><b>{r.label}</b><span>{r.detail}</span></div>
{st === "now" && <span className="pl-spin"></span>}
{st === "done" && <span className="pl-ok">완료</span>}
</div>
);
})}
</div>
</section>
</div>
);
}
/* 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 (
<div className="board">
<section className="card sp2 hero">
<div className="ch">
<span className="hero-spark"><I d="spark" />아리의 추천 계획</span>
<button className="pl-redo" onClick={reset}><I d="swap" />다시 짜기</button>
</div>
<div className="tp-head">
<span className="tp-title">{r.title}</span>
<span className="tp-dates">{r.meta}</span>
</div>
<p className="brief" dangerouslySetInnerHTML={{ __html: r.summary }}></p>
<div className="tp-tags">
<span className="tp-tag wx"><I d="sun" />{r.weather}</span>
</div>
<div className="pl-actions">
<button className={"pl-save" + (saved ? " done" : "")} onClick={() => { setSaved(true); onSave && onSave(r); }}>
<I d={saved ? "tick" : "plus"} />{saved ? "내 여행에 추가됨" : "이 계획으로 만들기"}
</button>
<button className="pl-ghost"><I d="send" />계획 공유</button>
</div>
<div className="pl-src">
<I d="search" /><span>{r.sources.join(" · ")} 종합했어요</span>
</div>
</section>
{/* 이동 + 숙소 */}
<section className="card sp2">
<div className="ch">
<div className="ico"><TI d="plane" /></div>
<div className="htext"><h3>이동 · 숙소</h3><div className="sub"> </div></div>
</div>
<div className="opt-sec">이동</div>
{r.transport.map((o) => (
<div className={"opt-row" + (o.pick ? " pick" : "")} key={o.name}>
<span className="opt-mode">{o.mode}</span>
<div className="opt-body"><div className="opt-name">{o.name}{o.pick && <span className="opt-pick"><I d="tick" />추천</span>}</div><div className="opt-desc">{o.desc}</div></div>
<div className="opt-price"><b>{o.price}</b>{o.note && <span>{o.note}</span>}</div>
</div>
))}
<div className="opt-sec">숙소</div>
{r.stay.map((o) => (
<div className={"opt-row" + (o.pick ? " pick" : "")} key={o.name}>
<span className="opt-mode stay"><TI d="bed" /></span>
<div className="opt-body"><div className="opt-name">{o.name}{o.pick && <span className="opt-pick"><I d="tick" />추천</span>}</div><div className="opt-desc">{o.desc}</div></div>
<div className="opt-price"><b>{o.price}</b><span>{o.unit}</span></div>
</div>
))}
</section>
{/* 일정 초안 */}
<section className="card sp2">
<div className="ch">
<div className="ico"><I d="route" /></div>
<div className="htext"><h3>일정 초안</h3><div className="sub">· </div></div>
</div>
<div className="tp-tabs">
{r.days.map((d, i) => (
<button key={i} className={"tp-tab" + (day === i ? " on" : "")} onClick={() => setDay(i)}>{d.tab}</button>
))}
</div>
<div className="sched">
{cur.items.map((ev, i) => (
<div className="ev" key={i}>
<div className="ev-time sm">{ev.t}</div>
<div className="ev-body" style={{ "--accent": "var(--" + ev.tone + ")" }}>
<div className="ev-title">{ev.title}</div>
</div>
</div>
))}
</div>
</section>
{/* 예상 경비 + 준비물 */}
<section className="card">
<div className="ch">
<div className="ico"><I d="wallet" /></div>
<div className="htext"><h3>예상 경비</h3></div>
</div>
<div className="fin-big"><b>{(parseInt(r.budget.total.replace(/,/g, "")) / 10000).toFixed(0)}</b><span className="u">/ 예산 {(parseInt(r.budget.cap.replace(/,/g, "")) / 10000).toFixed(0)}만원</span></div>
<div className="fin-bar"><i style={{ width: bpct + "%" }}></i></div>
<div className="fin-range"><span>{bpct}% · 예산 </span><span>{((parseInt(r.budget.cap.replace(/,/g, "")) - parseInt(r.budget.total.replace(/,/g, ""))) / 10000).toFixed(0)} 여유</span></div>
<div className="xp-rows">
{r.budget.rows.map((b) => (
<div className="xp-row" key={b.name}><span className="xp-name">{b.name}</span><span className="xp-amt">{b.amt}</span></div>
))}
</div>
</section>
<section className="card">
<div className="ch">
<div className="ico"><I d="check" /></div>
<div className="htext"><h3>준비물</h3><div className="sub"> </div></div>
</div>
<div className="pp-list">
{r.checklist.map((c, i) => (
<div className="pp-row" key={i}><span className="pp-ic"><I d="tick" /></span><span>{c}</span></div>
))}
</div>
</section>
</div>
);
}
/* ============ 찜 · 가격 추적 ============ */
function SavedView() {
const [items, setItems] = useState(T.saved);
const toggle = (id) => setItems(items.map((x) => (x.id === id ? { ...x, watch: !x.watch } : x)));
return (
<div className="board">
{items.map((s) => (
<section className="card" key={s.id}>
<div className="ch">
<div className="ico"><TI d="plane" /></div>
<div className="htext"><h3>{s.title}</h3><div className="sub">{s.tag}</div></div>
</div>
<p className="sv-note">{s.note}</p>
<div className="sv-price">
<span className="nx-price mono">{s.now}</span>
<span className={"sv-delta " + (s.down ? "down" : "up")}>
<TI d={s.down ? "down" : "up2"} />{s.delta}
</span>
</div>
<div className="nx-hint">{s.hint}</div>
<div className="nx-spark">
<Spark vals={s.spark} color={s.down ? "var(--green)" : "var(--coral)"} />
</div>
<div className="nx-watch">
<I d="bell" />
<span>목표가에 닿으면 바로 알려드려요</span>
<button className={"sw" + (s.watch ? " on" : "")} onClick={() => toggle(s.id)} aria-label="가격 알림"></button>
</div>
</section>
))}
</div>
);
}
/* ============ 셸 ============ */
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 (
<div className="dash" data-screen-label="여행">
<Topbar current="trip" />
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span>다음 일정 {T.trip.dday} · {T.trip.title}</span>
<span className="sep"></span>
<span>준비물 {doneN}/{allItems.length} 챙김</span>
</div>
<h1 className="ph-title">{h.title} <em>{h.em}</em></h1>
</div>
</div>
<div className="work">
<SubRail items={TP_RAIL} active={view} onPick={setView} />
{view === "upcoming" && <UpcomingView day={day} setDay={setDay} done={done} flip={flip} doneN={doneN} allN={allItems.length} />}
{view === "plan" && <PlanView onSave={onSave} />}
{view === "saved" && <SavedView />}
</div>
{toast && <div className="au-toast"><I d="tick" />{toast}</div>}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);