// ui.jsx — Shared UI primitives for ImmoBot
// Icons, buttons, cards, badges, score visualization, bottom nav, top bar
// ─── Icons (line, 24px viewbox, 1.7 stroke) ──────────────────────────────
const I = {
home: (p={}) => ,
search: (p={}) => ,
key: (p={}) => ,
bookmark: (p={}) => ,
bookmarkFill: (p={}) => ,
bell: (p={}) => ,
user: (p={}) => ,
heart: (p={}) => ,
heartFill: (p={}) => ,
archive: (p={}) => ,
share: (p={}) => ,
filter: (p={}) => ,
sort: (p={}) => ,
chevR: (p={}) => ,
chevL: (p={}) => ,
chevD: (p={}) => ,
x: (p={}) => ,
plus: (p={}) => ,
check: (p={}) => ,
spark: (p={}) => ,
brain: (p={}) => ,
pin: (p={}) => ,
bed: (p={}) => ,
ruler: (p={}) => ,
calendar: (p={}) => ,
euro: (p={}) => ,
refresh: (p={}) => ,
download: (p={}) => ,
trash: (p={}) => ,
more: (p={}) => ,
grid: (p={}) => ,
list: (p={}) => ,
swipe: (p={}) => ,
warn: (p={}) => ,
trend: (p={}) => ,
zap: (p={}) => ,
link: (p={}) => ,
message: (p={}) => ,
globe: (p={}) => ,
flame: (p={}) => ,
shield: (p={}) => ,
settings: (p={}) => ,
logout: (p={}) => ,
};
// ─── Platform badges ────────────────────────────────────────────────────
function PlatformBadge({ platform, size = 'sm' }) {
// Stylized — we draw our own monograms; not the actual brand logos
const fontSize = size === 'lg' ? 11 : 10;
const pad = size === 'lg' ? '5px 10px' : '3px 8px';
if (platform === 'immoscout24') {
return (
Scout24
);
}
return (
Kleinanz.
);
}
// ─── Score Ring (semi-circular with KPIs) ───────────────────────────────
function ScoreRing({ score, size = 96, stroke = 8, showGrade = true }) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const pct = score / 100;
const color = score >= 80 ? '#2F8F5A' : score >= 60 ? '#C28428' : '#C8331C';
const grade = score >= 90 ? 'A+' : score >= 80 ? 'A' : score >= 70 ? 'B' : score >= 60 ? 'C' : score >= 50 ? 'D' : 'F';
return (
{score}
{showGrade && (
{grade} · /100
)}
);
}
// ─── Score Bar (horizontal, for compact spots) ──────────────────────────
function ScoreBar({ score, label, note }) {
const color = score >= 80 ? '#2F8F5A' : score >= 60 ? '#C28428' : '#C8331C';
return (
{label}
{score}
{note &&
{note}
}
);
}
// ─── Photo Slider (horizontal swipeable image gallery) ─────────────────
function PhotoSlider({ photos, height = 240, rounded = 0, onPhotoTap }) {
const [idx, setIdx] = React.useState(0);
const startX = React.useRef(null);
const handleStart = (e) => { startX.current = (e.touches ? e.touches[0].clientX : e.clientX); };
React.useEffect(() => { setIdx(0); }, [JSON.stringify(photos)]);
const handleEnd = (e) => {
if (startX.current == null) return;
const endX = (e.changedTouches ? e.changedTouches[0].clientX : e.clientX);
const d = endX - startX.current;
if (Math.abs(d) > 40) {
if (d < 0 && idx < photos.length - 1) setIdx(idx + 1);
if (d > 0 && idx > 0) setIdx(idx - 1);
}
startX.current = null;
};
return (
{photos.map((src, i) => (
))}
{/* dots */}
{photos.map((_, i) => (
))}
{/* photo count */}
{idx + 1} / {photos.length}
);
}
// ─── Listing Card (used in lists, dashboard, results) ──────────────────
function ListingCard({ listing, onTap, isFav, onFav, compact = false }) {
const isKauf = listing.type === 'kauf';
const priceStr = isKauf ? fmtPrice(listing.price) : fmtPrice(listing.price) + ' /Mt.';
const aiScore = listing.aiScore;
const normalizedPricePerSqm = Number(listing.pricePerSqm || 0);
const pricePerSqm = isKauf && normalizedPricePerSqm
? normalizedPricePerSqm.toLocaleString('de-DE') + ' €/m²'
: (isKauf && listing.area ? Math.round(listing.price / listing.area).toLocaleString('de-DE') + ' €/m²' : null);
return (
{listing.provisionsfrei && (
PROVISIONSFREI
)}
{aiScore !== null && aiScore !== undefined && (
= 80 ? '#2F8F5A' : aiScore >= 60 ? '#C28428' : '#C8331C',
color: '#fff', fontSize: 12, fontWeight: 800,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{aiScore}
KI-Score
)}
{priceStr}
{pricePerSqm && (
{pricePerSqm}
)}
{listing.title}
{listing.district}, {listing.city}
{listing.area ? fmtArea(listing.area) : '—'}
{listing.rooms || '—'} Zi.
{listing.baujahr}
);
}
// ─── Compact Listing Row (for dashboard "best deals") ──────────────────
function ListingRow({ listing, onTap, rank }) {
return (
{rank != null && (
{rank}
)}
{listing.title}
{listing.district}, {listing.city} · {fmtArea(listing.area)} · {listing.rooms} Zi.
{fmtPriceShort(listing.price)}
{listing.aiScore && (
= 80 ? '#2F8F5A' : listing.aiScore >= 60 ? '#C28428' : '#C8331C',
padding: '2px 7px', borderRadius: 999,
}}>{listing.aiScore}
)}
);
}
// ─── Bottom Nav ─────────────────────────────────────────────────────────
function BottomNav({ active, onTab, notifBadge = 0 }) {
const items = [
{ id: 'immobilien', label: 'Kaufen', icon: },
{ id: 'mieten', label: 'Mieten', icon: },
{ id: 'portfolio', label: 'Merkliste',icon: },
{ id: 'notifications', label: 'Mitteil.', icon: , badge: notifBadge },
{ id: 'profile', label: 'Profil', icon: },
];
return (
{items.map(it => (
))}
);
}
// ─── Top Bar / Header ───────────────────────────────────────────────────
function TopBar({ title, onBack, trailing, large = false, transparent = false, dark = false }) {
return (
{onBack && (
)}
{!large && (
{title}
)}
{trailing}
{large && (
{title}
)}
);
}
// ─── Empty State ────────────────────────────────────────────────────────
function EmptyState({ icon, title, subtitle, cta }) {
return (
{icon}
{title}
{subtitle && (
{subtitle}
)}
{cta}
);
}
// ─── Sheet (modal sheet from bottom) ───────────────────────────────────
function Sheet({ open, onClose, children, title, height = '78%' }) {
if (!open) return null;
return (
{title && (
{title}
)}
{children}
);
}
// ─── Stat Tile ──────────────────────────────────────────────────────────
function StatTile({ value, label, delta, accent = false, icon }) {
return (
{value}
{delta && (
{delta}
)}
);
}
Object.assign(window, {
I, PlatformBadge, ScoreRing, ScoreBar, PhotoSlider,
ListingCard, ListingRow, BottomNav, TopBar, EmptyState, Sheet, StatTile,
});