Initial Commit

This commit is contained in:
2026-03-06 04:54:20 -04:00
commit 63677bfcf5
9332 changed files with 1507319 additions and 0 deletions

103
src/auth.js Normal file
View File

@@ -0,0 +1,103 @@
import {
GoogleAuthProvider,
FacebookAuthProvider,
TwitterAuthProvider,
signInWithPopup,
signOut,
onAuthStateChanged,
} from 'firebase/auth';
import { auth } from './firebase.js';
const googleProvider = new GoogleAuthProvider();
const facebookProvider = new FacebookAuthProvider();
const twitterProvider = new TwitterAuthProvider();
let currentUser = null;
const authListeners = [];
export function onAuthChange(callback) {
authListeners.push(callback);
// Call immediately with current state
callback(currentUser);
}
function notifyListeners() {
authListeners.forEach((cb) => cb(currentUser));
}
export function getCurrentUser() {
return currentUser;
}
export async function loginWithGoogle() {
try {
const result = await signInWithPopup(auth, googleProvider);
return result.user;
} catch (error) {
handleAuthError(error);
return null;
}
}
export async function loginWithFacebook() {
try {
const result = await signInWithPopup(auth, facebookProvider);
return result.user;
} catch (error) {
handleAuthError(error);
return null;
}
}
export async function loginWithTwitter() {
try {
const result = await signInWithPopup(auth, twitterProvider);
return result.user;
} catch (error) {
handleAuthError(error);
return null;
}
}
export async function logout() {
try {
await signOut(auth);
} catch (error) {
console.error('Sign out error:', error);
}
}
function handleAuthError(error) {
if (error.code === 'auth/popup-closed-by-user') return;
if (error.code === 'auth/cancelled-popup-request') return;
let message = 'Authentication failed. Please try again.';
if (error.code === 'auth/account-exists-with-different-credential') {
message = 'An account already exists with the same email. Try signing in with a different provider.';
} else if (error.code === 'auth/popup-blocked') {
message = 'Popup was blocked by your browser. Please allow popups and try again.';
} else if (error.code === 'auth/unauthorized-domain') {
message = 'This domain is not authorized for sign-in. Please check Firebase Console settings.';
}
showToast(message, 'error');
}
function showToast(message, type = 'info') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
export function initAuth() {
onAuthStateChanged(auth, (user) => {
currentUser = user;
notifyListeners();
});
}

49
src/comments.js Normal file
View File

@@ -0,0 +1,49 @@
import {
collection,
query,
orderBy,
onSnapshot,
addDoc,
deleteDoc,
doc,
serverTimestamp,
} from 'firebase/firestore';
import { db } from './firebase.js';
const commentsRef = collection(db, 'comments');
let unsubscribe = null;
export function subscribeToComments(callback) {
const q = query(commentsRef, orderBy('timestamp', 'desc'));
unsubscribe = onSnapshot(
q,
(snapshot) => {
const comments = [];
snapshot.forEach((docSnap) => {
comments.push({ id: docSnap.id, ...docSnap.data() });
});
callback(comments);
},
(error) => {
console.error('Error fetching comments:', error);
callback([]);
}
);
return unsubscribe;
}
export async function addComment(user, text) {
if (!user || !text.trim()) return null;
return addDoc(commentsRef, {
userId: user.uid,
userName: user.displayName || 'Anonymous',
userPhoto: user.photoURL || '',
text: text.trim(),
timestamp: serverTimestamp(),
});
}
export async function deleteComment(commentId) {
return deleteDoc(doc(db, 'comments', commentId));
}

60
src/donors.js Normal file
View File

@@ -0,0 +1,60 @@
import {
collection,
query,
orderBy,
onSnapshot,
addDoc,
serverTimestamp,
} from 'firebase/firestore';
import { db } from './firebase.js';
const donorsRef = collection(db, 'donors');
let unsubscribe = null;
export function subscribeToDonors(callback) {
const q = query(donorsRef, orderBy('date', 'desc'));
unsubscribe = onSnapshot(
q,
(snapshot) => {
const donors = [];
snapshot.forEach((doc) => {
donors.push({ id: doc.id, ...doc.data() });
});
callback(donors);
},
(error) => {
console.error('Error fetching donors:', error);
callback([]);
}
);
return unsubscribe;
}
export async function addDonor({ name, amount, classYear, message, anonymous }) {
return addDoc(donorsRef, {
name: anonymous ? 'Anonymous' : name,
amount: Number(amount),
classYear: classYear || '',
message: message || '',
anonymous: !!anonymous,
date: serverTimestamp(),
});
}
export function calculateTotal(donors) {
return donors.reduce((sum, d) => sum + (d.amount || 0), 0);
}
export function sortDonors(donors, sortBy) {
const sorted = [...donors];
if (sortBy === 'amount') {
sorted.sort((a, b) => (b.amount || 0) - (a.amount || 0));
} else {
sorted.sort((a, b) => {
const dateA = a.date?.toMillis?.() || 0;
const dateB = b.date?.toMillis?.() || 0;
return dateB - dateA;
});
}
return sorted;
}

19
src/firebase.js Normal file
View File

@@ -0,0 +1,19 @@
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
// TODO: Replace with your actual Firebase project configuration.
// Get this from Firebase Console > Project Settings > General > Your apps > Web app
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.firebasestorage.app",
messagingSenderId: "YOUR_SENDER_ID",
appId: "YOUR_APP_ID",
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export default app;

432
src/main.js Normal file
View File

@@ -0,0 +1,432 @@
import './style.css';
import { initAuth, onAuthChange, loginWithGoogle, loginWithFacebook, loginWithTwitter, logout, getCurrentUser } from './auth.js';
import { subscribeToDonors, calculateTotal, sortDonors } from './donors.js';
import { subscribeToComments, addComment, deleteComment } from './comments.js';
import { searchStudents } from './students.js';
import { addSubscriber } from './subscribers.js';
// ===== State =====
let allDonors = [];
let allComments = [];
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initTabs();
initCarousel();
initShare();
initAuthUI();
initCommentForm();
initDonorSort();
initNewsletter();
initModal();
initStudentSearch();
// Real-time Firestore listeners
subscribeToDonors(handleDonorsUpdate);
subscribeToComments(handleCommentsUpdate);
});
// ===== Tabs =====
function initTabs() {
const tabNav = document.getElementById('tabNav');
tabNav.addEventListener('click', (e) => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
tabNav.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.remove('active'));
const target = document.getElementById(`tab-${btn.dataset.tab}`);
if (target) target.classList.add('active');
});
}
// ===== Carousel =====
function initCarousel() {
const track = document.getElementById('carouselTrack');
const slides = track.querySelectorAll('.carousel-slide');
const dots = document.querySelectorAll('.dot');
const prevBtn = document.getElementById('carouselPrev');
const nextBtn = document.getElementById('carouselNext');
let currentIndex = 0;
let autoplayTimer;
function goToSlide(index) {
currentIndex = ((index % slides.length) + slides.length) % slides.length;
track.style.transform = `translateX(-${currentIndex * 100}%)`;
dots.forEach((d, i) => d.classList.toggle('active', i === currentIndex));
}
function startAutoplay() {
stopAutoplay();
autoplayTimer = setInterval(() => goToSlide(currentIndex + 1), 5000);
}
function stopAutoplay() {
clearInterval(autoplayTimer);
}
prevBtn.addEventListener('click', () => {
goToSlide(currentIndex - 1);
startAutoplay();
});
nextBtn.addEventListener('click', () => {
goToSlide(currentIndex + 1);
startAutoplay();
});
dots.forEach((dot) => {
dot.addEventListener('click', () => {
goToSlide(Number(dot.dataset.index));
startAutoplay();
});
});
startAutoplay();
}
// ===== Share =====
function initShare() {
const pageUrl = window.location.href;
const title = 'Wesley High School - 100th Anniversary Fundraiser';
const text = 'Support Wesley High School\'s 100th Anniversary Celebrations! Help us celebrate a century of excellence.';
document.getElementById('shareFacebook').addEventListener('click', () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(pageUrl)}`, '_blank', 'width=600,height=400');
});
document.getElementById('shareTwitter').addEventListener('click', () => {
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(pageUrl)}`, '_blank', 'width=600,height=400');
});
document.getElementById('shareEmail').addEventListener('click', () => {
window.location.href = `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(text + '\n\n' + pageUrl)}`;
});
}
// ===== Auth UI =====
function initAuthUI() {
document.getElementById('loginGoogle').addEventListener('click', loginWithGoogle);
document.getElementById('loginFacebook').addEventListener('click', loginWithFacebook);
document.getElementById('loginTwitter').addEventListener('click', loginWithTwitter);
document.getElementById('logoutBtn').addEventListener('click', logout);
onAuthChange(updateAuthUI);
}
function updateAuthUI(user) {
const authPrompt = document.getElementById('authPrompt');
const commentForm = document.getElementById('commentForm');
const userAvatar = document.getElementById('userAvatar');
const userName = document.getElementById('userName');
if (user) {
authPrompt.classList.add('hidden');
commentForm.classList.remove('hidden');
userAvatar.src = user.photoURL || generateAvatarUrl(user.displayName || 'U');
userAvatar.alt = user.displayName || 'User';
userName.textContent = user.displayName || user.email || 'User';
} else {
authPrompt.classList.remove('hidden');
commentForm.classList.add('hidden');
}
}
function generateAvatarUrl(name) {
// Return a data URI for a simple avatar
const initial = (name || 'U').charAt(0).toUpperCase();
const canvas = document.createElement('canvas');
canvas.width = 80;
canvas.height = 80;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1a1a6e';
ctx.fillRect(0, 0, 80, 80);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 36px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initial, 40, 40);
return canvas.toDataURL();
}
// ===== Comment Form =====
function initCommentForm() {
const textarea = document.getElementById('commentText');
const charCount = document.getElementById('charCount');
const postBtn = document.getElementById('postCommentBtn');
textarea.addEventListener('input', () => {
charCount.textContent = `${textarea.value.length}/500`;
});
postBtn.addEventListener('click', async () => {
const user = getCurrentUser();
const text = textarea.value.trim();
if (!user || !text) return;
postBtn.disabled = true;
postBtn.textContent = 'Posting...';
try {
await addComment(user, text);
textarea.value = '';
charCount.textContent = '0/500';
} catch (error) {
console.error('Error posting comment:', error);
showToast('Failed to post comment. Please try again.', 'error');
} finally {
postBtn.disabled = false;
postBtn.textContent = 'Post Comment';
}
});
}
// ===== Donors =====
function handleDonorsUpdate(donors) {
allDonors = donors;
const total = calculateTotal(donors);
document.getElementById('totalRaised').textContent = `$${total.toLocaleString()}`;
renderDonors();
}
function initDonorSort() {
document.getElementById('donorSort').addEventListener('change', renderDonors);
}
function renderDonors() {
const container = document.getElementById('donorsList');
const emptyState = document.getElementById('donorsEmpty');
const sortBy = document.getElementById('donorSort').value;
const sorted = sortDonors(allDonors, sortBy);
if (sorted.length === 0) {
container.innerHTML = '';
container.appendChild(emptyState);
emptyState.classList.remove('hidden');
return;
}
container.innerHTML = sorted
.map((d) => {
const initial = (d.name || 'A').charAt(0).toUpperCase();
const date = d.date?.toDate ? d.date.toDate().toLocaleDateString() : '';
const classLabel = d.classYear ? ` · Class of ${d.classYear}` : '';
return `
<div class="donor-card">
<div class="donor-info">
<div class="donor-avatar">${initial}</div>
<div class="donor-details">
<h4>${escapeHtml(d.name)}</h4>
<p>${date}${classLabel}</p>
${d.message ? `<p style="margin-top:4px;color:#374151;font-size:0.85rem">"${escapeHtml(d.message)}"</p>` : ''}
</div>
</div>
<div class="donor-amount">$${(d.amount || 0).toLocaleString()}</div>
</div>`;
})
.join('');
}
// ===== Comments =====
function handleCommentsUpdate(comments) {
allComments = comments;
renderComments();
}
function renderComments() {
const container = document.getElementById('commentsList');
const emptyState = document.getElementById('commentsEmpty');
const user = getCurrentUser();
if (allComments.length === 0) {
container.innerHTML = '';
container.appendChild(emptyState);
emptyState.classList.remove('hidden');
return;
}
container.innerHTML = allComments
.map((c) => {
const timeStr = c.timestamp?.toDate
? timeAgo(c.timestamp.toDate())
: 'just now';
const avatarHtml = c.userPhoto
? `<img class="comment-avatar" src="${escapeHtml(c.userPhoto)}" alt="${escapeHtml(c.userName)}" />`
: `<div class="comment-avatar-placeholder">${(c.userName || 'U').charAt(0).toUpperCase()}</div>`;
const deleteBtn =
user && user.uid === c.userId
? `<button class="comment-delete" data-id="${c.id}">Delete</button>`
: '';
return `
<div class="comment-card">
${avatarHtml}
<div class="comment-body">
<div class="comment-header">
<span class="comment-author">${escapeHtml(c.userName)}</span>
<span class="comment-time">${timeStr}</span>
</div>
<p class="comment-text">${escapeHtml(c.text)}</p>
${deleteBtn}
</div>
</div>`;
})
.join('');
// Attach delete handlers
container.querySelectorAll('.comment-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this comment?')) return;
try {
await deleteComment(btn.dataset.id);
} catch (error) {
console.error('Error deleting comment:', error);
showToast('Failed to delete comment.', 'error');
}
});
});
}
// ===== Student Search =====
function initStudentSearch() {
const input = document.getElementById('studentSearch');
const results = document.getElementById('studentResults');
const emptyState = document.getElementById('studentSearchEmpty');
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const term = input.value.trim();
if (!term) {
results.innerHTML = '';
results.appendChild(emptyState);
emptyState.querySelector('p').textContent = 'Enter a name above to search for students.';
emptyState.classList.remove('hidden');
return;
}
debounceTimer = setTimeout(async () => {
results.innerHTML = '<div class="search-loading"><div class="loading-spinner"></div> Searching...</div>';
const students = await searchStudents(term);
if (students.length === 0) {
results.innerHTML = '';
results.appendChild(emptyState);
emptyState.querySelector('p').textContent = `No students found matching "${escapeHtml(term)}".`;
emptyState.classList.remove('hidden');
return;
}
results.innerHTML = students
.map((s) => {
const initial = (s.name || '?').charAt(0).toUpperCase();
return `
<div class="student-card">
<div class="student-avatar">${initial}</div>
<div class="student-info">
<h4>${escapeHtml(s.name)}</h4>
<p>Class of ${escapeHtml(String(s.classYear || 'N/A'))}</p>
</div>
</div>`;
})
.join('');
}, 350);
});
}
// ===== Newsletter =====
function initNewsletter() {
const form = document.getElementById('newsletterForm');
const submitBtn = form.querySelector('button[type="submit"]');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('newsletterEmail').value.trim();
if (!email) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Joining...';
try {
await addSubscriber(email);
showToast('Thank you for subscribing!', 'success');
form.reset();
} catch (error) {
console.error('Newsletter subscribe error:', error);
showToast('Failed to subscribe. Please try again.', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Join';
}
});
}
// ===== Modal =====
function initModal() {
const overlay = document.getElementById('modalOverlay');
const closeBtn = document.getElementById('modalClose');
closeBtn.addEventListener('click', () => overlay.classList.add('hidden'));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.add('hidden');
});
// Become a Fundraiser button
document.getElementById('becomeFundraiserBtn').addEventListener('click', () => {
showModal(
'<h2>Become a Fundraiser</h2><p style="margin-top:12px">Create your own fundraising page and rally your classmates! Share the donation link with friends and family to help Wesley High School reach its goal.</p><a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary" style="margin-top:20px;display:inline-flex">Start Fundraising</a>'
);
});
// Start Fundraiser button in fundraisers tab
const startBtn = document.getElementById('startFundraiserBtn');
if (startBtn) {
startBtn.addEventListener('click', () => {
showModal(
'<h2>Start Your Fundraiser</h2><p style="margin-top:12px">Share the Wesley High School donation page with your network and help us celebrate 100 years!</p><a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary" style="margin-top:20px;display:inline-flex">Get Started</a>'
);
});
}
}
function showModal(html) {
document.getElementById('modalContent').innerHTML = html;
document.getElementById('modalOverlay').classList.remove('hidden');
}
// ===== Utilities =====
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function timeAgo(date) {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function showToast(message, type = 'info') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}

45
src/students.js Normal file
View File

@@ -0,0 +1,45 @@
import {
collection,
query,
where,
orderBy,
limit,
getDocs,
} from 'firebase/firestore';
import { db } from './firebase.js';
const studentsRef = collection(db, 'students');
/**
* Search students by name (case-insensitive prefix match).
* Firestore doesn't support native full-text search, so we use a
* `nameLower` field stored at write-time and do a range query.
*
* Expected document structure:
* { name: "John Smith", nameLower: "john smith", classYear: "1985" }
*/
export async function searchStudents(searchTerm) {
const term = searchTerm.trim().toLowerCase();
if (!term) return [];
const end = term + '\uf8ff'; // Unicode high character for prefix range
const q = query(
studentsRef,
where('nameLower', '>=', term),
where('nameLower', '<=', end),
orderBy('nameLower'),
limit(50)
);
try {
const snapshot = await getDocs(q);
const results = [];
snapshot.forEach((doc) => {
results.push({ id: doc.id, ...doc.data() });
});
return results;
} catch (error) {
console.error('Error searching students:', error);
return [];
}
}

1179
src/style.css Normal file

File diff suppressed because it is too large Load Diff

11
src/subscribers.js Normal file
View File

@@ -0,0 +1,11 @@
import { collection, addDoc, serverTimestamp } from 'firebase/firestore';
import { db } from './firebase.js';
const subscribersRef = collection(db, 'subscribers');
export async function addSubscriber(email) {
return addDoc(subscribersRef, {
email,
subscribedAt: serverTimestamp(),
});
}