Travel — Saved Trips / Wishlist
A warm, editorial saved-trips shelf where draft itineraries and pinned places share one tidy library. Each card carries a gradient landscape cover, destination, dates or a no-dates hint, a saved-stops count and a progress bar for trips, or a star rating and price tier for places. Filter by All, Trips or Places, duplicate a route to remix, open it in the planner, or remove with a five-second undo. State reads and writes the same localStorage the explore and planner views use, with a friendly empty state inviting more wandering.
MCP
Code
/* Travel — Saved Trips / Wishlist
Editorial wanderlust shelf: gradient covers, filters, progress rings,
undo toast. All visuals are CSS gradients + inline SVG. */
:root {
--bg: #fbf7f1;
--panel: #ffffff;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-deep: #156b6b;
--coral: #e8623f;
--coral-deep: #c84a2a;
--sand: #e7d8c3;
--gold: #d99c4a;
--line: rgba(36, 31, 26, 0.12);
--line-soft: rgba(36, 31, 26, 0.07);
--shadow: 0 1px 2px rgba(36, 31, 26, 0.05), 0 12px 30px -16px rgba(36, 31, 26, 0.28);
--radius: 16px;
--serif: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background:
radial-gradient(1200px 520px at 100% -10%, rgba(31, 138, 138, 0.07), transparent 60%),
radial-gradient(900px 480px at -10% 0%, rgba(232, 98, 63, 0.06), transparent 55%),
var(--bg);
color: var(--ink);
font-family: var(--sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
z-index: 50;
text-decoration: none;
}
.skip-link:focus { left: 12px; }
:where(button, a, [tabindex]):focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- header ---------- */
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px clamp(16px, 5vw, 56px);
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.66);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
color: #fff;
background: linear-gradient(140deg, var(--teal), var(--teal-deep));
box-shadow: var(--shadow);
}
.brand-text {
font-family: var(--serif);
font-weight: 600;
font-size: 1.18rem;
letter-spacing: -0.01em;
}
.brand-text em { color: var(--teal); font-style: normal; }
.head-meta { text-align: right; }
.kicker {
margin: 0;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
font-weight: 600;
}
.saved-readout { margin: 2px 0 0; font-size: 0.92rem; color: var(--ink); }
.saved-num { font-weight: 600; color: var(--coral-deep); }
/* ---------- layout ---------- */
.wrap {
width: min(1080px, 100%);
margin: 0 auto;
padding: clamp(26px, 5vw, 52px) clamp(16px, 5vw, 56px) 80px;
}
/* ---------- intro ---------- */
.intro { max-width: 660px; }
.eyebrow {
margin: 0 0 10px;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 600;
color: var(--teal-deep);
}
.display {
margin: 0 0 12px;
font-family: var(--serif);
font-weight: 600;
font-size: clamp(2rem, 6vw, 3.05rem);
line-height: 1.04;
letter-spacing: -0.02em;
}
.lede {
margin: 0;
color: var(--muted);
font-size: clamp(1rem, 2.4vw, 1.08rem);
max-width: 56ch;
}
/* ---------- toolbar ---------- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 26px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.filters {
display: inline-flex;
gap: 6px;
padding: 5px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow);
}
.chip-filter {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
padding: 7px 15px;
border-radius: 999px;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.chip-filter:hover { color: var(--ink); }
.chip-filter.is-active {
background: var(--ink);
color: #fff;
}
.chip-count {
min-width: 20px;
text-align: center;
font-size: 0.74rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: var(--line-soft);
color: var(--muted);
}
.chip-filter.is-active .chip-count {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.explore-link {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--teal-deep);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
padding: 6px 4px;
}
.explore-link:hover { color: var(--coral-deep); }
/* ---------- grid ---------- */
.grid {
list-style: none;
margin: 28px 0 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: clamp(16px, 2.4vw, 24px);
}
.trip[hidden] { display: none; }
.card {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.trip:hover .card {
transform: translateY(-3px);
box-shadow: 0 1px 2px rgba(36, 31, 26, 0.06), 0 22px 44px -20px rgba(36, 31, 26, 0.38);
}
/* covers — gradient "landscape photography" */
.cover {
position: relative;
height: 130px;
background: linear-gradient(135deg, var(--c1, var(--teal)), var(--c2, var(--teal-deep)));
}
.cover::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(120px 80px at 18% 18%, rgba(255, 255, 255, 0.28), transparent 70%),
linear-gradient(to top, rgba(0, 0, 0, 0.22), transparent 55%);
}
.cover::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 46%;
background:
radial-gradient(60% 120% at 22% 130%, rgba(255, 255, 255, 0.16), transparent 70%),
radial-gradient(50% 120% at 78% 140%, rgba(255, 255, 255, 0.12), transparent 70%);
-webkit-mask: linear-gradient(to top, #000, transparent);
mask: linear-gradient(to top, #000, transparent);
opacity: 0.6;
}
/* per-destination palettes */
.cover[data-scene="coast"] { --c1: #2bb0c0; --c2: #156b8a; }
.cover[data-scene="alps"] { --c1: #7d9fd1; --c2: #3a4f7a; }
.cover[data-scene="desert"] { --c1: #e8a85c; --c2: #c8632f; }
.cover[data-scene="city"] { --c1: #b06bb0; --c2: #5a3a6e; }
.cover[data-scene="forest"] { --c1: #4f9d6a; --c2: #1f5b41; }
.cover[data-scene="islands"] { --c1: #38c0a8; --c2: #1f7a8a; }
.cover[data-scene="lagoon"] { --c1: #f0a35a; --c2: #d36a4a; }
.cover[data-scene="harbor"] { --c1: #5a8fc0; --c2: #2a4a6e; }
.kind-badge {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
font-size: 0.66rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--ink);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
}
.trip[data-kind="trip"] .kind-badge { color: var(--coral-deep); }
.trip[data-kind="place"] .kind-badge { color: var(--teal-deep); }
.heart {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16);
transition: transform 0.16s ease, background 0.16s ease;
}
.heart svg { width: 18px; height: 18px; fill: var(--muted); transition: fill 0.16s ease; }
.heart.is-on svg { fill: var(--coral); }
.heart:hover { transform: scale(1.08); }
.heart.pulse { animation: pop 0.42s ease; }
@keyframes pop {
0% { transform: scale(1); }
45% { transform: scale(1.28); }
100% { transform: scale(1); }
}
/* body */
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 15px 16px 16px;
flex: 1;
}
.meta-top {
margin: 0;
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--muted);
}
.place { font-weight: 600; color: var(--teal-deep); }
.dot { color: var(--line); }
.dates.no-dates { font-style: italic; opacity: 0.85; }
.name {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: 1.18rem;
line-height: 1.16;
letter-spacing: -0.01em;
}
.blurb {
margin: 0;
font-size: 0.88rem;
color: var(--muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* progress */
.progress-wrap { margin-top: 2px; }
.progress-row {
display: flex;
justify-content: space-between;
font-size: 0.74rem;
color: var(--muted);
margin-bottom: 5px;
}
.progress-pct { font-weight: 600; color: var(--ink); }
.progress-track {
height: 6px;
border-radius: 999px;
background: var(--line-soft);
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--teal), var(--coral));
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
/* place footer (rating + price) */
.place-foot {
margin: 0;
display: flex;
align-items: center;
gap: 12px;
font-size: 0.84rem;
}
.rating { display: inline-flex; align-items: center; gap: 6px; }
.stars {
--pct: 0%;
font-size: 0.94rem;
letter-spacing: 1px;
background: linear-gradient(90deg, var(--gold) var(--pct), rgba(36, 31, 26, 0.18) var(--pct));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.stars::before { content: "★★★★★"; }
.rating-num { font-weight: 600; }
.price { color: var(--teal-deep); font-weight: 600; letter-spacing: 0.5px; }
.price.free { color: var(--coral-deep); }
/* actions */
.actions {
margin-top: auto;
padding-top: 12px;
display: flex;
gap: 8px;
}
.btn {
font: inherit;
font-weight: 600;
font-size: 0.84rem;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
padding: 8px 12px;
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease, transform 0.1s ease;
}
.btn:active { transform: translateY(1px); }
.btn--ghost {
background: transparent;
border-color: var(--line);
color: var(--ink);
}
.btn--ghost:hover { background: var(--sand); border-color: var(--sand); }
.act-open {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
.act-open:hover { background: #000; }
.act-remove { margin-left: auto; color: var(--muted); }
.act-remove:hover { color: var(--coral-deep); border-color: rgba(232, 98, 63, 0.4); background: rgba(232, 98, 63, 0.06); }
.btn--primary {
display: inline-block;
background: linear-gradient(135deg, var(--coral), var(--coral-deep));
color: #fff;
text-decoration: none;
padding: 11px 22px;
box-shadow: var(--shadow);
}
.btn--primary:hover { filter: brightness(1.04); }
/* ---------- empty state ---------- */
.empty {
text-align: center;
max-width: 460px;
margin: 36px auto 0;
padding: 34px 24px 38px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.empty-art {
width: 220px;
max-width: 80%;
margin: 0 auto 14px;
}
.empty-art svg { width: 100%; height: auto; border-radius: 12px; display: block; }
.e-sky { fill: #eaf3f2; }
.e-hill { fill: #cfe3dd; }
.e-hill2 { fill: #aacfc6; }
.e-sun { fill: #f3c879; }
.e-path { fill: none; stroke: #fff; stroke-width: 3; stroke-dasharray: 4 6; stroke-linecap: round; }
.e-pin { fill: var(--coral); }
.e-pin circle { fill: #fff; }
.empty-title {
margin: 0 0 8px;
font-family: var(--serif);
font-weight: 600;
font-size: 1.5rem;
}
.empty-text {
margin: 0 0 20px;
color: var(--muted);
font-size: 0.96rem;
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 16px);
display: flex;
align-items: center;
gap: 14px;
max-width: calc(100% - 32px);
padding: 11px 14px 11px 18px;
background: var(--ink);
color: #fff;
border-radius: 12px;
box-shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.5);
font-size: 0.9rem;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease, visibility 0.22s;
z-index: 60;
}
.toast.show {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
pointer-events: auto;
}
.toast.toast--save { background: var(--teal-deep); }
.toast-msg { font-weight: 500; }
.toast-undo {
font: inherit;
font-weight: 600;
font-size: 0.86rem;
color: #ffd9a0;
background: rgba(255, 255, 255, 0.1);
border: 0;
border-radius: 8px;
padding: 5px 12px;
cursor: pointer;
white-space: nowrap;
}
.toast-undo:hover { background: rgba(255, 255, 255, 0.18); }
/* ---------- removal animation ---------- */
.trip.removing .card {
transform: scale(0.94);
opacity: 0;
transition: transform 0.32s ease, opacity 0.32s ease;
}
/* ---------- responsive ---------- */
@media (max-width: 560px) {
.page-head { padding: 14px 18px; }
.toolbar { flex-direction: column; align-items: flex-start; }
.filters { width: 100%; justify-content: space-between; }
.chip-filter { flex: 1; justify-content: center; }
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 380px) {
.chip-filter { padding: 7px 9px; font-size: 0.84rem; }
.actions { flex-wrap: wrap; }
.act-remove { margin-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}/* Travel — Saved Trips / Wishlist
Vanilla JS. Renders saved trips + pinned places from localStorage, with
filtering, duplicate, remove-with-undo, progress bars, and an empty state.
Storage model (shared with explore / planner views):
- "wayfarer:saved:v1" -> array of place IDs (the simple shared wishlist
that the POI / explore feed reads & writes)
- "wayfarer:library:v1" -> richer metadata for this view: trips + places,
keyed by id. Place ids are kept in sync with
the shared list above so the two never drift. */
(function () {
"use strict";
var SHARED_KEY = "wayfarer:saved:v1";
var LIB_KEY = "wayfarer:library:v1";
var grid = document.getElementById("tripGrid");
var tpl = document.getElementById("cardTpl");
var emptyState = document.getElementById("emptyState");
var savedCountEl = document.getElementById("savedCount");
var filters = Array.prototype.slice.call(document.querySelectorAll(".chip-filter"));
var countEls = {};
document.querySelectorAll(".chip-count").forEach(function (el) {
countEls[el.getAttribute("data-count")] = el;
});
var activeFilter = "all";
/* ---------- toast (with optional undo action) ---------- */
var toastEl = document.getElementById("toast");
var toastMsg = document.getElementById("toastMsg");
var toastUndo = document.getElementById("toastUndo");
var toastTimer = null;
var undoAction = null;
function hideToast() {
toastEl.classList.remove("show");
undoAction = null;
}
function toast(msg, opts) {
opts = opts || {};
toastMsg.textContent = msg;
toastEl.classList.toggle("toast--save", opts.kind === "save");
if (opts.onUndo) {
undoAction = opts.onUndo;
toastUndo.hidden = false;
} else {
undoAction = null;
toastUndo.hidden = true;
}
toastEl.classList.add("show");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(hideToast, opts.onUndo ? 5200 : 2400);
}
toastUndo.addEventListener("click", function () {
if (typeof undoAction === "function") {
var fn = undoAction;
undoAction = null;
window.clearTimeout(toastTimer);
hideToast();
fn();
}
});
/* ---------- seed data (a personal, lived-in library) ---------- */
var SEED = [
{
id: "amalfi-loop", kind: "trip", name: "Amalfi Coast Loop", place: "Italy",
scene: "coast", dates: "Sep 14 – 19", saved: 11, done: 4,
blurb: "Five lazy days of lemon groves, ferry hops and cliffside dinners between Positano and Ravello."
},
{
id: "kyoto-autumn", kind: "trip", name: "Kyoto in Maple Season", place: "Japan",
scene: "forest", dates: null, saved: 8, done: 1,
blurb: "A loose draft chasing the first red maples — temples at dawn, kaiseki at dusk, no fixed dates yet."
},
{
id: "atlas-traverse", kind: "trip", name: "High Atlas Traverse", place: "Morocco",
scene: "desert", dates: "Apr 2 – 8", saved: 9, done: 9,
blurb: "Mule-supported ridge walk to Toubkal, mint tea in mountain villages, finishing in Marrakech."
},
{
id: "lofoten-light", kind: "trip", name: "Lofoten Slow Light", place: "Norway",
scene: "alps", dates: null, saved: 6, done: 0,
blurb: "Fishing huts on stilts, midnight sun and a rented rowboat — a someday trip kept warm for June."
},
{
id: "casa-tivira", kind: "place", name: "Casa Tivira", place: "Tavira, PT",
scene: "harbor", rating: 4.8, price: 3,
blurb: "Nine-room townhouse with a tiled courtyard and rooftop figs — the gentlest breakfast in the old town."
},
{
id: "praia-concha", kind: "place", name: "Praia da Concha", place: "Algarve, PT",
scene: "lagoon", rating: 4.7, price: 0,
blurb: "Shell-shaped cove reached by a cliff stair — calm clear water and one tiny café for grilled fish."
},
{
id: "blue-grotto-swim", kind: "place", name: "Blue Grotto Swim", place: "Capri, IT",
scene: "islands", rating: 4.6, price: 2,
blurb: "Row in through the low mouth at golden hour, when the water lights up an impossible electric blue."
},
{
id: "shirakawa-go", kind: "place", name: "Shirakawa-gō Hamlet", place: "Gifu, JP",
scene: "city", rating: 4.9, price: 1,
blurb: "Thatched farmhouses under snow, woodsmoke in the air, and a single inn that still pours its own sake."
}
];
/* ---------- persistence ---------- */
function read(key, fallback) {
try {
var raw = window.localStorage.getItem(key);
if (!raw) return fallback;
var v = JSON.parse(raw);
return v == null ? fallback : v;
} catch (e) { return fallback; }
}
function write(key, value) {
try { window.localStorage.setItem(key, JSON.stringify(value)); }
catch (e) { /* private mode — keep working in-session */ }
}
// library: array of item objects, ordered. seed once.
var library = read(LIB_KEY, null);
if (!Array.isArray(library) || !library.length) {
library = SEED.slice();
write(LIB_KEY, library);
}
// keep the shared wishlist (place ids) in sync from the library
function syncSharedFromLibrary() {
var ids = library
.filter(function (it) { return it.kind === "place"; })
.map(function (it) { return it.id; });
write(SHARED_KEY, ids);
}
syncSharedFromLibrary();
/* ---------- helpers ---------- */
function priceLabel(tier) {
if (!tier || tier <= 0) return { text: "Free", free: true };
var s = "";
for (var i = 0; i < 4; i++) s += i < tier ? "€" : "";
return { text: s, free: false };
}
/* ---------- render ---------- */
function buildCard(item) {
var node = tpl.content.firstElementChild.cloneNode(true);
node.setAttribute("data-id", item.id);
node.setAttribute("data-kind", item.kind);
var cover = node.querySelector(".cover");
cover.setAttribute("data-scene", item.scene || "coast");
cover.setAttribute("aria-label", item.name + " cover");
node.querySelector(".kind-badge").textContent = item.kind === "trip" ? "Trip" : "Place";
node.querySelector(".place").textContent = item.place;
node.querySelector(".name").textContent = item.name;
node.querySelector(".blurb").textContent = item.blurb;
var heart = node.querySelector(".heart");
heart.setAttribute("aria-label", "Remove " + item.name + " from saved");
var dateEl = node.querySelector(".dates");
if (item.kind === "trip") {
if (item.dates) {
dateEl.textContent = item.dates;
} else {
dateEl.textContent = "No dates yet";
dateEl.classList.add("no-dates");
}
// progress
var pw = node.querySelector(".progress-wrap");
pw.hidden = false;
var saved = item.saved || 0;
var done = item.done || 0;
var pct = saved ? Math.round((done / saved) * 100) : 0;
node.querySelector(".progress-label").textContent =
done + " of " + saved + " stops ready";
node.querySelector(".progress-pct").textContent = pct + "%";
// animate from 0
var fill = node.querySelector(".progress-fill");
window.requestAnimationFrame(function () { fill.style.width = pct + "%"; });
} else {
dateEl.textContent = "Saved place";
dateEl.classList.add("no-dates");
var foot = node.querySelector(".place-foot");
foot.hidden = false;
var stars = node.querySelector(".stars");
var pctStars = Math.max(0, Math.min(100, (item.rating / 5) * 100));
stars.style.setProperty("--pct", pctStars.toFixed(1) + "%");
node.querySelector(".rating").setAttribute(
"aria-label", "Rated " + item.rating + " out of 5"
);
node.querySelector(".rating-num").textContent = item.rating.toFixed(1);
var pl = priceLabel(item.price);
var priceEl = node.querySelector(".price");
priceEl.textContent = pl.text;
if (pl.free) priceEl.classList.add("free");
priceEl.setAttribute("aria-label", pl.free ? "Free to visit" : "Price tier " + item.price);
}
wireCard(node, item);
return node;
}
function render() {
grid.textContent = "";
var shown = 0;
library.forEach(function (item) {
var node = buildCard(item);
var match = activeFilter === "all" || item.kind === activeFilter;
node.hidden = !match;
if (match) shown++;
grid.appendChild(node);
});
// counts
var nTrip = library.filter(function (i) { return i.kind === "trip"; }).length;
var nPlace = library.filter(function (i) { return i.kind === "place"; }).length;
if (countEls.all) countEls.all.textContent = String(library.length);
if (countEls.trip) countEls.trip.textContent = String(nTrip);
if (countEls.place) countEls.place.textContent = String(nPlace);
if (savedCountEl) savedCountEl.textContent = String(library.length);
var isEmpty = library.length === 0;
emptyState.hidden = !isEmpty;
grid.hidden = isEmpty;
}
/* ---------- per-card actions ---------- */
function wireCard(node, item) {
var heart = node.querySelector(".heart");
var removeBtn = node.querySelector(".act-remove");
var openBtn = node.querySelector(".act-open");
var dupeBtn = node.querySelector(".act-dupe");
function doRemove() { removeItem(item.id); }
heart.addEventListener("click", doRemove);
removeBtn.addEventListener("click", doRemove);
openBtn.addEventListener("click", function () {
toast("Opening " + item.name + (item.kind === "trip" ? " in the planner…" : "…"), { kind: "save" });
});
dupeBtn.addEventListener("click", function () {
duplicateItem(item.id);
});
}
function indexOfId(id) {
for (var i = 0; i < library.length; i++) {
if (library[i].id === id) return i;
}
return -1;
}
function commit() {
write(LIB_KEY, library);
syncSharedFromLibrary();
}
/* ---------- remove with undo ---------- */
function removeItem(id) {
var idx = indexOfId(id);
if (idx === -1) return;
var item = library[idx];
// animate the card out, then re-render
var card = grid.querySelector('.trip[data-id="' + cssEscape(id) + '"]');
if (card) card.classList.add("removing");
window.setTimeout(function () {
library.splice(idx, 1);
commit();
render();
toast("Removed " + item.name, {
onUndo: function () {
// restore at the same position (clamped)
var at = Math.min(idx, library.length);
library.splice(at, 0, item);
commit();
render();
toast(item.name + " restored", { kind: "save" });
}
});
}, 300);
}
/* ---------- duplicate ---------- */
function duplicateItem(id) {
var idx = indexOfId(id);
if (idx === -1) return;
var orig = library[idx];
var copy = JSON.parse(JSON.stringify(orig));
// unique id + name; a duplicated trip resets its dates (fresh draft)
var base = orig.id;
var n = 2;
while (indexOfId(base + "-copy" + (n > 2 ? n : "")) !== -1) n++;
copy.id = base + "-copy" + (n > 2 ? n : "");
copy.name = orig.name + " (copy)";
if (copy.kind === "trip") {
copy.dates = null;
copy.done = 0;
}
library.splice(idx + 1, 0, copy);
commit();
render();
toast("Duplicated " + orig.name, { kind: "save" });
}
// minimal attribute-selector escape for our known id charset
function cssEscape(s) {
return String(s).replace(/["\\]/g, "\\$&");
}
/* ---------- filters ---------- */
filters.forEach(function (btn) {
btn.addEventListener("click", function () {
activeFilter = btn.getAttribute("data-filter");
filters.forEach(function (b) {
var on = b === btn;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
render();
});
});
/* ---------- init ---------- */
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Travel — Saved Trips & Wishlist</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="page-head" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 3l14 9-14 9V3z" />
</svg>
</span>
<span class="brand-text">Wayfarer<em>Guides</em></span>
</div>
<div class="head-meta">
<p class="kicker">Your library</p>
<p class="saved-readout"><span id="savedCount" class="saved-num">0</span> saved</p>
</div>
</header>
<main id="main" class="wrap" role="main">
<section class="intro" aria-labelledby="introTitle">
<p class="eyebrow">Saved trips & wishlist</p>
<h1 id="introTitle" class="display">Everywhere you mean to go.</h1>
<p class="lede">Draft trips and pinned places, kept in one tidy shelf. Pick up a plan where you left it, duplicate a route to remix, or clear out the maybes — your choices sync with the planner and the explore feed.</p>
<div class="toolbar">
<div class="filters" role="group" aria-label="Filter saved items">
<button class="chip-filter is-active" type="button" data-filter="all" aria-pressed="true">
All <span class="chip-count" data-count="all">0</span>
</button>
<button class="chip-filter" type="button" data-filter="trip" aria-pressed="false">
Trips <span class="chip-count" data-count="trip">0</span>
</button>
<button class="chip-filter" type="button" data-filter="place" aria-pressed="false">
Places <span class="chip-count" data-count="place">0</span>
</button>
</div>
<a class="explore-link" href="#main">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M16 8l-2.5 6.5L7 17l2.5-6.5L16 8z" />
</svg>
Explore more
</a>
</div>
</section>
<ul class="grid" id="tripGrid" aria-label="Saved trips and places"></ul>
<section class="empty" id="emptyState" hidden aria-labelledby="emptyTitle">
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 240 150" preserveAspectRatio="xMidYMid meet">
<path class="e-sky" d="M0 0h240v150H0z" />
<path class="e-hill" d="M0 150 L0 96 Q60 60 120 88 T240 78 L240 150 Z" />
<path class="e-hill2" d="M0 150 L0 120 Q70 96 150 114 T240 108 L240 150 Z" />
<circle class="e-sun" cx="186" cy="40" r="20" />
<path class="e-path" d="M120 150 C112 120 134 108 120 88" />
<g class="e-pin">
<path d="M120 64s-9-7.3-9-14a9 9 0 0 1 18 0c0 6.7-9 14-9 14z" />
<circle cx="120" cy="50" r="3.4" />
</g>
</svg>
</div>
<h2 id="emptyTitle" class="empty-title">Nothing saved yet</h2>
<p class="empty-text">Wander the guides and tap the heart on a place, or start a draft trip. Everything you keep lands right here.</p>
<a class="btn btn--primary" href="#main">Start exploring</a>
</section>
</main>
<!-- card template -->
<template id="cardTpl">
<li class="trip" data-id="" data-kind="">
<article class="card">
<div class="cover" role="img">
<span class="kind-badge"></span>
<button class="heart is-on" type="button" aria-pressed="true" aria-label="Remove from saved">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/></svg>
</button>
</div>
<div class="body">
<p class="meta-top">
<span class="place"></span>
<span class="dot" aria-hidden="true">·</span>
<span class="dates"></span>
</p>
<h2 class="name"></h2>
<p class="blurb"></p>
<div class="progress-wrap" hidden>
<div class="progress-row">
<span class="progress-label"></span>
<span class="progress-pct"></span>
</div>
<div class="progress-track"><div class="progress-fill"></div></div>
</div>
<p class="place-foot" hidden>
<span class="rating"><span class="stars"></span><strong class="rating-num"></strong></span>
<span class="price"></span>
</p>
<div class="actions">
<button class="btn btn--ghost act-open" type="button">Open</button>
<button class="btn btn--ghost act-dupe" type="button">Duplicate</button>
<button class="btn btn--ghost act-remove" type="button" aria-label="Remove">Remove</button>
</div>
</div>
</article>
</li>
</template>
<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true">
<span class="toast-msg" id="toastMsg"></span>
<button class="toast-undo" id="toastUndo" type="button" hidden>Undo</button>
</div>
<script src="script.js"></script>
</body>
</html>Saved Trips / Wishlist
A personal shelf for everywhere you mean to go. Draft trips and pinned places live side by side in one responsive grid — every card opens with a gradient “landscape photography” cover keyed to its destination (coast, alps, desert, forest, islands…), a kind badge, and a heart you can tap to let a place go. Trips show their dates, or an honest “No dates yet”, with a saved-stops count and a progress bar that fills to show how ready the plan is. Places swap that for a star rating and a price tier, from Free up to €€€.
The filter pill flips between All, Trips and Places, each with a live count. Every card has three real actions: Open (hands off to the planner), Duplicate (clones the item right below — a copied trip becomes a fresh, date-free draft), and Remove. Removing animates the card out and raises a toast with a five-second Undo that restores it to its original spot. Clear the shelf entirely and a warm illustrated empty state invites you back out to explore.
Everything is vanilla HTML, CSS and JavaScript — no frameworks, no build, no
external images. The library persists to localStorage and keeps the shared
wishlist of place IDs in sync with the explore and planner views, so saves never
drift between screens. The layout collapses from a multi-column grid to a single
stacked column on narrow phones.
Illustrative travel UI only — fictional destinations, prices, and maps.