// 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();
}