From 5b448aef0fe6ea9e1c2e4e51e4717fef611d9144 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 2 Jun 2026 14:02:00 -0400 Subject: [PATCH] stuff --- data/admin-content-draft.json | 116 ++- data/admin-content.json | 116 ++- data/questions.json | 2 +- data/study-community.json | 50 ++ .../3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json | 3 + .../3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json | 16 + data/study-users.json | 34 +- server.js | 453 +++++++++- server/helpers.js | 6 +- src/AdminPage.tsx | 2 + src/App.tsx | 3 +- src/colossiansStudy.tsx | 849 +++++++++++++++--- 12 files changed, 1466 insertions(+), 184 deletions(-) create mode 100644 data/study-community.json create mode 100644 data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json create mode 100644 data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json diff --git a/data/admin-content-draft.json b/data/admin-content-draft.json index 67b5e49..90c18ba 100644 --- a/data/admin-content-draft.json +++ b/data/admin-content-draft.json @@ -85,7 +85,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -101,7 +106,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -117,6 +124,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -133,6 +142,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -149,6 +160,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -165,6 +178,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -181,6 +196,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -197,6 +214,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -213,6 +232,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -229,6 +250,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -245,6 +268,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -261,6 +286,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -277,6 +304,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -293,6 +322,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -309,6 +340,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -325,6 +358,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -341,6 +376,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -357,6 +394,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -373,6 +412,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -389,6 +430,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -405,6 +448,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -421,6 +466,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -437,6 +484,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -453,6 +502,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -469,6 +520,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ] @@ -512,7 +565,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -528,7 +586,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -544,6 +604,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -560,6 +622,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -576,6 +640,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -592,6 +658,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -608,6 +676,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -624,6 +694,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -640,6 +712,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -656,6 +730,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -672,6 +748,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -688,6 +766,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -704,6 +784,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -720,6 +802,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -736,6 +820,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -752,6 +838,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -768,6 +856,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -784,6 +874,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -800,6 +892,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -816,6 +910,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -832,6 +928,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -848,6 +946,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -864,6 +964,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -880,6 +982,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -896,6 +1000,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ], @@ -1069,5 +1175,5 @@ ] } }, - "updatedAt": "2026-06-01T17:25:41.122Z" + "updatedAt": "2026-06-02T17:54:05.948Z" } \ No newline at end of file diff --git a/data/admin-content.json b/data/admin-content.json index a63b03f..63d5e42 100644 --- a/data/admin-content.json +++ b/data/admin-content.json @@ -85,7 +85,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -101,7 +106,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -117,6 +124,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -133,6 +142,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -149,6 +160,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -165,6 +178,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -181,6 +196,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -197,6 +214,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -213,6 +232,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -229,6 +250,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -245,6 +268,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -261,6 +286,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -277,6 +304,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -293,6 +322,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -309,6 +340,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -325,6 +358,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -341,6 +376,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -357,6 +394,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -373,6 +412,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -389,6 +430,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -405,6 +448,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -421,6 +466,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -437,6 +484,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -453,6 +502,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -469,6 +520,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ] @@ -512,7 +565,12 @@ "Why does Paul begin with grace and peace?", "What tone does this create for the rest of the study?" ], - "releasedAt": "2026-11-09T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [ + "Why does Paul begin with grace and peace?", + "What tone does this create for the rest of the study?" + ], + "releasedAt": "2026-05-06T00:00:00Z" }, { "id": "1-3-8", @@ -528,7 +586,9 @@ "What signs of gospel growth does Paul mention?", "How do faith, hope, and love work together here?" ], - "releasedAt": "2026-11-23T00:00:00Z" + "checkpointPrompt": "", + "checkpointQuestions": [], + "releasedAt": "2026-05-31T00:00:00Z" }, { "id": "1-9-14", @@ -544,6 +604,8 @@ "What does Paul ask God to produce?", "How does this prayer shape the goals of the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-07T00:00:00Z" }, { @@ -560,6 +622,8 @@ "What stands out most about Christ’s supremacy?", "Why is this passage central to the study?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2026-12-21T00:00:00Z" }, { @@ -576,6 +640,8 @@ "How does Paul describe the believer’s former condition?", "What does continuing in the faith look like?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-04T00:00:00Z" }, { @@ -592,6 +658,8 @@ "What is the goal of Paul’s ministry?", "Why is maturity such an important theme here?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-01-18T00:00:00Z" }, { @@ -608,6 +676,8 @@ "What does Paul want the believers to experience?", "Why does he emphasize encouragement and unity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-01T00:00:00Z" }, { @@ -624,6 +694,8 @@ "What does it mean to continue in Christ?", "What kinds of deception should believers watch for?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-02-15T00:00:00Z" }, { @@ -640,6 +712,8 @@ "Why is fullness in Christ such an important correction?", "How does this guard against spiritual insecurity?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-01T00:00:00Z" }, { @@ -656,6 +730,8 @@ "What changes because of Christ’s work here?", "How does victory over the powers encourage believers today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-15T00:00:00Z" }, { @@ -672,6 +748,8 @@ "What do shadows and substance mean here?", "Where do believers still feel pressure from external judgment?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-03-29T00:00:00Z" }, { @@ -688,6 +766,8 @@ "How does Paul describe false spirituality?", "Why is connection to Christ essential?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-12T00:00:00Z" }, { @@ -704,6 +784,8 @@ "Why do human regulations fail to transform the heart?", "What does real spiritual change require?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-04-26T00:00:00Z" }, { @@ -720,6 +802,8 @@ "What does it mean to seek the things above?", "How does hidden life in Christ shape daily priorities?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-10T00:00:00Z" }, { @@ -736,6 +820,8 @@ "Which old patterns does Paul name?", "Why is decisive change necessary?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-05-24T00:00:00Z" }, { @@ -752,6 +838,8 @@ "What does renewal in Christ produce?", "How does this reshape how believers see one another?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-07T00:00:00Z" }, { @@ -768,6 +856,8 @@ "Which virtues are most needed in your context?", "How does love bind these virtues together?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-06-21T00:00:00Z" }, { @@ -784,6 +874,8 @@ "What does it look like for Christ’s peace to rule?", "How can the word of Christ dwell richly in a church family?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-05T00:00:00Z" }, { @@ -800,6 +892,8 @@ "How do these instructions reflect Christ’s lordship?", "Which relationship is most challenging to apply today?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-07-19T00:00:00Z" }, { @@ -816,6 +910,8 @@ "How does serving the Lord reshape everyday work?", "What does Paul say about fairness and accountability?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-02T00:00:00Z" }, { @@ -832,6 +928,8 @@ "Why does Paul remind masters of their heavenly Master?", "What does fairness look like in this context?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-16T00:00:00Z" }, { @@ -848,6 +946,8 @@ "How are prayer and witness connected here?", "What does gracious speech look like in real life?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-08-30T00:00:00Z" }, { @@ -864,6 +964,8 @@ "Why do these messengers matter to the letter?", "What does this tell us about trusted coworkers?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-13T00:00:00Z" }, { @@ -880,6 +982,8 @@ "What stands out about Paul’s coworkers?", "How does shared ministry strengthen the church?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-09-27T00:00:00Z" }, { @@ -896,6 +1000,8 @@ "What final responsibility does Paul place on the church?", "How does the closing reinforce the letter’s themes?" ], + "checkpointPrompt": "", + "checkpointQuestions": [], "releasedAt": "2027-10-11T00:00:00Z" } ], @@ -1069,5 +1175,5 @@ ] } }, - "updatedAt": "2026-06-01T17:25:41.133Z" + "updatedAt": "2026-06-02T17:54:06.010Z" } \ No newline at end of file diff --git a/data/questions.json b/data/questions.json index 1c0c9fe..1ab7a58 100644 --- a/data/questions.json +++ b/data/questions.json @@ -98,5 +98,5 @@ "topic": "Faith & Life" } ], - "updatedAt": "2026-06-01T17:25:41.133Z" + "updatedAt": "2026-06-02T17:54:06.010Z" } \ No newline at end of file diff --git a/data/study-community.json b/data/study-community.json new file mode 100644 index 0000000..01b7656 --- /dev/null +++ b/data/study-community.json @@ -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" +} \ No newline at end of file diff --git a/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json b/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json new file mode 100644 index 0000000..a0d6277 --- /dev/null +++ b/data/study-notes/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json @@ -0,0 +1,3 @@ +{ + "colossians--1-1-2": "test....teset" +} \ No newline at end of file diff --git a/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json new file mode 100644 index 0000000..3baee1a --- /dev/null +++ b/data/study-progress/3307bdd7-a770-4fad-a28e-cf2d0c2a6e34.json @@ -0,0 +1,16 @@ +{ + "byStudy": { + "colossians": { + "completedSectionIds": [ + "1-1-2" + ], + "quizAnswers": { + "1-1-2": [ + "test", + "test" + ] + } + } + }, + "updatedAt": "2026-06-02T17:53:29.839Z" +} \ No newline at end of file diff --git a/data/study-users.json b/data/study-users.json index 2467799..ef513f8 100644 --- a/data/study-users.json +++ b/data/study-users.json @@ -6,19 +6,31 @@ "passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9", "displayName": "Student Test", "subscribeNewsletter": false, - "pendingEmailChange": { - "newEmail": "student.test+new@versebyverse.local", - "tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554", - "expiresAt": 1779464255007, - "requestedAt": "2026-05-21T15:37:35.007Z" - }, + "avatarUrl": "", + "lastLoginAt": "2026-05-21T15:41:16.259Z" + }, + { + "id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34", + "username": "test.user@versebyverse.local", + "passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5", + "displayName": "Test Student", + "subscribeNewsletter": false, + "avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E", + "lastLoginAt": "2026-06-02T17:56:40.769Z", + "updatedAt": "2026-06-02T17:57:04.582Z", "enrolledStudySlugs": [ "colossians" - ], - "createdAt": "2026-05-21T13:05:00.000Z", - "updatedAt": "2026-05-21T18:41:10.857Z", - "lastLoginAt": "2026-05-21T15:41:16.259Z" + ] + }, + { + "id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a", + "username": "test.avatar@versebyverse.local", + "passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5", + "displayName": "Avatar Test", + "subscribeNewsletter": false, + "avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E", + "lastLoginAt": "2026-06-02T17:33:13.669Z" } ], - "updatedAt": "2026-05-21T18:41:10.858Z" + "updatedAt": "2026-06-02T17:57:04.583Z" } \ No newline at end of file diff --git a/server.js b/server.js index 9c53d6b..bdd3ec7 100644 --- a/server.js +++ b/server.js @@ -635,6 +635,8 @@ let studyUsers = [] let studyUsersWritePromise = Promise.resolve() const studyNotesCache = new Map() // userId -> { [sectionId]: string } const studyNotesWriteQueues = new Map() // userId -> Promise +let studyReminders = { users: {}, updatedAt: new Date().toISOString() } +let studyRemindersWritePromise = Promise.resolve() const studyProgressCache = new Map() // userId -> { byStudy: Record } const studyProgressWriteQueues = new Map() // userId -> Promise let studyCommunityPosts = [] @@ -2244,6 +2246,28 @@ function normalizeStudyUsername(value) { return value.trim().toLowerCase() } +function getStudyAvatarUrl(subject) { + let customAvatar = '' + let username = '' + + if (subject && typeof subject === 'object') { + customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : '' + username = normalizeStudyUsername(subject.username) + } else if (typeof subject === 'string') { + username = normalizeStudyUsername(subject) + } + + if (customAvatar) return customAvatar + if (!username) return '' + const hash = createHash('md5').update(username).digest('hex') + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96` +} + +function findStudyUserById(userId) { + if (typeof userId !== 'string' || !userId.trim()) return undefined + return studyUsers.find(user => user.id === userId) +} + function normalizeStudySlug(value) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() @@ -2437,6 +2461,7 @@ function sanitizeStudyUsers(value) { : [] const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' const subscribeNewsletter = item?.subscribeNewsletter !== false + const studyRemindersEnabled = item?.studyRemindersEnabled === true const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange) ? { newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail)) @@ -2454,13 +2479,11 @@ function sanitizeStudyUsers(value) { passwordHash, displayName, subscribeNewsletter, - pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now() - ? pendingEmailChange - : null, + studyRemindersEnabled, enrolledStudySlugs, - createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(), - updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(), + avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, + pendingEmailChange, }) } @@ -2599,8 +2622,21 @@ function sanitizeStudyProgress(value) { const completedSectionIds = Array.isArray(studyData?.completedSectionIds) ? studyData.completedSectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) : [] + const quizAnswers = studyData?.quizAnswers && typeof studyData?.quizAnswers === 'object' && !Array.isArray(studyData.quizAnswers) + ? Object.fromEntries( + Object.entries(studyData.quizAnswers) + .filter(([sectionId]) => typeof sectionId === 'string' && sectionId.trim()) + .map(([sectionId, answers]) => [ + sectionId.trim(), + Array.isArray(answers) + ? answers.filter(answer => typeof answer === 'string').map(answer => answer.trim()) + : [], + ]) + ) + : {} progress.byStudy[studySlug.trim().toLowerCase()] = { completedSectionIds: Array.from(new Set(completedSectionIds)), + quizAnswers, } } } @@ -2636,6 +2672,130 @@ function queueUserProgressWrite(userId) { studyProgressWriteQueues.set(userId, next) } +function sanitizeStudyReminders(value) { + const defaultResult = { users: {}, updatedAt: new Date().toISOString() } + if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultResult + + const reminders = { users: {}, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString() } + if (value.users && typeof value.users === 'object') { + for (const [userId, userData] of Object.entries(value.users)) { + if (typeof userId !== 'string' || !userId.trim()) continue + const studies = typeof userData === 'object' && userData && !Array.isArray(userData) + ? userData + : {} + const normalizedStudies = {} + for (const [studySlug, sectionIds] of Object.entries(studies)) { + if (typeof studySlug !== 'string' || !studySlug.trim()) continue + const ids = Array.isArray(sectionIds) + ? sectionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()) + : [] + if (ids.length > 0) normalizedStudies[studySlug.trim().toLowerCase()] = Array.from(new Set(ids)) + } + reminders.users[userId.trim()] = normalizedStudies + } + } + + return reminders +} + +async function loadStudyRemindersFromDisk() { + try { + const raw = await readFile(STUDY_REMINDERS_FILE, 'utf8') + studyReminders = sanitizeStudyReminders(JSON.parse(raw)) + } catch { + studyReminders = { users: {}, updatedAt: new Date().toISOString() } + } +} + +function queueStudyRemindersWrite() { + studyRemindersWritePromise = studyRemindersWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(STUDY_REMINDERS_FILE, JSON.stringify(studyReminders, null, 2), 'utf8') + }) + .catch(err => { + console.error('[study-reminders] failed to write reminders:', err) + }) +} + +async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) { + if (!process.env.RESEND_API_KEY) return + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend' + const bodyHtml = ( + `

Hi ${escapeHtml(namePart)},

` + + `

A new study lesson is available in ${escapeHtml(studyTitle)}.

` + + `

${escapeHtml(sectionTitle)} (${escapeHtml(sectionReference)}) is now unlocked.

` + ) + const footerHtml = `

Grace and peace,
Verse by Verse with Nate

` + const { error } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', + to: [email], + subject: process.env.RESEND_REMINDER_SUBJECT ?? `New lesson available: ${sectionTitle}`, + text: + `Hi ${namePart},\n\n` + + `A new lesson is available in ${studyTitle}.\n` + + `${sectionTitle} (${sectionReference}) is now unlocked.\n\n` + + `Open it here: ${sectionUrl}\n\n` + + `Grace and peace,\nVerse by Verse with Nate`, + html: buildBrandedEmailHtml({ + title: 'New Lesson Available', + eyebrow: 'Study Reminder', + bodyHtml, + ctaLabel: 'Open the Lesson', + ctaUrl: sectionUrl, + footerHtml, + }), + }) + if (error) console.error('[study-reminder] send error:', error) + } catch (err) { + console.error('[study-reminder] send exception:', err) + } +} + +async function scheduleStudyReminders() { + if (!cachedSiteContent) return + const now = new Date() + const userIds = studyUsers.filter(user => user.studyRemindersEnabled === true).map(user => user.id) + if (userIds.length === 0) return + + for (const user of studyUsers) { + if (user.studyRemindersEnabled !== true) continue + const email = user.username + const displayName = user.displayName || email + const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] + if (enrolledStudySlugs.length === 0) continue + + const userSent = studyReminders.users[user.id] ?? {} + for (const studySlug of enrolledStudySlugs) { + const study = Array.isArray(cachedSiteContent.studies) + ? cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) + : undefined + if (!study) continue + + for (const section of study.sections ?? []) { + const sectionId = section.id + const releaseDate = getSectionReleaseDate(section) + if (!releaseDate) continue + if (releaseDate > now) continue + const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : [] + if (sentForStudy.includes(sectionId)) continue + + const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60) + if (hoursSinceRelease > 24) continue + + const sectionUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/${study.slug}/${section.id}`) + await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl) + userSent[studySlug] = [...sentForStudy, sectionId] + studyReminders.users[user.id] = userSent + } + } + } + studyReminders.updatedAt = new Date().toISOString() + queueStudyRemindersWrite() +} + async function migrateStudyNotesIfNeeded() { try { const raw = await readFile(STUDY_NOTES_FILE, 'utf8') @@ -2779,6 +2939,8 @@ app.get('/api/study-auth/status', (req, res) => { username: user?.username ?? '', displayName: user?.displayName ?? '', subscribeNewsletter: user?.subscribeNewsletter !== false, + studyRemindersEnabled: user?.studyRemindersEnabled === true, + avatarUrl: user ? getStudyAvatarUrl(user) : '', enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], }) }) @@ -2811,6 +2973,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { passwordHash: hashStudyPassword(password), displayName, subscribeNewsletter: subscribe, + studyRemindersEnabled: false, pendingEmailChange: null, enrolledStudySlugs: [], createdAt: now, @@ -2838,6 +3001,8 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { username: user.username, displayName: user.displayName, subscribeNewsletter: user.subscribeNewsletter, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs, }) }) @@ -2872,6 +3037,8 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user.username), enrolledStudySlugs: user.enrolledStudySlugs ?? [], }) }) @@ -3041,6 +3208,56 @@ app.post('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (r res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) }) +app.get('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' }) + return + } + + const progress = await loadUserProgress(user.id) + const quizAnswers = progress.byStudy[studySlug]?.quizAnswers?.[sectionId] ?? [] + res.json({ studySlug, sectionId, answers: quizAnswers }) +}) + +app.post('/api/study-quiz/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { + const user = req.studyUser + const studySlug = normalizeStudySlug(req.params.studySlug) + const sectionId = normalizeLessonSectionId(req.params.sectionId) + if (!studySlug || !sectionId) { + res.status(400).json({ message: 'Invalid study slug or section id.' }) + return + } + + if (!isStudyUserEnrolled(user, studySlug)) { + res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' }) + return + } + + const rawAnswers = req.body?.answers + const answers = Array.isArray(rawAnswers) + ? rawAnswers.map(answer => typeof answer === 'string' ? answer.trim() : '').filter(Boolean) + : [] + + const progress = await loadUserProgress(user.id) + const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [] } + studyProgress.quizAnswers = studyProgress.quizAnswers || {} + studyProgress.quizAnswers[sectionId] = answers + progress.byStudy[studySlug] = studyProgress + progress.updatedAt = new Date().toISOString() + studyProgressCache.set(user.id, progress) + queueUserProgressWrite(user.id) + + res.json({ ok: true, studySlug, sectionId, answers }) +}) + app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { const user = req.studyUser const studySlug = normalizeStudySlug(req.params.studySlug) @@ -3084,6 +3301,23 @@ app.get('/api/study-community', requireStudyAuth, async (req, res) => { .filter(post => post.studySlug === studySlug) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 50) + .map(post => { + const author = findStudyUserById(post.authorUserId) + const enrichedPost = { + ...post, + authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''), + replies: Array.isArray(post.replies) + ? post.replies.map(reply => { + const replyAuthor = findStudyUserById(reply.authorUserId) + return { + ...reply, + authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''), + } + }) + : [], + } + return enrichedPost + }) res.json({ studySlug, @@ -3115,6 +3349,7 @@ app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => { sectionId, authorUserId: user.id, authorName, + authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: now, replies: [], @@ -3152,6 +3387,7 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async ( id: randomUUID(), authorUserId: user.id, authorName: user.displayName?.trim() || user.username, + authorAvatarUrl: getStudyAvatarUrl(user.username), message, createdAt: new Date().toISOString(), } @@ -3167,86 +3403,151 @@ app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async ( app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => { const user = req.studyUser const notes = await loadUserNotes(user.id) + const progress = await loadUserProgress(user.id) - // Build a lookup of sectionId -> { title, reference, studyTitle } from cached content + // Build a lookup of sectionId -> { title, reference, studyTitle, description } from cached content const sectionMeta = {} const content = cachedSiteContent - if (content) { - const studies = Array.isArray(content.studies) && content.studies.length > 0 - ? content.studies - : [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }] - for (const study of studies) { - for (const section of (study.sections ?? [])) { - sectionMeta[`${study.slug}--${section.id}`] = { - studyTitle: study.title, - title: section.title, - reference: section.reference, - } + const studies = content && Array.isArray(content.studies) && content.studies.length > 0 + ? content.studies + : [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', description: '', sections: content?.colossiansStudySections ?? [] }] + + for (const study of studies) { + for (const section of (study.sections ?? [])) { + sectionMeta[`${study.slug}--${section.id}`] = { + studyTitle: study.title, + studyDescription: typeof study.description === 'string' ? study.description : '', + title: section.title, + reference: section.reference, + studyQuestions: Array.isArray(section.studyQuestions) ? section.studyQuestions : [], } } } - // Group notes by study - const byStudy = {} + const studyEntries = {} + for (const [noteKey, noteText] of Object.entries(notes)) { if (!noteText?.trim()) continue const dashIndex = noteKey.indexOf('--') const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown' - if (!byStudy[studySlug]) byStudy[studySlug] = [] - byStudy[studySlug].push({ noteKey, noteText }) + const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey + if (!studyEntries[studySlug]) studyEntries[studySlug] = {} + studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} + studyEntries[studySlug][sectionId].noteText = noteText.trim() } + for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) { + const quizAnswersBySection = studyProgress.quizAnswers || {} + for (const [sectionId, answers] of Object.entries(quizAnswersBySection)) { + if (!Array.isArray(answers) || answers.length === 0) continue + if (!studyEntries[studySlug]) studyEntries[studySlug] = {} + studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {} + studyEntries[studySlug][sectionId].quizAnswers = answers.filter(answer => typeof answer === 'string' && answer.trim()).map(answer => answer.trim()) + } + } + + const studySlugs = Array.from(new Set([ + ...Object.keys(studyEntries), + ...Object.values(studies).map(study => study.slug), + ])) + const docChildren = [ - new Paragraph({ - text: 'My Study Notes', - heading: HeadingLevel.TITLE, - }), - new Paragraph({ - children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })], - spacing: { after: 400 }, - }), + new Paragraph({ text: 'Verse by Verse with Nate', heading: HeadingLevel.TITLE }), + new Paragraph({ text: 'My Study Export', heading: HeadingLevel.HEADING_1, spacing: { after: 240 } }), + new Paragraph({ text: `Student: ${user.displayName || user.username}`, spacing: { after: 120 } }), + new Paragraph({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true, spacing: { after: 400 } }), ] - for (const [studySlug, entries] of Object.entries(byStudy)) { - const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug + for (const studySlug of studySlugs) { + const study = studies.find(item => normalizeStudySlug(item?.slug) === studySlug) + const studyTitle = study?.title || studySlug + const studyDescription = typeof study?.description === 'string' ? study.description : '' + const sectionIds = studyEntries[studySlug] ? Object.keys(studyEntries[studySlug]) : [] + + if (sectionIds.length === 0) continue + docChildren.push( new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }), ) - for (const { noteKey, noteText } of entries) { - const meta = sectionMeta[noteKey] - const lessonTitle = meta?.title ?? noteKey - const reference = meta?.reference ?? '' + if (studyDescription) { docChildren.push( - new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }), + new Paragraph({ text: studyDescription, spacing: { after: 240 } }), ) - if (reference) { + } + const noteCount = sectionIds.filter(sectionId => studyEntries[studySlug][sectionId].noteText).length + const quizCount = sectionIds.filter(sectionId => Array.isArray(studyEntries[studySlug][sectionId].quizAnswers) && studyEntries[studySlug][sectionId].quizAnswers.length > 0).length + docChildren.push( + new Paragraph({ text: `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: reference, italics: true, color: '555555' })], - spacing: { after: 120 }, - }), + new Paragraph({ children: [new TextRun({ text: meta.reference, italics: true, color: '555555' })], spacing: { after: 120 } }), ) } - for (const line of noteText.split('\n')) { + if (entry.noteText) { docChildren.push( - new Paragraph({ text: line.trim(), spacing: { after: 80 } }), + new Paragraph({ text: 'Notes', heading: HeadingLevel.HEADING_3, spacing: { before: 120 } }), ) + for (const line of entry.noteText.split('\n')) { + docChildren.push(new Paragraph({ text: line.trim(), spacing: { after: 80 } })) + } + } + if (Array.isArray(meta.studyQuestions) && meta.studyQuestions.length > 0) { + docChildren.push( + new Paragraph({ text: 'Quiz Questions', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }), + ) + meta.studyQuestions.forEach((question, index) => { + docChildren.push( + new Paragraph({ + children: [ + new TextRun({ text: `${index + 1}. `, bold: true }), + new TextRun({ text: question }), + ], + spacing: { after: 80 }, + }), + ) + }) + } + if (Array.isArray(entry.quizAnswers) && entry.quizAnswers.length > 0) { + docChildren.push( + new Paragraph({ text: 'Quiz Answers', heading: HeadingLevel.HEADING_3, spacing: { before: 160 } }), + ) + entry.quizAnswers.forEach((answer, index) => { + docChildren.push( + new Paragraph({ + children: [ + new TextRun({ text: `Answer ${index + 1}: `, bold: true }), + new TextRun({ text: answer }), + ], + spacing: { after: 80 }, + }), + ) + }) } } } - if (docChildren.length <= 2) { - docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } })) + if (docChildren.length <= 4) { + docChildren.push(new Paragraph({ text: 'No notes or quiz answers saved yet.', spacing: { before: 200 } })) } const doc = new Document({ creator: 'Verse by Verse with Nate', - title: 'My Study Notes', + title: 'My Study Export', sections: [{ children: docChildren }], }) const buffer = await Packer.toBuffer(doc) - const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx` + const filename = `my-study-export-${new Date().toISOString().slice(0, 10)}.docx` res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') res.send(buffer) @@ -3304,6 +3605,8 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { username: user.username, displayName: user.displayName ?? '', subscribeNewsletter: user.subscribeNewsletter !== false, + studyRemindersEnabled: user.studyRemindersEnabled === true, + avatarUrl: getStudyAvatarUrl(user), }, stats: { noteCount: Object.keys(notes).length, @@ -3317,16 +3620,59 @@ app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => { app.post('/api/study-account/profile', requireStudyAuth, (req, res) => { const user = req.studyUser const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : '' + const avatarUrl = typeof req.body?.avatarUrl === 'string' ? req.body.avatarUrl.trim() : '' + if (avatarUrl && !/^https?:\/\//i.test(avatarUrl) && !avatarUrl.startsWith('/uploads/') && !avatarUrl.startsWith('data:image/')) { + res.status(400).json({ message: 'Avatar must be a valid uploaded image, data URI, or https URL.' }) + return + } + user.displayName = displayName + user.avatarUrl = avatarUrl user.updatedAt = new Date().toISOString() queueStudyUsersWrite() - res.json({ ok: true, displayName: user.displayName }) + res.json({ ok: true, displayName: user.displayName, avatarUrl: user.avatarUrl || getStudyAvatarUrl(user) }) +}) + +app.post('/api/study-account/avatar-upload', requireStudyAuth, async (req, res) => { + try { + const filename = typeof req.body?.filename === 'string' ? req.body.filename : '' + const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' + const ext = inferImageExtensionFromDataUrl(dataUrl) + + if (!ext) { + res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' }) + return + } + + const base64 = dataUrl.split(',')[1] ?? '' + const buffer = Buffer.from(base64, 'base64') + if (buffer.length === 0 || buffer.length > (4 * 1024 * 1024)) { + res.status(400).json({ message: 'Upload must be between 1 byte and 4MB.' }) + return + } + + const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) + const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}` + + await mkdir(UPLOADS_DIR, { recursive: true }) + await writeFile(path.join(UPLOADS_DIR, finalName), buffer) + const metadata = await readUploadsMetadata() + metadata[finalName] = [] + await writeUploadsMetadata(metadata) + + res.json({ ok: true, url: `/uploads/${finalName}` }) + } catch (err) { + console.error('[study-account-avatar-upload] upload error:', err) + res.status(500).json({ message: 'Avatar upload failed.' }) + } }) app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => { const user = req.studyUser const subscribeNewsletter = req.body?.subscribeNewsletter === true + const studyRemindersEnabled = req.body?.studyRemindersEnabled === true user.subscribeNewsletter = subscribeNewsletter + user.studyRemindersEnabled = studyRemindersEnabled user.updatedAt = new Date().toISOString() queueStudyUsersWrite() @@ -3334,7 +3680,7 @@ app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => { syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err)) } - res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter }) + res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter, studyRemindersEnabled: user.studyRemindersEnabled === true }) }) app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => { @@ -5150,6 +5496,7 @@ Promise.all([ loadDraftQuestionsFromDisk(), loadStudyUsersFromDisk(), loadStudyCommunityFromDisk(), + loadStudyRemindersFromDisk(), migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadPodcastChecklistFromDisk(), @@ -5172,6 +5519,14 @@ Promise.all([ } }, 60 * 60 * 1000) + // Send study reminder emails for newly released lessons every hour + setInterval(() => { + scheduleStudyReminders().catch(err => { + console.error('[study-reminders] failed to schedule reminders:', err) + }) + }, 60 * 60 * 1000) + void scheduleStudyReminders() + app.listen(PORT, () => { logResendEmailAlignmentWarnings() console.log(`Portfolio app listening on http://localhost:${PORT}`) diff --git a/server/helpers.js b/server/helpers.js index 1e5a067..7f186ba 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -257,10 +257,14 @@ function sanitizeColossiansStudySections(value) { studyQuestions: Array.isArray(item.studyQuestions) ? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) : [], + checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '', + checkpointQuestions: Array.isArray(item.checkpointQuestions) + ? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20) + : [], ...(releasedAt ? { releasedAt } : {}), } }) - .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText) + .filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0) } function sanitizeStudies(value) { diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 1698486..1f2c201 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -2077,6 +2077,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { commentary: '', greekNotes: [], studyQuestions: [], + checkpointPrompt: '', + checkpointQuestions: [], }, ], } diff --git a/src/App.tsx b/src/App.tsx index f90dea7..6592ab1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro import AdminPage from './AdminPage' import QASection from './components/QASection' import ContactForm from './components/ContactForm' -import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage } from './colossiansStudy' +import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy' import { SpotifyIcon } from './icons' import type { SiteContent, ArchivedSeries, StudyProgram } from './content' import { DEFAULTS } from './content' @@ -2053,6 +2053,7 @@ export default function App() { } />} /> } />} /> } />} /> + } />} /> } />} /> } /> } /> diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx index 43d240e..96531ac 100644 --- a/src/colossiansStudy.tsx +++ b/src/colossiansStudy.tsx @@ -12,6 +12,8 @@ type StudyAuthState = { enrolledStudySlugs?: string[] displayName?: string subscribeNewsletter?: boolean + studyRemindersEnabled?: boolean + avatarUrl?: string } type StudyAuthStatusResponse = { @@ -20,6 +22,8 @@ type StudyAuthStatusResponse = { enrolledStudySlugs?: string[] displayName?: string subscribeNewsletter?: boolean + studyRemindersEnabled?: boolean + avatarUrl?: string } type StudyAccountOverview = { @@ -27,6 +31,8 @@ type StudyAccountOverview = { username: string displayName: string subscribeNewsletter: boolean + studyRemindersEnabled: boolean + avatarUrl?: string } stats: { noteCount: number @@ -53,6 +59,7 @@ type StudyProgress = { type StudyCommunityReply = { id: string authorName: string + authorAvatarUrl?: string message: string createdAt: string } @@ -62,6 +69,7 @@ type StudyCommunityPost = { studySlug: string sectionId: string authorName: string + authorAvatarUrl?: string message: string createdAt: string replies: StudyCommunityReply[] @@ -358,6 +366,13 @@ function CommunityBoard({
+ {post.authorAvatarUrl ? ( + {`${post.authorName + ) : ( +
+ {post.authorName?.slice(0, 1).toUpperCase() || 'S'} +
+ )} {post.authorName || 'Student'} {postSection && !activeSection && ( + +
+
+ + {studyModal !== 'none' && ( +
+
+ +

{studyModal === 'enrollments' ? 'Manage Enrollments' : 'Browse All Tracks'}

+

+ {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.'} +

+ {auth.authenticated && overview ? ( + studyModal === 'enrollments' ? ( + enrolledStudies.length > 0 ? ( +
+ {enrolledStudies.map(studyInfo => ( +
+
+
+

{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}

+

{studyInfo.title}

+

{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

+
+ +
+
+ ))} +
+ ) : ( +
+

You are not enrolled in any tracks yet. Use Browse All Tracks to find a study and enroll.

+ +
+ ) + ) : ( +
+ {availableStudies.map(studyInfo => ( +
+
+
+

{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}

+

{studyInfo.title}

+

{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

+
+ +
+
+ ))} +
+ ) + ) : auth.authenticated ? ( +

No study information is available yet. Please refresh the page or visit your account to load your enrollments.

+ ) : ( +
+

Sign in to manage your enrollments and browse tracks from within the app.

+
+ Sign In + +
+
+ )} + {enrollMessage &&

{enrollMessage}

} +
+
+ )} + +
+
+
+

Your Classroom

+

Enrolled Tracks

+
+ + {totalEnrolled === 0 ? ( +
+

No enrollments yet

+

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.

+ +
+ ) : ( +
+ {enrolledStudies.map(study => { + const studyMeta = getStudyBySlug(studies, study.slug) + const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0 + return ( +
+
+

{study.enrolled ? 'Enrolled' : 'Track'}

+

{study.title}

+

{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}

+
+ {study.completedLessons}/{study.totalLessons} lessons completed + {study.noteCount} notes +
+
+
+
+
+
+
+

Progress

+

{progress}% complete

+
+
+ Continue + My Notes + Community +
+
+
+ ) + })} +
+ )} +
+
+ + + ) + } + return (
@@ -453,7 +752,6 @@ export function StudyLandingPage({ content }: Props) {
{firstActiveStudyFirstReleasedSection && Start Learning} - Browse Tracks Create Account or Login My Account
@@ -588,6 +886,7 @@ export function StudySignupPage() { }) setDone(true) setLoggedInAs(data.username ?? email.trim().toLowerCase()) + navigate('/study') } catch (err) { setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.') } finally { @@ -1299,7 +1598,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
  • {question}
  • ))} -
    +
    + Take the Quiz Go to Community Ask Nate
    @@ -1585,12 +1885,17 @@ export function StudyAccountPage() { const [loadingOverview, setLoadingOverview] = useState(false) const [displayNameInput, setDisplayNameInput] = useState('') + const [avatarUrlInput, setAvatarUrlInput] = useState('') const [profileMessage, setProfileMessage] = useState('') const [profileBusy, setProfileBusy] = useState(false) + const [avatarUploadBusy, setAvatarUploadBusy] = useState(false) + const [avatarUploadMessage, setAvatarUploadMessage] = useState('') const [subscribeNewsletter, setSubscribeNewsletter] = useState(true) + const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true) const [prefMessage, setPrefMessage] = useState('') const [prefBusy, setPrefBusy] = useState(false) + const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none') const [emailCurrentPassword, setEmailCurrentPassword] = useState('') const [emailNew, setEmailNew] = useState('') @@ -1628,6 +1933,8 @@ export function StudyAccountPage() { username: data.username ?? '', displayName: data.displayName ?? '', subscribeNewsletter: data.subscribeNewsletter !== false, + studyRemindersEnabled: data.studyRemindersEnabled === true, + avatarUrl: data.avatarUrl ?? '', enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), }) }) @@ -1641,7 +1948,9 @@ export function StudyAccountPage() { .then(data => { setOverview(data) setDisplayNameInput(data.profile.displayName ?? '') + setAvatarUrlInput(data.profile.avatarUrl ?? '') setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) + setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true) }) .catch(() => {}) .finally(() => setLoadingOverview(false)) @@ -1671,19 +1980,55 @@ export function StudyAccountPage() { const data = await readJson('/api/study-account/overview') setOverview(data) setDisplayNameInput(data.profile.displayName ?? '') + setAvatarUrlInput(data.profile.avatarUrl ?? '') setSubscribeNewsletter(data.profile.subscribeNewsletter !== false) + setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true) + } + + async function handleAvatarUpload(file: File) { + setAvatarUploadBusy(true) + setAvatarUploadMessage('') + try { + if (!file.type.startsWith('image/')) { + throw new Error('Please upload an image file.') + } + const reader = new FileReader() + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result) + else reject(new Error('Unable to read image file.')) + } + reader.onerror = () => reject(new Error('Failed to read image file.')) + reader.readAsDataURL(file) + }) + + const result = await readJson<{ ok: boolean; url: string }>('/api/study-account/avatar-upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: file.name, dataUrl }), + }) + setAvatarUrlInput(result.url) + setAvatarUploadMessage('Avatar uploaded successfully. Save profile to keep it.') + } catch (err) { + setAvatarUploadMessage(err instanceof Error ? err.message : 'Unable to upload avatar.') + } finally { + setAvatarUploadBusy(false) + } } async function handleProfileSave() { setProfileBusy(true) setProfileMessage('') try { - const data = await readJson<{ ok: boolean; displayName: string }>('/api/study-account/profile', { + const data = await readJson<{ ok: boolean; displayName: string; avatarUrl?: string }>('/api/study-account/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ displayName: displayNameInput }), + body: JSON.stringify({ displayName: displayNameInput, avatarUrl: avatarUrlInput.trim() }), }) - setAuth(prev => ({ ...prev, displayName: data.displayName })) + setAuth(prev => ({ ...prev, displayName: data.displayName, avatarUrl: data.avatarUrl ?? prev.avatarUrl })) + if (data.avatarUrl) { + setAvatarUrlInput(data.avatarUrl) + } setProfileMessage('Profile saved.') await refreshOverview() } catch (err) { @@ -1697,12 +2042,12 @@ export function StudyAccountPage() { setPrefBusy(true) setPrefMessage('') try { - const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean }>('/api/study-account/preferences', { + const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean }>('/api/study-account/preferences', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ subscribeNewsletter }), + body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled }), }) - setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter })) + setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled })) setPrefMessage('Preferences updated.') await refreshOverview() } catch (err) { @@ -1833,6 +2178,13 @@ export function StudyAccountPage() { } } + function closeAccountModal() { + setAccountModal('none') + setEmailMessage('') + setPwMessage('') + setEnrollMessage('') + } + if (!auth.checked) { return (
    @@ -1861,7 +2213,20 @@ export function StudyAccountPage() {

    Student Account

    My Account

    -

    Signed in as {auth.username}

    +
    + {auth.avatarUrl ? ( + Your avatar + ) : ( +
    + {auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()} +
    + )} +
    +

    Signed in as

    +

    {auth.displayName || auth.username}

    +

    {auth.username}

    +
    +
    {overview?.stats && ( <> @@ -1880,109 +2245,191 @@ export function StudyAccountPage() { )}
    -

    Enrollment Manager

    -

    Enroll or unenroll by study and track your progress.

    - {loadingOverview &&

    Loading studies...

    } - {!loadingOverview && overview?.studies?.map(study => ( -
    -
    -

    {study.title}

    -

    - Status: {study.enrolled ? 'Enrolled' : 'Not Enrolled'} - {` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`} - {` • Notes: ${study.noteCount}`} -

    -
    - +

    Quick Actions

    +

    Open the settings you need without scrolling through the whole page.

    +
    + + + + + + +
    +
    + + {accountModal !== 'none' && ( +
    +
    + + {accountModal === 'enrollments' && ( + <> +

    Manage Enrollments

    +

    Enroll or leave studies from one place. Your active study list is shown below.

    + {loadingOverview ? ( +

    Loading study access...

    + ) : overview?.studies?.length ? ( +
    + {overview.studies.map(studyInfo => ( +
    +
    +
    +

    {studyInfo.status === 'active' ? 'Active study' : 'Planned study'}

    +

    {studyInfo.title}

    +

    {studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

    +
    + +
    +
    + ))} +
    + ) : ( +

    No study access information is available right now.

    + )} + {enrollMessage &&

    {enrollMessage}

    } + + )} + {accountModal === 'changeEmail' && ( + <> +

    Change Email

    +

    Update the email address used for sign in and course notifications.

    + + + + 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' }} /> + + 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' }} /> +
    + +
    + {emailTokenInput && ( +
    +

    Have a verification token?

    + 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' }} /> + +
    + )} + {emailMessage &&

    {emailMessage}

    } + + )} + {accountModal === 'profile' && ( + <> +

    Profile Settings

    +

    Update your display name and avatar in one place.

    + +

    Pick a preset avatar or upload your own image.

    +
    + {[ + { 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 = `${item.emoji}` + const url = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}` + return ( + + ) + })} +
    +
    + + { + const file = e.target.files?.[0] + if (file) { + void handleAvatarUpload(file) + } + }} + style={{ color: '#f0ead8' }} + /> + {avatarUploadBusy &&

    Uploading avatar…

    } + {avatarUploadMessage &&

    {avatarUploadMessage}

    } +
    + {(avatarUrlInput || auth.avatarUrl) && ( +
    + Avatar preview +

    Preview of your selected avatar. Save your profile to keep it.

    +
    + )} + + setDisplayNameInput(e.target.value)} + placeholder="How your name appears in emails" + style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} + /> +
    + +
    + {profileMessage &&

    {profileMessage}

    } + + )} + {accountModal === 'preferences' && ( + <> +

    Preferences

    +

    Adjust how you receive study reminders and newsletter updates.

    +
    + + +
    + + {prefMessage &&

    {prefMessage}

    } + + )} + {accountModal === 'export' && ( + <> +

    Export Notes

    +

    Download all saved notes in a single document.

    + + {exportMessage &&

    {exportMessage}

    } + + )} + {accountModal === 'changePassword' && ( + <> +

    Change Password

    +

    Use a strong password to keep your study materials and notes secure.

    + + 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' }} /> + + 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' }} /> + + 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' }} /> +
    + +
    + {pwMessage &&

    {pwMessage}

    } + {pwSuccess &&

    Password changed successfully.

    } + + )}
    - ))} - {enrollMessage &&

    {enrollMessage}

    } - - - {divider} - -
    -

    Profile Settings

    - - setDisplayNameInput(e.target.value)} - placeholder="How your name appears in emails" - style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} - /> -
    -
    - {profileMessage &&

    {profileMessage}

    } - -
    - - - {prefMessage &&

    {prefMessage}

    } -
    -
    - - {divider} - -
    -

    Change Email (Verification Required)

    -

    Enter your new email and current password. We will send a verification link to the new email.

    -
    - setEmailNew(e.target.value)} placeholder="new@email.com" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} /> - setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} /> -
    - -

    Have a token? Paste it here to verify manually.

    -
    - setEmailTokenInput(e.target.value)} placeholder="Verification token" style={{ minWidth: '260px', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} /> - -
    - {emailMessage &&

    {emailMessage}

    } -
    - - {divider} - -
    -

    Export My Notes

    -

    Download all your saved lesson notes as a Word document (.docx).

    - - {exportMessage &&

    {exportMessage}

    } -
    - - {divider} - -
    -

    Change Password

    -
    - setPwCurrent(e.target.value)} placeholder="Current password" autoComplete="current-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} /> - setPwNew(e.target.value)} placeholder="New password" autoComplete="new-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} /> - 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' }} /> -
    - {pwMessage &&

    {pwMessage}

    } - {pwSuccess &&

    Password updated successfully.

    } - -
    + )} {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({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' }) + const [answers, setAnswers] = useState([]) + 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('/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 ( +
    +
    +

    Study Quiz

    +

    Quiz not found

    +

    The lesson quiz you requested is not available.

    + Back to Studies +
    +
    + ) + } + + const sectionQuestions = section.studyQuestions ?? [] + const canSubmit = sectionQuestions.length > 0 + const isEnrolled = auth.authenticated && normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(study.slug) + + if (!auth.checked) { + return ( +
    +

    Loading quiz...

    +
    + ) + } + + if (!auth.authenticated) { + return ( +
    +
    +

    Study Quiz

    +

    Sign In Required

    +

    You need to sign in before you can view and save quiz answers.

    + Sign In +
    +
    + ) + } + + if (!isEnrolled) { + return ( +
    +
    +

    Study Quiz

    +

    Enroll to Access the Quiz

    +

    Please enroll in {study.title} to open the quiz for this lesson.

    + Go to Study +
    +
    + ) + } + + return ( +
    +
    +
    +

    Quiz

    +

    {section.title}

    +

    Answer the lesson questions below and save your responses for later review.

    +
    + Back to Lesson +
    +
    +
    + +
    +
    + {loading ? ( +

    Loading quiz...

    + ) : error ? ( +

    {error}

    + ) : !canSubmit ? ( +
    +

    No Quiz Questions

    +

    This lesson does not have quiz questions configured yet.

    +
    + ) : ( +
    { e.preventDefault(); saveQuiz() }}> + {sectionQuestions.map((question, index) => ( +
    +

    {`${index + 1}. ${question}`}

    +