Snapshot: full project state
This commit is contained in:
315
static/app.js
Normal file
315
static/app.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user