/* iColleges UI kit — reusable components shared across guideline + pages. Depends on tokens.css for CSS variables. Load order: React → ReactDOM → Babel → logo.jsx → ui-kit.jsx → page script. */ /* ───────────────────────────────────────────────────────────────── Icon system — line icons in a single component. Stroke 1.6, round caps, 24-grid; size prop controls width/height. ───────────────────────────────────────────────────────────────── */ const ICON_PATHS = { search: "M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13Zm5 11.5 4.5 4.5", pin: "M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Zm0-8.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z", filter: "M4 5h16M7 12h10M10 19h4", sort: "M7 5v14m0 0-3-3m3 3 3-3M17 19V5m0 0-3 3m3-3 3 3", star: "m12 4 2.4 5 5.6.8-4 3.9.9 5.5L12 16.6 7 19.2l1-5.5-4-3.9 5.6-.8L12 4Z", check: "m5 12 4 4 10-10", chevdown: "m6 9 6 6 6-6", chevright:"m9 6 6 6-6 6", close: "M6 6l12 12M6 18 18 6", user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 8a7 7 0 1 1 14 0", building: "M5 21V5h14v16M5 21h14M9 9h2m2 0h2M9 13h2m2 0h2M9 17h2m2 0h2", graduation:"M3 9l9-4 9 4-9 4-9-4Zm4 2v4c0 1.7 2.7 3 6 3s6-1.3 6-3v-4", rouble: "M8 5h5a4 4 0 0 1 0 8H6m2-8v14m-2-6h8", bed: "M3 18V8M3 14h18M21 18V12a3 3 0 0 0-3-3H10v5", spark: "M12 4v3m0 10v3M4 12h3m10 0h3M6.3 6.3l2.1 2.1m7.2 7.2 2.1 2.1M6.3 17.7l2.1-2.1m7.2-7.2 2.1-2.1", compass: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm4-13-2 6-6 2 2-6 6-2Z", heart: "M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.5-7 10-7 10Z", scales: "M12 4v16M5 20h14M7 8 4 16h6L7 8Zm10 0-3 8h6l-3-8ZM6 4h12", calendar: "M5 7h14v13H5zM5 7V5h14v2M9 3v4m6-4v4M5 11h14", clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v5l3 2", arrowR: "M5 12h14m-5-5 5 5-5 5", plus: "M12 5v14M5 12h14", trophy: "M8 4h8v4a4 4 0 0 1-8 0V4Zm-2 1H4v2a3 3 0 0 0 3 3m11-5h2v2a3 3 0 0 1-3 3M10 14h4v2h-4zM8 20h8", bolt: "M13 3 5 14h6l-1 7 8-11h-6l1-7Z", grid: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z", list: "M4 6h16M4 12h16M4 18h16", }; function Icon({ name, size = 20, color = "currentColor", strokeWidth = 1.6, style }) { const d = ICON_PATHS[name]; if (!d) return null; return ( ); } /* ───────────────────────────────────────────────────────────────── Buttons ───────────────────────────────────────────────────────────────── */ function Button({ children, variant = "primary", // primary | secondary | ghost size = "md", // sm | md | lg iconLeft, iconRight, disabled, onClick, style, full, type = "button", as: As = "button", href, }) { const pad = { sm: "0 12px", md: "0 18px", lg: "0 22px" }[size]; const h = { sm: 32, md: 40, lg: 48 }[size]; const fs = { sm: 13, md: 15, lg: 16 }[size]; const base = { display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8, height: h, padding: pad, borderRadius: "var(--r-md)", fontFamily: "var(--font-text)", fontWeight: 500, fontSize: fs, letterSpacing: "-0.005em", lineHeight: 1, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, border: "1px solid transparent", transition: "background .15s ease, border-color .15s ease, color .15s ease", width: full ? "100%" : "auto", textDecoration: "none", whiteSpace: "nowrap", }; const styles = { primary: { ...base, background: "var(--teal)", color: "#fff", borderColor: "var(--teal)" }, secondary: { ...base, background: "transparent", color: "var(--ink)", borderColor: "var(--line-strong)" }, ghost: { ...base, background: "transparent", color: "var(--teal-dark)",borderColor: "transparent", padding: pad }, }[variant]; const props = { onClick: disabled ? undefined : onClick, style: { ...styles, ...style }, className: `btn btn-${variant} btn-${size}`, disabled, }; if (As === "a") return {iconLeft && }{children}{iconRight && }; return ( ); } /* ───────────────────────────────────────────────────────────────── Input + Select ───────────────────────────────────────────────────────────────── */ function Input({ value, onChange, placeholder, iconLeft, iconRight, size = "md", type = "text", style, full, ...rest }) { const h = { sm: 32, md: 40, lg: 48 }[size]; const pl = iconLeft ? 38 : 14; const pr = iconRight ? 38 : 14; const isControlled = typeof onChange === "function"; return ( ); } /* ──────────────────────────────────────────────────────────────────── Dropdown — кастомный селект со своим попапом (вместо нативного { setQuery(e.target.value); setHover(0); }} placeholder={searchPlaceholder} style={{ width: "100%", boxSizing: "border-box", height: 32, padding: "0 10px", border: "1px solid var(--line-strong)", borderRadius: "var(--r-sm)", fontSize: 13, fontFamily: "inherit", color: "var(--ink)", outline: "none", }} /> )} {filteredOptions.length === 0 && (
Ничего не найдено
)} {filteredOptions.map((o, i) => { const v = typeof o === "string" ? o : o.value; const l = typeof o === "string" ? o : o.label; const isSelected = isSelectedVal(v); const isHover = hover === i; return (
setHover(i)} onClick={() => emit(v)} style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 10px 9px 10px", borderRadius: "var(--r-sm)", fontSize: 14, lineHeight: 1.25, color: isSelected ? "var(--teal-deep)" : "var(--ink)", background: isSelected ? "var(--teal-light)" : (isHover ? "var(--base)" : "transparent"), fontWeight: isSelected ? 600 : 500, cursor: "pointer", whiteSpace: "nowrap", transition: "background .12s ease", }}> {multi ? ( {isSelected && } ) : ( )} {l}
); })} )} ); } function Select({ value, onChange, options = [], placeholder, size = "md", full }) { const isControlled = typeof onChange === "function"; return ( ); } /* ───────────────────────────────────────────────────────────────── Chip — pill toggle. active = teal fill. ───────────────────────────────────────────────────────────────── */ function Chip({ children, active, onClick, icon, removable, onRemove, style }) { return ( ); } /* ───────────────────────────────────────────────────────────────── Badge — small label with tone. tone: "neutral" | "pos" | "warn" | "info" | "brand" | category names (it/design/media/eng). ───────────────────────────────────────────────────────────────── */ const BADGE_TONES = { neutral:{ bg: "var(--status-neutral-light)", fg: "var(--ink)" }, pos: { bg: "var(--status-pos-light)", fg: "#3F6014" }, warn: { bg: "var(--status-warn-light)", fg: "var(--amber-dark)" }, brand: { bg: "var(--teal-light)", fg: "var(--teal-deep)" }, it: { bg: "var(--blue-light)", fg: "var(--blue-dark)" }, design: { bg: "var(--pink-light)", fg: "var(--pink-dark)" }, media: { bg: "var(--purple-light)", fg: "var(--purple-dark)" }, eng: { bg: "var(--amber-light)", fg: "var(--amber-dark)" }, }; function Badge({ children, tone = "neutral", icon, style }) { const t = BADGE_TONES[tone] || BADGE_TONES.neutral; return ( {icon && } {children} ); } /* ───────────────────────────────────────────────────────────────── ChanceBadge — the signature element. level: "very-high" | "high" | "medium" | "low" ───────────────────────────────────────────────────────────────── */ const CHANCE_DEF = { "very-high": { label: "Очень высокий шанс", pct: 90, bg: "var(--status-pos-light)", fg: "#3F6014", bar: "#7BB527" }, "high": { label: "Высокий шанс", pct: 75, bg: "var(--status-pos-light)", fg: "#3F6014", bar: "#7BB527" }, "medium": { label: "Средний шанс", pct: 50, bg: "var(--amber-light)", fg: "var(--amber-dark)", bar: "#D9941F" }, "low": { label: "Низкий шанс", pct: 25, bg: "var(--status-neutral-light)", fg: "#5C5C68", bar: "#9A9AA8" }, }; function ChanceBadge({ level = "high", compact = false }) { const c = CHANCE_DEF[level]; if (compact) { return ( {c.label} ); } return (
{c.label} {c.pct}%
); } /* ───────────────────────────────────────────────────────────────── CategoryTile — placeholder for category illustration. Solid bg in category light tone + big letter (or icon) + counter. ───────────────────────────────────────────────────────────────── */ const CATEGORIES = { it: { label: "IT и разработка", hue: "it", letter: "IT", icon: "bolt", count: 320 }, design: { label: "Дизайн и творчество", hue: "design", letter: "D", icon: "spark", count: 180 }, media: { label: "Медиа и блогинг", hue: "media", letter: "M", icon: "spark", count: 95 }, med: { label: "Медицина", hue: "brand", letter: "Мед", icon: "heart", count: 240 }, eng: { label: "Инженерия и техника", hue: "eng", letter: "Инж", icon: "compass", count: 410 }, food: { label: "Кухня и сервис", hue: "warn", letter: "Кух", icon: "star", count: 165 }, edu: { label: "Педагогика", hue: "brand", letter: "Пед", icon: "graduation",count: 130 }, law: { label: "Право и экономика", hue: "media", letter: "Юэ", icon: "scales", count: 140 }, auto: { label: "Авто и транспорт", hue: "it", letter: "Авт", icon: "compass", count: 98 }, beauty: { label: "Красота", hue: "design", letter: "Крас", icon: "spark", count: 72 }, sport: { label: "Спорт", hue: "pos", letter: "Спорт", icon: "trophy", count: 64 }, agri: { label: "Агро и сельское хозяйство", hue: "pos", letter: "Агро", icon: "star", count: 110 }, }; function CategoryTile({ cat = "it", size = "md", showCount = true, onClick }) { const c = CATEGORIES[cat] || CATEGORIES.it; const tone = BADGE_TONES[c.hue]; const dim = { sm: 88, md: 160, lg: 220 }[size]; return ( ); } /* ───────────────────────────────────────────────────────────────── CategoryMark — large hero-style category illustration. Same shape as CategoryTile's icon but on the full hero scale. ───────────────────────────────────────────────────────────────── */ function CategoryMark({ cat = "it", size = 180, radius = 24 }) { const c = CATEGORIES[cat] || CATEGORIES.it; const tone = BADGE_TONES[c.hue]; return (
{c.letter}
); } /* ───────────────────────────────────────────────────────────────── MetricCard — 4-up row inside college card / page hero ───────────────────────────────────────────────────────────────── */ function MetricCard({ label, value, unit, icon, tone = "neutral", style }) { return (
{icon && } {label}
{value} {unit && {unit}}
); } /* ───────────────────────────────────────────────────────────────── CollegeCard — main aggregator component. props: view: "grid" | "list" name, type, premium, photo, price, metrics, chance, badges, onCompare, compared, onFav, favorited Стили инжектятся один раз через ensureCollegeCardCSS(). ───────────────────────────────────────────────────────────────── */ const __cc_state = { injected: false }; function ensureCollegeCardCSS() { if (__cc_state.injected || typeof document === "undefined") return; __cc_state.injected = true; const s = document.createElement("style"); s.id = "cc-card-styles"; s.textContent = ` .cc { position: relative; background: var(--white); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: transform .35s cubic-bezier(.2,.7,.2,1), box-shadow .3s ease, border-color .25s ease; cursor: pointer; } .cc:hover { transform: translateY(-6px); box-shadow: 0 28px 56px -20px rgba(4,52,44,0.22), 0 6px 14px rgba(4,52,44,0.06); border-color: var(--teal); } .cc.cc-premium { border-color: var(--teal); box-shadow: 0 0 0 1px var(--teal); } .cc.cc-premium:hover { box-shadow: 0 0 0 1px var(--teal), 0 28px 56px -20px rgba(4,52,44,0.22); } /* PHOTO */ .cc-photo { position: relative; overflow: hidden; background: var(--blue-light); } .cc-photo .stripes { position: absolute; inset: 0; opacity: 0.35; transition: transform .6s cubic-bezier(.2,.7,.2,1), opacity .3s; } .cc:hover .cc-photo .stripes { transform: scale(1.08); opacity: 0.45; } .cc-photo .ph-glyph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 6px; transition: transform .5s cubic-bezier(.2,.7,.2,1); } .cc:hover .cc-photo .ph-glyph { transform: scale(1.06); } .cc-photo .ph-glyph .ico { font-family: var(--font-display); font-weight: 700; font-size: 56px; letter-spacing: -0.02em; line-height: 1; opacity: 0.85; } .cc-photo .ph-glyph .lbl { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.65; } .cc-photo .ph-overlay { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0,0,0,0.32) 100%); pointer-events: none; opacity: 0; transition: opacity .25s ease; } .cc:hover .cc-photo .ph-overlay { opacity: 1; } /* Photo badges */ .cc-tag { position: absolute; z-index: 2; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; padding: 6px 10px; border-radius: 999px; backdrop-filter: blur(8px); white-space: nowrap; } .cc-tag.tg-premium { top: 12px; left: 12px; background: var(--teal); color: #fff; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; } .cc-tag.tg-budget { bottom: 12px; left: 12px; background: rgba(255,255,255,0.95); color: var(--status-pos); box-shadow: 0 4px 12px rgba(0,0,0,0.08); } .cc-tag.tg-chance { bottom: 12px; left: 12px; background: rgba(255,255,255,0.95); box-shadow: 0 4px 12px rgba(0,0,0,0.08); } .cc-fav { position: absolute; top: 12px; right: 12px; z-index: 2; width: 36px; height: 36px; border-radius: 999px; border: 0; cursor: pointer; background: rgba(255,255,255,0.95); color: var(--ink-2); display: inline-flex; align-items: center; justify-content: center; backdrop-filter: blur(8px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transition: transform .2s ease, color .2s ease; } .cc-fav:hover { transform: scale(1.08); color: var(--pink-dark); } .cc-fav.on { color: var(--pink-dark); } /* BODY */ .cc-body { display: flex; flex-direction: column; gap: 14px; padding: 20px; flex: 1; } /* В grid-виде прибиваем метрики, шанс и кнопки к низу — три карточки выравниваются */ .cc-grid .cc-metrics { margin-top: auto; } .cc-badges { display: flex; gap: 6px; flex-wrap: wrap; } .cc-name { font-family: var(--font-display); font-size: 22px; line-height: 1.18; letter-spacing: -0.02em; font-weight: 600; color: var(--ink); margin: 0; transition: color .2s ease; } .cc:hover .cc-name { color: var(--teal-deep); } .cc-addr { display: flex; align-items: center; gap: 6px; color: var(--ink-2); font-size: 14px; line-height: 1.4; } .cc-addr .pin { color: var(--teal); flex-shrink: 0; } /* Metric strip */ .cc-metrics { display: grid; grid-template-columns: repeat(4, 1fr); padding: 16px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); gap: 8px; } .cc-metric { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } .cc-metric .lbl { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-2); font-weight: 500; } .cc-metric .val { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.025em; line-height: 1; color: var(--ink); } .cc-metric .val.pos { color: var(--status-pos); } .cc-metric .val.muted { color: var(--ink-3); font-weight: 600; } .cc-metric .val.small { font-size: 16px; } /* Footer actions */ .cc-actions { display: flex; gap: 10px; } .cc-actions .btn-cmp { flex: 0 0 auto; height: 44px; min-width: 44px; padding: 0 14px; border: 1px solid var(--line-strong); border-radius: var(--r-md); background: var(--white); color: var(--ink-2); font-family: inherit; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all .2s ease; } .cc-actions .btn-cmp:hover { border-color: var(--teal); color: var(--teal-deep); } .cc-actions .btn-cmp.on { background: var(--teal-light); border-color: var(--teal); color: var(--teal-deep); font-weight: 600; } .cc-actions .btn-more { flex: 1; height: 44px; padding: 0 20px; border: 0; border-radius: var(--r-md); background: var(--ink); color: #fff; font-family: inherit; font-size: 15px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: background .2s ease, gap .2s ease; } .cc-actions .btn-more:hover { background: var(--teal-deep); gap: 12px; } .cc-actions .btn-more .arr { display: inline-flex; transition: transform .25s ease; } .cc:hover .cc-actions .btn-more { background: var(--teal-deep); } .cc:hover .cc-actions .btn-more .arr { transform: translateX(3px); } .cc-cmp-added { display: flex; align-items: center; gap: 6px; color: var(--status-pos); background: var(--status-pos-light); padding: 8px 12px; border-radius: var(--r-md); font-size: 13px; font-weight: 500; } /* GRID layout (default) */ .cc-grid .cc-photo { height: 220px; } /* LIST layout */ .cc-list { flex-direction: row; align-items: stretch; } .cc-list .cc-photo { width: 320px; flex-shrink: 0; min-height: 100%; } .cc-list .cc-body { padding: 24px 28px; gap: 16px; min-width: 0; } .cc-list .cc-head-row { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: start; } .cc-list .cc-name { font-size: 24px; } .cc-list .cc-metrics { padding: 14px 0; } .cc-list .cc-actions { margin-top: auto; } .cc-list .price-block { text-align: right; min-width: 140px; } .cc-list .price-block .big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.02em; line-height: 1; color: var(--ink); } .cc-list .price-block .small { font-size: 13px; color: var(--ink-2); margin-top: 4px; } .cc-list .price-block.budget .big { color: var(--status-pos); } @media (max-width: 800px) { .cc-list { flex-direction: column; } .cc-list .cc-photo { width: 100%; height: 200px; } .cc-list .cc-head-row { grid-template-columns: 1fr; } .cc-list .price-block { text-align: left; } } `; document.head.appendChild(s); } function CollegeCard({ name = "Колледж №1", city = "Москва", metro = "Чистые пруды", type = "Государственный", badges = [], price, hasBudget = true, programs = 4, budgetSeats = 25, avgScore = 4.2, dorm = true, chance = "high", premium = false, category = "it", view = "grid", // "grid" | "list" href = "/college/sample/", photoSrc = null, // реальное фото (если есть) onCompare, compared, onFav, favorited, }) { React.useLayoutEffect(ensureCollegeCardCSS, []); const c = CATEGORIES[category] || CATEGORIES.it; const tone = BADGE_TONES[c.hue]; const chanceDef = CHANCE_DEF[chance]; const isList = view === "list"; /* Card click → перейти на страницу колледжа, кроме случаев когда кликнули по интерактиву внутри */ function handleCardClick(e) { const t = e.target.closest("button, a, [data-stop]"); if (t && t !== e.currentTarget) return; window.location.href = href; } const photo = (
{photoSrc ? ( {`Фото { e.target.style.display = 'none'; }} /> ) : ( <>
{c.letter} фото колледжа
)}
{premium && ★ Реклама} {!isList && ( hasBudget ? ( Есть бюджет ) : ( от {price?.toLocaleString("ru")} ₽ ) )}
); const metrics = (
Ср. балл {avgScore.toFixed(1)}
Бюджет 0 ? "pos" : "muted")}> {budgetSeats > 0 ? budgetSeats : "—"}
Программ {programs}
Общежитие {dorm ? "Есть" : "Нет"}
); const actions = (
Подробнее
); if (isList) { return (
{photo}
{type} {badges.slice(0, 2).map((b, i) => {b})}

{name}

{city} · м. {metro}
{hasBudget ? ( <>
Бюджет
{budgetSeats} мест · от 0 ₽
) : ( <>
от {(price/1000)?.toFixed(0)} тыс. ₽
платное, год
)}
{metrics} {compared && (
Добавлено в сравнение
)} {actions}
); } /* GRID */ return (
{photo}
{type} {badges.slice(0, 1).map((b, i) => {b})}

{name}

{city} · м. {metro}
{metrics}
{compared && (
Добавлено в сравнение
)} {actions}
); } /* ───────────────────────────────────────────────────────────────── Breadcrumbs ───────────────────────────────────────────────────────────────── */ function Breadcrumbs({ items = [] }) { return ( ); } /* ───────────────────────────────────────────────────────────────── TopBar / SiteHeader — shared by all 4 pages ───────────────────────────────────────────────────────────────── */ function SiteHeader({ active = "home" }) { const [compareCount, setCompareCount] = React.useState(0); const [favCount, setFavCount] = React.useState(0); const [mobileOpen, setMobileOpen] = React.useState(false); React.useEffect(() => { const update = () => { try { const cmp = JSON.parse(localStorage.getItem("ic_compared") || "[]"); setCompareCount(Array.isArray(cmp) ? cmp.length : 0); } catch { setCompareCount(0); } try { const fav = JSON.parse(localStorage.getItem("ic_favorites") || "[]"); setFavCount(Array.isArray(fav) ? fav.length : 0); } catch { setFavCount(0); } }; update(); window.addEventListener("storage", update); window.addEventListener("ic:compareChanged", update); window.addEventListener("ic:favoritesChanged", update); return () => { window.removeEventListener("storage", update); window.removeEventListener("ic:compareChanged", update); window.removeEventListener("ic:favoritesChanged", update); }; }, []); React.useEffect(() => { if (mobileOpen) document.body.classList.add("nav-mobile-open"); else document.body.classList.remove("nav-mobile-open"); }, [mobileOpen]); const links = [ { id: "catalog", label: "Каталог", href: "/catalog/" }, { id: "prof", label: "Профессии", href: "/profession/" }, { id: "programs", label: "Программы", href: "/programs/" }, { id: "test", label: "Тест", href: "#" }, { id: "compare", label: "Сравнение", href: "/compare/", badge: compareCount }, { id: "favorites", label: "Избранное", href: "/favorites/", badge: favCount, heart: true }, { id: "blog", label: "Блог", href: "/blog/" }, ]; return (
{/* мобильное меню */}
); } function SiteFooter() { return ( ); } /* Expose */ Object.assign(window, { Icon, ICON_PATHS, Button, Input, Select, Dropdown, Chip, Badge, ChanceBadge, CategoryTile, CategoryMark, CATEGORIES, BADGE_TONES, MetricCard, CollegeCard, Breadcrumbs, SiteHeader, SiteFooter, });