Files
quasar-app/static/app.js
2026-10-06 23:43:23 -07:00

316 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Supernova — slow, cinematic starfield + satisfying interactivity.
// ---------------------------------------------------------------
// Starfield (slow, gentle parallax drift)
// ---------------------------------------------------------------
(function () {
const canvas = document.getElementById('starfield');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let stars = [], W, H;
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; }
function makeStars() {
stars = [];
const n = Math.min(260, Math.floor(W * H / 6000));
for (let i = 0; i < n; i++) {
stars.push({
x: Math.random() * W, y: Math.random() * H,
r: Math.random() * 1.3 + 0.3,
tw: Math.random() * Math.PI * 2, twSpeed: 0.4 + Math.random() * 0.9,
drift: 0.04 + Math.random() * 0.18, hue: Math.random(),
});
}
}
let t = 0;
function frame() {
t += 0.016;
ctx.clearRect(0, 0, W, H);
for (const s of stars) {
s.y += s.drift; s.x += s.drift * 0.15;
if (s.y > H + 4) { s.y = -4; s.x = Math.random() * W; }
if (s.x > W + 4) s.x = -4;
const tw = 0.55 + 0.45 * Math.sin(t * s.twSpeed + s.tw);
const a = tw * (0.5 + 0.5 * s.r / 1.6);
let col = s.hue < 0.72 ? `rgba(207,224,255,${a})`
: s.hue < 0.86 ? `rgba(186,160,255,${a})`
: s.hue < 0.95 ? `rgba(160,200,255,${a})` : `rgba(255,180,220,${a})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = col; ctx.fill();
}
requestAnimationFrame(frame);
}
function shootingStar() {
const x = Math.random() * W * 0.7 + W * 0.15, y = Math.random() * H * 0.4;
const len = 90 + Math.random() * 60, ang = Math.PI * 0.75; let p = 0;
const step = () => {
p += 0.04;
const cx = x + Math.cos(ang) * len * p, cy = y + Math.sin(ang) * len * p;
const g = ctx.createLinearGradient(cx, cy, cx - Math.cos(ang) * len * 0.4, cy - Math.sin(ang) * len * 0.4);
g.addColorStop(0, 'rgba(255,255,255,0.85)'); g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.strokeStyle = g; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.lineTo(cx - Math.cos(ang) * len * 0.4, cy - Math.sin(ang) * len * 0.4); ctx.stroke();
if (p < 1) requestAnimationFrame(step);
};
step(); setTimeout(shootingStar, 9000 + Math.random() * 12000);
}
resize(); makeStars(); requestAnimationFrame(frame);
setTimeout(shootingStar, 4000);
addEventListener('resize', () => { resize(); makeStars(); });
})();
// ---------------------------------------------------------------
// Confetti celebration (gentle galaxy burst)
// ---------------------------------------------------------------
function celebrate() {
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
canvas.width = innerWidth; canvas.height = innerHeight;
const colors = ['#7b2ff7', '#2b6cff', '#ff2d95', '#ffffff', '#cfe0ff', '#7cffb0'];
const parts = [];
for (let i = 0; i < 150; i++) {
parts.push({
x: Math.random() * canvas.width,
y: -20 - Math.random() * canvas.height * 0.4,
r: Math.random() * 4 + 2,
vy: 1.4 + Math.random() * 2.2,
vx: (Math.random() - 0.5) * 1.6,
rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 0.07,
color: colors[Math.floor(Math.random() * colors.length)],
star: Math.random() < 0.45,
});
}
let frames = 0;
function starPath(c, r) {
c.beginPath();
for (let i = 0; i < 5; i++) {
const a = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const a2 = a + (2 * Math.PI) / 5;
c.lineTo(Math.cos(a) * r, Math.sin(a) * r);
c.lineTo(Math.cos((a + a2) / 2) * r * 0.4, Math.sin((a + a2) / 2) * r * 0.4);
}
c.closePath(); c.fill();
}
function step() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
frames++;
for (const p of parts) {
p.x += p.vx; p.y += p.vy; p.rot += p.vr; p.vy += 0.015;
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
ctx.globalAlpha = Math.max(0, 1 - frames / 250);
ctx.fillStyle = p.color;
if (p.star) starPath(ctx, p.r);
else { ctx.beginPath(); ctx.arc(0, 0, p.r, 0, Math.PI * 2); ctx.fill(); }
ctx.restore();
}
if (frames < 270 && parts.some(p => p.y < canvas.height + 40)) requestAnimationFrame(step);
else canvas.remove();
}
requestAnimationFrame(step);
}
// ---------------------------------------------------------------
// Subtle 3D card tilt (slow, max 4deg)
// ---------------------------------------------------------------
(function () {
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mousemove', e => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.transform = `perspective(900px) rotateX(${(-py * 4).toFixed(2)}deg) rotateY(${(px * 4).toFixed(2)}deg) translateY(-3px)`;
});
card.addEventListener('mouseleave', () => { card.style.transform = ''; });
});
})();
// ---------------------------------------------------------------
// Scroll reveal (fade + rise, gentle)
// ---------------------------------------------------------------
(function () {
const els = document.querySelectorAll('.card, .stat, .seo-block, .section-title');
if (!('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(entries => {
entries.forEach(en => {
if (en.isIntersecting) { en.target.classList.add('revealed'); io.unobserve(en.target); }
});
}, { threshold: 0.08 });
els.forEach(el => {
el.classList.add('reveal');
io.observe(el);
});
})();
// ---------------------------------------------------------------
// Button ripple
// ---------------------------------------------------------------
(function () {
document.addEventListener('click', e => {
const btn = e.target.closest('.btn');
if (!btn) return;
const r = btn.getBoundingClientRect();
const rip = document.createElement('span');
const size = Math.max(r.width, r.height) * 1.6;
rip.style.cssText = `position:absolute;border-radius:50%;width:${size}px;height:${size}px;` +
`left:${e.clientX - r.left - size / 2}px;top:${e.clientY - r.top - size / 2}px;` +
`background:radial-gradient(circle,rgba(255,255,255,0.45),rgba(255,255,255,0));` +
`transform:scale(0);animation:ripple .6s ease-out forwards;pointer-events:none`;
btn.style.position = 'relative'; btn.style.overflow = 'hidden';
btn.appendChild(rip);
setTimeout(() => rip.remove(), 650);
});
})();
// ---------------------------------------------------------------
// Quantity slider — animated price counter + value tween
// ---------------------------------------------------------------
(function () {
const slider = document.getElementById('qty');
const priceEl = document.getElementById('live-price');
const qtyEl = document.getElementById('qty-val');
if (!slider || !priceEl) return;
const serviceId = parseInt(slider.dataset.service, 10);
let shown = 0;
function render(target, qty) {
const start = performance.now(), from = shown, dur = 320;
function step(t) {
const p = Math.min(1, (t - start) / dur);
const eased = 1 - Math.pow(1 - p, 3);
const val = from + (target - from) * eased;
priceEl.innerHTML = `$${val.toFixed(2)} <small>for ${qty.toLocaleString()} ×</small>`;
if (p < 1) requestAnimationFrame(step); else shown = target;
}
requestAnimationFrame(step);
if (qtyEl) qtyEl.textContent = qty.toLocaleString();
}
function update() {
const q = parseInt(slider.value, 10);
fetch(`/api/quote/${serviceId}/${q}`)
.then(r => r.json()).then(d => render(d.retail, q))
.catch(() => {});
}
slider.addEventListener('input', update);
update();
})();
// ---------------------------------------------------------------
// Order success celebration (detect ?launched=1)
// ---------------------------------------------------------------
(function () {
if (new URLSearchParams(location.search).has('launched')) {
const banner = document.getElementById('launch-banner');
if (banner) {
banner.classList.add('show');
setTimeout(() => banner.classList.remove('show'), 5000);
}
celebrate();
}
})();
// ---------------------------------------------------------------
// Launch whoosh (Web Audio synth — subtle rising sweep)
// ---------------------------------------------------------------
function launchSound() {
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = new AC();
const dur = 0.55;
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) {
const t = i / d.length;
d[i] = (Math.random() * 2 - 1) * Math.pow(t, 1.6) * (1 - t) * 0.9;
}
const src = ctx.createBufferSource(); src.buffer = buf;
const filt = ctx.createBiquadFilter();
filt.type = 'bandpass'; filt.Q.value = 1.1;
filt.frequency.setValueAtTime(280, ctx.currentTime);
filt.frequency.exponentialRampToValueAtTime(2600, ctx.currentTime + dur);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.22, ctx.currentTime + dur * 0.28);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
src.connect(filt).connect(gain).connect(ctx.destination);
src.start();
setTimeout(() => ctx.close(), dur * 1000 + 200);
} catch (e) {}
}
(function () {
const btn = document.querySelector('.btn.launch');
if (btn) btn.addEventListener('click', launchSound);
})();
// ---------------------------------------------------------------
// Orbital planet picker — drag to spin, slow auto-rotate
// ---------------------------------------------------------------
(function () {
const ring = document.getElementById('orbit');
if (!ring) return;
const planets = Array.from(ring.querySelectorAll('.planet-orb'));
let angle = 0, vel = 0.06, dragging = false, lastX = 0;
function layout() {
const R = 175;
planets.forEach(p => {
const i = parseFloat(p.dataset.i);
const a = ((i * 45 + angle) * Math.PI) / 180;
p.style.transform = `translate(${(Math.cos(a) * R).toFixed(1)}px, ${(Math.sin(a) * R).toFixed(1)}px)`;
});
}
function tick() {
if (!dragging) { vel += (0.06 - vel) * 0.03; angle += vel; }
layout();
requestAnimationFrame(tick);
}
const down = x => { dragging = true; lastX = x; ring.style.cursor = 'grabbing'; };
const move = x => { if (!dragging) return; vel = (x - lastX) * 0.18; angle += vel; lastX = x; layout(); };
const up = () => { dragging = false; ring.style.cursor = 'grab'; };
ring.style.cursor = 'grab';
ring.addEventListener('mousedown', e => down(e.clientX));
window.addEventListener('mousemove', e => move(e.clientX));
window.addEventListener('mouseup', up);
ring.addEventListener('touchstart', e => down(e.touches[0].clientX), { passive: true });
window.addEventListener('touchmove', e => move(e.touches[0].clientX), { passive: true });
window.addEventListener('touchend', up);
requestAnimationFrame(tick);
})();
// ---------------------------------------------------------------
// Delivery ticker (dashboard live progress, poll every 45s)
// ---------------------------------------------------------------
(function () {
const wrap = document.getElementById('ticker');
if (!wrap) return;
const fmt = n => n.toLocaleString();
function bar(name, delivered, total, status) {
const pct = total > 0 ? Math.min(100, (delivered / total) * 100) : 0;
const label = status === 'completed' ? '✅ Done' : status === 'canceled' ? 'Cancelled'
: status === 'inprogress' ? '⚡ Delivering…' : '⏳ Queued';
return `<div class="tick">
<div class="tick-top"><span class="tick-name">${name}</span><span class="tick-meta">${fmt(delivered)} / ${fmt(total)} · ${label}</span></div>
<div class="tick-bar"><div class="tick-fill" style="width:${pct}%"></div></div>
</div>`;
}
function poll() {
fetch('/api/orders-status').then(r => r.json()).then(d => {
if (!d.orders || !d.orders.length) {
wrap.innerHTML = '<div class="muted center" style="padding:18px">No boosts yet — <a href="/services" style="color:var(--nebula-b)">launch one →</a></div>';
return;
}
wrap.innerHTML = d.orders.map(o => bar(o.name, o.delivered, o.quantity, o.status)).join('');
}).catch(() => {});
}
poll();
setInterval(poll, 45000);
})();