This commit is contained in:
nmemmert
2026-06-02 14:02:00 -04:00
parent b18f4e88fa
commit 5b448aef0f
12 changed files with 1466 additions and 184 deletions
+111 -5
View File
@@ -85,7 +85,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -229,6 +250,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -656,6 +730,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
],
@@ -1069,5 +1175,5 @@
]
}
},
"updatedAt": "2026-06-01T17:25:41.122Z"
"updatedAt": "2026-06-02T17:54:05.948Z"
}
+111 -5
View File
@@ -85,7 +85,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -229,6 +250,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-11-09T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"releasedAt": "2026-05-06T00:00:00Z"
},
{
"id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"releasedAt": "2026-11-23T00:00:00Z"
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
},
{
"id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
},
{
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?",
"Why is this passage central to the study?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
},
{
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?",
"What does continuing in the faith look like?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
},
{
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
},
{
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
},
{
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
},
{
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
},
{
@@ -656,6 +730,8 @@
"What changes because of Christs work here?",
"How does victory over the powers encourage believers today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
},
{
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
},
{
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
},
{
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
},
{
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
},
{
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
},
{
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
},
{
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
},
{
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
},
{
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
},
{
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
},
{
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
},
{
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
},
{
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
},
{
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
},
{
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?"
],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
}
],
@@ -1069,5 +1175,5 @@
]
}
},
"updatedAt": "2026-06-01T17:25:41.133Z"
"updatedAt": "2026-06-02T17:54:06.010Z"
}
+1 -1
View File
@@ -98,5 +98,5 @@
"topic": "Faith & Life"
}
],
"updatedAt": "2026-06-01T17:25:41.133Z"
"updatedAt": "2026-06-02T17:54:06.010Z"
}
+50
View File
@@ -0,0 +1,50 @@
{
"posts": [
{
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "reading",
"createdAt": "2026-06-02T17:41:48.684Z",
"replies": []
},
{
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "sool",
"createdAt": "2026-06-02T17:41:41.608Z",
"replies": []
},
{
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
"studySlug": "colossians",
"sectionId": "",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:16.795Z",
"replies": [
{
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:25.003Z"
},
{
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"authorName": "Test Student",
"message": "test",
"createdAt": "2026-06-02T17:41:34.406Z"
}
]
}
],
"updatedAt": "2026-06-02T17:41:48.685Z"
}
@@ -0,0 +1,3 @@
{
"colossians--1-1-2": "test....teset"
}
@@ -0,0 +1,16 @@
{
"byStudy": {
"colossians": {
"completedSectionIds": [
"1-1-2"
],
"quizAnswers": {
"1-1-2": [
"test",
"test"
]
}
}
},
"updatedAt": "2026-06-02T17:53:29.839Z"
}
+22 -10
View File
@@ -6,19 +6,31 @@
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
"displayName": "Student Test",
"subscribeNewsletter": false,
"pendingEmailChange": {
"newEmail": "student.test+new@versebyverse.local",
"tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554",
"expiresAt": 1779464255007,
"requestedAt": "2026-05-21T15:37:35.007Z"
"avatarUrl": "",
"lastLoginAt": "2026-05-21T15:41:16.259Z"
},
{
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
"username": "test.user@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Test Student",
"subscribeNewsletter": false,
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
"lastLoginAt": "2026-06-02T17:56:40.769Z",
"updatedAt": "2026-06-02T17:57:04.582Z",
"enrolledStudySlugs": [
"colossians"
],
"createdAt": "2026-05-21T13:05:00.000Z",
"updatedAt": "2026-05-21T18:41:10.857Z",
"lastLoginAt": "2026-05-21T15:41:16.259Z"
]
},
{
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
"username": "test.avatar@versebyverse.local",
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
"displayName": "Avatar Test",
"subscribeNewsletter": false,
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
"lastLoginAt": "2026-06-02T17:33:13.669Z"
}
],
"updatedAt": "2026-05-21T18:41:10.858Z"
"updatedAt": "2026-06-02T17:57:04.583Z"
}
+399 -44
View File
@@ -635,6 +635,8 @@ let studyUsers = []
let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise
let studyReminders = { users: {}, updatedAt: new Date().toISOString() }
let studyRemindersWritePromise = Promise.resolve()
const studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
const studyProgressWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = []
@@ -2244,6 +2246,28 @@ function normalizeStudyUsername(value) {
return value.trim().toLowerCase()
}
function getStudyAvatarUrl(subject) {
let customAvatar = ''
let username = ''
if (subject && typeof subject === 'object') {
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
username = normalizeStudyUsername(subject.username)
} else if (typeof subject === 'string') {
username = normalizeStudyUsername(subject)
}
if (customAvatar) return customAvatar
if (!username) return ''
const hash = createHash('md5').update(username).digest('hex')
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
}
function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return studyUsers.find(user => user.id === userId)
}
function normalizeStudySlug(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase()
@@ -2437,6 +2461,7 @@ function sanitizeStudyUsers(value) {
: []
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true
const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange)
? {
newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail))
@@ -2454,13 +2479,11 @@ function sanitizeStudyUsers(value) {
passwordHash,
displayName,
subscribeNewsletter,
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now()
? pendingEmailChange
: null,
studyRemindersEnabled,
enrolledStudySlugs,
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
pendingEmailChange,
})
}
@@ -2599,8 +2622,21 @@ function sanitizeStudyProgress(value) {
const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers)
? Object.fromEntries(
Object.entries(studyData.quizAnswers)
.filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim())
.map(([sectionId, answers]) => [
sectionId.trim(),
Array.isArray(answers)
? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim())
: [],
])
)
: {}
progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)),
quizAnswers,
}
}
}
@@ -2636,6 +2672,130 @@ function queueUserProgressWrite(userId) {
studyProgressWriteQueues.set(userId, next)
}
function sanitizeStudyReminders(value) {
const defaultResult = { users: {}, updatedAt: new Date().toISOString() }
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult
const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() }
if (value.users && typeof value.users === 'object') {
for (const [userId, userData] of Object.entries(value.users)) {
if (typeof userId !== 'string' || !userId.trim()) continue
const studies = typeof userData === 'object' && userData && !Array.isArray(userData)
? userData
: {}
const normalizedStudies = {}
for (const [studySlug, sectionIds] of Object.entries(studies)) {
if (typeof studySlug !== 'string' || !studySlug.trim()) continue
const ids = Array.isArray(sectionIds)
? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
: []
if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids))
}
reminders.users[userId.trim()] = normalizedStudies
}
}
return reminders
}
async function loadStudyRemindersFromDisk() {
try {
const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8')
studyReminders = sanitizeStudyReminders(JSON.parse(raw))
} catch {
studyReminders = { users: {}, updatedAt: new Date().toISOString() }
}
}
function queueStudyRemindersWrite() {
studyRemindersWritePromise = studyRemindersWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8')
})
.catch(err => {
console.error('[study-reminders] failed to write reminders:', err)
})
}
async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">A new study lesson is available in ${escapeHtml(studyTitle)}.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
to: [email],
subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`,
text:
`Hi ${namePart},\n\n` +
`A new lesson is available in ${studyTitle}.\n` +
`${sectionTitle} (${sectionReference}) is now unlocked.\n\n` +
`Open it here: ${sectionUrl}\n\n` +
`Grace and peace,\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel: 'Open the Lesson',
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}
async function scheduleStudyReminders() {
if (!cachedSiteContent) return
const now = new Date()
const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id)
if (userIds.length === 0) return
for (const user of studyUsers) {
if (user.studyRemindersEnabled !== true) continue
const email = user.username
const displayName = user.displayName || email
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
if (enrolledStudySlugs.length === 0) continue
const userSent = studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
const study = Array.isArray(cachedSiteContent.studies)
? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
if (!study) continue
for (const section of study.sections ?? []) {
const sectionId = section.id
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) continue
if (releaseDate > now) continue
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
if (sentForStudy.includes(sectionId)) continue
const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60)
if (hoursSinceRelease > 24) continue
const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`)
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
userSent[studySlug] = [...sentForStudy, sectionId]
studyReminders.users[user.id] = userSent
}
}
}
studyReminders.updatedAt = new Date().toISOString()
queueStudyRemindersWrite()
}
async function migrateStudyNotesIfNeeded() {
try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
@@ -2779,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => {
username: user?.username ?? '',
displayName: user?.displayName ?? '',
subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
})
})
@@ -2811,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
passwordHash: hashStudyPassword(password),
displayName,
subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
pendingEmailChange: null,
enrolledStudySlugs: [],
createdAt: now,
@@ -2838,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName,
subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs,
})
})
@@ -2872,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
})
})
@@ -3041,6 +3208,56 @@ app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (r
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
})
app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
const progress = await loadUserProgress(user.id)
const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? []
res.json({ studySlug, sectionId, answers: quizAnswers })
})
app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) {
res.status(400).json({ message: 'Invalid study slug or section id.' })
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
const rawAnswers = req.body?.answers
const answers = Array.isArray(rawAnswers)
? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean)
: []
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] }
studyProgress.quizAnswers = studyProgress.quizAnswers || {}
studyProgress.quizAnswers[sectionId] = answers
progress.byStudy[studySlug] = studyProgress
progress.updatedAt = new Date().toISOString()
studyProgressCache.set(user.id, progress)
queueUserProgressWrite(user.id)
res.json({ ok: true, studySlug, sectionId, answers })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug)
@@ -3084,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => {
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50)
.map(post => {
const author = findStudyUserById(post.authorUserId)
const enrichedPost = {
...post,
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
replies: Array.isArray(post.replies)
? post.replies.map(reply => {
const replyAuthor = findStudyUserById(reply.authorUserId)
return {
...reply,
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
}
})
: [],
}
return enrichedPost
})
res.json({
studySlug,
@@ -3115,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
sectionId,
authorUserId: user.id,
authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: now,
replies: [],
@@ -3152,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
id: randomUUID(),
authorUserId: user.id,
authorName: user.displayName?.trim() || user.username,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message,
createdAt: new Date().toISOString(),
}
@@ -3167,86 +3403,151 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser
const notes = await loadUserNotes(user.id)
const progress = await loadUserProgress(user.id)
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
// Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content
const sectionMeta = {}
const content = cachedSiteContent
if (content) {
const studies = Array.isArray(content.studies) && content.studies.length > 0
const studies = content && Array.isArray(content.studies) && content.studies.length > 0
? content.studies
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }]
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }]
for (const study of studies) {
for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title,
reference: section.reference,
}
studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
}
}
}
// Group notes by study
const byStudy = {}
const studyEntries = {}
for (const [noteKey, noteText] of Object.entries(notes)) {
if (!noteText?.trim()) continue
const dashIndex = noteKey.indexOf('--')
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
if (!byStudy[studySlug]) byStudy[studySlug] = []
byStudy[studySlug].push({ noteKey, noteText })
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
const quizAnswersBySection = studyProgress.quizAnswers || {}
for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) {
if (!Array.isArray(answers) || answers.length === 0) continue
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim())
}
}
const studySlugs = Array.from(new Set([
...Object.keys(studyEntries),
...Object.values(studies).map(study => study.slug),
]))
const docChildren = [
new Paragraph({
text: 'My Study Notes',
heading: HeadingLevel.TITLE,
}),
new Paragraph({
children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })],
spacing: { after: 400 },
}),
new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }),
new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }),
]
for (const [studySlug, entries] of Object.entries(byStudy)) {
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug
for (const studySlug of studySlugs) {
const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
const studyTitle = study?.title || studySlug
const studyDescription = typeof study?.description === 'string' ? study.description : ''
const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : []
if (sectionIds.length === 0) continue
docChildren.push(
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
)
for (const { noteKey, noteText } of entries) {
const meta = sectionMeta[noteKey]
const lessonTitle = meta?.title ?? noteKey
const reference = meta?.reference ?? ''
if (studyDescription) {
docChildren.push(
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
)
if (reference) {
docChildren.push(
new Paragraph({
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
spacing: { after: 120 },
}),
new Paragraph({ text: studyDescription, spacing: { after: 240 } }),
)
}
for (const line of noteText.split('\n')) {
const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length
const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length
docChildren.push(
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
new Paragraph({ text: `Notes: ${noteCount} | Quiz sections: ${quizCount}`, italics: true, spacing: { after: 240 } }),
)
const orderedSectionIds = study?.sections?.map(section => section.id).filter(id => sectionIds.includes(id)) ?? sectionIds
for (const sectionId of orderedSectionIds) {
const entry = studyEntries[studySlug][sectionId]
if (!entry) continue
const meta = sectionMeta[`${studySlug}--${sectionId}`] || { title: sectionId, reference: '' }
docChildren.push(
new Paragraph({ text: meta.title, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
)
if (meta.reference) {
docChildren.push(
new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }),
)
}
if (entry.noteText) {
docChildren.push(
new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }),
)
for (const line of entry.noteText.split('\n')) {
docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } }))
}
}
if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
meta.studyQuestions.forEach((question, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `${index + 1}. `, bold: true }),
new TextRun({ text: question }),
],
spacing: { after: 80 },
}),
)
})
}
if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) {
docChildren.push(
new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }),
)
entry.quizAnswers.forEach((answer, index) => {
docChildren.push(
new Paragraph({
children: [
new TextRun({ text: `Answer ${index + 1}: `, bold: true }),
new TextRun({ text: answer }),
],
spacing: { after: 80 },
}),
)
})
}
}
}
if (docChildren.length <= 2) {
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
}
const doc = new Document({
creator: 'Verse by Verse with Nate',
title: 'My Study Notes',
title: 'My Study Export',
sections: [{ children: docChildren }],
})
const buffer = await Packer.toBuffer(doc)
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx`
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
res.send(buffer)
@@ -3304,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
username: user.username,
displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user),
},
stats: {
noteCount: Object.keys(notes).length,
@@ -3317,16 +3620,59 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
const user = req.studyUser
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : ''
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) {
res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' })
return
}
user.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName })
res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) })
})
app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
const metadata = await readUploadsMetadata()
metadata[finalName] = []
await writeUploadsMetadata(metadata)
res.json({ ok: true, url: `/uploads/${finalName}` })
} catch (err) {
console.error('[study-account-avatar-upload] upload error:', err)
res.status(500).json({ message: 'Avatar upload failed.' })
}
})
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
@@ -3334,7 +3680,7 @@ app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
}
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter })
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true })
})
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
@@ -5150,6 +5496,7 @@ Promise.all([
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(),
@@ -5172,6 +5519,14 @@ Promise.all([
}
}, 60 * 60 * 1000)
// Send study reminder emails for newly released lessons every hour
setInterval(() => {
scheduleStudyReminders().catch(err => {
console.error('[study-reminders] failed to schedule reminders:', err)
})
}, 60 * 60 * 1000)
void scheduleStudyReminders()
app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`)
+5 -1
View File
@@ -257,10 +257,14 @@ function sanitizeColossiansStudySections(value) {
studyQuestions: Array.isArray(item.studyQuestions)
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '',
checkpointQuestions: Array.isArray(item.checkpointQuestions)
? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
...(releasedAt ? { releasedAt } : {}),
}
})
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText)
.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 sanitizeStudies(value) {
+2
View File
@@ -2077,6 +2077,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
commentary: '',
greekNotes: [],
studyQuestions: [],
checkpointPrompt: '',
checkpointQuestions: [],
},
],
}
+2 -1
View File
@@ -4,7 +4,7 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro
import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage } from './colossiansStudy'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
import { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content'
@@ -2053,6 +2053,7 @@ export default function App() {
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
+712 -85
View File
@@ -12,6 +12,8 @@ type StudyAuthState = {
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAuthStatusResponse = {
@@ -20,6 +22,8 @@ type StudyAuthStatusResponse = {
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAccountOverview = {
@@ -27,6 +31,8 @@ type StudyAccountOverview = {
username: string
displayName: string
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
avatarUrl?: string
}
stats: {
noteCount: number
@@ -53,6 +59,7 @@ type StudyProgress = {
type StudyCommunityReply = {
id: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
}
@@ -62,6 +69,7 @@ type StudyCommunityPost = {
studySlug: string
sectionId: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
replies: StudyCommunityReply[]
@@ -358,6 +366,13 @@ function CommunityBoard({
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
{post.authorAvatarUrl ? (
<img src={post.authorAvatarUrl} alt={`${post.authorName || 'Student'} avatar`} width={28} height={28} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.85rem', fontWeight: 700 }}>
{post.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
{postSection && !activeSection && (
<button
@@ -393,7 +408,16 @@ function CommunityBoard({
{post.replies.map(reply => (
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem', flexWrap: 'wrap' }}>
{reply.authorAvatarUrl ? (
<img src={reply.authorAvatarUrl} alt={`${reply.authorName || 'Student'} avatar`} width={22} height={22} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.75rem', fontWeight: 700 }}>
{reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
</div>
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
@@ -432,12 +456,287 @@ function isEnrolledInStudy(auth: StudyAuthState, studySlug: string | undefined):
}
export function StudyLandingPage({ content }: Props) {
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [overview, setOverview] = useState<StudyAccountOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const studies = getStudies(content)
const activeStudies = studies.filter(study => study.status !== 'planned')
const plannedStudies = studies.filter(study => study.status === 'planned')
const firstActiveStudy = activeStudies[0]
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
async function refreshOverview() {
if (!auth.authenticated) return
try {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(dashboard)
} catch (err) {
console.error(err)
}
}
async function handleEnrollmentToggle(studySlug: string, enrolled: boolean) {
if (!auth.authenticated) {
setEnrollMessage('Sign in to change your enrollments.')
return
}
setEnrollBusySlug(studySlug)
setEnrollMessage('')
try {
const data = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(studySlug)}`, {
method: enrolled ? 'DELETE' : 'POST',
})
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
} finally {
setEnrollBusySlug('')
}
}
function closeStudyModal() {
setStudyModal('none')
setEnrollMessage('')
}
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(authData.enrolledStudySlugs),
})
if (authData.authenticated) {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
if (cancelled) return
setOverview(dashboard)
}
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load your study dashboard.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [])
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
const availableStudies = overview?.studies ?? []
const totalEnrolled = enrolledStudies.length
const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0)
if (loading) {
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Study Dashboard</p>
<h1>Loading your study progress</h1>
<p className="study-course-hero-copy">Hang tight while we gather your enrolled tracks and lesson progress.</p>
</div>
</section>
</main>
)
}
if (auth.authenticated && overview) {
return (
<main className="study-index-page" aria-label="Study dashboard">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner" style={{ alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={76} height={76} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 76, height: 76, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.75rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p className="eyebrow">Welcome back</p>
<h1 style={{ marginTop: 0 }}>{auth.displayName || auth.username.split('@')[0]}</h1>
<p className="study-course-hero-copy">Your progress, enrollments, and classroom resources are all in one place.</p>
</div>
</div>
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
<span>{overview.stats.noteCount} notes saved</span>
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
</div>
{error && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{error}</p>}
<div className="study-course-hero-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/account" className="btn-primary">Open My Account</Link>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('enrollments')}>View Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
</div>
</section>
{studyModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.86)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '760px', maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.4rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="study-modal-heading">
<button type="button" onClick={closeStudyModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close study modal">×</button>
<h2 id="study-modal-heading">{studyModal === 'enrollments' ? 'Manage Enrollments' : 'Browse All Tracks'}</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>
{studyModal === 'enrollments'
? 'Review and change the study tracks you are enrolled in. Use the buttons below to join or leave each track.'
: 'See every study track available now. Enroll in a study directly from this popup and start right away.'}
</p>
{auth.authenticated && overview ? (
studyModal === 'enrollments' ? (
enrolledStudies.length > 0 ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{enrolledStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
</button>
</div>
</div>
))}
</div>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>You are not enrolled in any tracks yet. Use Browse All Tracks to find a study and enroll.</p>
<button type="button" className="btn-primary" style={{ marginTop: '1rem' }} onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
)
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{availableStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
</div>
)
) : auth.authenticated ? (
<p>No study information is available yet. Please refresh the page or visit your account to load your enrollments.</p>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>Sign in to manage your enrollments and browse tracks from within the app.</p>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
<button type="button" className="btn-secondary" onClick={closeStudyModal}>Close</button>
</div>
</div>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</div>
</div>
)}
<section id="my-tracks" className="section-study-module" aria-label="Your enrolled tracks" style={{ paddingTop: '1rem' }}>
<div className="section-inner">
<div className="study-module-header">
<p>Your Classroom</p>
<h2>Enrolled Tracks</h2>
</div>
{totalEnrolled === 0 ? (
<article className="study-class-block">
<h2>No enrollments yet</h2>
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
<button type="button" className="btn-primary" onClick={() => setStudyModal('browseTracks')}>Browse Tracks</button>
</article>
) : (
<div className="study-module-list">
{enrolledStudies.map(study => {
const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return (
<article key={study.slug} className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.completedLessons}/{study.totalLessons} lessons completed</span>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.noteCount} notes</span>
</div>
<div style={{ marginTop: '0.75rem', height: '10px', width: '100%', background: '#27231b', borderRadius: '999px', overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: '#e0c070' }} />
</div>
</div>
<div className="study-module-row-right" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<p className="study-module-focus">Progress</p>
<p>{progress}% complete</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<Link to={`/study/${study.slug}`} className="btn-primary">Continue</Link>
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
</div>
</div>
</article>
)
})}
</div>
)}
</div>
</section>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
@@ -453,7 +752,6 @@ export function StudyLandingPage({ content }: Props) {
</div>
<div className="study-course-hero-actions">
{firstActiveStudyFirstReleasedSection && <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>}
<Link to="#study-tracks" className="btn-secondary">Browse Tracks</Link>
<Link to="/study/signup" className="btn-primary">Create Account or Login</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
@@ -588,6 +886,7 @@ export function StudySignupPage() {
})
setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.')
} finally {
@@ -1299,7 +1598,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
<li key={index}>{question}</li>
))}
</ol>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Take the Quiz</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
@@ -1585,12 +1885,17 @@ export function StudyAccountPage() {
const [loadingOverview, setLoadingOverview] = useState(false)
const [displayNameInput, setDisplayNameInput] = useState('')
const [avatarUrlInput, setAvatarUrlInput] = useState('')
const [profileMessage, setProfileMessage] = useState('')
const [profileBusy, setProfileBusy] = useState(false)
const [avatarUploadBusy, setAvatarUploadBusy] = useState(false)
const [avatarUploadMessage, setAvatarUploadMessage] = useState('')
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('')
@@ -1628,6 +1933,8 @@ export function StudyAccountPage() {
username: data.username ?? '',
displayName: data.displayName ?? '',
subscribeNewsletter: data.subscribeNewsletter !== false,
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
@@ -1641,7 +1948,9 @@ export function StudyAccountPage() {
.then(data => {
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
})
.catch(() => {})
.finally(() => setLoadingOverview(false))
@@ -1671,19 +1980,55 @@ export function StudyAccountPage() {
const data = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
}
async function handleAvatarUpload(file: File) {
setAvatarUploadBusy(true)
setAvatarUploadMessage('')
try {
if (!file.type.startsWith('image/')) {
throw new Error('Please upload an image file.')
}
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Unable to read image file.'))
}
reader.onerror = () => reject(new Error('Failed to read image file.'))
reader.readAsDataURL(file)
})
const result = await readJson<{ ok: boolean; url: string }>('/api/study-account/avatar-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
setAvatarUrlInput(result.url)
setAvatarUploadMessage('Avatar uploaded successfully. Save profile to keep it.')
} catch (err) {
setAvatarUploadMessage(err instanceof Error ? err.message : 'Unable to upload avatar.')
} finally {
setAvatarUploadBusy(false)
}
}
async function handleProfileSave() {
setProfileBusy(true)
setProfileMessage('')
try {
const data = await readJson<{ ok: boolean; displayName: string }>('/api/study-account/profile', {
const data = await readJson<{ ok: boolean; displayName: string; avatarUrl?: string }>('/api/study-account/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: displayNameInput }),
body: JSON.stringify({ displayName: displayNameInput, avatarUrl: avatarUrlInput.trim() }),
})
setAuth(prev => ({ ...prev, displayName: data.displayName }))
setAuth(prev => ({ ...prev, displayName: data.displayName, avatarUrl: data.avatarUrl ?? prev.avatarUrl }))
if (data.avatarUrl) {
setAvatarUrlInput(data.avatarUrl)
}
setProfileMessage('Profile saved.')
await refreshOverview()
} catch (err) {
@@ -1697,12 +2042,12 @@ export function StudyAccountPage() {
setPrefBusy(true)
setPrefMessage('')
try {
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean }>('/api/study-account/preferences', {
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean }>('/api/study-account/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscribeNewsletter }),
body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled }),
})
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter }))
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
setPrefMessage('Preferences updated.')
await refreshOverview()
} catch (err) {
@@ -1833,6 +2178,13 @@ export function StudyAccountPage() {
}
}
function closeAccountModal() {
setAccountModal('none')
setEmailMessage('')
setPwMessage('')
setEnrollMessage('')
}
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading">
@@ -1861,7 +2213,20 @@ export function StudyAccountPage() {
<div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}>
<p className="eyebrow">Student Account</p>
<h1>My Account</h1>
<p style={{ marginBottom: '1.5rem' }}>Signed in as <strong>{auth.username}</strong></p>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={64} height={64} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#2a2518', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.5rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.95rem' }}>Signed in as</p>
<p style={{ margin: '0.25rem 0 0', fontWeight: 700 }}>{auth.displayName || auth.username}</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.9rem', color: '#7a7060' }}>{auth.username}</p>
</div>
</div>
{overview?.stats && (
<>
@@ -1880,36 +2245,127 @@ export function StudyAccountPage() {
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Enrollment Manager</h2>
<p style={{ fontSize: '0.92rem' }}>Enroll or unenroll by study and track your progress.</p>
{loadingOverview && <p>Loading studies...</p>}
{!loadingOverview && overview?.studies?.map(study => (
<div key={study.slug} style={{ border: '1px solid #2a2518', background: '#151511', borderRadius: '8px', padding: '0.9rem', marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<h2 style={{ marginTop: 0 }}>Quick Actions</h2>
<p style={{ fontSize: '0.92rem' }}>Open the settings you need without scrolling through the whole page.</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<button type="button" className="btn-primary" onClick={() => setAccountModal('profile')}>Edit Profile</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('preferences')}>Preferences</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('export')}>Export Notes</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('enrollments')}>Manage Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changeEmail')}>Change Email</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</button>
</div>
</section>
{accountModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '720px', maxHeight: 'calc(100vh - 2.5rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.5rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="account-modal-heading">
<button type="button" onClick={closeAccountModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close account settings">×</button>
{accountModal === 'enrollments' && (
<>
<h2 id="account-modal-heading">Manage Enrollments</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Enroll or leave studies from one place. Your active study list is shown below.</p>
{loadingOverview ? (
<p>Loading study access...</p>
) : overview?.studies?.length ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{overview.studies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontWeight: 600 }}>{study.title}</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.84rem' }}>
Status: <strong>{study.enrolled ? 'Enrolled' : 'Not Enrolled'}</strong>
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`}
{` • Notes: ${study.noteCount}`}
</p>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={study.enrolled ? 'btn-secondary' : 'btn-primary'}
disabled={study.status === 'planned' || enrollBusySlug === study.slug}
onClick={() => handleEnrollmentToggle(study.slug, study.enrolled)}
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === study.slug ? 'Updating...' : (study.enrolled ? 'Unenroll' : 'Enroll')}
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
{enrollMessage && <p className="study-note-status">{enrollMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Profile Settings</h2>
</div>
) : (
<p>No study access information is available right now.</p>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</>
)}
{accountModal === 'changeEmail' && (
<>
<h2 id="account-modal-heading">Change Email</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update the email address used for sign in and course notifications.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current email</label>
<input type="email" value={auth.username} disabled style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#1a1912', color: '#b9b09b', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New email</label>
<input type="email" value={emailNew} onChange={e => setEmailNew(e.target.value)} placeholder="you@example.com" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={emailCurrentPassword} onChange={e => setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={emailBusy || !emailNew || !emailCurrentPassword} onClick={handleRequestEmailChange}>
{emailBusy ? 'Sending…' : 'Send Verification Email'}
</button>
</div>
{emailTokenInput && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem' }}>Have a verification token?</p>
<input type="text" value={emailTokenInput} onChange={e => setEmailTokenInput(e.target.value)} placeholder="Paste token here" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }} />
<button type="button" className="btn-secondary" disabled={emailBusy || !emailTokenInput.trim()} onClick={handleVerifyTokenInput}>Verify Token</button>
</div>
)}
{emailMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{emailMessage}</p>}
</>
)}
{accountModal === 'profile' && (
<>
<h2 id="account-modal-heading">Profile Settings</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update your display name and avatar in one place.</p>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Choose an avatar</label>
<p style={{ margin: '0 0 0.85rem', color: '#b9b09b' }}>Pick a preset avatar or upload your own image.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(92px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
{[
{ key: 'dog', label: 'Dog', emoji: '🐶' },
{ key: 'cat', label: 'Cat', emoji: '🐱' },
{ key: 'rabbit', label: 'Rabbit', emoji: '🐰' },
{ key: 'fox', label: 'Fox', emoji: '🦊' },
{ key: 'panda', label: 'Panda', emoji: '🐼' },
{ key: 'lion', label: 'Lion', emoji: '🦁' },
].map(item => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="88" height="88"><rect width="100%" height="100%" rx="18" ry="18" fill="#11100d"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="56">${item.emoji}</text></svg>`
const url = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
return (
<button key={item.key} type="button" onClick={() => { setAvatarUrlInput(url); setAvatarUploadMessage('Preset avatar selected.') }} style={{ border: avatarUrlInput === url ? '2px solid #e0c070' : '1px solid #2a2518', borderRadius: '14px', padding: 0, background: '#11100d', cursor: 'pointer' }}>
<img src={url} alt={item.label} width={88} height={88} style={{ display: 'block', borderRadius: '14px' }} />
</button>
)
})}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Upload your own image</label>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file) {
void handleAvatarUpload(file)
}
}}
style={{ color: '#f0ead8' }}
/>
{avatarUploadBusy && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>Uploading avatar</p>}
{avatarUploadMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{avatarUploadMessage}</p>}
</div>
{(avatarUrlInput || auth.avatarUrl) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<img src={avatarUrlInput || auth.avatarUrl || ''} alt="Avatar preview" width={48} height={48} style={{ borderRadius: '50%', border: '1px solid #2a2518', objectFit: 'cover' }} />
<p style={{ margin: 0, color: '#b9b09b', fontSize: '0.92rem' }}>Preview of your selected avatar. Save your profile to keep it.</p>
</div>
)}
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Display Name (optional)</label>
<input
type="text"
@@ -1919,70 +2375,61 @@ export function StudyAccountPage() {
style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }}
/>
<div>
<button type="button" className="btn-primary" disabled={profileBusy} onClick={handleProfileSave}>{profileBusy ? 'Saving...' : 'Save Display Name'}</button>
<button type="button" className="btn-primary" disabled={profileBusy} onClick={handleProfileSave}>{profileBusy ? 'Saving...' : 'Save Profile'}</button>
</div>
{profileMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{profileMessage}</p>}
<div style={{ marginTop: '1.5rem' }}>
<label className="contact-consent">
</>
)}
{accountModal === 'preferences' && (
<>
<h2 id="account-modal-heading">Preferences</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Adjust how you receive study reminders and newsletter updates.</p>
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1rem' }}>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={studyRemindersEnabled} onChange={e => setStudyRemindersEnabled(e.target.checked)} />
<span>Send email reminders when a new lesson is released</span>
</label>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span>
</label>
<button type="button" className="btn-secondary" disabled={prefBusy} onClick={handlePreferenceSave} style={{ marginTop: '0.5rem' }}>
{prefBusy ? 'Saving...' : 'Save Preferences'}
</button>
{prefMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{prefMessage}</p>}
</div>
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Change Email (Verification Required)</h2>
<p style={{ fontSize: '0.9rem' }}>Enter your new email and current password. We will send a verification link to the new email.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: '420px' }}>
<input type="email" value={emailNew} onChange={e => setEmailNew(e.target.value)} placeholder="new@email.com" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={emailCurrentPassword} onChange={e => setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
</div>
<button type="button" className="btn-primary" disabled={emailBusy} onClick={handleRequestEmailChange} style={{ marginTop: '0.65rem' }}>
{emailBusy ? 'Sending...' : 'Send Verification Email'}
</button>
<p style={{ marginTop: '1rem', fontSize: '0.85rem' }}>Have a token? Paste it here to verify manually.</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" value={emailTokenInput} onChange={e => setEmailTokenInput(e.target.value)} placeholder="Verification token" style={{ minWidth: '260px', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<button type="button" className="btn-secondary" disabled={emailBusy || !emailTokenInput.trim()} onClick={handleVerifyTokenInput}>
{emailBusy ? 'Verifying...' : 'Verify Token'}
</button>
</div>
{emailMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{emailMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Export My Notes</h2>
<p style={{ fontSize: '0.9rem' }}>Download all your saved lesson notes as a Word document (.docx).</p>
<button type="button" className="btn-primary" disabled={prefBusy} onClick={handlePreferenceSave}>{prefBusy ? 'Saving...' : 'Save Preferences'}</button>
{prefMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{prefMessage}</p>}
</>
)}
{accountModal === 'export' && (
<>
<h2 id="account-modal-heading">Export Notes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Download all saved notes in a single document.</p>
<button type="button" className="btn-primary" disabled={exporting} onClick={handleExport}>
{exporting ? 'Preparing...' : 'Download Notes (.docx)'}
</button>
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{exportMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Change Password</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', maxWidth: '420px' }}>
<input type="password" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} placeholder="Current password" autoComplete="current-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} placeholder="New password" autoComplete="new-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} placeholder="Confirm new password" autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handleChangePassword()} style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
</div>
{pwMessage && <p style={{ color: '#e57373', fontSize: '0.875rem', marginTop: '0.5rem' }}>{pwMessage}</p>}
{pwSuccess && <p style={{ color: '#81c784', fontSize: '0.875rem', marginTop: '0.5rem' }}>Password updated successfully.</p>}
<button type="button" className="btn-primary" disabled={pwBusy} onClick={handleChangePassword} style={{ marginTop: '0.75rem' }}>
{pwBusy ? 'Updating...' : 'Update Password'}
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</>
)}
{accountModal === 'changePassword' && (
<>
<h2 id="account-modal-heading">Change Password</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Use a strong password to keep your study materials and notes secure.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New password</label>
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} placeholder="New password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Confirm new password</label>
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} placeholder="Confirm new password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={pwBusy || !pwCurrent || !pwNew || !pwConfirm} onClick={handleChangePassword}>
{pwBusy ? 'Saving…' : 'Update Password'}
</button>
</section>
</div>
{pwMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{pwMessage}</p>}
{pwSuccess && <p className="study-note-status" style={{ marginTop: '1rem', color: '#a5d6a7' }}>Password changed successfully.</p>}
</>
)}
</div>
</div>
)}
{divider}
@@ -2036,6 +2483,186 @@ export function StudyAccountPage() {
)
}
export function StudyQuizPage({ content }: Props) {
const { studySlug, sectionId } = useParams<{ studySlug: string, sectionId: string }>()
const navigate = useNavigate()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const section = study ? getSectionById(study.sections, sectionId) : undefined
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [answers, setAnswers] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
if (!studySlug || !sectionId) return
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
const enrolledStudySlugs = normalizeEnrolledStudySlugs(authData.enrolledStudySlugs)
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs,
})
if (!authData.authenticated || !enrolledStudySlugs.includes(studySlug?.trim().toLowerCase())) {
return
}
const data = await readJson<{ studySlug: string; sectionId: string; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`)
if (cancelled) return
setAnswers(Array.isArray(data.answers) ? data.answers : [])
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load quiz answers.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [studySlug, sectionId])
async function saveQuiz() {
if (!studySlug || !sectionId || !study || !section) return
setSaving(true)
setMessage('')
try {
await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
setMessage('Quiz answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save quiz answers.')
} finally {
setSaving(false)
}
}
if (!study || !section) {
return (
<main className="thanks-page" aria-label="Quiz not found">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Quiz not found</h1>
<p>The lesson quiz you requested is not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
const sectionQuestions = section.studyQuestions ?? []
const canSubmit = sectionQuestions.length > 0
const isEnrolled = auth.authenticated && normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(study.slug)
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading quiz">
<div className="thanks-card"><p>Loading quiz...</p></div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save quiz answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Enroll to Access the Quiz</h1>
<p>Please enroll in {study.title} to open the quiz for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study quiz">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Quiz</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Answer the lesson questions below and save your responses for later review.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
</div>
</section>
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading quiz...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Quiz Questions</h2>
<p className="study-detail-copy">This lesson does not have quiz questions configured yet.</p>
</article>
) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => (
<div key={index} style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p>
<textarea
rows={4}
value={answers[index] ?? ''}
onChange={e => setAnswers(prev => {
const next = [...prev]
next[index] = e.target.value
return next
})}
placeholder="Your answer..."
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Quiz Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
</form>
)}
</div>
</section>
</main>
)
}
export function StudyCommunityPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug: string }>()
const location = useLocation()