feat: flatten routes and merge marketing and onboarding directories
This commit is contained in:
@@ -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` 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,
|
||||
});
|
||||
Reference in New Issue
Block a user