// ============================================================
// 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 (
{label && (
{label}
)}
{children}
);
};
// ── 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(
{/* Empty */}
{/* Filled overlay clipped to fill */}
{fill > 0 && (
)}
);
}
return (
{stars}
{showValue && {value.toFixed(2)} }
);
};
// ── 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 (
} style={style}>
{label || k.txt}
);
};
// ── 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 (
{original && (
{currency}{original}
)}
{currency}{amount}
{period && {period} }
);
};
// ── 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 (
{/* Top-left tags */}
{tags.length > 0 && (
{tags.map(t => (
{t}
))}
)}
{/* Favorite */}
{title}
{rating != null && (
{rating.toFixed(2)}
)}
{subtitle && (
{subtitle}
)}
{reviews != null && (
{reviews} reviews
)}
{badges.length > 0 && (
{badges.map(b => )}
)}
{price.amount != null && (
)}
);
};
// ── ListingCardHorizontal — for search results lists ─────────
const ListingCardHorizontal = ({ listing = {}, onClick, style }) => {
const { photo = {}, title, subtitle, price = {}, rating, reviews, badges = [], description, amenities = [] } = listing;
return (
{badges.map(b => )}
{title}
{subtitle && (
{subtitle}
)}
{description && (
{description}
)}
{amenities.length > 0 && (
{amenities.slice(0, 4).map(a =>
)}
)}
{rating != null &&
}
{reviews != null && {reviews} reviews
}
{price.amount != null &&
}
);
};
// ── AmenityChip ──────────────────────────────────────────────
const AmenityChip = ({ label, icon, style }) => (
{icon && }
{label}
);
// ── ReviewCard ───────────────────────────────────────────────
const ReviewCard = ({ author, avatarColor, rating, date, body, location, style }) => (
{author}
{location && <>{location} · >}{date}
{rating != null && (
)}
{body}
);
// ── RatingsSummary ──────────────────────────────────────────
// Header block for review section: big number + category bars.
const RatingsSummary = ({ value = 4.92, total = 184, categories = [], style }) => (
★ {value.toFixed(2)}
· {total} reviews
{categories.map(c => (
{c.label}
{c.value.toFixed(1)}
))}
);
// ── PriceBreakdown ───────────────────────────────────────────
// Receipt-style line items + total.
// items: [{ label, amount, note?, strike? }]
// total: number
// currency
const PriceBreakdown = ({ items = [], total, currency = "$", style }) => (
{items.map((it, i) => (
{it.label}
{it.note === "discount" ? "−" : ""}{currency}{it.amount}
))}
Total
{currency}{total}
);
// ── HostHeader ───────────────────────────────────────────────
const HostHeader = ({ name, avatar, color, joined, location, languages = [], blurb, kpis = [], style }) => (
{(joined || location) && (
{location && <>Lives in {location}{joined ? " · " : ""}>}
{joined && <>Hosting since {joined}>}
)}
{languages.length > 0 && (
Speaks {languages.join(", ")}
)}
{blurb && (
{blurb}
)}
);
// ── MessageBubble ────────────────────────────────────────────
const MessageBubble = ({ mine, name, body, time, avatar, color, attachment, style }) => (
{!mine &&
}
{body}
{attachment && (
{attachment}
)}
{time &&
{time}
}
);
// ── 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 }) => (
{label}
{value || placeholder}
);
return (
);
};
// ── CategoryRail — horizontal scrolling category chips ───────
const CategoryRail = ({ categories = [], active, onChange = () => {}, style }) => (
{categories.map(c => {
const sel = c.id === active || c.label === active;
return (
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,
}}>
{c.emoji || "○"}
{c.label}
);
})}
);
// ── FilterChips — horizontal chip strip ──────────────────────
const FilterChips = ({ filters = [], onClear, style }) => (
{filters.map((f, i) => (
{f.label}
{f.count != null && {f.count} }
))}
{onClear && (
Clear all
)}
);
// ── 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 }) => (
{/* Map illustration via SVG */}
{/* Faux water */}
{/* Faux roads */}
{/* 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) => (
))}
{/* Pins */}
{pins.map((p, i) => (
${p.price}
))}
{/* Map controls */}
+
−
);
// ── 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 (
{month}
{["S","M","T","W","T","F","S"].map((d, i) => {d} )}
{days.map((d, i) => {
if (d == null) return ;
const blocked = isBlocked(d);
const range = inRange(d);
const end = isEnd(d);
return (
{d}
);
})}
);
};
// ── 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 (
{weeks.map((w, i) => (
{w.map((cell, j) => (
))}
))}
);
};
// ── PhotoGallery — main + 4 thumbs grid ──────────────────────
const PhotoGallery = ({ photos = [], style }) => {
// photos: [{ label, tone }] up to 5
const [main, ...rest] = photos;
return (
{rest.slice(0, 4).map((p, i) => (
))}
);
};
// ── StatTile — small dashboard stat ──────────────────────────
const StatTile = ({ label, value, sub, trend, icon, style }) => (
{label}
{icon && (
)}
{value}
{(sub || trend) && (
{trend != null && (
= 0 ? "var(--success)" : "var(--danger)", marginRight: 4 }}>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}%
)}
{sub}
)}
);
// ─── Exports ─────────────────────────────────────────────────
Object.assign(window, {
PhotoSlot, ListingCard, ListingCardHorizontal,
RatingStars, ReviewCard, RatingsSummary,
PriceTag, PriceBreakdown,
HostHeader, MessageBubble,
TrustBadge, AmenityChip, FilterChips,
SearchBar, MiniMap, CalendarMonth, AvailabilityHeatmap,
CategoryRail, PhotoGallery, StatTile,
});