Revert "Add student birthday tracking with celebration banners and admin notification; v1.1.31"
This reverts commit c0a79b9ed0.
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.31",
|
"version": "1.1.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -121,51 +121,6 @@ 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 Mon–Sun 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
|
||||||
|
|||||||
+1
-59
@@ -1,8 +1,7 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
import { queuePodcastChecklistWrite, queueCalendarEventsWrite, queueStudyUsersWrite } from './data.js'
|
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } 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)
|
||||||
@@ -65,63 +64,6 @@ 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: '✅' }
|
||||||
|
|||||||
@@ -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, isBirthdayThisWeek } from '../helpers.js'
|
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } 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,11 +132,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -147,7 +142,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, birthdayMonth, birthdayDay, birthdayAlternateMonth, birthdayAlternateDay } = req.body ?? {}
|
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
|
||||||
|
|
||||||
if (typeof displayName === 'string') {
|
if (typeof displayName === 'string') {
|
||||||
user.displayName = displayName.trim().slice(0, 80)
|
user.displayName = displayName.trim().slice(0, 80)
|
||||||
@@ -177,28 +172,6 @@ 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 })
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
verifyTotpCode,
|
verifyTotpCode,
|
||||||
generateRecoveryCodes,
|
generateRecoveryCodes,
|
||||||
} from '../auth.js'
|
} from '../auth.js'
|
||||||
import { parseCookies, isBirthdayThisWeek } from '../helpers.js'
|
import { parseCookies } 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,7 +57,6 @@ 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,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -143,11 +143,6 @@ 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[] }) {
|
||||||
@@ -159,8 +154,6 @@ 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 }))
|
||||||
@@ -218,27 +211,6 @@ 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 1–12.'); return }
|
|
||||||
if (bDay !== null && (bDay < 1 || bDay > 31)) { flashMsg(user.id, 'Day must be 1–31.'); 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 })
|
||||||
@@ -293,17 +265,6 @@ 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">
|
||||||
@@ -314,7 +275,6 @@ 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>
|
||||||
@@ -345,60 +305,6 @@ 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 (June–August). 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>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ type StudyAuthState = {
|
|||||||
totpEnabled?: boolean
|
totpEnabled?: boolean
|
||||||
twoFaMethod?: 'app' | 'email' | null
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
totpRecoveryCodesRemaining?: number
|
totpRecoveryCodesRemaining?: number
|
||||||
isBirthdayWeek?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAuthStatusResponse = {
|
type StudyAuthStatusResponse = {
|
||||||
@@ -37,7 +36,6 @@ type StudyAuthStatusResponse = {
|
|||||||
totpEnabled?: boolean
|
totpEnabled?: boolean
|
||||||
twoFaMethod?: 'app' | 'email' | null
|
twoFaMethod?: 'app' | 'email' | null
|
||||||
totpRecoveryCodesRemaining?: number
|
totpRecoveryCodesRemaining?: number
|
||||||
isBirthdayWeek?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudyAccountOverview = {
|
type StudyAccountOverview = {
|
||||||
@@ -1260,10 +1258,6 @@ 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
|
||||||
@@ -1274,9 +1268,7 @@ 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(() => {
|
||||||
@@ -1359,19 +1351,6 @@ 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 }]} />
|
||||||
|
|||||||
Reference in New Issue
Block a user