|
|
/* 아리 — 여행·출장 도우미 페이지
|
|
|
좌측 레일로 전환: 다가오는 출장 · 새 여행 계획(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 />);
|