// CrashyStars landing — interactivity layer. // Most of the page is static HTML; this layer wires up: // - quick-buy tabs (Stars / Gift / TON) // - pack selection + summary recomputation // - payment method selection // - showcase → buy tab deep-link // - tweaks panel (color, density, SEO length, hero alignment) const { useState, useEffect } = React; /* ── Quick-buy widget ──────────────────────────────────────────── */ function initBuyWidget() { const card = document.querySelector('.buy-card'); if (!card) return; const tabs = card.querySelectorAll('.buy-tab'); const panels = card.querySelectorAll('.panel'); const side = card.querySelector('.buy-side'); if (!side) return; const rcp = { target: side.querySelector('[data-rcp-target]'), em: side.querySelector('[data-rcp-em]'), title: side.querySelector('[data-rcp-title]'), meta: side.querySelector('[data-rcp-meta]'), tariff: side.querySelector('[data-rcp-tariff]'), user: side.querySelector('[data-rcp-user]'), pay: side.querySelector('[data-rcp-pay]'), save: side.querySelector('[data-rcp-save]'), price: side.querySelector('[data-rcp-price]'), price2: side.querySelector('[data-rcp-price2]'), avatar: side.querySelector('[data-rcp-avatar]'), }; function fmtRub(v) { return (+v).toLocaleString('ru-RU') + '₽'; } function fmtUsd(v) { return '$' + (+v).toFixed(2); } function fmtMoney(rub, usd) { return document.body.dataset.currency === 'usd' ? fmtUsd(usd) : fmtRub(rub); } // Telegram username: letters/digits/underscore, 4–32 chars (mirrors server). const TG_USERNAME_RE = /^[a-zA-Z0-9_]{4,32}$/; const _avatarCache = new Map(); // username(lower) -> url|null const _avatarTimers = new WeakMap(); // panel -> timeoutId const _avatarSeq = new WeakMap(); // panel -> last seq id function setAvatar(url) { if (!rcp.avatar) return; if (url) { rcp.avatar.src = url; rcp.avatar.hidden = false; } else { rcp.avatar.removeAttribute('src'); rcp.avatar.hidden = true; } } function fetchAvatar(panel, uname) { const key = uname.toLowerCase(); if (_avatarCache.has(key)) { setAvatar(_avatarCache.get(key)); return; } const seq = (_avatarSeq.get(panel) || 0) + 1; _avatarSeq.set(panel, seq); fetch('/api/avatar?username=' + encodeURIComponent(uname), { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : null) .then(j => { if (_avatarSeq.get(panel) !== seq) return; // a newer query superseded us const url = (j && j.ok && j.url) ? j.url : null; _avatarCache.set(key, url); // Only paint if the input still matches const input = panel.querySelector('[data-username-input]'); const cur = (input && input.value || '').replace(/^@+/, '').trim(); if (cur.toLowerCase() === key) setAvatar(url); }) .catch(() => { /* ignore */ }); } function setUser(panel) { const input = panel.querySelector('[data-username-input]') || panel.querySelector('input[type="text"]'); const raw = (input && input.value || '').replace(/^@+/, '').trim(); const display = raw || 'username'; if (rcp.target) rcp.target.textContent = '@' + display; if (rcp.user) rcp.user.textContent = '@' + display; const box = input && input.closest('.field-box'); const valid = TG_USERNAME_RE.test(raw); if (box) { box.classList.toggle('is-valid', valid); box.classList.toggle('is-invalid', !!raw && !valid); } // Debounce the avatar fetch — only fire when the username is shape-valid. clearTimeout(_avatarTimers.get(panel)); if (!valid) { setAvatar(null); return; } _avatarTimers.set(panel, setTimeout(() => fetchAvatar(panel, raw), 400)); } function update(panel) { if (!panel) return; const sel = panel.querySelector('.pack.sel'); const pay = panel.querySelector('.pay-opt.sel'); if (sel) { if (rcp.em) rcp.em.textContent = sel.dataset.em || '⭐'; if (rcp.title) rcp.title.textContent = (sel.dataset.label || '') + ' · от CrashyStars'; if (rcp.meta) rcp.meta.textContent = sel.dataset.meta || 'Доставка за ~30 секунд'; if (rcp.tariff) rcp.tariff.textContent = sel.dataset.label || ''; if (rcp.save) rcp.save.textContent = sel.dataset.save || '0%'; const rubVal = +(sel.dataset.priceRub || 0); const usdVal = +(sel.dataset.priceUsd || (rubVal / 73.2)); const price = fmtMoney(rubVal, usdVal); if (price) { if (rcp.price) { rcp.price.classList.remove('popping'); void rcp.price.offsetWidth; rcp.price.classList.add('popping'); rcp.price.textContent = price; } if (rcp.price2) rcp.price2.textContent = price; } } if (pay && rcp.pay) { const lbl = pay.dataset.payLabel || (pay.querySelector('b') && pay.querySelector('b').textContent) || ''; const sub = lbl === 'СБП' ? ' · 0%' : ''; rcp.pay.textContent = lbl + sub; } setUser(panel); } function activate(tabName) { tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); panels.forEach(p => p.classList.toggle('active', p.dataset.panel === tabName)); side.dataset.mode = tabName; const panel = card.querySelector(`.panel[data-panel="${tabName}"]`); update(panel); } tabs.forEach(t => t.addEventListener('click', () => activate(t.dataset.tab))); document.querySelectorAll('[data-tab]').forEach(el => { if (el.classList.contains('buy-tab')) return; el.addEventListener('click', () => activate(el.dataset.tab)); }); panels.forEach(panel => { panel.querySelectorAll('.pack').forEach(pk => { pk.addEventListener('click', () => { panel.querySelectorAll('.pack').forEach(p => p.classList.remove('sel')); pk.classList.add('sel'); // Sync custom-qty input back to pack value if present const qIn = panel.querySelector('input[type="number"]'); if (qIn && pk.dataset.qty && !isNaN(+pk.dataset.qty)) qIn.value = +pk.dataset.qty; update(panel); }); }); panel.querySelectorAll('.select-opt').forEach(opt => { opt.addEventListener('click', e => { const box = opt.closest('.select-box'); const label = box && box.querySelector('.select-label'); const b = opt.querySelector('b'); if (label && b) { label.textContent = b.textContent; box.classList.add('has-value'); } if (box) box.classList.remove('open'); // Update receipt pay if (rcp.pay) { const lbl = opt.dataset.payLabel || (b && b.textContent) || ''; rcp.pay.textContent = lbl; } e.stopPropagation(); }); }); const userInput = panel.querySelector('[data-username-input]') || panel.querySelector('input[type="text"]'); if (userInput) userInput.addEventListener('input', () => setUser(panel)); // Custom quantity → recompute from per-unit rate of currently selected pack const qtyInput = panel.querySelector('input[type="number"]'); if (qtyInput) { qtyInput.addEventListener('input', () => { const packs = panel.querySelectorAll('.pack'); const sel = panel.querySelector('.pack.sel') || packs[0]; if (!sel) return; const baseQty = +sel.dataset.qty || 1; const baseRub = +sel.dataset.priceRub || 0; let v = parseFloat(qtyInput.value); if (!isFinite(v) || v < 1) return; const max = +qtyInput.max || Infinity; const min = +qtyInput.min || 1; v = Math.min(Math.max(v, min), max); const rub = (baseRub / baseQty) * v; const baseUsd = +sel.dataset.priceUsd || 0; const usd = (baseUsd / baseQty) * v; const unit = sel.dataset.em || '⭐'; // Update receipt directly (skip the pack-derived update path) if (rcp.em) rcp.em.textContent = unit; if (rcp.title) rcp.title.textContent = `${v.toLocaleString('ru-RU')} ${unit} · от CrashyStars`; if (rcp.tariff) rcp.tariff.textContent = `${v.toLocaleString('ru-RU')} ${unit}`; if (rcp.meta) rcp.meta.textContent = sel.dataset.meta || 'Доставка за ~30 секунд'; const priceStr = fmtMoney(Math.round(rub), Math.round(usd * 100) / 100); if (rcp.price) { rcp.price.classList.remove('popping'); void rcp.price.offsetWidth; rcp.price.classList.add('popping'); rcp.price.textContent = priceStr; } if (rcp.price2) rcp.price2.textContent = priceStr; // De-select packs that don't match (so the UI stays consistent) const matching = [...packs].find(p => +p.dataset.qty === v); packs.forEach(p => p.classList.remove('sel')); if (matching) matching.classList.add('sel'); setUser(panel); }); } }); update(card.querySelector('.panel.active')); // Expose for currency toggle in the header window.__refreshReceipt = () => update(card.querySelector('.panel.active')); // ── Dropdown open/close (delegated, works for all .select-box on the page) ── const dropdowns = card.querySelectorAll('.select-box'); dropdowns.forEach(box => { box.addEventListener('click', e => { // Clicks on .select-opt are handled separately (they close + select). if (e.target.closest('.select-opt')) return; // Clicks anywhere else inside the menu shouldn't toggle. if (e.target.closest('.select-menu')) return; const wasOpen = box.classList.contains('open'); dropdowns.forEach(d => d !== box && d.classList.remove('open')); box.classList.toggle('open', !wasOpen); }); }); document.addEventListener('click', e => { if (!e.target.closest('.select-box')) { dropdowns.forEach(d => d.classList.remove('open')); } }); } /* ── Live feed: subtle re-ordering every few seconds ──────────── */ function initLiveFeed() { const list = document.getElementById('live-list'); if (!list) return; const TIME_LABELS = ['только что', '8 сек', '14 сек', '23 сек', '38 сек', '52 сек', '1 мин', '2 мин', '3 мин']; setInterval(() => { const rows = [...list.children]; if (rows.length < 2) return; // pop last → prepend (rotate ticker) list.insertBefore(rows[rows.length - 1], rows[0]); [...list.children].forEach((r, i) => { const t = r.querySelector('.lr-time'); if (t) t.textContent = TIME_LABELS[i] || `${i + 1} мин`; }); }, 4200); } /* ── Header toggle groups (lang / currency) ───────────────────── */ function initHeaderToggles() { document.querySelectorAll('[data-toggle-group]').forEach(group => { const groupName = group.dataset.toggleGroup; // "lang" | "currency" group.addEventListener('click', e => { const opt = e.target.closest('.opt'); if (!opt || !group.contains(opt)) return; group.querySelectorAll('.opt').forEach(o => o.classList.remove('active')); opt.classList.add('active'); // Persist on body so CSS .ccy-rub / .ccy-usd rules + JS formatters react if (groupName === 'currency') { const v = opt.dataset.currency || 'rub'; document.body.dataset.currency = v; // Refresh the receipt to use the new currency if (typeof window.__refreshReceipt === 'function') window.__refreshReceipt(); } else if (groupName === 'lang') { document.body.dataset.lang = opt.dataset.lang || 'ru'; } }); }); } /* ── Smooth-scroll for in-page anchor links ───────────────────── */ function initSmoothScroll() { document.querySelectorAll('a[href^="#"]').forEach(a => { a.addEventListener('click', e => { const href = a.getAttribute('href'); if (!href || href === '#') return; const tgt = document.querySelector(href); if (!tgt) return; e.preventDefault(); tgt.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); } /* ── Hero deck mouse tilt ─────────────────────────────────────── */ function initHeroDeckTilt() { const deck = document.querySelector('.hero-deck'); if (!deck) return; // Respect reduced-motion preference — keep static drift, no tilt if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; // Touch / coarse pointer — skip tilt; tap should just navigate if (matchMedia('(pointer: coarse)').matches) return; let raf = 0; let targetMx = 0, targetMy = 0; let curMx = 0, curMy = 0; const host = deck.parentElement; // .hero-art function loop() { // ease toward target for smoothness curMx += (targetMx - curMx) * 0.12; curMy += (targetMy - curMy) * 0.12; deck.style.setProperty('--mx', curMx.toFixed(3)); deck.style.setProperty('--my', curMy.toFixed(3)); if (Math.abs(targetMx - curMx) > 0.001 || Math.abs(targetMy - curMy) > 0.001) { raf = requestAnimationFrame(loop); } else { raf = 0; // If we've coasted back to 0,0, drop tilt mode to resume drift animation if (targetMx === 0 && targetMy === 0) deck.removeAttribute('data-tilt'); } } function start() { if (!raf) raf = requestAnimationFrame(loop); } host.addEventListener('mousemove', e => { const rect = host.getBoundingClientRect(); targetMx = ((e.clientX - rect.left) / rect.width - 0.5) * 2; targetMy = ((e.clientY - rect.top) / rect.height - 0.5) * 2; deck.setAttribute('data-tilt', 'on'); start(); }); host.addEventListener('mouseleave', () => { targetMx = 0; targetMy = 0; start(); }); } /* ── Tweaks panel ─────────────────────────────────────────────── */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "brand", "density": "regular", "seoLength": "full", "showLiveFeed": true, "showCompare": true, "showReferral": true, "starsBadge": "★ Хит" }/*EDITMODE-END*/; const ACCENT_PRESETS = { brand: { teal: '#5ee5d9', blue: '#6d98ef', violet: '#b39aff' }, sunset: { teal: '#ffd86b', blue: '#ff9bd2', violet: '#b39aff' }, neon: { teal: '#39ff88', blue: '#39d4ff', violet: '#b78aff' }, tg: { teal: '#7ec0ff', blue: '#5b8cff', violet: '#9badff' }, }; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); useEffect(() => { // accent const p = ACCENT_PRESETS[t.accent] || ACCENT_PRESETS.brand; const root = document.documentElement; root.style.setProperty('--c-teal', p.teal); root.style.setProperty('--c-blue', p.blue); root.style.setProperty('--c-violet', p.violet); root.style.setProperty('--grad-brand', `linear-gradient(110deg, ${p.teal} 0%, ${p.blue} 50%, ${p.violet} 100%)`); root.style.setProperty('--grad-brand-soft', `linear-gradient(110deg, ${hexA(p.teal, .18)} 0%, ${hexA(p.blue, .18)} 50%, ${hexA(p.violet, .18)} 100%)`); // density + seo length document.body.dataset.density = t.density; document.body.dataset.seo = t.seoLength; // section visibility const live = document.querySelector('.live'); if (live) live.style.display = t.showLiveFeed ? '' : 'none'; const cmp = document.querySelector('.compare'); if (cmp) cmp.style.display = t.showCompare ? '' : 'none'; const ref = document.querySelector('.refer'); if (ref) ref.style.display = t.showReferral ? '' : 'none'; }, [t]); return ( setTweak('accent', v)} /> setTweak('density', v)} /> setTweak('seoLength', v)} /> setTweak('showLiveFeed', v)} /> setTweak('showCompare', v)} /> setTweak('showReferral', v)} /> ); } function hexA(hex, alpha) { const h = hex.replace('#', ''); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } /* ── Mount ─────────────────────────────────────────────────────── */ // Babel-standalone transpiles async — by the time this runs, DOMContentLoaded // has already fired, so we run init inline (or wait if doc is still loading). function bootAll() { initBuyWidget(); initLiveFeed(); initSmoothScroll(); initHeaderToggles(); const root = document.createElement('div'); root.id = '__tweaks_root'; document.body.appendChild(root); ReactDOM.createRoot(root).render(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootAll); } else { bootAll(); }