feat: flatten routes and merge marketing and onboarding directories

This commit is contained in:
2026-06-06 18:52:03 -07:00
parent 47417d13a0
commit 0480b306f1
139 changed files with 36409 additions and 229 deletions

View File

@@ -0,0 +1,870 @@
// ============================================================
// vibn-marketplace · marketplace-components.jsx
// ------------------------------------------------------------
// Components specific to two-sided marketplaces. Built on top
// of vibn-ai-templates/components.jsx — load that first so
// Icon, Button, Card, Badge, Avatar etc. are available on the
// global scope.
//
// All visual properties read from CSS variables so any theme
// (atlas / minimal / dark / glass / editorial) restyles them.
//
// Components:
// PhotoSlot, ListingCard, ListingCardHorizontal,
// RatingStars, ReviewCard, RatingsSummary,
// PriceTag, PriceBreakdown,
// HostHeader, MessageBubble, MessageThread,
// TrustBadge, AmenityChip, FilterChips,
// SearchBar, MiniMap, CalendarMonth, AvailabilityHeatmap,
// CategoryRail, PhotoGallery, StatTile.
// ============================================================
// ── PhotoSlot ────────────────────────────────────────────────
// Placeholder image — stripe pattern with a label. Used wherever
// the user is expected to plug in a real photo.
const PhotoSlot = ({ label = "Photo", aspect = "4/3", tone = "warm", style, children }) => {
const palettes = {
warm: ["#efe2cc", "#e3d2b3"],
sage: ["#e0e8d4", "#cfdcb9"],
blush: ["#f1dcd2", "#e6c5b8"],
ocean: ["#d6e0e8", "#bccada"],
night: ["#1f2530", "#2a3140"],
sand: ["#ece4d3", "#dccfb3"],
};
const [a, b] = palettes[tone] || palettes.warm;
const isDark = tone === "night";
return (
<div style={{
width: "100%", aspectRatio: aspect, position: "relative",
overflow: "hidden", borderRadius: "inherit",
background: a,
backgroundImage: `repeating-linear-gradient(135deg, ${a} 0 14px, ${b} 14px 15px)`,
...style,
}}>
{label && (
<span style={{
position: "absolute", left: 10, bottom: 10,
fontFamily: "var(--font-mono)", fontSize: 10,
letterSpacing: "0.08em", textTransform: "uppercase",
padding: "3px 8px",
color: isDark ? "rgba(255,255,255,0.7)" : "rgba(26,22,18,0.55)",
background: isDark ? "rgba(0,0,0,0.4)" : "rgba(255,255,255,0.7)",
border: `1px solid ${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)"}`,
backdropFilter: "blur(4px)",
}}>{label}</span>
)}
{children}
</div>
);
};
// ── RatingStars ─────────────────────────────────────────────
// `value` 05 (fractional ok). `size` and `gap` adjust scale.
const RatingStars = ({ value = 4.5, max = 5, size = 14, showValue = true, style }) => {
const stars = [];
for (let i = 0; i < max; i++) {
const fill = Math.max(0, Math.min(1, value - i));
stars.push(
<span key={i} style={{
position: "relative", display: "inline-block", width: size, height: size,
}}>
{/* Empty */}
<svg width={size} height={size} viewBox="0 0 24 24" fill="var(--rating-empty)"
style={{ position: "absolute", inset: 0 }} aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
{/* Filled overlay clipped to fill */}
{fill > 0 && (
<span style={{
position: "absolute", inset: 0, width: `${fill * 100}%`,
overflow: "hidden",
}}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="var(--rating)" aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
</span>
)}
</span>
);
}
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6, ...style,
}}>
<span style={{ display: "inline-flex", gap: 1 }}>{stars}</span>
{showValue && <span style={{
fontSize: "var(--text-sm)", color: "var(--text)",
fontWeight: "var(--weight-medium)", fontVariantNumeric: "tabular-nums",
}}>{value.toFixed(2)}</span>}
</span>
);
};
// ── TrustBadge ───────────────────────────────────────────────
const TrustBadge = ({ kind = "verified", label, style }) => {
const kinds = {
verified: { icon: "shield", txt: "Verified host", tone: "info" },
superhost: { icon: "star", txt: "Superhost", tone: "warn" },
"top-rated": { icon: "star", txt: "Top rated", tone: "accent" },
instant: { icon: "bolt", txt: "Instant book", tone: "success"},
new: { icon: "spark", txt: "New on Atlas", tone: "info" },
};
const k = kinds[kind] || kinds.verified;
return (
<Badge tone={k.tone} leadingIcon={<Icon name={k.icon} size={11} stroke={2}/>} style={style}>
{label || k.txt}
</Badge>
);
};
// ── PriceTag ─────────────────────────────────────────────────
// Inline pricing display. `period` like "/ night", "/ project".
// `original` shows a strikethrough above for discounts.
const PriceTag = ({ amount, currency = "$", period, original, size = "md", emphasis = true, style }) => {
const sizing = {
sm: { num: 14, lbl: 11 },
md: { num: 20, lbl: 12 },
lg: { num: 28, lbl: 13 },
xl: { num: 36, lbl: 14 },
}[size];
return (
<span style={{
display: "inline-flex", alignItems: "baseline", gap: 6,
color: emphasis ? "var(--text)" : "var(--text-2)",
...style,
}}>
{original && (
<span style={{
fontSize: sizing.lbl, color: "var(--text-3)",
textDecoration: "line-through", marginRight: 2,
}}>{currency}{original}</span>
)}
<span style={{
fontSize: sizing.num, fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.01em", fontVariantNumeric: "tabular-nums",
}}>{currency}{amount}</span>
{period && <span style={{ fontSize: sizing.lbl, color: "var(--text-2)" }}>{period}</span>}
</span>
);
};
// ── ListingCard (vertical) ───────────────────────────────────
// Standard photo-on-top card.
// listing.photo — { label, tone } (placeholder hints)
// listing.title — string
// listing.subtitle— string (location, host, dates etc.)
// listing.price — { amount, period }
// listing.rating — number
// listing.reviews — number
// listing.badges — array of TrustBadge kinds
// listing.tags — short strings for top-left chips
const ListingCard = ({ listing = {}, onClick, style }) => {
const { photo = {}, title, subtitle, price = {}, rating, reviews, badges = [], tags = [], favorite } = listing;
return (
<div onClick={onClick} style={{
cursor: onClick ? "pointer" : "default",
display: "flex", flexDirection: "column", gap: 12,
...style,
}}>
<div style={{
position: "relative", borderRadius: "var(--listing-radius)",
overflow: "hidden", boxShadow: "var(--listing-shadow)",
}}>
<PhotoSlot label={photo.label || title} tone={photo.tone} aspect="4/3"/>
{/* Top-left tags */}
{tags.length > 0 && (
<div style={{
position: "absolute", top: 12, left: 12,
display: "flex", gap: 6, flexWrap: "wrap",
}}>
{tags.map(t => (
<span key={t} style={{
padding: "3px 9px", borderRadius: 999,
background: "rgba(255,255,255,0.9)",
color: "var(--text)", fontSize: 11,
fontWeight: "var(--weight-medium)",
backdropFilter: "blur(6px)",
}}>{t}</span>
))}
</div>
)}
{/* Favorite */}
<button aria-label="Favorite" style={{
position: "absolute", top: 10, right: 10,
width: 32, height: 32, borderRadius: "50%",
background: "rgba(255,255,255,0.95)", border: "none",
color: favorite ? "var(--accent)" : "var(--text)",
cursor: "pointer", display: "flex",
alignItems: "center", justifyContent: "center",
backdropFilter: "blur(6px)",
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill={favorite ? "currentColor" : "none"}
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
</div>
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 10,
}}>
<h3 style={{
margin: 0, fontSize: "var(--text-md)",
fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "-0.01em",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
flex: 1, minWidth: 0,
}}>{title}</h3>
{rating != null && (
<span style={{
display: "inline-flex", alignItems: "center", gap: 4,
fontSize: "var(--text-sm)", color: "var(--text)",
fontWeight: "var(--weight-medium)",
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="var(--rating)" aria-hidden="true">
<path d="m12 3 2.6 6.2 6.7.5-5.1 4.4 1.6 6.6L12 17.3 6.2 20.7l1.6-6.6L2.7 9.7l6.7-.5z"/>
</svg>
{rating.toFixed(2)}
</span>
)}
</div>
{subtitle && (
<div style={{
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
}}>{subtitle}</div>
)}
{reviews != null && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-3)", marginTop: 2 }}>
{reviews} reviews
</div>
)}
{badges.length > 0 && (
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{badges.map(b => <TrustBadge key={b} kind={b}/>)}
</div>
)}
{price.amount != null && (
<div style={{ marginTop: 10 }}>
<PriceTag {...price} size="md"/>
</div>
)}
</div>
</div>
);
};
// ── ListingCardHorizontal — for search results lists ─────────
const ListingCardHorizontal = ({ listing = {}, onClick, style }) => {
const { photo = {}, title, subtitle, price = {}, rating, reviews, badges = [], description, amenities = [] } = listing;
return (
<div onClick={onClick} style={{
display: "grid", gridTemplateColumns: "320px 1fr",
gap: 20, padding: 16, borderRadius: "var(--card-radius)",
background: "var(--surface)", border: "1px solid var(--border)",
cursor: onClick ? "pointer" : "default",
...style,
}}>
<div style={{
borderRadius: "var(--listing-radius)", overflow: "hidden",
boxShadow: "var(--listing-shadow)", alignSelf: "stretch",
}}>
<PhotoSlot label={photo.label || title} tone={photo.tone} aspect="4/3"/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 0 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{badges.map(b => <TrustBadge key={b} kind={b}/>)}
</div>
<h3 style={{
margin: 0, fontSize: "var(--text-lg)",
fontWeight: "var(--weight-semibold)", letterSpacing: "-0.01em",
color: "var(--text)",
}}>{title}</h3>
{subtitle && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)" }}>{subtitle}</div>
)}
{description && (
<p style={{
margin: "4px 0 0", fontSize: "var(--text-sm)",
color: "var(--text-2)", lineHeight: 1.5,
display: "-webkit-box", WebkitLineClamp: 2,
WebkitBoxOrient: "vertical", overflow: "hidden",
}}>{description}</p>
)}
{amenities.length > 0 && (
<div style={{ display: "flex", gap: 6, marginTop: 4, flexWrap: "wrap" }}>
{amenities.slice(0, 4).map(a => <AmenityChip key={a} label={a}/>)}
</div>
)}
<div style={{
marginTop: "auto", paddingTop: 12,
display: "flex", justifyContent: "space-between", alignItems: "flex-end",
}}>
<div>
{rating != null && <RatingStars value={rating} size={12}/>}
{reviews != null && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 4,
}}>{reviews} reviews</div>}
</div>
{price.amount != null && <PriceTag {...price} size="md"/>}
</div>
</div>
</div>
);
};
// ── AmenityChip ──────────────────────────────────────────────
const AmenityChip = ({ label, icon, style }) => (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "4px 10px", borderRadius: 999,
background: "var(--surface-2)", color: "var(--text-2)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
...style,
}}>
{icon && <Icon name={icon} size={11}/>}
{label}
</span>
);
// ── ReviewCard ───────────────────────────────────────────────
const ReviewCard = ({ author, avatarColor, rating, date, body, location, style }) => (
<div style={{
padding: 0, ...style,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<Avatar name={author} color={avatarColor} size={40}/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>{author}</div>
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)" }}>
{location && <>{location} · </>}{date}
</div>
</div>
</div>
{rating != null && (
<div style={{ marginBottom: 8 }}>
<RatingStars value={rating} size={12} showValue={false}/>
</div>
)}
<p style={{
margin: 0, fontSize: "var(--text-md)", color: "var(--text)",
lineHeight: 1.55,
}}>{body}</p>
</div>
);
// ── RatingsSummary ──────────────────────────────────────────
// Header block for review section: big number + category bars.
const RatingsSummary = ({ value = 4.92, total = 184, categories = [], style }) => (
<div style={{ ...style }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginBottom: 16 }}>
<span style={{
fontFamily: "var(--font-display)", fontSize: 44,
fontWeight: "var(--weight-medium)", letterSpacing: "-0.02em",
color: "var(--text)", lineHeight: 1,
}}> {value.toFixed(2)}</span>
<span style={{ fontSize: "var(--text-md)", color: "var(--text-2)" }}>
· {total} reviews
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "10px 28px" }}>
{categories.map(c => (
<div key={c.label} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
gap: 14, padding: "6px 0", borderBottom: "1px solid var(--divider)",
}}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--text)" }}>{c.label}</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
<span style={{
width: 120, height: 4, borderRadius: 2,
background: "var(--surface-alt)", overflow: "hidden",
}}>
<span style={{
display: "block", height: "100%", width: `${(c.value / 5) * 100}%`,
background: "var(--text)",
}}/>
</span>
<span style={{
fontSize: "var(--text-sm)", color: "var(--text-2)",
fontVariantNumeric: "tabular-nums",
}}>{c.value.toFixed(1)}</span>
</span>
</div>
))}
</div>
</div>
);
// ── PriceBreakdown ───────────────────────────────────────────
// Receipt-style line items + total.
// items: [{ label, amount, note?, strike? }]
// total: number
// currency
const PriceBreakdown = ({ items = [], total, currency = "$", style }) => (
<div style={{ ...style }}>
{items.map((it, i) => (
<div key={i} style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "8px 0", fontSize: "var(--text-md)",
color: "var(--text-2)",
}}>
<span style={{
textDecoration: it.note === "discount" ? "none" : "underline",
textDecorationStyle: "dotted", textUnderlineOffset: 2,
}}>{it.label}</span>
<span style={{
color: it.note === "discount" ? "var(--success)" : "var(--text)",
fontVariantNumeric: "tabular-nums",
}}>
{it.note === "discount" ? "" : ""}{currency}{it.amount}
</span>
</div>
))}
<div style={{
display: "flex", justifyContent: "space-between",
paddingTop: 14, marginTop: 6,
borderTop: "1px solid var(--border-strong)",
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
color: "var(--text)",
}}>
<span>Total</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>{currency}{total}</span>
</div>
</div>
);
// ── HostHeader ───────────────────────────────────────────────
const HostHeader = ({ name, avatar, color, joined, location, languages = [], blurb, kpis = [], style }) => (
<div style={{
display: "grid", gridTemplateColumns: "auto 1fr", gap: 20,
...style,
}}>
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
gap: 12, padding: 22,
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: "var(--card-radius)", boxShadow: "var(--shadow-sm)",
minWidth: 200,
}}>
<Avatar name={name} color={color} size={80}/>
<div style={{ textAlign: "center" }}>
<div style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-xl)",
fontWeight: "var(--weight-semibold)", color: "var(--text)",
}}>{name}</div>
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-3)", marginTop: 2 }}>Host</div>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
<TrustBadge kind="superhost"/>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 0 }}>
<div style={{
display: "grid", gridTemplateColumns: `repeat(${kpis.length}, auto)`,
gap: 26, marginBottom: 14,
}}>
{kpis.map(k => (
<div key={k.label}>
<div style={{
fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", fontFamily: "var(--font-display)",
}}>{k.value}</div>
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)",
textTransform: "uppercase", letterSpacing: "0.06em", marginTop: 2,
}}>{k.label}</div>
</div>
))}
</div>
{(joined || location) && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", marginBottom: 6 }}>
{location && <>Lives in {location}{joined ? " · " : ""}</>}
{joined && <>Hosting since {joined}</>}
</div>
)}
{languages.length > 0 && (
<div style={{ fontSize: "var(--text-sm)", color: "var(--text-2)", marginBottom: 10 }}>
Speaks {languages.join(", ")}
</div>
)}
{blurb && (
<p style={{
margin: 0, fontSize: "var(--text-md)", color: "var(--text)",
lineHeight: 1.55, maxWidth: 580,
}}>{blurb}</p>
)}
</div>
</div>
);
// ── MessageBubble ────────────────────────────────────────────
const MessageBubble = ({ mine, name, body, time, avatar, color, attachment, style }) => (
<div style={{
display: "flex", flexDirection: mine ? "row-reverse" : "row",
gap: 10, alignItems: "flex-end", marginBottom: 12, ...style,
}}>
{!mine && <Avatar name={name || "?"} color={color} size={28}/>}
<div style={{ maxWidth: "62%" }}>
<div style={{
padding: "10px 14px", borderRadius: 16,
background: mine ? "var(--text)" : "var(--surface-2)",
color: mine ? "var(--bg)" : "var(--text)",
fontSize: "var(--text-md)", lineHeight: 1.45,
borderBottomRightRadius: mine ? 4 : 16,
borderBottomLeftRadius: mine ? 16 : 4,
boxShadow: "var(--shadow-sm)",
}}>{body}</div>
{attachment && (
<div style={{
marginTop: 6, padding: "8px 12px",
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: 12, fontSize: "var(--text-sm)", color: "var(--text)",
display: "inline-flex", alignItems: "center", gap: 8,
}}>
<Icon name="doc" size={14}/>
{attachment}
</div>
)}
{time && <div style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
marginTop: 4, textAlign: mine ? "right" : "left",
}}>{time}</div>}
</div>
</div>
);
// ── SearchBar — destination + dates + guests ────────────────
const SearchBar = ({ destination, dates, guests, compact, onSearch, style }) => {
const segPad = compact ? "10px 18px" : "14px 22px";
const Segment = ({ label, value, placeholder, last }) => (
<div style={{
flex: 1, padding: segPad, minWidth: 0,
borderRight: last ? "none" : "1px solid var(--border)",
cursor: "pointer",
}}>
<div style={{
fontSize: "var(--text-xs)", fontWeight: "var(--weight-semibold)",
color: "var(--text)", letterSpacing: "0.02em",
}}>{label}</div>
<div style={{
fontSize: compact ? "var(--text-sm)" : "var(--text-md)",
color: value ? "var(--text)" : "var(--text-3)",
marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}}>{value || placeholder}</div>
</div>
);
return (
<div style={{
display: "flex", alignItems: "center",
background: "var(--surface)", border: "1px solid var(--border)",
borderRadius: 999, boxShadow: "var(--shadow)",
padding: 4, ...style,
}}>
<Segment label="Where" value={destination} placeholder="Search destinations"/>
<Segment label="Check in" value={dates?.in} placeholder="Add dates"/>
<Segment label="Check out" value={dates?.out} placeholder="Add dates"/>
<Segment label="Who" value={guests} placeholder="Add guests" last/>
<button onClick={onSearch} aria-label="Search" style={{
width: compact ? 38 : 50, height: compact ? 38 : 50,
borderRadius: 999, border: "none",
background: "var(--accent)", color: "var(--text-on-accent)",
cursor: "pointer", display: "flex", alignItems: "center",
justifyContent: "center", marginLeft: 6,
}}>
<Icon name="search" size={compact ? 15 : 18} stroke={2.2}/>
</button>
</div>
);
};
// ── CategoryRail — horizontal scrolling category chips ───────
const CategoryRail = ({ categories = [], active, onChange = () => {}, style }) => (
<div style={{
display: "flex", gap: 28, overflowX: "auto", padding: "10px 0",
...style,
}}>
{categories.map(c => {
const sel = c.id === active || c.label === active;
return (
<button key={c.id || c.label} onClick={() => onChange(c.id || c.label)} style={{
display: "flex", flexDirection: "column", alignItems: "center",
gap: 6, padding: "4px 2px", background: "transparent",
border: "none", cursor: "pointer", color: sel ? "var(--text)" : "var(--text-2)",
borderBottom: sel ? "2px solid var(--text)" : "2px solid transparent",
fontFamily: "var(--font-sans)", flexShrink: 0,
opacity: sel ? 1 : 0.7,
}}>
<span style={{ fontSize: 22, lineHeight: 1 }}>{c.emoji || "○"}</span>
<span style={{ fontSize: "var(--text-xs)", fontWeight: 500, whiteSpace: "nowrap" }}>{c.label}</span>
</button>
);
})}
</div>
);
// ── FilterChips — horizontal chip strip ──────────────────────
const FilterChips = ({ filters = [], onClear, style }) => (
<div style={{
display: "flex", gap: 8, flexWrap: "wrap",
alignItems: "center", ...style,
}}>
{filters.map((f, i) => (
<span key={i} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "6px 12px", borderRadius: 999,
background: f.active ? "var(--text)" : "var(--surface)",
color: f.active ? "var(--bg)" : "var(--text)",
border: `1px solid ${f.active ? "var(--text)" : "var(--border)"}`,
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
cursor: "pointer",
}}>
{f.label}
{f.count != null && <span style={{
fontSize: 10, padding: "1px 6px", borderRadius: 999,
background: f.active ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
color: "inherit",
}}>{f.count}</span>}
</span>
))}
{onClear && (
<button onClick={onClear} style={{
marginLeft: 4, padding: "6px 12px", borderRadius: 999,
background: "transparent", border: "none", cursor: "pointer",
fontSize: "var(--text-sm)", color: "var(--text-2)",
textDecoration: "underline",
}}>Clear all</button>
)}
</div>
);
// ── MiniMap — schematic map with pins ────────────────────────
// Not a real map — a stylized SVG you can drop in for layout.
// Pins: [{ x: 0-100, y: 0-100, price, active }]
const MiniMap = ({ pins = [], style }) => (
<div style={{
position: "relative", width: "100%", height: "100%",
borderRadius: "var(--card-radius)", overflow: "hidden",
background: "var(--surface-2)", border: "1px solid var(--border)",
...style,
}}>
{/* Map illustration via SVG */}
<svg width="100%" height="100%" viewBox="0 0 600 600" preserveAspectRatio="xMidYMid slice"
style={{ position: "absolute", inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="600" height="600" fill="var(--surface-2)"/>
<rect width="600" height="600" fill="url(#grid)" opacity="0.6"/>
{/* Faux water */}
<path d="M0,380 Q100,360 220,400 T440,420 T600,400 L600,600 L0,600 Z"
fill="var(--accent-soft)" opacity="0.7"/>
{/* Faux roads */}
<path d="M0,200 Q200,210 380,180 T600,220"
fill="none" stroke="var(--text-3)" strokeWidth="2" strokeDasharray="6 4" opacity="0.3"/>
<path d="M280,0 L260,600" fill="none" stroke="var(--text-3)"
strokeWidth="2" strokeDasharray="6 4" opacity="0.3"/>
{/* Faux blocks */}
{[[80,80,90,70],[200,60,80,100],[340,90,110,80],[470,70,80,90],
[60,250,80,90],[180,260,110,80],[330,260,90,70],[460,260,80,90]].map(([x,y,w,h], i) => (
<rect key={i} x={x} y={y} width={w} height={h} rx="4"
fill="var(--surface)" stroke="var(--border)" strokeWidth="1"/>
))}
</svg>
{/* Pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: "absolute", left: `${p.x}%`, top: `${p.y}%`,
transform: "translate(-50%, -100%)",
padding: "4px 10px", borderRadius: 999,
background: p.active ? "var(--text)" : "var(--surface)",
color: p.active ? "var(--bg)" : "var(--text)",
border: `1.5px solid ${p.active ? "var(--text)" : "var(--border-strong)"}`,
fontSize: "var(--text-xs)",
fontWeight: "var(--weight-semibold)",
fontVariantNumeric: "tabular-nums",
boxShadow: "var(--shadow)", cursor: "pointer", whiteSpace: "nowrap",
zIndex: p.active ? 2 : 1,
}}>${p.price}</div>
))}
{/* Map controls */}
<div style={{
position: "absolute", right: 12, top: 12,
display: "flex", flexDirection: "column", gap: 0,
background: "var(--surface)", borderRadius: "var(--radius)",
border: "1px solid var(--border)", boxShadow: "var(--shadow)",
}}>
<button style={{
width: 32, height: 32, border: "none", background: "transparent",
borderBottom: "1px solid var(--border)", cursor: "pointer",
color: "var(--text)", fontSize: 16,
}}>+</button>
<button style={{
width: 32, height: 32, border: "none", background: "transparent",
cursor: "pointer", color: "var(--text)", fontSize: 16,
}}></button>
</div>
</div>
);
// ── CalendarMonth — single-month booking calendar ────────────
// `start` and `end` are 131 day numbers within the displayed
// month. Tinted range between them, range endpoints highlighted.
const CalendarMonth = ({ month = "August 2026", firstWeekday = 6, daysInMonth = 31, start, end, today, blocked = [], style }) => {
const days = [];
// Blank cells before day 1
for (let i = 0; i < firstWeekday; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
const inRange = (d) => start != null && end != null && d > start && d < end;
const isEnd = (d) => d === start || d === end;
const isBlocked = (d) => blocked.includes(d);
return (
<div style={{ ...style }}>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 12,
}}>
<IconButton name="chevLeft" size="sm" label="Previous month"/>
<span style={{
fontFamily: "var(--font-display)", fontSize: "var(--text-lg)",
fontWeight: "var(--weight-semibold)", color: "var(--text)",
}}>{month}</span>
<IconButton name="chevRight" size="sm" label="Next month"/>
</div>
<div style={{
display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4,
fontSize: "var(--text-xs)", color: "var(--text-3)",
textAlign: "center", marginBottom: 6,
textTransform: "uppercase", letterSpacing: "0.08em",
}}>
{["S","M","T","W","T","F","S"].map((d, i) => <span key={i}>{d}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2 }}>
{days.map((d, i) => {
if (d == null) return <span key={i}/>;
const blocked = isBlocked(d);
const range = inRange(d);
const end = isEnd(d);
return (
<button key={i} disabled={blocked} style={{
aspectRatio: "1", border: "none", cursor: blocked ? "not-allowed" : "pointer",
borderRadius: end ? "50%" : 6,
background: end ? "var(--text)" : range ? "var(--surface-alt)" : "transparent",
color: end ? "var(--bg)" : blocked ? "var(--text-3)" : "var(--text)",
fontSize: "var(--text-sm)",
fontWeight: end ? 600 : 400,
textDecoration: blocked ? "line-through" : "none",
opacity: blocked ? 0.5 : 1,
outline: d === today ? "1px solid var(--accent)" : "none",
fontFamily: "var(--font-sans)",
}}>{d}</button>
);
})}
</div>
</div>
);
};
// ── AvailabilityHeatmap — for host calendar ──────────────────
// 4-week strip showing booked vs free vs blocked nights.
// weeks: array of 7-element arrays of "open"|"booked"|"blocked"
const AvailabilityHeatmap = ({ weeks = [], style }) => {
const colors = {
open: { bg: "var(--success-soft)", dot: "var(--success)" },
booked: { bg: "var(--accent-soft)", dot: "var(--accent)" },
blocked: { bg: "var(--surface-alt)", dot: "var(--text-3)" },
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4, ...style }}>
{weeks.map((w, i) => (
<div key={i} style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4 }}>
{w.map((cell, j) => (
<div key={j} style={{
aspectRatio: "1", borderRadius: 4,
background: colors[cell]?.bg || "transparent",
border: `1px solid ${colors[cell]?.dot || "transparent"}33`,
}}/>
))}
</div>
))}
</div>
);
};
// ── PhotoGallery — main + 4 thumbs grid ──────────────────────
const PhotoGallery = ({ photos = [], style }) => {
// photos: [{ label, tone }] up to 5
const [main, ...rest] = photos;
return (
<div style={{
display: "grid",
gridTemplateColumns: "2fr 1fr 1fr",
gridTemplateRows: "1fr 1fr",
gap: 8, borderRadius: "var(--card-radius)", overflow: "hidden",
height: 460, ...style,
}}>
<div style={{ gridColumn: "1 / 2", gridRow: "1 / 3" }}>
<PhotoSlot label={main?.label || "Photo 1"} tone={main?.tone} aspect="auto" style={{ height: "100%", aspectRatio: "auto" }}/>
</div>
{rest.slice(0, 4).map((p, i) => (
<PhotoSlot key={i} label={p.label || `Photo ${i + 2}`} tone={p.tone}
aspect="auto" style={{ height: "100%", aspectRatio: "auto" }}/>
))}
</div>
);
};
// ── StatTile — small dashboard stat ──────────────────────────
const StatTile = ({ label, value, sub, trend, icon, style }) => (
<Card padding={20} style={style}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<span style={{
fontSize: "var(--text-xs)", color: "var(--text-3)",
textTransform: "uppercase", letterSpacing: "0.06em",
fontWeight: "var(--weight-medium)",
}}>{label}</span>
{icon && (
<span style={{
width: 28, height: 28, borderRadius: 8,
background: "var(--accent-soft)", color: "var(--accent)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<Icon name={icon} size={14}/>
</span>
)}
</div>
<div style={{
fontFamily: "var(--font-display)",
fontSize: 28, fontWeight: "var(--weight-semibold)",
letterSpacing: "-0.02em", color: "var(--text)",
marginTop: 10,
}}>{value}</div>
{(sub || trend) && (
<div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 4 }}>
{trend != null && (
<span style={{ color: trend >= 0 ? "var(--success)" : "var(--danger)", marginRight: 4 }}>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}%
</span>
)}
{sub}
</div>
)}
</Card>
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
PhotoSlot, ListingCard, ListingCardHorizontal,
RatingStars, ReviewCard, RatingsSummary,
PriceTag, PriceBreakdown,
HostHeader, MessageBubble,
TrustBadge, AmenityChip, FilterChips,
SearchBar, MiniMap, CalendarMonth, AvailabilityHeatmap,
CategoryRail, PhotoGallery, StatTile,
});