871 lines
36 KiB
JavaScript
871 lines
36 KiB
JavaScript
// ============================================================
|
||
// 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` 0–5 (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 1–31 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,
|
||
});
|