Add student birthday tracking with celebration banners and admin notification; v1.1.31

- Admin can set birthday (month/day) per student in Study Users panel
- Summer birthdays (Jun–Aug) prompt admin to set an alternate school-year date
- 🎂 badge on student card header during birthday week
- Dismissible happy birthday banner shown to student on their study hub
- Admin receives email notification on the celebration day, once per year

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-10 08:42:52 -04:00
parent 6a4399f7a6
commit c0a79b9ed0
7 changed files with 251 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.30", "version": "1.1.31",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+45
View File
@@ -121,6 +121,51 @@ export function sanitizeRedirectRules(value) {
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
} }
// Returns true if the user's celebration date falls within the current MonSun week.
// Uses birthdayAlternateMonth/Day if set (for summer birthday workarounds), otherwise
// falls back to birthdayMonth/Day. Checks both the current year and next year so
// year-wrap birthdays (e.g. Dec 30 checked in late December) work correctly.
export function isBirthdayThisWeek(user) {
const month = user.birthdayMonth
const day = user.birthdayDay
if (!month || !day) return false
const celebMonth = user.birthdayAlternateMonth ?? month
const celebDay = user.birthdayAlternateDay ?? day
const today = new Date()
const monday = new Date(today)
monday.setUTCHours(0, 0, 0, 0)
const dow = monday.getUTCDay()
monday.setUTCDate(monday.getUTCDate() - (dow === 0 ? 6 : dow - 1))
const sunday = new Date(monday)
sunday.setUTCDate(sunday.getUTCDate() + 6)
sunday.setUTCHours(23, 59, 59, 999)
const yr = today.getUTCFullYear()
for (const year of [yr, yr + 1]) {
const bday = new Date(Date.UTC(year, celebMonth - 1, celebDay))
if (bday >= monday && bday <= sunday) return true
}
return false
}
// June, July, August are considered summer months.
export function isSummerBirthday(month) {
return Number.isInteger(month) && month >= 6 && month <= 8
}
// Returns "YYYY-MM-DD" for the celebration date (alternate if set, else birthday)
// in the given year. Returns null if no birthday is set.
export function celebrationDateForYear(user, year) {
const month = user.birthdayMonth
const day = user.birthdayDay
if (!month || !day) return null
const m = user.birthdayAlternateMonth ?? month
const d = user.birthdayAlternateDay ?? day
return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
}
export function sanitizeFeaturedLinks(value) { export function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : [] const source = Array.isArray(value) ? value : []
return source return source
+59 -1
View File
@@ -1,7 +1,8 @@
import { Resend } from 'resend' import { Resend } from 'resend'
import { state } from './state.js' import { state } from './state.js'
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js' import { queuePodcastChecklistWrite, queueCalendarEventsWrite, queueStudyUsersWrite } from './data.js'
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js' import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
import { celebrationDateForYear } from './helpers.js'
function toDateKey(date) { function toDateKey(date) {
return date.toISOString().slice(0, 10) return date.toISOString().slice(0, 10)
@@ -64,6 +65,63 @@ async function checkReminders() {
} }
if (changed) queueCalendarEventsWrite() if (changed) queueCalendarEventsWrite()
} }
// Birthdays — fire on the student's celebration day, once per calendar year
const users = state.studyUsers ?? []
let birthdayUsersChanged = false
const todayYear = new Date().getUTCFullYear()
const birthdayKids = []
for (const user of users) {
if (!user.birthdayMonth || !user.birthdayDay) continue
const celebDate = celebrationDateForYear(user, todayYear)
if (!celebDate) continue
if (todayKey !== celebDate) continue
if (user.birthdayEmailSentYear === todayYear) continue
birthdayKids.push({ name: user.displayName || user.username, birthdayMonth: user.birthdayMonth, birthdayDay: user.birthdayDay, hasAlternate: Boolean(user.birthdayAlternateMonth) })
user.birthdayEmailSentYear = todayYear
birthdayUsersChanged = true
}
if (birthdayKids.length > 0) {
await sendBirthdayNotificationEmail(birthdayKids)
if (birthdayUsersChanged) queueStudyUsersWrite()
}
}
async function sendBirthdayNotificationEmail(kids) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
const lines = kids.map(k => {
const bdayStr = `${MONTHS[k.birthdayMonth - 1]} ${k.birthdayDay}`
const alt = k.hasAlternate ? ' (celebrating on alternate date)' : ''
return `<li style="margin-bottom:6px"><strong>${k.name}</strong> — birthday: ${bdayStr}${alt}</li>`
}).join('')
const subject = kids.length === 1
? `🎂 Birthday today: ${kids[0].name}`
: `🎂 ${kids.length} student birthdays today`
const html = `
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
<h2 style="color:#c8860a;margin-bottom:4px">🎂 Student Birthday Celebration</h2>
<p>The following student${kids.length > 1 ? 's have' : ' has'} a birthday celebration today — consider sending a special message or acknowledging them in your study community!</p>
<ul style="padding-left:1.2em">${lines}</ul>
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
<p style="color:#777;font-size:0.85rem">To edit birthday info, open the Study Users panel in your admin settings.</p>
</div>
`
const { error } = await resend.emails.send({ from, to, subject, html })
if (error) console.error('[birthday] send error:', error)
else console.log(`[birthday] notification sent for ${kids.map(k => k.name).join(', ')}`)
} catch (err) {
console.error('[birthday] send exception:', err)
}
} }
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' } const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
+29 -2
View File
@@ -1,6 +1,6 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js' import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, isBirthdayThisWeek } from '../helpers.js'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js' import { UPLOADS_DIR } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
@@ -132,6 +132,11 @@ export function register(app) {
noteCount, noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true, studyRemindersEnabled: user.studyRemindersEnabled === true,
birthdayMonth: user.birthdayMonth ?? null,
birthdayDay: user.birthdayDay ?? null,
birthdayAlternateMonth: user.birthdayAlternateMonth ?? null,
birthdayAlternateDay: user.birthdayAlternateDay ?? null,
isBirthdayWeek: isBirthdayThisWeek(user),
} }
})) }))
@@ -142,7 +147,7 @@ export function register(app) {
const user = state.studyUsers.find(u => u.id === req.params.id) const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return } if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {} const { displayName, newPassword, addEnrollment, removeEnrollment, birthdayMonth, birthdayDay, birthdayAlternateMonth, birthdayAlternateDay } = req.body ?? {}
if (typeof displayName === 'string') { if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80) user.displayName = displayName.trim().slice(0, 80)
@@ -172,6 +177,28 @@ export function register(app) {
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug) user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
} }
if (birthdayMonth !== undefined) {
const m = parseInt(birthdayMonth, 10)
user.birthdayMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null
}
if (birthdayDay !== undefined) {
const d = parseInt(birthdayDay, 10)
user.birthdayDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null
}
if (birthdayAlternateMonth !== undefined) {
const m = parseInt(birthdayAlternateMonth, 10)
user.birthdayAlternateMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null
if (!user.birthdayAlternateMonth) user.birthdayAlternateDay = null
}
if (birthdayAlternateDay !== undefined) {
const d = parseInt(birthdayAlternateDay, 10)
user.birthdayAlternateDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null
}
// Clear the sent-year so a changed birthday can trigger a new notification
if (birthdayMonth !== undefined || birthdayDay !== undefined || birthdayAlternateMonth !== undefined || birthdayAlternateDay !== undefined) {
user.birthdayEmailSentYear = null
}
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
+2 -1
View File
@@ -6,7 +6,7 @@ import {
verifyTotpCode, verifyTotpCode,
generateRecoveryCodes, generateRecoveryCodes,
} from '../auth.js' } from '../auth.js'
import { parseCookies } from '../helpers.js' import { parseCookies, isBirthdayThisWeek } from '../helpers.js'
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js' import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js'
import { state } from '../state.js' import { state } from '../state.js'
import { import {
@@ -57,6 +57,7 @@ export function register(app) {
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))), totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
twoFaMethod: user?.twoFaMethod ?? null, twoFaMethod: user?.twoFaMethod ?? null,
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0, totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
isBirthdayWeek: user ? isBirthdayThisWeek(user) : false,
}) })
}) })
+94
View File
@@ -143,6 +143,11 @@ interface StudyUserRecord {
noteCount: number noteCount: number
subscribeNewsletter: boolean subscribeNewsletter: boolean
studyRemindersEnabled: boolean studyRemindersEnabled: boolean
birthdayMonth: number | null
birthdayDay: number | null
birthdayAlternateMonth: number | null
birthdayAlternateDay: number | null
isBirthdayWeek: boolean
} }
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
@@ -154,6 +159,8 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
const [newPassword, setNewPassword] = useState<Record<string, string>>({}) const [newPassword, setNewPassword] = useState<Record<string, string>>({})
const [msg, setMsg] = useState<Record<string, string>>({}) const [msg, setMsg] = useState<Record<string, string>>({})
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null) const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [editBirthday, setEditBirthday] = useState<Record<string, { month: string; day: string; altMonth: string; altDay: string }>>({})
function flashMsg(id: string, text: string) { function flashMsg(id: string, text: string) {
setMsg(prev => ({ ...prev, [id]: text })) setMsg(prev => ({ ...prev, [id]: text }))
@@ -211,6 +218,27 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
} }
async function handleSaveBirthday(user: StudyUserRecord) {
const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') }
const bMonth = b.month ? parseInt(b.month, 10) : null
const bDay = b.day ? parseInt(b.day, 10) : null
const altMonth = b.altMonth ? parseInt(b.altMonth, 10) : null
const altDay = b.altDay ? parseInt(b.altDay, 10) : null
if (bMonth !== null && (bMonth < 1 || bMonth > 12)) { flashMsg(user.id, 'Month must be 112.'); return }
if (bDay !== null && (bDay < 1 || bDay > 31)) { flashMsg(user.id, 'Day must be 131.'); return }
if ((altMonth == null) !== (altDay == null)) { flashMsg(user.id, 'Set both alternate month and day, or neither.'); return }
try {
await patchUser(user.id, {
birthdayMonth: bMonth !== null ? String(bMonth) : '',
birthdayDay: bDay !== null ? String(bDay) : '',
birthdayAlternateMonth: altMonth !== null ? String(altMonth) : '',
birthdayAlternateDay: altDay !== null ? String(altDay) : '',
})
setUsers(prev => prev.map(u => u.id !== user.id ? u : { ...u, birthdayMonth: bMonth, birthdayDay: bDay, birthdayAlternateMonth: altMonth, birthdayAlternateDay: altDay }))
flashMsg(user.id, 'Birthday saved.')
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
async function handleUnenroll(user: StudyUserRecord, slug: string) { async function handleUnenroll(user: StudyUserRecord, slug: string) {
try { try {
await patchUser(user.id, { removeEnrollment: slug }) await patchUser(user.id, { removeEnrollment: slug })
@@ -265,6 +293,17 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
onClick={() => { onClick={() => {
setExpandedId(isExpanded ? null : user.id) setExpandedId(isExpanded ? null : user.id)
setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName })) setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName }))
if (!isExpanded) {
setEditBirthday(prev => ({
...prev,
[user.id]: {
month: user.birthdayMonth ? String(user.birthdayMonth) : '',
day: user.birthdayDay ? String(user.birthdayDay) : '',
altMonth: user.birthdayAlternateMonth ? String(user.birthdayAlternateMonth) : '',
altDay: user.birthdayAlternateDay ? String(user.birthdayAlternateDay) : '',
},
}))
}
}} }}
> >
<div className="admin-study-user-header-left"> <div className="admin-study-user-header-left">
@@ -275,6 +314,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span> <span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
<span className="admin-study-user-pill">{user.noteCount} notes</span> <span className="admin-study-user-pill">{user.noteCount} notes</span>
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>} {(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
{user.isBirthdayWeek && <span className="admin-study-user-pill" title="Birthday this week!" style={{ background: 'rgba(255,200,0,0.18)', color: '#b38600' }}>🎂 Birthday Week!</span>}
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span> <span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
</div> </div>
</button> </button>
@@ -305,6 +345,60 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
</div> </div>
</div> </div>
{/* Birthday */}
{(() => {
const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') }
const isSummer = (() => { const m = parseInt(b.month, 10); return Number.isInteger(m) && m >= 6 && m <= 8 })()
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
function bdayUpdate(field: string, val: string) {
setEditBirthday(prev => ({ ...prev, [user.id]: { ...(prev[user.id] ?? { month: '', day: '', altMonth: '', altDay: '' }), [field]: val } }))
}
return (
<div className="admin-study-user-section">
<h4>Birthday</h4>
<p className="admin-stats-note" style={{ marginBottom: '0.5rem' }}>Admin-only. Used for birthday celebration notifications and the student's birthday week banner.</p>
<div className="admin-study-user-row" style={{ flexWrap: 'wrap', gap: '0.4rem' }}>
<select value={b.month} onChange={e => bdayUpdate('month', e.target.value)} style={{ width: '130px' }}>
<option value="">Month</option>
{MONTHS.map((name, i) => <option key={i + 1} value={String(i + 1)}>{name}</option>)}
</select>
<select value={b.day} onChange={e => bdayUpdate('day', e.target.value)} style={{ width: '80px' }}>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => <option key={i + 1} value={String(i + 1)}>{i + 1}</option>)}
</select>
<button type="button" className="btn-admin-save" onClick={() => handleSaveBirthday(user)}>Save</button>
{(b.month && b.day) && (
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.78rem' }} onClick={() => {
setEditBirthday(prev => ({ ...prev, [user.id]: { month: '', day: '', altMonth: '', altDay: '' } }))
patchUser(user.id, { birthdayMonth: '', birthdayDay: '', birthdayAlternateMonth: '', birthdayAlternateDay: '' })
.then(() => { setUsers(prev => prev.map(u => u.id !== user.id ? u : { ...u, birthdayMonth: null, birthdayDay: null, birthdayAlternateMonth: null, birthdayAlternateDay: null })); flashMsg(user.id, 'Birthday cleared.') })
.catch(() => flashMsg(user.id, 'Error clearing birthday.'))
}}>Clear</button>
)}
</div>
{isSummer && (
<div style={{ marginTop: '0.65rem', padding: '0.55rem 0.7rem', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.3)', borderRadius: '5px' }}>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.83rem', color: '#8a6200' }}><strong> Summer birthday</strong> this falls during summer break (JuneAugust). Set an alternate school-year celebration date below so they still get recognized during the school year.</p>
<div className="admin-study-user-row" style={{ flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.35rem' }}>
<select value={b.altMonth} onChange={e => bdayUpdate('altMonth', e.target.value)} style={{ width: '130px' }}>
<option value="">Celebrate in</option>
{MONTHS.map((name, i) => <option key={i + 1} value={String(i + 1)}>{name}</option>)}
</select>
<select value={b.altDay} onChange={e => bdayUpdate('altDay', e.target.value)} style={{ width: '80px' }}>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => <option key={i + 1} value={String(i + 1)}>{i + 1}</option>)}
</select>
</div>
{(b.altMonth && b.altDay) && <p className="admin-stats-note" style={{ marginTop: '0.3rem' }}>Birthday banner and email will fire on {MONTHS[parseInt(b.altMonth, 10) - 1]} {b.altDay} instead of their summer birthday.</p>}
</div>
)}
{user.isBirthdayWeek && (
<p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#b38600' }}>🎂 This student's celebration week is <strong>right now</strong>!</p>
)}
</div>
)
})()}
{/* Password Reset */} {/* Password Reset */}
<div className="admin-study-user-section"> <div className="admin-study-user-section">
<h4>Reset Password</h4> <h4>Reset Password</h4>
+21
View File
@@ -23,6 +23,7 @@ type StudyAuthState = {
totpEnabled?: boolean totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number totpRecoveryCodesRemaining?: number
isBirthdayWeek?: boolean
} }
type StudyAuthStatusResponse = { type StudyAuthStatusResponse = {
@@ -36,6 +37,7 @@ type StudyAuthStatusResponse = {
totpEnabled?: boolean totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number totpRecoveryCodesRemaining?: number
isBirthdayWeek?: boolean
} }
type StudyAccountOverview = { type StudyAccountOverview = {
@@ -1258,6 +1260,10 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const [enrollMessage, setEnrollMessage] = useState('') const [enrollMessage, setEnrollMessage] = useState('')
const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [] }) const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false) const [progressLoading, setProgressLoading] = useState(false)
const [birthdayBannerDismissed, setBirthdayBannerDismissed] = useState(() => {
const yr = new Date().getFullYear()
return localStorage.getItem(`birthday-banner-dismissed-${yr}`) === '1'
})
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -1268,7 +1274,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
checked: true, checked: true,
authenticated: Boolean(data.authenticated), authenticated: Boolean(data.authenticated),
username: data.username ?? '', username: data.username ?? '',
displayName: data.displayName ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
isBirthdayWeek: data.isBirthdayWeek === true,
}) })
}) })
.catch(() => { .catch(() => {
@@ -1351,6 +1359,19 @@ export function ColossiansStudyIndexPage({ content }: Props) {
return ( return (
<main className="study-index-page" aria-label={`${study.title} study`}> <main className="study-index-page" aria-label={`${study.title} study`}>
{auth.authenticated && auth.isBirthdayWeek && !birthdayBannerDismissed && (
<div role="alert" style={{ background: 'linear-gradient(135deg, #fffbe6 0%, #fff3cc 100%)', borderBottom: '2px solid #f0c930', padding: '0.9rem 1.25rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '1.05rem', color: '#7a5800' }}>
🎂 <strong>Happy Birthday{auth.displayName ? `, ${auth.displayName}` : ''}!</strong> Wishing you a wonderful birthday week — we're so glad you're part of this study community. 🎉
</span>
<button
type="button"
onClick={() => { setBirthdayBannerDismissed(true); localStorage.setItem(`birthday-banner-dismissed-${new Date().getFullYear()}`, '1') }}
aria-label="Dismiss birthday banner"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#7a5800', fontSize: '1.1rem', lineHeight: 1, padding: '0.2rem 0.4rem', borderRadius: '3px' }}
>✕</button>
</div>
)}
<section className="section-study-course-hero"> <section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner"> <div className="section-inner study-course-hero-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} /> <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />