Two concurrent series lanes, Complete studies, self-paced finished books; v2.9.0
Publish Container / build (push) Successful in 17m50s
Publish Container / build (push) Successful in 17m50s
- Study status 'complete' (manual or auto when all lessons released); stays enrollable and self-paced (no sequential lock, Resume button, Finished Studies hub section) - Studies get lane (A/B) and expected start date; planned studies show "Starts <date>"; hub shows next lesson date instead of hardcoded pace - Second concurrent series (homepage card, admin panel) with its own scheduled launch; transitions are per lane and flip the ending study to Complete and the incoming study to Active - Finished books link to their study: "Study This Book" on playlist page, audio-only link from hub (playlist stays public, no account) - Admin: bulk release scheduler (every N days), same-day collision warning, linked-study selects - Weekly digest no longer dumps a finished study's backlog on late enrollees - CI: build and tag the container as :beta on pushes to the beta branch Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d8e4be5ea
commit
ddcaec0ddf
12 files changed
+628
-105
No files matched your search
@@ -2,7 +2,7 @@ name: Publish Container
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main", "beta"]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ jobs:
|
|||||||
images: git.necloud.us/nate/siteforge
|
images: git.necloud.us/nate/siteforge
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||||
type=sha
|
type=sha
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Publish Container
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main", "beta"]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ jobs:
|
|||||||
images: git.necloud.us/nate/siteforge
|
images: git.necloud.us/nate/siteforge
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||||
type=sha
|
type=sha
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.8.5",
|
"version": "2.9.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+26
-1
@@ -290,6 +290,28 @@ function sanitizeColossiansStudySections(value) {
|
|||||||
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeIsoDate(value) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return ''
|
||||||
|
const time = Date.parse(value)
|
||||||
|
return Number.isFinite(time) ? new Date(time).toISOString() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSecondSeries(value) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||||
|
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||||
|
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
|
||||||
|
return {
|
||||||
|
label: str(value.label, 80) || 'Now Playing',
|
||||||
|
title: str(value.title, 160),
|
||||||
|
description: str(value.description, 800),
|
||||||
|
imageUrl: str(value.imageUrl, 500),
|
||||||
|
listenUrl: str(value.listenUrl, 500),
|
||||||
|
listenBtnLabel: str(value.listenBtnLabel, 80),
|
||||||
|
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
||||||
|
...(season ? { season } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeStudies(value) {
|
function sanitizeStudies(value) {
|
||||||
const source = Array.isArray(value) ? value : []
|
const source = Array.isArray(value) ? value : []
|
||||||
return source
|
return source
|
||||||
@@ -303,7 +325,9 @@ function sanitizeStudies(value) {
|
|||||||
showOnHomepage: item.showOnHomepage === true,
|
showOnHomepage: item.showOnHomepage === true,
|
||||||
showNewTag: item.showNewTag === true,
|
showNewTag: item.showNewTag === true,
|
||||||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||||
status: item.status === 'planned' ? 'planned' : 'active',
|
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
||||||
|
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
||||||
|
startsAt: sanitizeIsoDate(item.startsAt),
|
||||||
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
||||||
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
||||||
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
||||||
@@ -384,6 +408,7 @@ export function sanitizeSiteContent(siteContent) {
|
|||||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||||
studies: sanitizeStudies(siteContent.studies),
|
studies: sanitizeStudies(siteContent.studies),
|
||||||
|
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
|
||||||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||||
|
|||||||
+107
-32
@@ -53,15 +53,45 @@ function invokeWebhook(url, action) {
|
|||||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the content that results from applying a scheduled series transition.
|
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
|
||||||
function applySeriesTransition(content, sched) {
|
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
|
||||||
const archivedBook = {
|
const SERIES_LANES = [
|
||||||
id: Date.now().toString(36),
|
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
|
||||||
title: content.seriesTitle ?? '',
|
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
|
||||||
description: content.seriesDescription ?? '',
|
]
|
||||||
imageUrl: content.seriesImageUrl ?? '',
|
|
||||||
season: (sched.newSeason ?? 2) - 1 || 1,
|
function readCurrentSeries(content, lane) {
|
||||||
|
if (lane === 'second') {
|
||||||
|
const s = content.secondSeries ?? {}
|
||||||
|
return {
|
||||||
|
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
|
||||||
|
season: s.season, studySlug: s.studySlug ?? '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
|
||||||
|
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the content that results from applying one lane's scheduled series transition.
|
||||||
|
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
|
||||||
|
function applySeriesTransition(content, sched, lane = 'primary') {
|
||||||
|
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
|
||||||
|
const current = readCurrentSeries(content, lane)
|
||||||
|
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
|
||||||
|
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
|
||||||
|
|
||||||
|
const archivedBook = current.title.trim()
|
||||||
|
? {
|
||||||
|
id: Date.now().toString(36),
|
||||||
|
title: current.title,
|
||||||
|
description: current.description,
|
||||||
|
imageUrl: current.imageUrl,
|
||||||
|
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
|
||||||
|
...(current.studySlug ? { studySlug: current.studySlug } : {}),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
const patch = {}
|
const patch = {}
|
||||||
const copy = (from, to) => {
|
const copy = (from, to) => {
|
||||||
@@ -82,55 +112,100 @@ function applySeriesTransition(content, sched) {
|
|||||||
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
||||||
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
||||||
|
|
||||||
|
// The finished series' study becomes Complete (still enrollable, self-paced); the
|
||||||
|
// incoming series' study opens up, taking the lane it launches in.
|
||||||
|
const laneLetter = lane === 'second' ? 'B' : 'A'
|
||||||
|
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
|
||||||
|
if (!study) return study
|
||||||
|
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
|
||||||
|
return { ...study, status: 'complete' }
|
||||||
|
}
|
||||||
|
if (newStudySlug && study.slug === newStudySlug) {
|
||||||
|
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
|
||||||
|
}
|
||||||
|
return study
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasOtherLane = lane === 'second'
|
||||||
|
? Boolean(content.seriesTitle?.trim())
|
||||||
|
: Boolean(content.secondSeries?.title?.trim())
|
||||||
|
|
||||||
|
const lanePatch = lane === 'second'
|
||||||
|
? {
|
||||||
|
secondSeries: {
|
||||||
|
...(content.secondSeries ?? {}),
|
||||||
|
label: 'Now Playing',
|
||||||
|
title: sched.newTitle.trim(),
|
||||||
|
description: sched.newDescription?.trim() ?? '',
|
||||||
|
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
|
||||||
|
studySlug: newStudySlug,
|
||||||
|
...(newSeason ? { season: newSeason } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
seriesTitle: sched.newTitle.trim(),
|
||||||
|
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||||
|
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||||
|
seriesLabel: 'Now Playing',
|
||||||
|
seriesStudySlug: newStudySlug,
|
||||||
|
...(newSeason ? { seriesSeason: newSeason } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const next = sanitizeSiteContent({
|
const next = sanitizeSiteContent({
|
||||||
...content,
|
...content,
|
||||||
seriesTitle: sched.newTitle.trim(),
|
...lanePatch,
|
||||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
studies,
|
||||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
// Highlights belong to whatever is playing now; don't wipe them while the other lane is still running.
|
||||||
seriesLabel: 'Now Playing',
|
podcastFeaturedLinks: hasOtherLane ? content.podcastFeaturedLinks : [],
|
||||||
podcastFeaturedLinks: [],
|
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
|
||||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
|
||||||
...patch,
|
...patch,
|
||||||
})
|
})
|
||||||
delete next.scheduledSeriesTransition
|
delete next[scheduleKey]
|
||||||
return { next, archivedBook }
|
return { next, archivedBook }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies a scheduled series transition if its triggersAt time has passed.
|
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
|
||||||
// The schedule is saved with the draft (Save Schedule), so look in the draft as
|
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
|
||||||
// well as published content. Applies to both so a later publish doesn't undo it.
|
// published content. Applies to both so a later publish doesn't undo it.
|
||||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
||||||
export async function runScheduledSeriesTransition() {
|
export async function runScheduledSeriesTransition() {
|
||||||
const published = state.cachedSiteContent
|
const published = state.cachedSiteContent
|
||||||
const draft = state.cachedDraftSiteContent
|
const draft = state.cachedDraftSiteContent
|
||||||
if (!published) return
|
if (!published) return
|
||||||
|
|
||||||
const sched = draft?.scheduledSeriesTransition ?? published.scheduledSeriesTransition
|
let nextPublished = published
|
||||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
|
let nextDraft = draft
|
||||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
const fired = []
|
||||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) return
|
for (const { lane, scheduleKey } of SERIES_LANES) {
|
||||||
|
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
|
||||||
|
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
|
||||||
|
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||||
|
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
|
||||||
|
|
||||||
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
|
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
|
||||||
|
const result = applySeriesTransition(nextPublished, sched, lane)
|
||||||
const { next: updatedContent, archivedBook } = applySeriesTransition(published, sched)
|
nextPublished = result.next
|
||||||
|
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
|
||||||
|
fired.push(result.archivedBook?.title ?? '(nothing archived)')
|
||||||
|
}
|
||||||
|
if (fired.length === 0) return
|
||||||
|
|
||||||
const publishedAt = new Date().toISOString()
|
const publishedAt = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: updatedContent, updatedAt: publishedAt }, null, 2), 'utf8')
|
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||||
state.cachedSiteContent = updatedContent
|
state.cachedSiteContent = nextPublished
|
||||||
state.publishState.publishedAt = publishedAt
|
state.publishState.publishedAt = publishedAt
|
||||||
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[series-scheduler] Failed to write transition:', err)
|
console.error('[series-scheduler] Failed to write transition:', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draft) {
|
if (nextDraft && nextDraft !== draft) {
|
||||||
try {
|
try {
|
||||||
const { next: updatedDraft } = applySeriesTransition(draft, sched)
|
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: updatedDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
state.cachedDraftSiteContent = nextDraft
|
||||||
state.cachedDraftSiteContent = updatedDraft
|
|
||||||
state.publishState.draftUpdatedAt = publishedAt
|
state.publishState.draftUpdatedAt = publishedAt
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[series-scheduler] Failed to update draft after transition:', err)
|
console.error('[series-scheduler] Failed to update draft after transition:', err)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const publicCertRateLimiter = rateLimit({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of required section IDs for a study (only released sections).
|
* Returns the list of required section IDs for a study. Every lesson is required, so a
|
||||||
|
* certificate is only available once the whole study has been released and completed.
|
||||||
*/
|
*/
|
||||||
function getRequiredSectionIds(studySlug) {
|
function getRequiredSectionIds(studySlug) {
|
||||||
const content = state.cachedSiteContent
|
const content = state.cachedSiteContent
|
||||||
@@ -24,7 +25,7 @@ function getRequiredSectionIds(studySlug) {
|
|||||||
const study = content.studies.find(s => s?.slug === studySlug)
|
const study = content.studies.find(s => s?.slug === studySlug)
|
||||||
if (!study || !Array.isArray(study.sections)) return null
|
if (!study || !Array.isArray(study.sections)) return null
|
||||||
return study.sections
|
return study.sections
|
||||||
.filter(s => s?.status !== 'unreleased' && s?.id)
|
.filter(s => s?.id)
|
||||||
.map(s => s.id)
|
.map(s => s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -74,6 +74,17 @@ export function hashEmailVerifyToken(token) {
|
|||||||
|
|
||||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// A study is 'complete' when an admin marks it so, or when it has lessons and every
|
||||||
|
// one of them has already been released. Planned studies never auto-complete.
|
||||||
|
export function getEffectiveStudyStatus(study) {
|
||||||
|
if (!study || typeof study !== 'object') return 'active'
|
||||||
|
if (study.status === 'planned') return 'planned'
|
||||||
|
if (study.status === 'complete') return 'complete'
|
||||||
|
const sections = Array.isArray(study.sections) ? study.sections : []
|
||||||
|
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
|
||||||
|
return 'active'
|
||||||
|
}
|
||||||
|
|
||||||
export function getStudyCatalog() {
|
export function getStudyCatalog() {
|
||||||
const fallback = [
|
const fallback = [
|
||||||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||||
@@ -91,7 +102,7 @@ export function getStudyCatalog() {
|
|||||||
out.push({
|
out.push({
|
||||||
slug,
|
slug,
|
||||||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||||
status: study?.status === 'planned' ? 'planned' : 'active',
|
status: getEffectiveStudyStatus(study),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (out.length > 0) return out
|
if (out.length > 0) return out
|
||||||
@@ -743,9 +754,13 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
|||||||
: undefined
|
: undefined
|
||||||
if (!study) continue
|
if (!study) continue
|
||||||
|
|
||||||
|
// Finished studies are self-paced: only mention lessons from the last week so a
|
||||||
|
// late enrollee isn't sent the whole back catalogue in one digest.
|
||||||
|
const isComplete = getEffectiveStudyStatus(study) === 'complete'
|
||||||
for (const section of study.sections ?? []) {
|
for (const section of study.sections ?? []) {
|
||||||
const releaseTime = getSectionReleaseTime(section)
|
const releaseTime = getSectionReleaseTime(section)
|
||||||
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
||||||
|
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
|
||||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||||
if (sentForStudy.includes(section.id)) continue
|
if (sentForStudy.includes(section.id)) continue
|
||||||
|
|
||||||
|
|||||||
+275
-3
@@ -3,7 +3,7 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from
|
|||||||
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content'
|
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries } from './content'
|
||||||
import { DEFAULTS } from './content'
|
import { DEFAULTS } from './content'
|
||||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||||
@@ -1613,6 +1613,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
||||||
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
|
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
|
||||||
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
|
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
|
||||||
|
const [bulkStart, setBulkStart] = useState('')
|
||||||
|
const [bulkInterval, setBulkInterval] = useState('14')
|
||||||
|
const [bulkOnlyEmpty, setBulkOnlyEmpty] = useState(false)
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<Question[]>([])
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||||||
@@ -2784,6 +2787,50 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stamps release dates on a study's lessons in order: lesson N = start + N × interval days.
|
||||||
|
// Uses local-time date arithmetic so the time of day survives daylight-saving changes.
|
||||||
|
function applyBulkSchedule(studyId: string) {
|
||||||
|
if (!bulkStart) return
|
||||||
|
const interval = Math.max(1, parseInt(bulkInterval) || 14)
|
||||||
|
const base = new Date(bulkStart)
|
||||||
|
if (isNaN(base.getTime())) return
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
studies: (f.studies ?? []).map(study => study.id !== studyId ? study : {
|
||||||
|
...study,
|
||||||
|
sections: (study.sections ?? []).map((section, index) => {
|
||||||
|
if (bulkOnlyEmpty && section.releasedAt) return section
|
||||||
|
const date = new Date(base)
|
||||||
|
date.setDate(date.getDate() + index * interval)
|
||||||
|
return { ...section, releasedAt: date.toISOString() }
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lessons from this study that land on the same calendar day as a lesson in another study.
|
||||||
|
function findReleaseCollisions(study: StudyProgram) {
|
||||||
|
const dayKey = (iso?: string) => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA')
|
||||||
|
}
|
||||||
|
const otherDays = new Map<string, string>()
|
||||||
|
for (const other of form.studies ?? []) {
|
||||||
|
if (other.id === study.id || other.status === 'planned') continue
|
||||||
|
for (const section of other.sections ?? []) {
|
||||||
|
const key = dayKey(section.releasedAt)
|
||||||
|
if (key) otherDays.set(key, other.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hits: { date: string; title: string; other: string }[] = []
|
||||||
|
for (const section of study.sections ?? []) {
|
||||||
|
const key = dayKey(section.releasedAt)
|
||||||
|
if (key && otherDays.has(key)) hits.push({ date: key, title: section.title || section.reference, other: otherDays.get(key) ?? '' })
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
@@ -4040,6 +4087,94 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="series-study-slug">Linked Study (Lane A)</label>
|
||||||
|
<select id="series-study-slug" value={form.seriesStudySlug ?? ''} onChange={e => setForm(f => ({ ...f, seriesStudySlug: e.target.value }))}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-help">Adds a “Study Along” button to the series card. When this series ends, its study is marked Complete.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="series-season">Podcast Season Number (Lane A)</label>
|
||||||
|
<input id="series-season" type="number" min="1" value={form.seriesSeason ?? ''} onChange={e => setForm(f => ({ ...f, seriesSeason: parseInt(e.target.value) || undefined }))} />
|
||||||
|
<p className="admin-field-help">Must match the season number you set on these episodes in Spotify for Creators. Used for the Finished Books playlist.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const second = form.secondSeries
|
||||||
|
const patchSecond = (p: Partial<SecondSeries>) =>
|
||||||
|
setForm(f => ({ ...f, secondSeries: { label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '', ...(f.secondSeries ?? {}), ...p } }))
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||||
|
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Second Series (Lane B)</h3>
|
||||||
|
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||||
|
Running two books at once? Fill this in and the homepage shows a second Now Playing card. Leave the title blank to show only one series.
|
||||||
|
</p>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-label">Label</label>
|
||||||
|
<input id="second-label" type="text" value={second?.label ?? 'Now Playing'} onChange={e => patchSecond({ label: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-title">Series Title</label>
|
||||||
|
<input id="second-title" type="text" value={second?.title ?? ''} onChange={e => patchSecond({ title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-desc">Series Description</label>
|
||||||
|
<textarea id="second-desc" rows={3} value={second?.description ?? ''} onChange={e => patchSecond({ description: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-image">Cover Image URL</label>
|
||||||
|
<input id="second-image" type="text" value={second?.imageUrl ?? ''} onChange={e => patchSecond({ imageUrl: e.target.value })} />
|
||||||
|
{renderImageAssetSelector(second?.imageUrl ?? '', value => patchSecond({ imageUrl: value }), 'second-image-asset')}
|
||||||
|
{renderImagePreview(second?.imageUrl ?? '', 'Second series preview')}
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-listen">Listen URL</label>
|
||||||
|
<input id="second-listen" type="text" placeholder="Leave blank to use the main Spotify link" value={second?.listenUrl ?? ''} onChange={e => patchSecond({ listenUrl: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-study">Linked Study (Lane B)</label>
|
||||||
|
<select id="second-study" value={second?.studySlug ?? ''} onChange={e => patchSecond({ studySlug: e.target.value })}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="second-season">Podcast Season Number</label>
|
||||||
|
<input id="second-season" type="number" min="1" value={second?.season ?? ''} onChange={e => patchSecond({ season: parseInt(e.target.value) || undefined })} />
|
||||||
|
</div>
|
||||||
|
{second?.title?.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger-outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(`Archive "${second.title}" to Finished Books and remove the second series card?`)) return
|
||||||
|
setForm(f => {
|
||||||
|
const cur = f.secondSeries
|
||||||
|
if (!cur?.title?.trim()) return f
|
||||||
|
const archived = {
|
||||||
|
id: Date.now().toString(36),
|
||||||
|
title: cur.title,
|
||||||
|
description: cur.description,
|
||||||
|
imageUrl: cur.imageUrl,
|
||||||
|
season: cur.season ?? 1,
|
||||||
|
...(cur.studySlug ? { studySlug: cur.studySlug } : {}),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
finishedBooks: [...(f.finishedBooks ?? []), archived],
|
||||||
|
studies: (f.studies ?? []).map(st => st.slug === cur.studySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
|
||||||
|
secondSeries: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>End Second Series & Archive</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
|
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
|
||||||
{renderSaveStatus()}
|
{renderSaveStatus()}
|
||||||
<div className="admin-end-series-wrap">
|
<div className="admin-end-series-wrap">
|
||||||
@@ -4077,12 +4212,16 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
title: form.seriesTitle,
|
title: form.seriesTitle,
|
||||||
description: form.seriesDescription,
|
description: form.seriesDescription,
|
||||||
imageUrl: form.seriesImageUrl,
|
imageUrl: form.seriesImageUrl,
|
||||||
season: parseInt(endSeriesNewSeason) - 1 || 1,
|
season: form.seriesSeason ?? (parseInt(endSeriesNewSeason) - 1 || 1),
|
||||||
|
...(form.seriesStudySlug ? { studySlug: form.seriesStudySlug } : {}),
|
||||||
}
|
}
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
|
studies: (f.studies ?? []).map(st => st.slug === f.seriesStudySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
|
||||||
|
seriesStudySlug: '',
|
||||||
|
seriesSeason: parseInt(endSeriesNewSeason) || undefined,
|
||||||
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
|
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
|
||||||
podcastFeaturedLinks: [],
|
podcastFeaturedLinks: f.secondSeries?.title?.trim() ? f.podcastFeaturedLinks : [],
|
||||||
seriesTitle: endSeriesNewTitle.trim(),
|
seriesTitle: endSeriesNewTitle.trim(),
|
||||||
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
|
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
|
||||||
seriesLabel: 'Now Playing',
|
seriesLabel: 'Now Playing',
|
||||||
@@ -4149,6 +4288,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<label htmlFor="sched-new-season">New Season Number</label>
|
<label htmlFor="sched-new-season">New Season Number</label>
|
||||||
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched-new-study">Study to Open</label>
|
||||||
|
<select id="sched-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-help">At launch this study switches from Planned to Active, and the ending series' study is marked Complete.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
@@ -4244,6 +4391,63 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Schedule Auto-Transition — second series (Lane B) */}
|
||||||
|
{(() => {
|
||||||
|
const sched = form.scheduledSecondSeriesTransition
|
||||||
|
const patch = (p: Partial<NonNullable<typeof sched>>) =>
|
||||||
|
setForm(f => ({ ...f, scheduledSecondSeriesTransition: { ...(f.scheduledSecondSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||||
|
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Second Series (Lane B) Launch</h3>
|
||||||
|
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||||
|
Same as above, but only the second series changes: its current book is archived to Finished Books and marked Complete, and the main series keeps playing. If no second series is running yet, nothing is archived and the new book simply appears.
|
||||||
|
</p>
|
||||||
|
{sched?.triggersAt && (
|
||||||
|
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
|
||||||
|
<strong>Scheduled:</strong> Launch “{sched.newTitle}” on{' '}
|
||||||
|
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-triggers-at">Launch Date & Time</label>
|
||||||
|
<input id="sched2-triggers-at" type="datetime-local" value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''} onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-new-title">Series Title</label>
|
||||||
|
<input id="sched2-new-title" type="text" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-new-desc">Series Description</label>
|
||||||
|
<textarea id="sched2-new-desc" rows={3} value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-new-image">Series Image URL</label>
|
||||||
|
<input id="sched2-new-image" type="text" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-new-season">New Season Number</label>
|
||||||
|
<input id="sched2-new-season" type="number" min="1" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="sched2-new-study">Study to Open</label>
|
||||||
|
<select id="sched2-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn-primary" disabled={!sched?.triggersAt || !sched?.newTitle?.trim()} onClick={() => handleSaveDraft()}>Save Schedule</button>
|
||||||
|
{sched?.triggersAt && (
|
||||||
|
<button type="button" className="btn-danger-outline" onClick={() => setForm(f => ({ ...f, scheduledSecondSeriesTransition: undefined }))}>Clear Schedule</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4420,6 +4624,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<label>Season Number</label>
|
<label>Season Number</label>
|
||||||
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
|
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Linked Study</label>
|
||||||
|
<select value={book.studySlug ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, studySlug: e.target.value } : b) }))}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-help">Adds a “Study This Book” button to the playlist page and an audio-only link on the study hub.</p>
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Download URL <span style={{ fontWeight: 400, opacity: 0.7 }}>(ZIP — Google Drive, Dropbox, etc.)</span></label>
|
<label>Download URL <span style={{ fontWeight: 400, opacity: 0.7 }}>(ZIP — Google Drive, Dropbox, etc.)</span></label>
|
||||||
<input type="url" placeholder="https://drive.google.com/…" value={book.downloadUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadUrl: e.target.value } : b) }))} />
|
<input type="url" placeholder="https://drive.google.com/…" value={book.downloadUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadUrl: e.target.value } : b) }))} />
|
||||||
@@ -5024,9 +5236,27 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<label htmlFor={`study-status-${activeStudy.id}`}>Status</label>
|
<label htmlFor={`study-status-${activeStudy.id}`}>Status</label>
|
||||||
<select id={`study-status-${activeStudy.id}`} value={activeStudy.status} onChange={e => updateStudyProgram(activeStudy.id, 'status', e.target.value)}>
|
<select id={`study-status-${activeStudy.id}`} value={activeStudy.status} onChange={e => updateStudyProgram(activeStudy.id, 'status', e.target.value)}>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
|
<option value="complete">Complete (finished — self-paced)</option>
|
||||||
<option value="planned">Planned</option>
|
<option value="planned">Planned</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p className="admin-field-help">A study whose lessons have all been released is shown as Complete automatically. Complete studies stay open for enrollment.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-lane-${activeStudy.id}`}>Release Lane</label>
|
||||||
|
<select id={`study-lane-${activeStudy.id}`} value={activeStudy.lane ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'lane', e.target.value)}>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="A">Lane A (primary series)</option>
|
||||||
|
<option value="B">Lane B (second series)</option>
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-help">Two series can air at once on opposite Mondays. Lane is a label for planning; dates come from each lesson.</p>
|
||||||
|
</div>
|
||||||
|
{activeStudy.status === 'planned' && (
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-starts-at-${activeStudy.id}`}>Expected Start Date</label>
|
||||||
|
<input id={`study-starts-at-${activeStudy.id}`} type="datetime-local" value={activeStudy.startsAt ? toLocalInputValue(activeStudy.startsAt) : ''} onChange={e => updateStudyProgram(activeStudy.id, 'startsAt', fromLocalInputValue(e.target.value))} />
|
||||||
|
<p className="admin-field-help">Shown as “Starts <date>” on the study hub while the study is still Planned.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
|
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
|
||||||
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
|
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
|
||||||
@@ -5060,6 +5290,48 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
|
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(activeStudy.sections ?? []).length > 0 && (
|
||||||
|
<details className="admin-collapsible-card" style={{ marginBottom: '1rem' }}>
|
||||||
|
<summary className="admin-collapsible-summary">
|
||||||
|
<div><strong>Bulk Schedule Releases</strong><p>Set every lesson's date at once (e.g. every other Monday)</p></div>
|
||||||
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||||
|
</summary>
|
||||||
|
<div className="admin-collapsible-body">
|
||||||
|
<div className="admin-array-fields">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="bulk-start">First lesson releases</label>
|
||||||
|
<input id="bulk-start" type="datetime-local" value={bulkStart} onChange={e => setBulkStart(e.target.value)} />
|
||||||
|
<p className="admin-field-help">Pick the Monday and time for lesson 1. Lane B should start the Monday after Lane A.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="bulk-interval">Days between lessons</label>
|
||||||
|
<input id="bulk-interval" type="number" min="1" value={bulkInterval} onChange={e => setBulkInterval(e.target.value)} />
|
||||||
|
<p className="admin-field-help">14 = every other week.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
<input id="bulk-only-empty" type="checkbox" checked={bulkOnlyEmpty} onChange={e => setBulkOnlyEmpty(e.target.checked)} />
|
||||||
|
<label htmlFor="bulk-only-empty">Only fill lessons that have no date yet</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-primary" disabled={!bulkStart} onClick={() => applyBulkSchedule(activeStudy.id)}>Apply to {(activeStudy.sections ?? []).length} lessons</button>
|
||||||
|
<p className="admin-field-help" style={{ marginTop: '0.5rem' }}>Remember to Save Draft and Publish afterwards.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const hits = findReleaseCollisions(activeStudy)
|
||||||
|
if (hits.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(211,47,47,0.1)', border: '1px solid rgba(211,47,47,0.4)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.88rem', color: '#ef9a9a' }}>
|
||||||
|
<strong>{hits.length} lesson{hits.length === 1 ? '' : 's'} release on the same day as another study:</strong>{' '}
|
||||||
|
{hits.slice(0, 4).map(h => `${h.date} (${h.title} · ${h.other})`).join('; ')}{hits.length > 4 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{(activeStudy.sections ?? []).length === 0 && <p className="admin-stats-note">No lessons yet. Add one below.</p>}
|
{(activeStudy.sections ?? []).length === 0 && <p className="admin-stats-note">No lessons yet. Add one below.</p>}
|
||||||
|
|
||||||
<div className="study-lesson-list-header">
|
<div className="study-lesson-list-header">
|
||||||
|
|||||||
+4
-1
@@ -1079,7 +1079,9 @@
|
|||||||
|
|
||||||
.series-grid {
|
.series-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-card {
|
.series-card {
|
||||||
@@ -13122,3 +13124,4 @@
|
|||||||
}
|
}
|
||||||
.study-status-badge.active { background: rgba(201, 168, 76, 0.18); color: #c9a84c; border: 1px solid rgba(201, 168, 76, 0.45); }
|
.study-status-badge.active { background: rgba(201, 168, 76, 0.18); color: #c9a84c; border: 1px solid rgba(201, 168, 76, 0.45); }
|
||||||
.study-status-badge.planned { background: rgba(255, 255, 255, 0.05); color: #9a9486; border: 1px solid rgba(255, 255, 255, 0.15); }
|
.study-status-badge.planned { background: rgba(255, 255, 255, 0.05); color: #9a9486; border: 1px solid rgba(255, 255, 255, 0.15); }
|
||||||
|
.study-status-badge.complete { background: rgba(120, 170, 130, 0.16); color: #8fc79b; border: 1px solid rgba(120, 170, 130, 0.45); }
|
||||||
+54
-22
@@ -1314,28 +1314,55 @@ function LandingPage({ content }: { content: SiteContent }) {
|
|||||||
<span className="ornament">✦</span>
|
<span className="ornament">✦</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="series-grid">
|
<div className="series-grid">
|
||||||
<article className="series-card">
|
{[
|
||||||
<img
|
{
|
||||||
src={content.seriesImageUrl}
|
key: 'primary',
|
||||||
alt={content.seriesTitle}
|
imageUrl: content.seriesImageUrl,
|
||||||
className="series-art"
|
title: content.seriesTitle,
|
||||||
/>
|
label: content.seriesLabel,
|
||||||
<div className="series-info">
|
description: content.seriesDescription,
|
||||||
<p className="series-label">{content.seriesLabel}</p>
|
listenUrl: content.seriesListenUrl,
|
||||||
<h3>{content.seriesTitle}</h3>
|
listenBtnLabel: content.seriesListenBtnLabel,
|
||||||
<p>{content.seriesDescription}</p>
|
studySlug: content.seriesStudySlug,
|
||||||
<a
|
},
|
||||||
href={content.seriesListenUrl || content.platformSpotifyUrl || '/spotify'}
|
...(content.secondSeries?.title?.trim()
|
||||||
target="_blank"
|
? [{
|
||||||
rel="noreferrer"
|
key: 'second',
|
||||||
className="btn-primary"
|
imageUrl: content.secondSeries.imageUrl,
|
||||||
>
|
title: content.secondSeries.title,
|
||||||
<SpotifyIcon />
|
label: content.secondSeries.label,
|
||||||
{content.seriesListenBtnLabel || 'Listen to Series'}
|
description: content.secondSeries.description,
|
||||||
</a>
|
listenUrl: content.secondSeries.listenUrl,
|
||||||
<Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>
|
listenBtnLabel: content.secondSeries.listenBtnLabel,
|
||||||
</div>
|
studySlug: content.secondSeries.studySlug,
|
||||||
</article>
|
}]
|
||||||
|
: []),
|
||||||
|
].map(series => (
|
||||||
|
<article className="series-card" key={series.key}>
|
||||||
|
<img
|
||||||
|
src={series.imageUrl}
|
||||||
|
alt={series.title}
|
||||||
|
className="series-art"
|
||||||
|
/>
|
||||||
|
<div className="series-info">
|
||||||
|
<p className="series-label">{series.label}</p>
|
||||||
|
<h3>{series.title}</h3>
|
||||||
|
<p>{series.description}</p>
|
||||||
|
<a
|
||||||
|
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<SpotifyIcon />
|
||||||
|
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
|
||||||
|
</a>
|
||||||
|
{series.studySlug
|
||||||
|
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
|
||||||
|
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1708,6 +1735,11 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
⬇ Download Album
|
⬇ Download Album
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{book.studySlug && (
|
||||||
|
<Link to={`/study/${book.studySlug}`} className="finished-series-btn-download">
|
||||||
|
📖 Study This Book
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{book.downloadQrUrl && book.downloadUrl && (
|
{book.downloadQrUrl && book.downloadUrl && (
|
||||||
<div className="finished-series-qr">
|
<div className="finished-series-qr">
|
||||||
|
|||||||
+58
-29
@@ -14,7 +14,13 @@ export interface StudyProgram {
|
|||||||
showOnHomepage?: boolean
|
showOnHomepage?: boolean
|
||||||
showNewTag?: boolean
|
showNewTag?: boolean
|
||||||
newTagLabel?: string
|
newTagLabel?: string
|
||||||
status: 'active' | 'planned'
|
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
|
||||||
|
// have all been released is treated as complete even if left as 'active'.
|
||||||
|
status: 'active' | 'planned' | 'complete'
|
||||||
|
// Which of the two concurrent release lanes this study airs in (A = primary series, B = second series)
|
||||||
|
lane?: '' | 'A' | 'B'
|
||||||
|
// When a planned study is expected to start (ISO) — shown as "Starts <date>" on the hub
|
||||||
|
startsAt?: string
|
||||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||||
estimatedHours: number
|
estimatedHours: number
|
||||||
completionBadge: string
|
completionBadge: string
|
||||||
@@ -85,16 +91,60 @@ export interface Testimonial {
|
|||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecondSeries {
|
||||||
|
label: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
listenUrl: string
|
||||||
|
listenBtnLabel: string
|
||||||
|
studySlug?: string
|
||||||
|
season?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinishedBook {
|
export interface FinishedBook {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
season: number
|
season: number
|
||||||
|
// The study that goes with this book, so the card can offer both "Listen" and "Study"
|
||||||
|
studySlug?: string
|
||||||
downloadUrl?: string
|
downloadUrl?: string
|
||||||
downloadQrUrl?: string
|
downloadQrUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduledSeriesTransition {
|
||||||
|
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
|
||||||
|
// New series card
|
||||||
|
newTitle: string
|
||||||
|
newDescription?: string
|
||||||
|
newImageUrl?: string
|
||||||
|
newSeason: number
|
||||||
|
// Study that opens (planned → active) when this series launches; the ending series' study becomes Complete
|
||||||
|
newStudySlug?: string
|
||||||
|
// Start Here section (optional)
|
||||||
|
newStartHereHeading?: string
|
||||||
|
newStartHereIntro?: string
|
||||||
|
newStartHereStep1Title?: string
|
||||||
|
newStartHereStep1Body?: string
|
||||||
|
newStartHereStep1Cta?: string
|
||||||
|
newStartHereStep2Title?: string
|
||||||
|
newStartHereStep2Body?: string
|
||||||
|
newStartHereStep2Cta?: string
|
||||||
|
newStartHereStep3Title?: string
|
||||||
|
newStartHereStep3Body?: string
|
||||||
|
newStartHereStep3Cta?: string
|
||||||
|
// Companion Study Guide / Featured Download (optional)
|
||||||
|
newStudyGuideTitle?: string
|
||||||
|
newStudyGuideDescription?: string
|
||||||
|
newStudyGuideDownloadUrl?: string
|
||||||
|
newStudyGuideUrl?: string
|
||||||
|
newStudyGuideAmazonButtonLabel?: string
|
||||||
|
newStudyGuideAppleBooksUrl?: string
|
||||||
|
newStudyGuideAppleBooksLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PodcastHost {
|
export interface PodcastHost {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -184,6 +234,10 @@ export interface SiteContent {
|
|||||||
seriesImageUrl: string
|
seriesImageUrl: string
|
||||||
seriesListenUrl: string
|
seriesListenUrl: string
|
||||||
seriesListenBtnLabel: string
|
seriesListenBtnLabel: string
|
||||||
|
seriesStudySlug?: string
|
||||||
|
seriesSeason?: number
|
||||||
|
// Optional second concurrent series (Lane B). Same card as the primary series.
|
||||||
|
secondSeries?: SecondSeries
|
||||||
seriesDownloadsBtnLabel: string
|
seriesDownloadsBtnLabel: string
|
||||||
// ── Downloads ──
|
// ── Downloads ──
|
||||||
studyGuideTitle: string
|
studyGuideTitle: string
|
||||||
@@ -334,34 +388,9 @@ export interface SiteContent {
|
|||||||
// ── Scheduled Series Transition ──
|
// ── Scheduled Series Transition ──
|
||||||
// All fields fire together at triggersAt. Start Here and Study Guide fields are optional —
|
// All fields fire together at triggersAt. Start Here and Study Guide fields are optional —
|
||||||
// omit them to leave those sections unchanged when the series transitions.
|
// omit them to leave those sections unchanged when the series transitions.
|
||||||
scheduledSeriesTransition?: {
|
scheduledSeriesTransition?: ScheduledSeriesTransition
|
||||||
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
|
// Same, for the second series lane
|
||||||
// New series card
|
scheduledSecondSeriesTransition?: ScheduledSeriesTransition
|
||||||
newTitle: string
|
|
||||||
newDescription?: string
|
|
||||||
newImageUrl?: string
|
|
||||||
newSeason: number
|
|
||||||
// Start Here section (optional)
|
|
||||||
newStartHereHeading?: string
|
|
||||||
newStartHereIntro?: string
|
|
||||||
newStartHereStep1Title?: string
|
|
||||||
newStartHereStep1Body?: string
|
|
||||||
newStartHereStep1Cta?: string
|
|
||||||
newStartHereStep2Title?: string
|
|
||||||
newStartHereStep2Body?: string
|
|
||||||
newStartHereStep2Cta?: string
|
|
||||||
newStartHereStep3Title?: string
|
|
||||||
newStartHereStep3Body?: string
|
|
||||||
newStartHereStep3Cta?: string
|
|
||||||
// Companion Study Guide / Featured Download (optional)
|
|
||||||
newStudyGuideTitle?: string
|
|
||||||
newStudyGuideDescription?: string
|
|
||||||
newStudyGuideDownloadUrl?: string
|
|
||||||
newStudyGuideUrl?: string
|
|
||||||
newStudyGuideAmazonButtonLabel?: string
|
|
||||||
newStudyGuideAppleBooksUrl?: string
|
|
||||||
newStudyGuideAppleBooksLabel?: string
|
|
||||||
}
|
|
||||||
// ── Testimonials ──
|
// ── Testimonials ──
|
||||||
testimonials: Testimonial[]
|
testimonials: Testimonial[]
|
||||||
// ── Nuggets (sub-show) ──
|
// ── Nuggets (sub-show) ──
|
||||||
|
|||||||
+80
-11
@@ -64,7 +64,7 @@ type StudyAccountOverview = {
|
|||||||
studies: Array<{
|
studies: Array<{
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
status: 'active' | 'planned'
|
status: 'active' | 'planned' | 'complete'
|
||||||
enrolled: boolean
|
enrolled: boolean
|
||||||
totalLessons: number
|
totalLessons: number
|
||||||
completedLessons: number
|
completedLessons: number
|
||||||
@@ -107,8 +107,9 @@ type StudyCommunityPost = {
|
|||||||
pending?: boolean
|
pending?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function StudyStatusBadge({ status }: { status: 'active' | 'planned' }) {
|
function StudyStatusBadge({ status }: { status: 'active' | 'planned' | 'complete' }) {
|
||||||
return <span className={`study-status-badge ${status}`}>{status === 'active' ? 'Active' : 'Planned'}</span>
|
const label = status === 'active' ? 'Active' : status === 'complete' ? 'Complete' : 'Planned'
|
||||||
|
return <span className={`study-status-badge ${status}`}>{label}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||||
@@ -200,6 +201,24 @@ function isSectionReleased(section: StudySection): boolean {
|
|||||||
return releaseDate <= new Date()
|
return releaseDate <= new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A study is complete when an admin marks it so, or when every lesson has been released.
|
||||||
|
// Complete studies stay open for enrollment and are fully self-paced.
|
||||||
|
function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'complete' {
|
||||||
|
if (study.status === 'planned') return 'planned'
|
||||||
|
if (study.status === 'complete') return 'complete'
|
||||||
|
if (study.sections.length > 0 && study.sections.every(isSectionReleased)) return 'complete'
|
||||||
|
return 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextReleaseLabel(study: StudyProgram): string {
|
||||||
|
const upcoming = study.sections
|
||||||
|
.map(getSectionReleaseDate)
|
||||||
|
.filter((date): date is Date => date !== null && date > new Date())
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime())[0]
|
||||||
|
if (!upcoming) return ''
|
||||||
|
return upcoming.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
function getReleaseDateDisplay(section: StudySection): string {
|
function getReleaseDateDisplay(section: StudySection): string {
|
||||||
const date = getSectionReleaseDate(section)
|
const date = getSectionReleaseDate(section)
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
@@ -589,9 +608,10 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||||
|
|
||||||
const studies = getStudies(content)
|
const studies = getStudies(content)
|
||||||
const activeStudies = studies.filter(study => study.status !== 'planned')
|
const activeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'active')
|
||||||
const plannedStudies = studies.filter(study => study.status === 'planned')
|
const completeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'complete')
|
||||||
const firstActiveStudy = activeStudies[0]
|
const plannedStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'planned')
|
||||||
|
const firstActiveStudy = activeStudies[0] ?? completeStudies[0]
|
||||||
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
|
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
|
||||||
|
|
||||||
async function refreshOverview() {
|
async function refreshOverview() {
|
||||||
@@ -929,7 +949,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<h1>Study at Your Own Pace</h1>
|
<h1>Study at Your Own Pace</h1>
|
||||||
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
||||||
<div className="study-course-meta">
|
<div className="study-course-meta">
|
||||||
<span>{activeStudies.length} {activeStudies.length === 1 ? 'study' : 'studies'} available{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
<span>{activeStudies.length + completeStudies.length} {activeStudies.length + completeStudies.length === 1 ? 'study' : 'studies'} available{completeStudies.length > 0 ? ` (${completeStudies.length} finished, start anytime)` : ''}{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
||||||
<span>Self-paced flow</span>
|
<span>Self-paced flow</span>
|
||||||
<span>Personal notes</span>
|
<span>Personal notes</span>
|
||||||
<span>Audio + commentary</span>
|
<span>Audio + commentary</span>
|
||||||
@@ -992,7 +1012,11 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<div className="study-module-row-right">
|
<div className="study-module-row-right">
|
||||||
<p className="study-module-focus">Track Snapshot</p>
|
<p className="study-module-focus">Track Snapshot</p>
|
||||||
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
|
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
|
||||||
<p>{study.sections.length > 0 ? 'Estimated pace: every other Monday' : 'Pacing details coming with first lesson release.'}</p>
|
<p>{(() => {
|
||||||
|
const next = getNextReleaseLabel(study)
|
||||||
|
if (next) return `Next lesson: ${next}`
|
||||||
|
return study.sections.length > 0 ? 'New lessons release on a regular schedule.' : 'Pacing details coming with first lesson release.'
|
||||||
|
})()}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -1002,6 +1026,43 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{completeStudies.length > 0 && (
|
||||||
|
<section className="section-study-module" aria-label="Finished studies" id="finished-studies">
|
||||||
|
<div className="section-inner">
|
||||||
|
<div className="study-module-header">
|
||||||
|
<p>Study On Your Own Time</p>
|
||||||
|
<h2>Finished Studies</h2>
|
||||||
|
</div>
|
||||||
|
<div className="study-module-list">
|
||||||
|
{completeStudies.map(study => {
|
||||||
|
const book = (content.finishedBooks ?? []).find(item => item.studySlug === study.slug)
|
||||||
|
return (
|
||||||
|
<div key={study.id}>
|
||||||
|
<Link to={`/study/${study.slug}`} className="study-module-row">
|
||||||
|
<div className="study-module-row-left">
|
||||||
|
<p className="study-module-lesson">Complete · Start Anytime</p>
|
||||||
|
<h3>{study.title}</h3>
|
||||||
|
<p>{study.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="study-module-row-right">
|
||||||
|
<p className="study-module-focus">Self-Paced</p>
|
||||||
|
<p>All {study.sections.length} lessons available now</p>
|
||||||
|
<p>No schedule — go as fast or slow as you like.</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{book && (
|
||||||
|
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>
|
||||||
|
Just want to listen? <Link to={`/finished/${book.id}`}>Open the audio playlist — no account needed →</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{plannedStudies.length > 0 && (
|
{plannedStudies.length > 0 && (
|
||||||
<section className="section-study-module" aria-label="Upcoming studies">
|
<section className="section-study-module" aria-label="Upcoming studies">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
@@ -1019,7 +1080,9 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="study-module-row-right">
|
<div className="study-module-row-right">
|
||||||
<p className="study-module-focus">Status</p>
|
<p className="study-module-focus">Status</p>
|
||||||
<p>Preparing lesson structure and media.</p>
|
<p>{study.startsAt && new Date(study.startsAt).getTime() > Date.now()
|
||||||
|
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
|
||||||
|
: 'Preparing lesson structure and media.'}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1567,6 +1630,11 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
const chapterSummaries = getChapterSummaries(study)
|
const chapterSummaries = getChapterSummaries(study)
|
||||||
const releasedSections = sections.filter(isSectionReleased)
|
const releasedSections = sections.filter(isSectionReleased)
|
||||||
const firstReleasedSection = releasedSections[0]
|
const firstReleasedSection = releasedSections[0]
|
||||||
|
const studyStatus = getEffectiveStudyStatus(study)
|
||||||
|
const isSelfPaced = studyStatus === 'complete'
|
||||||
|
// Resume at the first released lesson the student hasn't finished yet
|
||||||
|
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
|
||||||
|
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
|
||||||
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
||||||
|
|
||||||
async function enrollInStudy() {
|
async function enrollInStudy() {
|
||||||
@@ -1606,9 +1674,10 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
<span>Text + commentary + discussion</span>
|
<span>Text + commentary + discussion</span>
|
||||||
<span>Student notes enabled</span>
|
<span>Student notes enabled</span>
|
||||||
|
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="study-course-hero-actions">
|
<div className="study-course-hero-actions">
|
||||||
{enrolled && firstReleasedSection && <Link to={`/study/${study.slug}/${firstReleasedSection.id}`} className="btn-primary">Start Class</Link>}
|
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
||||||
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
||||||
{auth.authenticated && !enrolled && (
|
{auth.authenticated && !enrolled && (
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
@@ -1671,7 +1740,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
const coming = isComingSoon(section)
|
const coming = isComingSoon(section)
|
||||||
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : null
|
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : null
|
||||||
const prevCompleted = !prevSection || studyProgress.completedSectionIds.includes(prevSection.id)
|
const prevCompleted = !prevSection || studyProgress.completedSectionIds.includes(prevSection.id)
|
||||||
const lockedByProgress = enrolled && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
||||||
const isDisabled = coming || !enrolled || lockedByProgress
|
const isDisabled = coming || !enrolled || lockedByProgress
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in new issue
Block a user