Files
vibn-agent-runner/design-templates/VIBN (2)/vibn-marketplace/marketplace-components.jsx

871 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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,
});