mirror of
https://github.com/hanzoai/mcp-server-cloudflare.git
synced 2026-10-30 18:28:06 +00:00
677 lines
19 KiB
JavaScript
677 lines
19 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const container = document.querySelector('.page-wrapper')
|
|
const starfield = document.createElement('div')
|
|
starfield.className = 'starfield'
|
|
container.appendChild(starfield)
|
|
|
|
// Create initial stars
|
|
const numberOfStars = 300
|
|
const stars = []
|
|
|
|
function createStar() {
|
|
const star = document.createElement('div')
|
|
star.className = 'star'
|
|
|
|
// Random position
|
|
const x = Math.random() * window.innerWidth
|
|
const y = Math.random() * window.innerHeight
|
|
|
|
// Random size (more variation in sizes)
|
|
const size = Math.random() * 2 + (Math.random() > 0.95 ? 1.5 : 0)
|
|
|
|
// More subtle initial opacity
|
|
const opacity = Math.random() * 0.15 + 0.05
|
|
|
|
star.style.cssText = `
|
|
left: ${x}px;
|
|
top: ${y}px;
|
|
width: ${size}px;
|
|
height: ${size}px;
|
|
opacity: ${opacity};
|
|
`
|
|
|
|
return star
|
|
}
|
|
|
|
// Initialize stars
|
|
for (let i = 0; i < numberOfStars; i++) {
|
|
const star = createStar()
|
|
starfield.appendChild(star)
|
|
stars.push(star)
|
|
}
|
|
|
|
// Animate stars
|
|
function twinkle() {
|
|
stars.forEach((star) => {
|
|
// Random chance to twinkle
|
|
if (Math.random() > 0.98) {
|
|
const currentOpacity = parseFloat(star.style.opacity)
|
|
const targetOpacity =
|
|
currentOpacity < 0.1
|
|
? Math.random() * 0.15 + 0.05 // Brighter
|
|
: Math.random() * 0.05 + 0.02 // Dimmer
|
|
|
|
// Slower transition for more subtle effect
|
|
star.style.transition = 'opacity 1.5s ease-in-out'
|
|
star.style.opacity = targetOpacity
|
|
}
|
|
})
|
|
|
|
requestAnimationFrame(twinkle)
|
|
}
|
|
|
|
// Start animation
|
|
twinkle()
|
|
|
|
// Form handling
|
|
const emailForm = document.querySelector('.input-group')
|
|
//const //emailInput = null //emailForm.querySelector('input[type="email"]')
|
|
const honeypotInput = emailForm.querySelector('input[name="contact_me_by_fax"]')
|
|
//const notifyButton = emailForm.querySelector('.notify-btn')
|
|
|
|
// Check if user has already signed up
|
|
/*if (localStorage.getItem('mcp_demo_signup')) {
|
|
const savedEmail = localStorage.getItem('mcp_demo_signup')
|
|
showSuccessState(savedEmail)
|
|
}*/
|
|
|
|
/*function showSuccessState(email) {
|
|
const inputGroup = //emailInput.closest('.input-group')
|
|
inputGroup.classList.add('success')
|
|
//emailInput.value = email
|
|
//emailInput.disabled = true
|
|
notifyButton.disabled = true
|
|
|
|
// Update button
|
|
notifyButton.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
`
|
|
notifyButton.classList.add('success')
|
|
createConfetti(notifyButton)
|
|
}*/
|
|
|
|
// Rate limiting configuration
|
|
const RATE_LIMIT_DURATION = 60000 // 1 minute
|
|
const MAX_ATTEMPTS = 5
|
|
let attemptCount = 0
|
|
let lastAttemptTime = 0
|
|
|
|
function checkRateLimit() {
|
|
const now = Date.now()
|
|
if (now - lastAttemptTime > RATE_LIMIT_DURATION) {
|
|
attemptCount = 0
|
|
}
|
|
|
|
if (attemptCount >= MAX_ATTEMPTS) {
|
|
return false
|
|
}
|
|
|
|
attemptCount++
|
|
lastAttemptTime = now
|
|
return true
|
|
}
|
|
|
|
// Enhanced email validation
|
|
function isValidEmail(email) {
|
|
if (email.length > 254) return false
|
|
const re =
|
|
/^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/
|
|
return re.test(email)
|
|
}
|
|
|
|
// XSS Prevention
|
|
function sanitizeInput(str) {
|
|
const div = document.createElement('div')
|
|
div.textContent = str
|
|
return div.innerHTML
|
|
}
|
|
|
|
// Debounced email validation
|
|
const debounce = (fn, delay) => {
|
|
let timeoutId
|
|
return (...args) => {
|
|
clearTimeout(timeoutId)
|
|
timeoutId = setTimeout(() => fn(...args), delay)
|
|
}
|
|
}
|
|
|
|
// Retry logic for API calls
|
|
async function retryFetch(url, options, maxRetries = 3) {
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
const response = await fetch(url, options)
|
|
const data = await response.json()
|
|
return { response, data }
|
|
} catch (error) {
|
|
if (i === maxRetries - 1) throw error
|
|
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, i)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enhanced toast function with accessibility
|
|
function showToast(message, duration = 3000) {
|
|
const existingToast = document.querySelector('.toast')
|
|
if (existingToast) {
|
|
existingToast.remove()
|
|
}
|
|
|
|
const toast = document.createElement('div')
|
|
toast.className = 'toast'
|
|
toast.setAttribute('role', 'alert')
|
|
toast.setAttribute('aria-live', 'polite')
|
|
toast.textContent = sanitizeInput(message)
|
|
document.body.appendChild(toast)
|
|
|
|
toast.offsetHeight
|
|
toast.classList.add('show')
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show')
|
|
setTimeout(() => toast.remove(), 300)
|
|
}, duration)
|
|
}
|
|
|
|
// Setup accessibility attributes
|
|
/*function setupAccessibility() {
|
|
//emailInput.setAttribute('aria-label', 'Email address for notification')
|
|
notifyButton.setAttribute('aria-label', 'Sign up for notification')
|
|
document.querySelector('.success-message')?.setAttribute('role', 'status')
|
|
}*/
|
|
|
|
// Debounced email validation on input
|
|
const validateEmailDebounced = debounce((email) => {
|
|
const isValid = isValidEmail(email)
|
|
//emailInput.style.border = isValid ? '' : '1px solid red'
|
|
}, 300)
|
|
|
|
//emailInput.addEventListener('input', (e) => validateEmailDebounced(e.target.value))
|
|
|
|
// Enhanced click handler with all improvements
|
|
/*notifyButton.addEventListener('click', async (e) => {
|
|
e.preventDefault()
|
|
//const email = //emailInput.value.trim()
|
|
|
|
// Rate limit check
|
|
if (!checkRateLimit()) {
|
|
showToast('Please wait a minute before trying again.')
|
|
return
|
|
}
|
|
|
|
// Basic validation
|
|
if (!email) {
|
|
//emailInput.style.border = '1px solid red'
|
|
return
|
|
}
|
|
|
|
if (!isValidEmail(email)) {
|
|
//emailInput.style.border = '1px solid red'
|
|
showToast('Please enter a valid email address.')
|
|
return
|
|
}
|
|
|
|
// Check if already signed up
|
|
/*if (localStorage.getItem('mcp_demo_signup')) {
|
|
return
|
|
}*/
|
|
|
|
// Honeypot check
|
|
/*if (honeypotInput.value) {
|
|
console.log('Bot detected')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const { response, data } = await retryFetch(
|
|
'https://starbasedb-3285.outerbase.workers.dev/query',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Starbase-Source': 'internal',
|
|
Authorization: 'Bearer 8gmjguywgvsy2hvxnqpqzapwjq896ke3',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
sql: 'INSERT INTO signups (email, status, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)',
|
|
params: [sanitizeInput(email), 'pending'],
|
|
}),
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Signup failed')
|
|
}
|
|
|
|
localStorage.setItem('mcp_demo_signup', email)
|
|
showSuccessState(email)
|
|
setupCalendarActions()
|
|
setupAccessibility()
|
|
} catch (error) {
|
|
console.error('Error:', error)
|
|
//emailInput.style.border = '1px solid red'
|
|
|
|
if (error.message.includes('UNIQUE constraint failed')) {
|
|
showToast('This email is already registered for the demo. Check your inbox for details.')
|
|
localStorage.setItem('mcp_demo_signup', email)
|
|
showSuccessState(email)
|
|
setupCalendarActions()
|
|
setupAccessibility()
|
|
} else {
|
|
showToast('Something went wrong. Please try again.')
|
|
}
|
|
}
|
|
})*/
|
|
|
|
// Initialize accessibility
|
|
//()
|
|
|
|
function setupCalendarActions() {
|
|
const calendarActions = document.querySelectorAll(
|
|
'.success-message .calendar-action, .calendar-option'
|
|
)
|
|
const eventDetails =
|
|
"Get a preview of the future of agentic software. See how the world's most innovative platforms have connected agents to their services with MCP to build a new class of product experiences.\n\nJoin Live: https://cloudflare.tv/mcp-demo-day"
|
|
const location = 'https://cloudflare.tv/mcp-demo-day'
|
|
|
|
calendarActions.forEach((option) => {
|
|
option.addEventListener('click', () => {
|
|
const calendarType = option.dataset.calendarType
|
|
|
|
switch (calendarType) {
|
|
case 'google':
|
|
window.open(
|
|
'https://calendar.google.com/calendar/render?action=TEMPLATE&text=MCP+Demo+Day&details=Get+a+preview+of+the+future+of+agentic+software.+See+how+the+world%27s+most+innovative+platforms+have+connected+agents+to+their+services+with+MCP+to+build+a+new+class+of+product+experiences.%0A%0AJoin+Live%3A+https%3A%2F%2Fcloudflare.tv%2Fmcp-demo-day&location=https%3A%2F%2Fcloudflare.tv%2Fmcp-demo-day&dates=20250501T170000Z%2F20250501T183000Z',
|
|
'_blank'
|
|
)
|
|
break
|
|
|
|
case 'outlook':
|
|
window.open(
|
|
'https://outlook.live.com/calendar/0/deeplink/compose?subject=MCP+Demo+Day&body=Get+a+preview+of+the+future+of+agentic+software.+See+how+the+world%27s+most+innovative+platforms+have+connected+agents+to+their+services+with+MCP+to+build+a+new+class+of+product+experiences.%0A%0AJoin+Live%3A+https%3A%2F%2Fcloudflare.tv%2Fmcp-demo-day&startdt=2025-05-01T17%3A00%3A00Z&enddt=2025-05-01T18%3A30%3A00Z&location=https%3A%2F%2Fcloudflare.tv%2Fmcp-demo-day',
|
|
'_blank'
|
|
)
|
|
break
|
|
|
|
case 'apple':
|
|
case 'ics':
|
|
const icsContent = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//MCP Demo Day//EN
|
|
CALSCALE:GREGORIAN
|
|
BEGIN:VEVENT
|
|
SUMMARY:MCP Demo Day
|
|
DESCRIPTION:${eventDetails.replace(/\n/g, '\\n')}
|
|
LOCATION:${location}
|
|
DTSTART:20250501T170000Z
|
|
DTEND:20250501T183000Z
|
|
STATUS:CONFIRMED
|
|
SEQUENCE:0
|
|
END:VEVENT
|
|
END:VCALENDAR`
|
|
|
|
if (calendarType === 'apple') {
|
|
const dataUri = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(icsContent)
|
|
window.open(dataUri)
|
|
} else {
|
|
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' })
|
|
const link = document.createElement('a')
|
|
link.href = window.URL.createObjectURL(blob)
|
|
link.download = 'mcp_demo_day.ics'
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
}
|
|
break
|
|
}
|
|
|
|
// Close the dialog if it's open
|
|
const dialog = document.getElementById('calendarDialog')
|
|
if (dialog) {
|
|
dialog.close()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Call setupCalendarActions immediately if needed
|
|
setupCalendarActions()
|
|
|
|
// Remove red border on input focus
|
|
//emailInput.addEventListener('focus', () => {
|
|
//emailInput.style.border = 'none'
|
|
//})
|
|
|
|
// Company list hover effect
|
|
const companies = document.querySelectorAll('.demo-companies li')
|
|
companies.forEach((company) => {
|
|
company.addEventListener('mouseenter', () => {
|
|
companies.forEach((c) => {
|
|
if (c !== company) {
|
|
c.style.opacity = '0.5'
|
|
}
|
|
})
|
|
})
|
|
|
|
company.addEventListener('mouseleave', () => {
|
|
companies.forEach((c) => {
|
|
c.style.opacity = '1'
|
|
})
|
|
})
|
|
})
|
|
|
|
// Setup company backgrounds
|
|
const companyNames = [
|
|
'asana',
|
|
'atlassian',
|
|
'cloudflare',
|
|
'intercom',
|
|
'linear',
|
|
'paypal',
|
|
'sentry',
|
|
'square',
|
|
'stripe',
|
|
'webflow',
|
|
'more',
|
|
]
|
|
|
|
const demoList = document.querySelector('.demo-companies')
|
|
const companyItems = demoList.querySelectorAll('li')
|
|
|
|
// Add data attributes to company items
|
|
companyItems.forEach((item, index) => {
|
|
const company = companyNames[index]
|
|
if (company) {
|
|
item.setAttribute('data-company', company)
|
|
}
|
|
console.log('add attrib')
|
|
})
|
|
|
|
// Create background containers for each company
|
|
companyNames.forEach((company) => {
|
|
console.log('add container')
|
|
const background = document.createElement('div')
|
|
background.className = `company-background ${company}`
|
|
|
|
// Load SVG from file
|
|
fetch(`/public/${company}.svg`)
|
|
.then((response) => response.text())
|
|
.then((svgContent) => {
|
|
// Clean up the SVG to remove any fill paths
|
|
const parser = new DOMParser()
|
|
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
|
|
const svg = doc.querySelector('svg')
|
|
|
|
// Remove any fill attributes and set stroke
|
|
svg.querySelectorAll('path, circle, rect').forEach((path) => {
|
|
path.setAttribute('fill', 'none')
|
|
path.setAttribute('stroke', 'currentColor')
|
|
path.setAttribute('stroke-width', '.5')
|
|
})
|
|
|
|
background.innerHTML = svg.outerHTML
|
|
})
|
|
.catch((error) => console.error(`Error loading ${company}.svg:`, error))
|
|
|
|
demoList.appendChild(background)
|
|
})
|
|
|
|
// Add cycling animation for company backgrounds
|
|
let currentBackgroundIndex = 0
|
|
let isHovering = false
|
|
let hoverCompany = null
|
|
const backgrounds = document.querySelectorAll('.company-background')
|
|
|
|
function cycleBackgrounds() {
|
|
// Remove active class from all backgrounds and company names
|
|
backgrounds.forEach((bg) => bg.classList.remove('active'))
|
|
companyItems.forEach((item) => item.classList.remove('active'))
|
|
|
|
// If hovering, show the hovered company's background and highlight its name
|
|
if (isHovering && hoverCompany) {
|
|
const hoverBackground = Array.from(backgrounds).find((bg) =>
|
|
bg.classList.contains(hoverCompany)
|
|
)
|
|
const hoverItem = Array.from(companyItems).find(
|
|
(item) => item.getAttribute('data-company') === hoverCompany
|
|
)
|
|
if (hoverBackground) {
|
|
hoverBackground.classList.add('active')
|
|
if (hoverItem) hoverItem.classList.add('active')
|
|
return
|
|
}
|
|
}
|
|
|
|
// Otherwise, show the next background in the cycle and highlight its name
|
|
backgrounds[currentBackgroundIndex].classList.add('active')
|
|
companyItems[currentBackgroundIndex].classList.add('active')
|
|
currentBackgroundIndex = (currentBackgroundIndex + 1) % backgrounds.length
|
|
console.log('cucle')
|
|
}
|
|
|
|
// Start cycling every 3 seconds
|
|
setInterval(cycleBackgrounds, 3000)
|
|
// Show first background immediately
|
|
cycleBackgrounds()
|
|
|
|
// Handle hover states
|
|
companyItems.forEach((item) => {
|
|
item.addEventListener('mouseenter', () => {
|
|
isHovering = true
|
|
hoverCompany = item.getAttribute('data-company')
|
|
cycleBackgrounds() // Show the hovered company immediately
|
|
})
|
|
|
|
item.addEventListener('mouseleave', () => {
|
|
isHovering = false
|
|
hoverCompany = null
|
|
cycleBackgrounds() // Resume normal cycling
|
|
})
|
|
})
|
|
|
|
function parseDateTime() {
|
|
const dateText = document.querySelector('.date-time h2').textContent.trim() // e.g., "APRIL 30TH, 2025"
|
|
const timeText = document.querySelector('.date-time h3').textContent.trim() // e.g., "ONLINE, 1:00 PM PT"
|
|
|
|
// Remove ordinal indicators (st, nd, rd, th) and parse date
|
|
const cleanDateText = dateText.replace(/(ST|ND|RD|TH),/i, ',')
|
|
|
|
// Create a more precise regex to match the date format
|
|
const dateTimeParts = cleanDateText.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/i)
|
|
// Handle "ONLINE, " prefix in time
|
|
const timeParts = timeText
|
|
.replace(/^ONLINE,\s*/i, '')
|
|
.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)\s*(PST|PDT|EST|EDT|CST|CDT|MST|MDT|PT)?$/i)
|
|
|
|
if (!dateTimeParts || !timeParts) {
|
|
throw new Error('Invalid date/time format')
|
|
}
|
|
|
|
const [, month, day, year] = dateTimeParts
|
|
const [, hours, minutes, ampm, timezone] = timeParts
|
|
|
|
let hour24 = parseInt(hours)
|
|
|
|
// Convert to 24-hour format
|
|
if (ampm.toUpperCase() === 'PM' && hour24 < 12) hour24 += 12
|
|
if (ampm.toUpperCase() === 'AM' && hour24 === 12) hour24 = 0
|
|
|
|
// Create date in UTC
|
|
const date = new Date(
|
|
Date.UTC(parseInt(year), getMonthIndex(month), parseInt(day), hour24, parseInt(minutes), 0)
|
|
)
|
|
|
|
// Since the time is specified in PT (Pacific Time), adjust for PT (-7 hours from UTC during PDT)
|
|
date.setUTCHours(date.getUTCHours() - 7)
|
|
|
|
if (isNaN(date.getTime())) {
|
|
throw new Error('Invalid date/time format')
|
|
}
|
|
|
|
return date
|
|
}
|
|
|
|
// Helper function to get month index (0-11) from month name
|
|
function getMonthIndex(month) {
|
|
const months = {
|
|
JANUARY: 0,
|
|
JAN: 0,
|
|
FEBRUARY: 1,
|
|
FEB: 1,
|
|
MARCH: 2,
|
|
MAR: 2,
|
|
APRIL: 3,
|
|
APR: 3,
|
|
MAY: 4,
|
|
JUNE: 5,
|
|
JUN: 5,
|
|
JULY: 6,
|
|
JUL: 6,
|
|
AUGUST: 7,
|
|
AUG: 7,
|
|
SEPTEMBER: 8,
|
|
SEP: 8,
|
|
OCTOBER: 9,
|
|
OCT: 9,
|
|
NOVEMBER: 10,
|
|
NOV: 10,
|
|
DECEMBER: 11,
|
|
DEC: 11,
|
|
}
|
|
return months[month.toUpperCase()]
|
|
}
|
|
})
|
|
|
|
// Helper functions
|
|
function isValidEmail(email) {
|
|
const re =
|
|
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
|
return re.test(email.toLowerCase())
|
|
}
|
|
|
|
// Particle System
|
|
function createParticle() {
|
|
const particle = document.createElement('div')
|
|
particle.className = 'particle'
|
|
|
|
// Random size between 3-6px
|
|
const size = Math.random() * 3 + 3
|
|
particle.style.width = `${size}px`
|
|
particle.style.height = `${size}px`
|
|
|
|
// Random starting position, but keep particles within viewport bounds
|
|
const startX = Math.random() * (window.innerWidth - size)
|
|
const startY = window.innerHeight + size
|
|
|
|
particle.style.left = `${startX}px`
|
|
particle.style.top = `${startY}px`
|
|
|
|
// Random animation duration between 6-10 seconds
|
|
const duration = Math.random() * 4000 + 6000
|
|
particle.style.animation = `floatUp ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) forwards`
|
|
|
|
document.body.appendChild(particle)
|
|
|
|
// Cleanup after animation
|
|
setTimeout(() => {
|
|
if (particle && particle.parentNode) {
|
|
particle.parentNode.removeChild(particle)
|
|
}
|
|
}, duration)
|
|
}
|
|
|
|
// Particle manager
|
|
let particleInterval
|
|
const startParticles = () => {
|
|
// Create initial batch of particles
|
|
for (let i = 0; i < 5; i++) {
|
|
setTimeout(() => createParticle(), i * 200)
|
|
}
|
|
|
|
// Create a new particle every 400ms
|
|
particleInterval = setInterval(() => {
|
|
// Limit to 15 particles at a time for better performance
|
|
if (document.querySelectorAll('.particle').length < 15) {
|
|
createParticle()
|
|
}
|
|
}, 400)
|
|
}
|
|
|
|
// Cleanup function
|
|
const cleanupParticles = () => {
|
|
if (particleInterval) {
|
|
clearInterval(particleInterval)
|
|
particleInterval = null
|
|
}
|
|
document.querySelectorAll('.particle').forEach((particle) => {
|
|
if (particle.parentNode) {
|
|
particle.parentNode.removeChild(particle)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Start particles when page loads
|
|
startParticles()
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener('unload', cleanupParticles)
|
|
|
|
// Pause particles when page is not visible
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
cleanupParticles()
|
|
} else {
|
|
startParticles()
|
|
}
|
|
})
|
|
|
|
// Restart particles on window resize
|
|
let resizeTimeout
|
|
window.addEventListener('resize', () => {
|
|
if (resizeTimeout) {
|
|
clearTimeout(resizeTimeout)
|
|
}
|
|
resizeTimeout = setTimeout(() => {
|
|
cleanupParticles()
|
|
startParticles()
|
|
}, 200)
|
|
})
|
|
|
|
function createConfetti(button) {
|
|
const colors = ['#FF6633', '#FF8533', '#FF9966', '#FFAA80']
|
|
const confettiCount = 20
|
|
|
|
for (let i = 0; i < confettiCount; i++) {
|
|
const confetti = document.createElement('div')
|
|
confetti.className = 'confetti'
|
|
|
|
// Random size between 4-8px
|
|
const size = Math.random() * 4 + 4
|
|
confetti.style.width = `${size}px`
|
|
confetti.style.height = `${size}px`
|
|
|
|
// Random color from our palette
|
|
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]
|
|
|
|
// Random position behind the button
|
|
const startX = Math.random() * button.offsetWidth
|
|
confetti.style.left = `${startX}px`
|
|
confetti.style.top = '50%'
|
|
|
|
// Random animation duration and delay
|
|
const duration = Math.random() * 400 + 600
|
|
const delay = Math.random() * 200
|
|
confetti.style.animation = `confettiFall ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) ${delay}ms forwards`
|
|
|
|
button.appendChild(confetti)
|
|
|
|
// Cleanup
|
|
setTimeout(() => {
|
|
if (confetti.parentNode === button) {
|
|
button.removeChild(confetti)
|
|
}
|
|
}, duration + delay)
|
|
}
|
|
}
|