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?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "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", "id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "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", "id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?", "What stands out most about Christs supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?", "How does Paul describe the believers former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?", "What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
@@ -229,6 +250,8 @@
"What changes because of Christs work here?", "What changes because of Christs work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?", "What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?", "How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?", "What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?" "How does the closing reinforce the letters themes?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "releasedAt": "2027-10-11T00:00:00Z"
} }
] ]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "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", "id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "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", "id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?", "What stands out most about Christs supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?", "How does Paul describe the believers former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?", "What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
@@ -656,6 +730,8 @@
"What changes because of Christs work here?", "What changes because of Christs work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?", "What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?", "How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?", "What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?" "How does the closing reinforce the letters themes?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "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?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "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", "id": "1-3-8",
@@ -101,7 +106,9 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "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", "id": "1-9-14",
@@ -117,6 +124,8 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
@@ -133,6 +142,8 @@
"What stands out most about Christs supremacy?", "What stands out most about Christs supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
@@ -149,6 +160,8 @@
"How does Paul describe the believers former condition?", "How does Paul describe the believers former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
@@ -165,6 +178,8 @@
"What is the goal of Pauls ministry?", "What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
@@ -181,6 +196,8 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
@@ -197,6 +214,8 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
@@ -213,6 +232,8 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
@@ -229,6 +250,8 @@
"What changes because of Christs work here?", "What changes because of Christs work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
@@ -245,6 +268,8 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
@@ -261,6 +286,8 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
@@ -277,6 +304,8 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
@@ -293,6 +322,8 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
@@ -309,6 +340,8 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
@@ -325,6 +358,8 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
@@ -341,6 +376,8 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
@@ -357,6 +394,8 @@
"What does it look like for Christs peace to rule?", "What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
@@ -373,6 +412,8 @@
"How do these instructions reflect Christs lordship?", "How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
@@ -389,6 +430,8 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
@@ -405,6 +448,8 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
@@ -421,6 +466,8 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
@@ -437,6 +484,8 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
@@ -453,6 +502,8 @@
"What stands out about Pauls coworkers?", "What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
@@ -469,6 +520,8 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?" "How does the closing reinforce the letters themes?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "releasedAt": "2027-10-11T00:00:00Z"
} }
] ]
@@ -512,7 +565,12 @@
"Why does Paul begin with grace and peace?", "Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?" "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", "id": "1-3-8",
@@ -528,7 +586,9 @@
"What signs of gospel growth does Paul mention?", "What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?" "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", "id": "1-9-14",
@@ -544,6 +604,8 @@
"What does Paul ask God to produce?", "What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?" "How does this prayer shape the goals of the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
@@ -560,6 +622,8 @@
"What stands out most about Christs supremacy?", "What stands out most about Christs supremacy?",
"Why is this passage central to the study?" "Why is this passage central to the study?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
@@ -576,6 +640,8 @@
"How does Paul describe the believers former condition?", "How does Paul describe the believers former condition?",
"What does continuing in the faith look like?" "What does continuing in the faith look like?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
@@ -592,6 +658,8 @@
"What is the goal of Pauls ministry?", "What is the goal of Pauls ministry?",
"Why is maturity such an important theme here?" "Why is maturity such an important theme here?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
@@ -608,6 +676,8 @@
"What does Paul want the believers to experience?", "What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?" "Why does he emphasize encouragement and unity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
@@ -624,6 +694,8 @@
"What does it mean to continue in Christ?", "What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?" "What kinds of deception should believers watch for?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
@@ -640,6 +712,8 @@
"Why is fullness in Christ such an important correction?", "Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?" "How does this guard against spiritual insecurity?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
@@ -656,6 +730,8 @@
"What changes because of Christs work here?", "What changes because of Christs work here?",
"How does victory over the powers encourage believers today?" "How does victory over the powers encourage believers today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
@@ -672,6 +748,8 @@
"What do shadows and substance mean here?", "What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?" "Where do believers still feel pressure from external judgment?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
@@ -688,6 +766,8 @@
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
@@ -704,6 +784,8 @@
"Why do human regulations fail to transform the heart?", "Why do human regulations fail to transform the heart?",
"What does real spiritual change require?" "What does real spiritual change require?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
@@ -720,6 +802,8 @@
"What does it mean to seek the things above?", "What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?" "How does hidden life in Christ shape daily priorities?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
@@ -736,6 +820,8 @@
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
@@ -752,6 +838,8 @@
"What does renewal in Christ produce?", "What does renewal in Christ produce?",
"How does this reshape how believers see one another?" "How does this reshape how believers see one another?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
@@ -768,6 +856,8 @@
"Which virtues are most needed in your context?", "Which virtues are most needed in your context?",
"How does love bind these virtues together?" "How does love bind these virtues together?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
@@ -784,6 +874,8 @@
"What does it look like for Christs peace to rule?", "What does it look like for Christs peace to rule?",
"How can the word of Christ dwell richly in a church family?" "How can the word of Christ dwell richly in a church family?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
@@ -800,6 +892,8 @@
"How do these instructions reflect Christs lordship?", "How do these instructions reflect Christs lordship?",
"Which relationship is most challenging to apply today?" "Which relationship is most challenging to apply today?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
@@ -816,6 +910,8 @@
"How does serving the Lord reshape everyday work?", "How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?" "What does Paul say about fairness and accountability?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
@@ -832,6 +928,8 @@
"Why does Paul remind masters of their heavenly Master?", "Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?" "What does fairness look like in this context?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
@@ -848,6 +946,8 @@
"How are prayer and witness connected here?", "How are prayer and witness connected here?",
"What does gracious speech look like in real life?" "What does gracious speech look like in real life?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
@@ -864,6 +964,8 @@
"Why do these messengers matter to the letter?", "Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?" "What does this tell us about trusted coworkers?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
@@ -880,6 +982,8 @@
"What stands out about Pauls coworkers?", "What stands out about Pauls coworkers?",
"How does shared ministry strengthen the church?" "How does shared ministry strengthen the church?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
@@ -896,6 +1000,8 @@
"What final responsibility does Paul place on the church?", "What final responsibility does Paul place on the church?",
"How does the closing reinforce the letters themes?" "How does the closing reinforce the letters themes?"
], ],
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z" "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" "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", "passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
"displayName": "Student Test", "displayName": "Student Test",
"subscribeNewsletter": false, "subscribeNewsletter": false,
"pendingEmailChange": { "avatarUrl": "",
"newEmail": "student.test+new@versebyverse.local", "lastLoginAt": "2026-05-21T15:41:16.259Z"
"tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554",
"expiresAt": 1779464255007,
"requestedAt": "2026-05-21T15:37:35.007Z"
}, },
{
"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": [ "enrolledStudySlugs": [
"colossians" "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() let studyUsersWritePromise = Promise.resolve()
const studyNotesCache = new Map() // userId -> { [sectionId]: string } const studyNotesCache = new Map() // userId -> { [sectionId]: string }
const studyNotesWriteQueues = new Map() // userId -> Promise 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 studyProgressCache = new Map() // userId -> { byStudy: Record<string, { completedSectionIds: string[] }> }
const studyProgressWriteQueues = new Map() // userId -> Promise const studyProgressWriteQueues = new Map() // userId -> Promise
let studyCommunityPosts = [] let studyCommunityPosts = []
@@ -2244,6 +2246,28 @@ function normalizeStudyUsername(value) {
return value.trim().toLowerCase() 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) { function normalizeStudySlug(value) {
if (typeof value !== 'string') return '' if (typeof value !== 'string') return ''
const trimmed = value.trim().toLowerCase() 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 displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true
const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange)
? { ? {
newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail))
@@ -2454,13 +2479,11 @@ function sanitizeStudyUsers(value) {
passwordHash, passwordHash,
displayName, displayName,
subscribeNewsletter, subscribeNewsletter,
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now() studyRemindersEnabled,
? pendingEmailChange
: null,
enrolledStudySlugs, enrolledStudySlugs,
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(), avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
pendingEmailChange,
}) })
} }
@@ -2599,8 +2622,21 @@ function sanitizeStudyProgress(value) {
const completedSectionIds = Array.isArray(studyData?.completedSectionIds) const completedSectionIds = Array.isArray(studyData?.completedSectionIds)
? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) ? 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()] = { progress.byStudy[studySlug.trim().toLowerCase()] = {
completedSectionIds: Array.from(new Set(completedSectionIds)), completedSectionIds: Array.from(new Set(completedSectionIds)),
quizAnswers,
} }
} }
} }
@@ -2636,6 +2672,130 @@ function queueUserProgressWrite(userId) {
studyProgressWriteQueues.set(userId, next) 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() { async function migrateStudyNotesIfNeeded() {
try { try {
const raw = await readFile(STUDY_NOTES_FILE, 'utf8') const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
@@ -2779,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => {
username: user?.username ?? '', username: user?.username ?? '',
displayName: user?.displayName ?? '', displayName: user?.displayName ?? '',
subscribeNewsletter: user?.subscribeNewsletter !== false, subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
}) })
}) })
@@ -2811,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
passwordHash: hashStudyPassword(password), passwordHash: hashStudyPassword(password),
displayName, displayName,
subscribeNewsletter: subscribe, subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
pendingEmailChange: null, pendingEmailChange: null,
enrolledStudySlugs: [], enrolledStudySlugs: [],
createdAt: now, createdAt: now,
@@ -2838,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
subscribeNewsletter: user.subscribeNewsletter, subscribeNewsletter: user.subscribeNewsletter,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs, enrolledStudySlugs: user.enrolledStudySlugs,
}) })
}) })
@@ -2872,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
username: user.username, username: user.username,
displayName: user.displayName ?? '', displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [], 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 }) 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) => { app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
const user = req.studyUser const user = req.studyUser
const studySlug = normalizeStudySlug(req.params.studySlug) const studySlug = normalizeStudySlug(req.params.studySlug)
@@ -3084,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => {
.filter(post => post.studySlug === studySlug) .filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 50) .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({ res.json({
studySlug, studySlug,
@@ -3115,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
sectionId, sectionId,
authorUserId: user.id, authorUserId: user.id,
authorName, authorName,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message, message,
createdAt: now, createdAt: now,
replies: [], replies: [],
@@ -3152,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (
id: randomUUID(), id: randomUUID(),
authorUserId: user.id, authorUserId: user.id,
authorName: user.displayName?.trim() || user.username, authorName: user.displayName?.trim() || user.username,
authorAvatarUrl: getStudyAvatarUrl(user.username),
message, message,
createdAt: new Date().toISOString(), 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) => { app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
const user = req.studyUser const user = req.studyUser
const notes = await loadUserNotes(user.id) 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 sectionMeta = {}
const content = cachedSiteContent const content = cachedSiteContent
if (content) { const studies = content && Array.isArray(content.studies) && content.studies.length > 0
const studies = Array.isArray(content.studies) && content.studies.length > 0
? content.studies ? 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 study of studies) {
for (const section of (study.sections ?? [])) { for (const section of (study.sections ?? [])) {
sectionMeta[`${study.slug}--${section.id}`] = { sectionMeta[`${study.slug}--${section.id}`] = {
studyTitle: study.title, studyTitle: study.title,
studyDescription: typeof study.description === 'string' ? study.description : '',
title: section.title, title: section.title,
reference: section.reference, reference: section.reference,
} studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [],
} }
} }
} }
// Group notes by study const studyEntries = {}
const byStudy = {}
for (const [noteKey, noteText] of Object.entries(notes)) { for (const [noteKey, noteText] of Object.entries(notes)) {
if (!noteText?.trim()) continue if (!noteText?.trim()) continue
const dashIndex = noteKey.indexOf('--') const dashIndex = noteKey.indexOf('--')
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown' const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
if (!byStudy[studySlug]) byStudy[studySlug] = [] const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
byStudy[studySlug].push({ noteKey, noteText }) 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 = [ const docChildren = [
new Paragraph({ new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }),
text: 'My Study Notes', new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }),
heading: HeadingLevel.TITLE, 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 } }),
new Paragraph({
children: [new TextRun({ 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)) { for (const studySlug of studySlugs) {
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug 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( docChildren.push(
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }), new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
) )
for (const { noteKey, noteText } of entries) { if (studyDescription) {
const meta = sectionMeta[noteKey]
const lessonTitle = meta?.title ?? noteKey
const reference = meta?.reference ?? ''
docChildren.push( docChildren.push(
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }), new Paragraph({ text: studyDescription, spacing: { after: 240 } }),
)
if (reference) {
docChildren.push(
new Paragraph({
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
spacing: { after: 120 },
}),
) )
} }
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( 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) { if (docChildren.length <= 4) {
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } })) docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } }))
} }
const doc = new Document({ const doc = new Document({
creator: 'Verse by Verse with Nate', creator: 'Verse by Verse with Nate',
title: 'My Study Notes', title: 'My Study Export',
sections: [{ children: docChildren }], sections: [{ children: docChildren }],
}) })
const buffer = await Packer.toBuffer(doc) 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-Disposition', `attachment; filename="${filename}"`)
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
res.send(buffer) res.send(buffer)
@@ -3304,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
username: user.username, username: user.username,
displayName: user.displayName ?? '', displayName: user.displayName ?? '',
subscribeNewsletter: user.subscribeNewsletter !== false, subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user),
}, },
stats: { stats: {
noteCount: Object.keys(notes).length, 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) => { app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
const user = req.studyUser const user = req.studyUser
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' 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.displayName = displayName
user.avatarUrl = avatarUrl
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() 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) => { app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
const user = req.studyUser const user = req.studyUser
const subscribeNewsletter = req.body?.subscribeNewsletter === true const subscribeNewsletter = req.body?.subscribeNewsletter === true
const studyRemindersEnabled = req.body?.studyRemindersEnabled === true
user.subscribeNewsletter = subscribeNewsletter user.subscribeNewsletter = subscribeNewsletter
user.studyRemindersEnabled = studyRemindersEnabled
user.updatedAt = new Date().toISOString() user.updatedAt = new Date().toISOString()
queueStudyUsersWrite() 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)) 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) => { app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
@@ -5150,6 +5496,7 @@ Promise.all([
loadDraftQuestionsFromDisk(), loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(), loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(), loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
migrateStudyNotesIfNeeded(), migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadPodcastChecklistFromDisk(), loadPodcastChecklistFromDisk(),
@@ -5172,6 +5519,14 @@ Promise.all([
} }
}, 60 * 60 * 1000) }, 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, () => { app.listen(PORT, () => {
logResendEmailAlignmentWarnings() logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`) 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) studyQuestions: Array.isArray(item.studyQuestions)
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) ? 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 } : {}), ...(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) { function sanitizeStudies(value) {
+2
View File
@@ -2077,6 +2077,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
commentary: '', commentary: '',
greekNotes: [], greekNotes: [],
studyQuestions: [], 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 AdminPage from './AdminPage'
import QASection from './components/QASection' import QASection from './components/QASection'
import ContactForm from './components/ContactForm' 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 { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content' import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } 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" 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/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/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="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} /> <Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} /> <Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
+712 -85
View File
@@ -12,6 +12,8 @@ type StudyAuthState = {
enrolledStudySlugs?: string[] enrolledStudySlugs?: string[]
displayName?: string displayName?: string
subscribeNewsletter?: boolean subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
} }
type StudyAuthStatusResponse = { type StudyAuthStatusResponse = {
@@ -20,6 +22,8 @@ type StudyAuthStatusResponse = {
enrolledStudySlugs?: string[] enrolledStudySlugs?: string[]
displayName?: string displayName?: string
subscribeNewsletter?: boolean subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
} }
type StudyAccountOverview = { type StudyAccountOverview = {
@@ -27,6 +31,8 @@ type StudyAccountOverview = {
username: string username: string
displayName: string displayName: string
subscribeNewsletter: boolean subscribeNewsletter: boolean
studyRemindersEnabled: boolean
avatarUrl?: string
} }
stats: { stats: {
noteCount: number noteCount: number
@@ -53,6 +59,7 @@ type StudyProgress = {
type StudyCommunityReply = { type StudyCommunityReply = {
id: string id: string
authorName: string authorName: string
authorAvatarUrl?: string
message: string message: string
createdAt: string createdAt: string
} }
@@ -62,6 +69,7 @@ type StudyCommunityPost = {
studySlug: string studySlug: string
sectionId: string sectionId: string
authorName: string authorName: string
authorAvatarUrl?: string
message: string message: string
createdAt: string createdAt: string
replies: StudyCommunityReply[] 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 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', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}> <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> <strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
{postSection && !activeSection && ( {postSection && !activeSection && (
<button <button
@@ -393,7 +408,16 @@ function CommunityBoard({
{post.replies.map(reply => ( {post.replies.map(reply => (
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}> <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', 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> <strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
</div>
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span> <span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
</div> </div>
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p> <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) { 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 studies = getStudies(content)
const activeStudies = studies.filter(study => study.status !== 'planned') const activeStudies = studies.filter(study => study.status !== 'planned')
const plannedStudies = studies.filter(study => study.status === 'planned') const plannedStudies = studies.filter(study => study.status === 'planned')
const firstActiveStudy = activeStudies[0] const firstActiveStudy = activeStudies[0]
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased) 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 ( return (
<main className="study-index-page" aria-label="Study hub"> <main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero"> <section className="section-study-course-hero">
@@ -453,7 +752,6 @@ export function StudyLandingPage({ content }: Props) {
</div> </div>
<div className="study-course-hero-actions"> <div className="study-course-hero-actions">
{firstActiveStudyFirstReleasedSection && <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>} {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/signup" className="btn-primary">Create Account or Login</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link> <Link to="/study/account" className="btn-secondary">My Account</Link>
</div> </div>
@@ -588,6 +886,7 @@ export function StudySignupPage() {
}) })
setDone(true) setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase()) setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
} catch (err) { } catch (err) {
setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.') setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.')
} finally { } finally {
@@ -1299,7 +1598,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
<li key={index}>{question}</li> <li key={index}>{question}</li>
))} ))}
</ol> </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={`/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> <Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div> </div>
@@ -1585,12 +1885,17 @@ export function StudyAccountPage() {
const [loadingOverview, setLoadingOverview] = useState(false) const [loadingOverview, setLoadingOverview] = useState(false)
const [displayNameInput, setDisplayNameInput] = useState('') const [displayNameInput, setDisplayNameInput] = useState('')
const [avatarUrlInput, setAvatarUrlInput] = useState('')
const [profileMessage, setProfileMessage] = useState('') const [profileMessage, setProfileMessage] = useState('')
const [profileBusy, setProfileBusy] = useState(false) const [profileBusy, setProfileBusy] = useState(false)
const [avatarUploadBusy, setAvatarUploadBusy] = useState(false)
const [avatarUploadMessage, setAvatarUploadMessage] = useState('')
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true) const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('') const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false) const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('') const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('') const [emailNew, setEmailNew] = useState('')
@@ -1628,6 +1933,8 @@ export function StudyAccountPage() {
username: data.username ?? '', username: data.username ?? '',
displayName: data.displayName ?? '', displayName: data.displayName ?? '',
subscribeNewsletter: data.subscribeNewsletter !== false, subscribeNewsletter: data.subscribeNewsletter !== false,
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
}) })
}) })
@@ -1641,7 +1948,9 @@ export function StudyAccountPage() {
.then(data => { .then(data => {
setOverview(data) setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '') setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoadingOverview(false)) .finally(() => setLoadingOverview(false))
@@ -1671,19 +1980,55 @@ export function StudyAccountPage() {
const data = await readJson<StudyAccountOverview>('/api/study-account/overview') const data = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(data) setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '') setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) 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() { async function handleProfileSave() {
setProfileBusy(true) setProfileBusy(true)
setProfileMessage('') setProfileMessage('')
try { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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.') setProfileMessage('Profile saved.')
await refreshOverview() await refreshOverview()
} catch (err) { } catch (err) {
@@ -1697,12 +2042,12 @@ export function StudyAccountPage() {
setPrefBusy(true) setPrefBusy(true)
setPrefMessage('') setPrefMessage('')
try { 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', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, 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.') setPrefMessage('Preferences updated.')
await refreshOverview() await refreshOverview()
} catch (err) { } catch (err) {
@@ -1833,6 +2178,13 @@ export function StudyAccountPage() {
} }
} }
function closeAccountModal() {
setAccountModal('none')
setEmailMessage('')
setPwMessage('')
setEnrollMessage('')
}
if (!auth.checked) { if (!auth.checked) {
return ( return (
<main className="thanks-page" aria-label="Loading"> <main className="thanks-page" aria-label="Loading">
@@ -1861,7 +2213,20 @@ export function StudyAccountPage() {
<div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}> <div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}>
<p className="eyebrow">Student Account</p> <p className="eyebrow">Student Account</p>
<h1>My Account</h1> <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 && ( {overview?.stats && (
<> <>
@@ -1880,36 +2245,127 @@ export function StudyAccountPage() {
)} )}
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Enrollment Manager</h2> <h2 style={{ marginTop: 0 }}>Quick Actions</h2>
<p style={{ fontSize: '0.92rem' }}>Enroll or unenroll by study and track your progress.</p> <p style={{ fontSize: '0.92rem' }}>Open the settings you need without scrolling through the whole page.</p>
{loadingOverview && <p>Loading studies...</p>} <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
{!loadingOverview && overview?.studies?.map(study => ( <button type="button" className="btn-primary" onClick={() => setAccountModal('profile')}>Edit Profile</button>
<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' }}> <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> <div>
<p style={{ margin: 0, fontWeight: 600 }}>{study.title}</p> <p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.84rem' }}> <h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
Status: <strong>{study.enrolled ? 'Enrolled' : 'Not Enrolled'}</strong> <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`}
{` • Notes: ${study.noteCount}`}
</p>
</div> </div>
<button <button
type="button" type="button"
className={study.enrolled ? 'btn-secondary' : 'btn-primary'} className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={study.status === 'planned' || enrollBusySlug === study.slug} disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(study.slug, study.enrolled)} onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
> >
{enrollBusySlug === study.slug ? 'Updating...' : (study.enrolled ? 'Unenroll' : 'Enroll')} {enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button> </button>
</div> </div>
</div>
))} ))}
{enrollMessage && <p className="study-note-status">{enrollMessage}</p>} </div>
</section> ) : (
<p>No study access information is available right now.</p>
{divider} )}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
<section style={{ marginBottom: '2rem' }}> </>
<h2 style={{ marginTop: 0 }}>Profile Settings</h2> )}
{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> <label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Display Name (optional)</label>
<input <input
type="text" 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' }} style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }}
/> />
<div> <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> </div>
{profileMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{profileMessage}</p>} {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)} /> <input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span> <span>Subscribe to newsletter updates</span>
</label> </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> </div>
</section> <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>}
{divider} </>
)}
<section style={{ marginBottom: '2rem' }}> {accountModal === 'export' && (
<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> <h2 id="account-modal-heading">Export Notes</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: '420px' }}> <p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Download all saved notes in a single document.</p>
<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={exporting} onClick={handleExport}> <button type="button" className="btn-primary" disabled={exporting} onClick={handleExport}>
{exporting ? 'Preparing...' : 'Download Notes (.docx)'} {exporting ? 'Preparing...' : 'Download Notes (.docx)'}
</button> </button>
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{exportMessage}</p>} {exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</section> </>
)}
{divider} {accountModal === 'changePassword' && (
<>
<section style={{ marginBottom: '2rem' }}> <h2 id="account-modal-heading">Change Password</h2>
<h2 style={{ marginTop: 0 }}>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>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', maxWidth: '420px' }}> <label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<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={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' }} />
<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' }} /> <label style={{ display: 'block', marginBottom: '0.5rem' }}>New password</label>
<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' }} /> <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' }} />
</div> <label style={{ display: 'block', marginBottom: '0.5rem' }}>Confirm new password</label>
{pwMessage && <p style={{ color: '#e57373', fontSize: '0.875rem', marginTop: '0.5rem' }}>{pwMessage}</p>} <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' }} />
{pwSuccess && <p style={{ color: '#81c784', fontSize: '0.875rem', marginTop: '0.5rem' }}>Password updated successfully.</p>} <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={pwBusy} onClick={handleChangePassword} style={{ marginTop: '0.75rem' }}> <button type="button" className="btn-primary" disabled={pwBusy || !pwCurrent || !pwNew || !pwConfirm} onClick={handleChangePassword}>
{pwBusy ? 'Updating...' : 'Update Password'} {pwBusy ? 'Saving…' : 'Update Password'}
</button> </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} {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) { export function StudyCommunityPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug: string }>() const { studySlug } = useParams<{ studySlug: string }>()
const location = useLocation() const location = useLocation()