Improve admin UX and autosave handling

This commit is contained in:
nmemmert
2026-06-01 12:56:26 -04:00
parent b9ed8761db
commit 1d536827e3
5 changed files with 1504 additions and 416 deletions
+106 -63
View File
@@ -54,7 +54,15 @@
"slug": "colossians", "slug": "colossians",
"title": "Colossians: Rooted in Christ", "title": "Colossians: Rooted in Christ",
"description": "Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.", "description": "Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.",
"homepageEyebrow": "",
"showOnHomepage": false,
"showNewTag": false,
"newTagLabel": "NEW",
"status": "active", "status": "active",
"difficulty": "beginner",
"estimatedHours": 8,
"completionBadge": "",
"numberOfChapters": 1,
"sections": [ "sections": [
{ {
"id": "1-1-2", "id": "1-1-2",
@@ -65,10 +73,6 @@
"passageText": "v. 1 Paul, an apostle of Christ Jesus by the will of God, and Timothy our brother,\n\nv. 2 To the saints in Colossae, the faithful brothers in Christ: Grace and peace to you from God our Father.", "passageText": "v. 1 Paul, an apostle of Christ Jesus by the will of God, and Timothy our brother,\n\nv. 2 To the saints in Colossae, the faithful brothers in Christ: Grace and peace to you from God our Father.",
"summary": "Paul opens with apostolic authority and warm fellowship, greeting the saints at Colossae with the twin gifts that frame every believer's life: grace and peace.", "summary": "Paul opens with apostolic authority and warm fellowship, greeting the saints at Colossae with the twin gifts that frame every believer's life: grace and peace.",
"commentary": "Paul opens with his credentials: an apostle of Christ Jesus. But the manner of the claim matters - he immediately attributes this not to his own merit but to the will of God. This is the same humility that runs beneath the letter's entire argument: everything comes from God, not from human achievement. Timothy is included not as a co-author but as a fellow laborer, identified simply as 'our brother' - the relational warmth is immediate. The recipients are addressed as 'saints' - the Greek hagios means holy ones, set apart by God - and as 'faithful brothers in Christ.' These are not terms of flattery but of theological definition: these are people who belong to God. Paul's greeting, 'grace and peace,' is more than convention. Grace (charis) is God's unmerited favor; peace (eirene) is the wholeness and well-being that flows from it. Every believer's life is framed by these two realities.", "commentary": "Paul opens with his credentials: an apostle of Christ Jesus. But the manner of the claim matters - he immediately attributes this not to his own merit but to the will of God. This is the same humility that runs beneath the letter's entire argument: everything comes from God, not from human achievement. Timothy is included not as a co-author but as a fellow laborer, identified simply as 'our brother' - the relational warmth is immediate. The recipients are addressed as 'saints' - the Greek hagios means holy ones, set apart by God - and as 'faithful brothers in Christ.' These are not terms of flattery but of theological definition: these are people who belong to God. Paul's greeting, 'grace and peace,' is more than convention. Grace (charis) is God's unmerited favor; peace (eirene) is the wholeness and well-being that flows from it. Every believer's life is framed by these two realities.",
"studyQuestions": [
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"greekNotes": [ "greekNotes": [
"Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.", "Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.",
"Will (thelema): desire or purpose; Paul was not self-appointed but called by Gods initiative.", "Will (thelema): desire or purpose; Paul was not self-appointed but called by Gods initiative.",
@@ -77,6 +81,10 @@
"Grace (charis): God's unmerited favor that initiates and sustains the believer's life.", "Grace (charis): God's unmerited favor that initiates and sustains the believer's life.",
"Peace (eirene): wholeness and well-being that flows from reconciliation with God." "Peace (eirene): wholeness and well-being that flows from reconciliation with God."
], ],
"studyQuestions": [
"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" "releasedAt": "2026-11-09T00:00:00Z"
}, },
{ {
@@ -88,11 +96,11 @@
"passageText": "", "passageText": "",
"summary": "Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.", "summary": "Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.",
"commentary": "The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.", "commentary": "The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2026-11-23T00:00:00Z" "releasedAt": "2026-11-23T00:00:00Z"
}, },
{ {
@@ -104,11 +112,11 @@
"passageText": "", "passageText": "",
"summary": "Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.", "summary": "Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.",
"commentary": "This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know Gods will and walk in a way that reflects it.", "commentary": "This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know Gods will and walk in a way that reflects it.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2026-12-07T00:00:00Z" "releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
@@ -120,11 +128,11 @@
"passageText": "", "passageText": "",
"summary": "Christ is supreme over creation, the church, and reconciliation.", "summary": "Christ is supreme over creation, the church, and reconciliation.",
"commentary": "This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.", "commentary": "This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2026-12-21T00:00:00Z" "releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
@@ -136,11 +144,11 @@
"passageText": "", "passageText": "",
"summary": "Paul explains how Christs work changes alienation into steadfast faith.", "summary": "Paul explains how Christs work changes alienation into steadfast faith.",
"commentary": "The gospel moves from doctrine to real life. Paul shows what reconciliation looks like for people who have been brought near to God.", "commentary": "The gospel moves from doctrine to real life. Paul shows what reconciliation looks like for people who have been brought near to God.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-01-04T00:00:00Z" "releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
@@ -152,11 +160,11 @@
"passageText": "", "passageText": "",
"summary": "Paul describes his labor to proclaim Christ and present believers mature in Him.", "summary": "Paul describes his labor to proclaim Christ and present believers mature in Him.",
"commentary": "Pauls ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.", "commentary": "Pauls ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-01-18T00:00:00Z" "releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
@@ -168,11 +176,11 @@
"passageText": "", "passageText": "",
"summary": "Paul reveals his struggle for the believers and his desire for encouragement and unity.", "summary": "Paul reveals his struggle for the believers and his desire for encouragement and unity.",
"commentary": "This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.", "commentary": "This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-02-01T00:00:00Z" "releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
@@ -184,11 +192,11 @@
"passageText": "", "passageText": "",
"summary": "Paul calls believers to continue in Christ and resist deceptive teaching.", "summary": "Paul calls believers to continue in Christ and resist deceptive teaching.",
"commentary": "The Christian life begins in Christ and must continue in Christ. Paul warns that subtle deception can slowly pull believers off course.", "commentary": "The Christian life begins in Christ and must continue in Christ. Paul warns that subtle deception can slowly pull believers off course.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-02-15T00:00:00Z" "releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
@@ -200,11 +208,11 @@
"passageText": "", "passageText": "",
"summary": "The fullness of deity dwells in Christ, and believers are made complete in Him.", "summary": "The fullness of deity dwells in Christ, and believers are made complete in Him.",
"commentary": "This section corrects the idea that anything must be added to Christ. He is fully sufficient for the believers life and growth.", "commentary": "This section corrects the idea that anything must be added to Christ. He is fully sufficient for the believers life and growth.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-03-01T00:00:00Z" "releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
@@ -216,11 +224,11 @@
"passageText": "", "passageText": "",
"summary": "Paul explains the believers union with Christ in His death, resurrection, and victory.", "summary": "Paul explains the believers union with Christ in His death, resurrection, and victory.",
"commentary": "The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.", "commentary": "The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-03-15T00:00:00Z" "releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
@@ -232,11 +240,11 @@
"passageText": "", "passageText": "",
"summary": "Paul warns against letting others judge believers over shadows when Christ is the substance.", "summary": "Paul warns against letting others judge believers over shadows when Christ is the substance.",
"commentary": "Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.", "commentary": "Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-03-29T00:00:00Z" "releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
@@ -248,11 +256,11 @@
"passageText": "", "passageText": "",
"summary": "Paul warns against prideful spirituality that disconnects believers from Christ the head.", "summary": "Paul warns against prideful spirituality that disconnects believers from Christ the head.",
"commentary": "Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.", "commentary": "Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
], ],
"greekNotes": [],
"releasedAt": "2027-04-12T00:00:00Z" "releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
@@ -264,11 +272,11 @@
"passageText": "", "passageText": "",
"summary": "Human rules cannot produce true transformation.", "summary": "Human rules cannot produce true transformation.",
"commentary": "External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.", "commentary": "External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-04-26T00:00:00Z" "releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
@@ -280,11 +288,11 @@
"passageText": "", "passageText": "",
"summary": "Paul calls believers to set their hearts and minds on Christ.", "summary": "Paul calls believers to set their hearts and minds on Christ.",
"commentary": "Identity in Christ leads to a new direction for the mind and heart. The believers life is hidden with Christ, so priorities shift upward.", "commentary": "Identity in Christ leads to a new direction for the mind and heart. The believers life is hidden with Christ, so priorities shift upward.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-05-10T00:00:00Z" "releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
@@ -296,11 +304,11 @@
"passageText": "", "passageText": "",
"summary": "Paul calls believers to put away the practices of the old life.", "summary": "Paul calls believers to put away the practices of the old life.",
"commentary": "Christian growth includes real moral change. Pauls language is strong because the old life cannot be carried into the new one.", "commentary": "Christian growth includes real moral change. Pauls language is strong because the old life cannot be carried into the new one.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
], ],
"greekNotes": [],
"releasedAt": "2027-05-24T00:00:00Z" "releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
@@ -312,11 +320,11 @@
"passageText": "", "passageText": "",
"summary": "Believers are renewed in Christ and formed into one new people.", "summary": "Believers are renewed in Christ and formed into one new people.",
"commentary": "Renewal in Christ changes both personal identity and community life. The new self is not merely private; it is shared by the body of Christ.", "commentary": "Renewal in Christ changes both personal identity and community life. The new self is not merely private; it is shared by the body of Christ.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-06-07T00:00:00Z" "releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
@@ -328,11 +336,11 @@
"passageText": "", "passageText": "",
"summary": "Paul lists the virtues believers should wear as Christs character becomes visible in them.", "summary": "Paul lists the virtues believers should wear as Christs character becomes visible in them.",
"commentary": "The image of clothing makes virtue practical and visible. Paul wants Christs character to define the way believers live together.", "commentary": "The image of clothing makes virtue practical and visible. Paul wants Christs character to define the way believers live together.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-06-21T00:00:00Z" "releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
@@ -344,11 +352,11 @@
"passageText": "", "passageText": "",
"summary": "Paul calls the church to let Christs peace rule and His word dwell richly among them.", "summary": "Paul calls the church to let Christs peace rule and His word dwell richly among them.",
"commentary": "Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.", "commentary": "Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-07-05T00:00:00Z" "releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
@@ -360,11 +368,11 @@
"passageText": "", "passageText": "",
"summary": "Paul applies the new life in Christ to family relationships.", "summary": "Paul applies the new life in Christ to family relationships.",
"commentary": "The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.", "commentary": "The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-07-19T00:00:00Z" "releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
@@ -376,11 +384,11 @@
"passageText": "", "passageText": "",
"summary": "Paul instructs believers to serve faithfully and wholeheartedly.", "summary": "Paul instructs believers to serve faithfully and wholeheartedly.",
"commentary": "Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.", "commentary": "Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-08-02T00:00:00Z" "releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
@@ -392,11 +400,11 @@
"passageText": "", "passageText": "",
"summary": "Paul reminds masters to act justly because they too answer to the Lord.", "summary": "Paul reminds masters to act justly because they too answer to the Lord.",
"commentary": "The gospel confronts authority as well as submission. All authority is accountable to Christ.", "commentary": "The gospel confronts authority as well as submission. All authority is accountable to Christ.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-08-16T00:00:00Z" "releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
@@ -408,11 +416,11 @@
"passageText": "", "passageText": "",
"summary": "Paul closes with a call to steadfast prayer and wise witness toward outsiders.", "summary": "Paul closes with a call to steadfast prayer and wise witness toward outsiders.",
"commentary": "Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.", "commentary": "Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-08-30T00:00:00Z" "releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
@@ -424,11 +432,11 @@
"passageText": "", "passageText": "",
"summary": "Paul sends trusted messengers to encourage the church and bring news from the ministry field.", "summary": "Paul sends trusted messengers to encourage the church and bring news from the ministry field.",
"commentary": "These greetings reveal the relational side of ministry. The letter is carried by real people who serve the church with loyalty and care.", "commentary": "These greetings reveal the relational side of ministry. The letter is carried by real people who serve the church with loyalty and care.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-09-13T00:00:00Z" "releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
@@ -440,11 +448,11 @@
"passageText": "", "passageText": "",
"summary": "Paul names the coworkers who are with him and commends their ministry support.", "summary": "Paul names the coworkers who are with him and commends their ministry support.",
"commentary": "The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.", "commentary": "The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-09-27T00:00:00Z" "releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
@@ -456,32 +464,30 @@
"passageText": "", "passageText": "",
"summary": "Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.", "summary": "Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.",
"commentary": "The letter ends with community, responsibility, and grace. Pauls final words keep the church focused on the mission it has received.", "commentary": "The letter ends with community, responsibility, and grace. Pauls final words keep the church focused on the mission it has received.",
"greekNotes": [],
"studyQuestions": [ "studyQuestions": [
"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?"
], ],
"greekNotes": [],
"releasedAt": "2027-10-11T00:00:00Z" "releasedAt": "2027-10-11T00:00:00Z"
} }
], ]
"homepageEyebrow": "",
"showOnHomepage": false,
"showNewTag": false,
"newTagLabel": "NEW",
"numberOfChapters": 1
}, },
{ {
"id": "study-titus", "id": "study-titus",
"slug": "titus", "slug": "titus",
"title": "Titus: Sound Doctrine", "title": "Titus: Sound Doctrine",
"description": "A full online class version of Titus with lesson-by-lesson notes and discussion.", "description": "A full online class version of Titus with lesson-by-lesson notes and discussion.",
"status": "planned",
"sections": [],
"homepageEyebrow": "", "homepageEyebrow": "",
"showOnHomepage": false, "showOnHomepage": false,
"showNewTag": false, "showNewTag": false,
"newTagLabel": "NEW", "newTagLabel": "NEW",
"numberOfChapters": 1 "status": "planned",
"difficulty": "beginner",
"estimatedHours": 8,
"completionBadge": "",
"numberOfChapters": 1,
"sections": []
} }
], ],
"colossiansStudySections": [ "colossiansStudySections": [
@@ -505,7 +511,8 @@
"studyQuestions": [ "studyQuestions": [
"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"
}, },
{ {
"id": "1-3-8", "id": "1-3-8",
@@ -520,7 +527,8 @@
"studyQuestions": [ "studyQuestions": [
"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"
}, },
{ {
"id": "1-9-14", "id": "1-9-14",
@@ -535,7 +543,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2026-12-07T00:00:00Z"
}, },
{ {
"id": "1-15-20", "id": "1-15-20",
@@ -550,7 +559,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2026-12-21T00:00:00Z"
}, },
{ {
"id": "1-21-23", "id": "1-21-23",
@@ -565,7 +575,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-01-04T00:00:00Z"
}, },
{ {
"id": "1-24-29", "id": "1-24-29",
@@ -580,7 +591,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-01-18T00:00:00Z"
}, },
{ {
"id": "2-1-5", "id": "2-1-5",
@@ -595,7 +607,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-02-01T00:00:00Z"
}, },
{ {
"id": "2-6-8", "id": "2-6-8",
@@ -610,7 +623,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-02-15T00:00:00Z"
}, },
{ {
"id": "2-9-10", "id": "2-9-10",
@@ -625,7 +639,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-03-01T00:00:00Z"
}, },
{ {
"id": "2-11-15", "id": "2-11-15",
@@ -640,7 +655,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-03-15T00:00:00Z"
}, },
{ {
"id": "2-16-17", "id": "2-16-17",
@@ -655,7 +671,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-03-29T00:00:00Z"
}, },
{ {
"id": "2-18-19", "id": "2-18-19",
@@ -670,7 +687,8 @@
"studyQuestions": [ "studyQuestions": [
"How does Paul describe false spirituality?", "How does Paul describe false spirituality?",
"Why is connection to Christ essential?" "Why is connection to Christ essential?"
] ],
"releasedAt": "2027-04-12T00:00:00Z"
}, },
{ {
"id": "2-20-23", "id": "2-20-23",
@@ -685,7 +703,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-04-26T00:00:00Z"
}, },
{ {
"id": "3-1-4", "id": "3-1-4",
@@ -700,7 +719,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-05-10T00:00:00Z"
}, },
{ {
"id": "3-5-9", "id": "3-5-9",
@@ -715,7 +735,8 @@
"studyQuestions": [ "studyQuestions": [
"Which old patterns does Paul name?", "Which old patterns does Paul name?",
"Why is decisive change necessary?" "Why is decisive change necessary?"
] ],
"releasedAt": "2027-05-24T00:00:00Z"
}, },
{ {
"id": "3-10-11", "id": "3-10-11",
@@ -730,7 +751,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-06-07T00:00:00Z"
}, },
{ {
"id": "3-12-14", "id": "3-12-14",
@@ -745,7 +767,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-06-21T00:00:00Z"
}, },
{ {
"id": "3-15-17", "id": "3-15-17",
@@ -760,7 +783,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-07-05T00:00:00Z"
}, },
{ {
"id": "3-18-21", "id": "3-18-21",
@@ -775,7 +799,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-07-19T00:00:00Z"
}, },
{ {
"id": "3-22-25", "id": "3-22-25",
@@ -790,7 +815,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-08-02T00:00:00Z"
}, },
{ {
"id": "4-1", "id": "4-1",
@@ -805,7 +831,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-08-16T00:00:00Z"
}, },
{ {
"id": "4-2-6", "id": "4-2-6",
@@ -820,7 +847,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-08-30T00:00:00Z"
}, },
{ {
"id": "4-7-9", "id": "4-7-9",
@@ -835,7 +863,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-09-13T00:00:00Z"
}, },
{ {
"id": "4-10-14", "id": "4-10-14",
@@ -850,7 +879,8 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-09-27T00:00:00Z"
}, },
{ {
"id": "4-15-18", "id": "4-15-18",
@@ -865,11 +895,24 @@
"studyQuestions": [ "studyQuestions": [
"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?"
] ],
"releasedAt": "2027-10-11T00:00:00Z"
} }
], ],
"shareHeading": "Help one more person hear the Word this week.", "shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"prismVideoUrl": "/images/PRISM.mp4",
"prismEyebrow": "Featured Video",
"prismHeading": "PRISM",
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
"prismPatternIntro": "In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.",
"prismStep1": "P — Pray before you read",
"prismStep2": "R — Read slowly and observe",
"prismStep3": "I — Interpret with context",
"prismStep4": "S — Study and apply specifically",
"prismStep5": "M — Memorize one verse at a time",
"prismOutro": "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day.",
"prismClosing": "The goal isn't to get through the text - it's to let the text get through to you.",
"whereToNextEyebrow": "Where to Next", "whereToNextEyebrow": "Where to Next",
"whereToNextHeading": "Choose the page you need.", "whereToNextHeading": "Choose the page you need.",
"whereToNextCards": [ "whereToNextCards": [
@@ -1003,5 +1046,5 @@
] ]
} }
}, },
"updatedAt": "2026-05-12T16:46:21.985Z" "updatedAt": "2026-06-01T16:33:33.885Z"
} }
+36 -6
View File
@@ -2,37 +2,67 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://versebyversewithnate.us/</loc> <loc>https://versebyversewithnate.us/</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://versebyversewithnate.us/start-here</loc> <loc>https://versebyversewithnate.us/start-here</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url>
<loc>https://versebyversewithnate.us/episodes</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/study</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/resources</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url> <url>
<loc>https://versebyversewithnate.us/questions</loc> <loc>https://versebyversewithnate.us/questions</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url>
<loc>https://versebyversewithnate.us/about</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/contact</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url> <url>
<loc>https://versebyversewithnate.us/subscribe</loc> <loc>https://versebyversewithnate.us/subscribe</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>
<url> <url>
<loc>https://versebyversewithnate.us/privacy</loc> <loc>https://versebyversewithnate.us/privacy</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url> <url>
<loc>https://versebyversewithnate.us/terms</loc> <loc>https://versebyversewithnate.us/terms</loc>
<lastmod>2026-04-27</lastmod> <lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
+623 -152
View File
@@ -610,6 +610,7 @@ const EMPTY_VISITOR_STATS = {
} }
const MAX_CONTACT_SUBMISSIONS = 5000 const MAX_CONTACT_SUBMISSIONS = 5000
const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
const titusDownloadTokens = new Map() const titusDownloadTokens = new Map()
@@ -625,6 +626,8 @@ let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve() let visitorStatsWritePromise = Promise.resolve()
let contactSubmissions = [] let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve() let contactSubmissionsWritePromise = Promise.resolve()
const contactSubmitCooldownByEmail = new Map()
const resendEmailSubmissionIndex = new Map()
let questions = [] let questions = []
let questionsWritePromise = Promise.resolve() let questionsWritePromise = Promise.resolve()
let studyUsers = [] let studyUsers = []
@@ -780,9 +783,7 @@ function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => { .then(raw => {
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
contactSubmissions = Array.isArray(parsed?.submissions) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.submissions)
? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS)
: []
}) })
.catch(() => { .catch(() => {
contactSubmissions = [] contactSubmissions = []
@@ -841,8 +842,186 @@ function normalizeMessageType(value) {
return 'general' return 'general'
} }
function createEmailDeliveryState(status = 'pending') {
return {
status,
lastEventAt: null,
lastEventType: null,
resendEmailId: null,
error: null,
}
}
function normalizeEmailDeliveryState(value, fallbackStatus = 'pending') {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return createEmailDeliveryState(fallbackStatus)
}
return {
status: typeof value.status === 'string' && value.status.trim() ? value.status.trim().slice(0, 40) : fallbackStatus,
lastEventAt: typeof value.lastEventAt === 'string' ? value.lastEventAt : null,
lastEventType: typeof value.lastEventType === 'string' ? value.lastEventType.trim().slice(0, 120) : null,
resendEmailId: typeof value.resendEmailId === 'string' && value.resendEmailId.trim() ? value.resendEmailId.trim().slice(0, 200) : null,
error: typeof value.error === 'string' && value.error.trim() ? value.error.trim().slice(0, 600) : null,
}
}
function normalizeContactEmailStatus(value, subscribe) {
const base = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
return {
welcome: normalizeEmailDeliveryState(base.welcome, subscribe === true ? 'pending' : 'not-requested'),
adminNotification: normalizeEmailDeliveryState(base.adminNotification, 'pending'),
adminReply: normalizeEmailDeliveryState(base.adminReply, 'idle'),
}
}
function upsertContactEmailStatus(submissionId, stream, patch) {
if (!submissionId || typeof submissionId !== 'string') return
if (!stream || typeof stream !== 'string') return
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
let updated = false
contactSubmissions = contactSubmissions.map(submission => {
if (submission.id !== submissionId) return submission
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
const current = normalizeEmailDeliveryState(next[stream], 'pending')
next[stream] = {
...current,
...patch,
lastEventAt: at,
}
updated = true
return {
...submission,
emailStatus: next,
}
})
if (updated) {
queueContactSubmissionsWrite()
}
}
function extractResendMessageId(result) {
if (!result || typeof result !== 'object') return ''
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
return result.data.id.trim()
}
return ''
}
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
upsertContactEmailStatus(submissionId, stream, {
resendEmailId: resendMessageId,
})
}
function noteContactEmailCooldown(emailAddress) {
const normalized = String(emailAddress || '').trim().toLowerCase()
if (!normalized) return { ok: true, retryAfterMs: 0 }
const now = Date.now()
const lastAt = contactSubmitCooldownByEmail.get(normalized)
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
}
contactSubmitCooldownByEmail.set(normalized, now)
// Prevent unbounded growth while keeping this in-memory cache simple.
if (contactSubmitCooldownByEmail.size > 8000) {
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
for (const [email, timestamp] of contactSubmitCooldownByEmail.entries()) {
if (timestamp < cutoff) {
contactSubmitCooldownByEmail.delete(email)
}
}
}
return { ok: true, retryAfterMs: 0 }
}
function extractTagValue(tags, name) {
if (!Array.isArray(tags)) return ''
const target = String(name || '').trim().toLowerCase()
if (!target) return ''
for (const tag of tags) {
if (!tag || typeof tag !== 'object') continue
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
if (key === target && value) return value
}
return ''
}
function mapResendEventToStatus(eventType) {
const normalized = String(eventType || '').trim().toLowerCase()
if (!normalized) return 'updated'
if (normalized.includes('delivered')) return 'delivered'
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
if (normalized.includes('bounce')) return 'bounced'
if (normalized.includes('complain')) return 'complained'
if (normalized.includes('click')) return 'clicked'
if (normalized.includes('open')) return 'opened'
if (normalized.includes('send')) return 'sent'
return 'updated'
}
function getAddressDomain(addressValue) {
const raw = String(addressValue || '').trim()
if (!raw) return ''
const candidate = raw.includes('<') && raw.includes('>')
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
: raw
const at = candidate.lastIndexOf('@')
if (at <= 0 || at === candidate.length - 1) return ''
return candidate.slice(at + 1).toLowerCase()
}
function logResendEmailAlignmentWarnings() {
const warnings = []
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
if (!hasApiKey) {
warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
}
if (!fromDomain) {
warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
}
if (fromDomain.endsWith('resend.dev')) {
warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
}
if (fromDomain && replyDomain && fromDomain !== replyDomain) {
warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
}
if (!process.env.RESEND_WEBHOOK_TOKEN) {
warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
}
if (hasApiKey) {
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
}
if (warnings.length > 0) {
console.warn('[email-health] Resend alignment checks:')
for (const warning of warnings) {
console.warn(`[email-health] - ${warning}`)
}
}
}
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>' const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
function shouldSendWelcomeEmail({ subscribe }) { function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true return subscribe === true
@@ -884,7 +1063,201 @@ function buildAdminReplyTemplate({ recipientName, message }) {
` `
} }
function buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeHeading,
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
}) {
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
return {
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`Spotify: ${welcomeSpotifyUrl}\n` +
`Apple Podcasts: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html:
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` +
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` +
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` +
`</td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
}
}
function buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
}) {
return {
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
function getResendFromAddress() {
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
}
function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
}
function getResendInboxAddress() {
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
}
async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await resend.emails.send(payload)
if (!result?.error) return result
lastError = result.error
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} catch (err) {
lastError = err
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
}
}
throw lastError ?? new Error(`[${context}] email send failed`)
}
function addContactSubmission({ name, email, message, messageType, subscribe }) { function addContactSubmission({ name, email, message, messageType, subscribe }) {
const wantsWelcome = subscribe === true
const submission = { const submission = {
id: randomUUID(), id: randomUUID(),
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
@@ -892,8 +1265,9 @@ function addContactSubmission({ name, email, message, messageType, subscribe })
email, email,
message, message,
messageType: normalizeMessageType(messageType), messageType: normalizeMessageType(messageType),
subscribe: subscribe === true, subscribe: wantsWelcome,
archived: false, archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
} }
contactSubmissions.unshift(submission) contactSubmissions.unshift(submission)
@@ -1369,6 +1743,7 @@ function sanitizeLoadedContactSubmissions(value) {
messageType: normalizeMessageType(entry.messageType), messageType: normalizeMessageType(entry.messageType),
subscribe: entry.subscribe === true, subscribe: entry.subscribe === true,
archived: entry.archived === true, archived: entry.archived === true,
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
})) }))
} }
@@ -3325,8 +3700,8 @@ app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) =>
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => { app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({ res.json({
fromEmail: 'hello@versebyversewithnate.us', fromEmail: getResendReplyToAddress(),
fromIdentity: ADMIN_REPLY_FROM, fromIdentity: getResendFromAddress() || ADMIN_REPLY_FROM,
resendApiConfigured: Boolean(process.env.RESEND_API_KEY), resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
canSendReplies: Boolean(process.env.RESEND_API_KEY), canSendReplies: Boolean(process.env.RESEND_API_KEY),
note: process.env.RESEND_API_KEY note: process.env.RESEND_API_KEY
@@ -3407,26 +3782,44 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
const recipientName = splitName(submission.name).firstName || submission.name || 'friend' const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
const html = buildAdminReplyTemplate({ recipientName, message }) const html = buildAdminReplyTemplate({ recipientName, message })
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us` const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
const resend = new Resend(process.env.RESEND_API_KEY) const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({ const sendResult = await sendResendEmailWithRetry({
from: ADMIN_REPLY_FROM, resend,
context: 'admin-contact-reply',
payload: {
from: fromAddress || ADMIN_REPLY_FROM,
to: [submission.email], to: [submission.email],
subject, subject,
replyTo: 'hello@versebyversewithnate.us', replyTo: replyToAddress,
tags: [
{ name: 'flow', value: 'admin-reply' },
{ name: 'message_type', value: submission.messageType ?? 'general' },
{ name: 'submission_id', value: submission.id },
],
headers: {
'X-Contact-Submission-Id': submission.id,
},
text, text,
html, html,
},
})
registerResendMessageForSubmission(submission.id, 'adminReply', sendResult)
upsertContactEmailStatus(submission.id, 'adminReply', {
status: 'sent',
lastEventType: 'email.sent',
error: null,
}) })
if (error) throw error
replyHistory.unshift({ replyHistory.unshift({
id: randomUUID(), id: randomUUID(),
submissionId: submission.id, submissionId: submission.id,
toEmail: submission.email, toEmail: submission.email,
toName: submission.name, toName: submission.name,
fromEmail: 'hello@versebyversewithnate.us', fromEmail: replyToAddress,
subject, subject,
preview: message.slice(0, 500), preview: message.slice(0, 500),
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
@@ -3436,6 +3829,13 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
res.json({ ok: true }) res.json({ ok: true })
} catch (err) { } catch (err) {
if (typeof req.params?.id === 'string' && req.params.id.trim()) {
upsertContactEmailStatus(req.params.id.trim(), 'adminReply', {
status: 'failed',
lastEventType: 'email.failed',
error: String(err?.message ?? err ?? 'unknown error').slice(0, 600),
})
}
console.error('[admin-reply] send error:', err) console.error('[admin-reply] send error:', err)
res.status(500).json({ message: 'Failed to send reply email.' }) res.status(500).json({ message: 'Failed to send reply email.' })
} }
@@ -3831,6 +4231,12 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
const trimmedEmail = email.trim() const trimmedEmail = email.trim()
const trimmedMessage = message.trim() const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType) const normalizedMessageType = normalizeMessageType(messageType)
const cooldown = noteContactEmailCooldown(trimmedEmail)
if (!cooldown.ok) {
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` })
return
}
const submittedAt = new Date().toLocaleString('en-US', { const submittedAt = new Date().toLocaleString('en-US', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
@@ -3839,7 +4245,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
subscribe, subscribe,
}) })
addContactSubmission({ const submission = addContactSubmission({
name: trimmedName, name: trimmedName,
email: trimmedEmail, email: trimmedEmail,
message: trimmedMessage, message: trimmedMessage,
@@ -3870,6 +4276,18 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
queueQuestionsWrite() queueQuestionsWrite()
} }
const resend = new Resend(process.env.RESEND_API_KEY) const resend = new Resend(process.env.RESEND_API_KEY)
const adminInbox = getResendInboxAddress()
const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
const adminTemplate = buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
})
let welcomeSent = false
if (subscribe === true) { if (subscribe === true) {
await syncContactToResend(trimmedName, trimmedEmail) await syncContactToResend(trimmedName, trimmedEmail)
@@ -3908,7 +4326,6 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.' const welcomeScripture = emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.'
const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB' const welcomeScriptureRef = emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB'
const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate' const welcomeSignoff = emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate'
const welcomeSignoffHtml = escapeHtml(welcomeSignoff).replace(/\n/g, '<br/>')
const welcomeSpotifyUrl = buildAbsoluteUrl( const welcomeSpotifyUrl = buildAbsoluteUrl(
welcomeBaseUrl, welcomeBaseUrl,
process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify', process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify',
@@ -3934,156 +4351,209 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg', process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg',
) )
const { error: welcomeError } = await resend.emails.send({ const welcomeTemplate = buildContactWelcomeEmailTemplate({
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>', greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeHeading,
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
})
try {
const welcomeSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-welcome',
payload: {
from: fromAddress,
to: [trimmedEmail], to: [trimmedEmail],
replyTo: replyToAddress,
subject: welcomeSubject, subject: welcomeSubject,
text: tags: [
`Welcome to Verse by Verse with Nate!\n\n` + { name: 'flow', value: 'contact-welcome' },
`${greetingName ? `Glad you're here, ${greetingName}.` : "Glad you're here."}\n\n` + { name: 'message_type', value: safeMessageTypeTag },
`${welcomeIntro}\n\n` + { name: 'submission_id', value: submission.id },
`${welcomeCurrentSeries}\n\n` + ],
`Start here: ${welcomeEpisodeUrl}\n` + headers: {
`${welcomeStartHereTitle}\n` + 'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
`${welcomeStartHereSummary}\n` + 'X-Contact-Submission-Id': submission.id,
`Spotify: ${welcomeSpotifyUrl}\n` + },
`Apple Podcasts: ${welcomeAppleUrl}\n` + text: welcomeTemplate.text,
`Amazon Music: ${welcomeAmazonUrl}\n` + html: welcomeTemplate.html,
`Website: ${welcomeWebsiteUrl}\n\n` + },
`What to expect:\n` + })
`- ${welcomeExpect1}\n` + registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
`- ${welcomeExpect2}\n` + upsertContactEmailStatus(submission.id, 'welcome', {
`- ${welcomeExpect3}\n\n` + status: 'sent',
`${welcomeScripture}\n${welcomeScriptureRef}\n\n` + lastEventType: 'email.sent',
`${welcomeSignoff}`, error: null,
html: })
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` + welcomeSent = true
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` + } catch (welcomeErr) {
`<tr><td align="center" style="padding:40px 20px;">` + upsertContactEmailStatus(submission.id, 'welcome', {
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` + status: 'failed',
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` + lastEventType: 'email.failed',
`<img src="${escapeHtml(welcomeImageUrl)}" alt="Verse by Verse with Nate" width="110" height="110" style="display:block;margin:0 auto 20px;border-radius:12px;border:2px solid #2a2518;" />` + error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em;">Verse by Verse with Nate</p>` + })
`<p style="margin:0;font-family:Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase;">Verse by verse. Nugget by nugget.</p>` + throw welcomeErr
`</td></tr>` + }
`<tr><td style="padding:40px 40px 0;">` + } else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Welcome</p>` + upsertContactEmailStatus(submission.id, 'welcome', {
`<h1 style="margin:0 0 20px;font-family:Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2;">${welcomeHeading}</h1>` + status: 'automation-enabled',
`<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px;"></div>` + lastEventType: 'email.automation.enabled',
`</td></tr>` + error: null,
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeIntro)}</p>` +
`<p style="margin:0 0 18px;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">${escapeHtml(welcomeCurrentSeries)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75;">If you&rsquo;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">Start here</p>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8;">${escapeHtml(welcomeStartHereTitle)}</p>` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(welcomeStartHereSummary)}</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation"><tr>` +
`<td style="padding-right:12px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:#c9a84c;color:#0d0d0a;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;">Listen on Spotify</a></td>` +
`<td><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="display:inline-block;padding:11px 22px;background-color:transparent;color:#c9a84c;font-family:Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;text-decoration:none;border-radius:3px;border:1px solid #c9a84c;">Apple Podcasts</a></td>` +
`</tr></table>` +
`<p style="margin:18px 0 0;"><a href="${escapeHtml(welcomeEpisodeUrl)}" target="_blank" style="color:#c9a84c;text-decoration:underline;font-family:Georgia,serif;font-size:14px;">Open Start Here page</a></p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 32px;">` +
`<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">What to expect</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect1)}</p>` +
`<p style="margin:0 0 12px;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect2)}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65;">${escapeHtml(welcomeExpect3)}</p>` +
`</td></tr>` +
`<tr><td style="padding:0 40px;"><div style="height:1px;background-color:#2a2518;margin-bottom:32px;"></div></td></tr>` +
`<tr><td style="padding:0 40px 40px;">` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="width:100%;border-left:2px solid #c9a84c;"><tr><td style="padding:4px 0 4px 20px;">` +
`<p style="margin:0 0 8px;font-family:Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6;">&ldquo;${escapeHtml(welcomeScripture)}&rdquo;</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase;">${escapeHtml(welcomeScriptureRef)}</p>` +
`</td></tr></table>` +
`</td></tr>` +
`<tr><td style="background-color:#0a0a08;border-top:1px solid #2a2518;padding:28px 40px;text-align:center;">` +
`<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;">Find the podcast on</p>` +
`<table cellpadding="0" cellspacing="0" border="0" role="presentation" style="margin:0 auto 24px;"><tr>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeSpotifyUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Spotify</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAppleUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Apple Podcasts</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeAmazonUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Amazon Music</a></td>` +
`<td style="color:#2a2518;font-size:12px;">&middot;</td>` +
`<td style="padding:0 10px;"><a href="${escapeHtml(welcomeWebsiteUrl)}" target="_blank" style="font-family:Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;text-decoration:none;letter-spacing:0.04em;">Website</a></td>` +
`</tr></table>` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#4a4438;line-height:1.6;">You&rsquo;re receiving this because you subscribed to <strong style="color:#5a5035;font-weight:400;">Verse by Verse with Nate</strong>.</p>` +
`<p style="margin:10px 0 0;font-family:Georgia,serif;font-size:14px;font-weight:400;color:#c8c0ac;line-height:1.5;">${welcomeSignoffHtml}</p>` +
`</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`,
}) })
if (welcomeError) throw welcomeError
} }
if (shouldSendWelcome) { try {
res.json({ ok: true }) const adminSendResult = await sendResendEmailWithRetry({
return resend,
} context: 'contact-admin-notification',
payload: {
const { error } = await resend.emails.send({ from: fromAddress,
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>', to: [adminInbox],
to: [process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'],
replyTo: trimmedEmail, replyTo: trimmedEmail,
subject: `Verse by Verse contact form: ${trimmedName}`, subject: adminTemplate.subject,
text: tags: [
`New contact form submission\n\n` + { name: 'flow', value: 'contact-admin' },
`Message Type: ${normalizedMessageType}\n` + { name: 'message_type', value: safeMessageTypeTag },
`Name: ${trimmedName}\n` + { name: 'submission_id', value: submission.id },
`Email: ${trimmedEmail}\n` + ],
`Submitted: ${submittedAt}\n\n` + headers: {
`Message:\n${trimmedMessage}`, 'X-Contact-Submission-Id': submission.id,
html: },
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` + text: adminTemplate.text,
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` + html: adminTemplate.html,
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` + },
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
`</tr>` +
`<tr>` +
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
`</tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}) })
if (error) throw error registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
upsertContactEmailStatus(submission.id, 'adminNotification', {
status: 'sent',
lastEventType: 'email.sent',
error: null,
})
} catch (adminSendErr) {
upsertContactEmailStatus(submission.id, 'adminNotification', {
status: 'failed',
lastEventType: 'email.failed',
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
})
throw adminSendErr
}
res.json({ ok: true }) res.json({
ok: true,
welcomeSent,
welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME,
})
} catch (err) { } catch (err) {
console.error('[contact] send error:', err) console.error('[contact] send error:', err)
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' }) res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
} }
}) })
app.post('/api/resend/webhook', (req, res) => {
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
if (!expectedToken) {
res.status(503).json({ message: 'Webhook token is not configured.' })
return
}
const providedToken = (req.get('x-webhook-token') || '').trim()
|| (req.get('x-resend-webhook-token') || '').trim()
|| String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
if (!providedToken || providedToken !== expectedToken) {
res.status(401).json({ message: 'Unauthorized webhook.' })
return
}
const body = req.body && typeof req.body === 'object' ? req.body : {}
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
const data = body.data && typeof body.data === 'object' ? body.data : {}
const tags = Array.isArray(data.tags) ? data.tags : []
const resendMessageId = (
typeof data.email_id === 'string' && data.email_id.trim()
? data.email_id.trim()
: (typeof data.emailId === 'string' && data.emailId.trim()
? data.emailId.trim()
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
)
const indexed = resendMessageId ? resendEmailSubmissionIndex.get(resendMessageId) : null
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
const submissionId = indexed?.submissionId || taggedSubmissionId
const flow = extractTagValue(tags, 'flow')
const stream = indexed?.stream
|| (flow === 'contact-welcome' ? 'welcome' : '')
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|| (flow === 'admin-reply' ? 'adminReply' : '')
if (!submissionId || !stream) {
res.json({ ok: true, ignored: true })
return
}
upsertContactEmailStatus(submissionId, stream, {
status: mapResendEventToStatus(eventType),
lastEventType: eventType || 'webhook.event',
resendEmailId: resendMessageId || null,
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
})
res.json({ ok: true })
})
app.get('/api/admin-contact-email-health', requireAdminAuth, (_req, res) => {
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const warnings = []
if (!process.env.RESEND_API_KEY) warnings.push('RESEND_API_KEY is missing.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or invalid.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Prefer a verified custom domain.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('Sender and reply-to domains are different.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not configured.')
warnings.push('Verify SPF, DKIM, and DMARC for the sender domain.')
const recent = contactSubmissions.slice(0, 300)
const failed = recent.filter(item => {
const status = normalizeContactEmailStatus(item.emailStatus, item.subscribe === true)
return ['failed', 'bounced', 'complained'].includes(status.welcome.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminNotification.status)
|| ['failed', 'bounced', 'complained'].includes(status.adminReply.status)
}).length
res.json({
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
webhookConfigured: Boolean(process.env.RESEND_WEBHOOK_TOKEN),
fromAddress,
replyToAddress,
fromDomain,
replyDomain,
warnings,
recentSubmissionFailures: failed,
trackedSubmissions: recent.length,
})
})
// Get all questions (for admin) // Get all questions (for admin)
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
res.json({ questions: draftQuestions ?? questions }) res.json({ questions: draftQuestions ?? questions })
@@ -4576,6 +5046,7 @@ Promise.all([
}, 60 * 60 * 1000) }, 60 * 60 * 1000)
app.listen(PORT, () => { app.listen(PORT, () => {
logResendEmailAlignmentWarnings()
console.log(`Portfolio app listening on http://localhost:${PORT}`) console.log(`Portfolio app listening on http://localhost:${PORT}`)
}) })
}) })
+588 -168
View File
File diff suppressed because it is too large Load Diff
+137 -13
View File
@@ -4994,6 +4994,37 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.admin-view-picker {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(201,168,76,0.22);
border-radius: 999px;
background: rgba(201,168,76,0.06);
}
.admin-view-picker-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240,234,216,0.6);
white-space: nowrap;
}
.admin-view-picker-select {
border: none;
background: transparent;
color: var(--brand-warm-white);
font-family: var(--brand-font-body);
font-size: 0.86rem;
font-weight: 600;
letter-spacing: 0.03em;
min-width: 11rem;
outline: none;
}
.admin-topbar-meta { .admin-topbar-meta {
font-size: 0.78rem; font-size: 0.78rem;
color: rgba(240,234,216,0.45); color: rgba(240,234,216,0.45);
@@ -5065,18 +5096,64 @@
color: var(--brand-warm-white); color: var(--brand-warm-white);
} }
.admin-nav-search {
display: grid;
gap: 0.4rem;
padding: 0 1.1rem 1rem;
}
.admin-nav-search label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240,234,216,0.6);
}
.admin-nav-search input {
width: 100%;
border: 1px solid rgba(201,168,76,0.18);
border-radius: 10px;
background: rgba(201,168,76,0.05);
color: var(--brand-warm-white);
padding: 0.65rem 0.8rem;
font-size: 0.88rem;
}
.admin-nav-search input::placeholder {
color: rgba(240,234,216,0.34);
}
.admin-nav-empty {
padding: 0 1.1rem;
}
.admin-nav-group { .admin-nav-group {
margin-bottom: 1.25rem; margin-bottom: 1.15rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(201, 168, 76, 0.14);
}
.admin-nav-group:first-of-type {
padding-top: 0;
border-top: 0;
} }
.admin-nav-label { .admin-nav-label {
display: block; display: inline-flex;
font-size: 0.68rem; align-items: center;
letter-spacing: 0.16em; font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(201,168,76,0.45); color: #ffe3a3;
padding: 0 1.1rem; background: rgba(201, 168, 76, 0.22);
margin-bottom: 0.25rem; border: 1px solid rgba(201, 168, 76, 0.45);
border-radius: 999px;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
padding: 0.2rem 0.62rem;
margin-left: 1.1rem;
margin-bottom: 0.42rem;
} }
.admin-nav-item { .admin-nav-item {
@@ -5088,9 +5165,10 @@
background: none; background: none;
border: none; border: none;
border-left: 2px solid transparent; border-left: 2px solid transparent;
color: rgba(240,234,216,0.6); color: rgba(240,234,216,0.84);
padding: 0.4rem 1.1rem; padding: 0.4rem 1.1rem;
font-size: 0.88rem; font-size: 0.9rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: color 150ms, background 150ms; transition: color 150ms, background 150ms;
line-height: 1.4; line-height: 1.4;
@@ -5118,13 +5196,13 @@
} }
.admin-nav-item:hover { .admin-nav-item:hover {
color: var(--brand-warm-white); color: #fff6de;
background: rgba(201,168,76,0.06); background: rgba(201,168,76,0.11);
} }
.admin-nav-item--active { .admin-nav-item--active {
color: var(--brand-gold) !important; color: #ffe4a8 !important;
background: rgba(201,168,76,0.1); background: rgba(201,168,76,0.18);
border-left-color: var(--brand-gold); border-left-color: var(--brand-gold);
} }
@@ -5169,6 +5247,11 @@
border-bottom: 1px solid rgba(201,168,76,0.08); border-bottom: 1px solid rgba(201,168,76,0.08);
} }
.admin-panel-subhead[id],
.admin-section-header[id] {
scroll-margin-top: 6rem;
}
.admin-panel-subhead h3 { .admin-panel-subhead h3 {
font-family: var(--brand-font-heading); font-family: var(--brand-font-heading);
font-size: 1.05rem; font-size: 1.05rem;
@@ -5202,6 +5285,47 @@
margin: 0; margin: 0;
} }
.admin-section-jump-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem;
margin: -0.4rem 0 1.5rem;
}
.admin-section-jump-nav-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240,234,216,0.55);
}
.admin-section-jump-nav-links {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.admin-section-jump-nav-btn {
border: 1px solid rgba(201,168,76,0.22);
border-radius: 999px;
background: rgba(201,168,76,0.07);
color: var(--brand-warm-white);
padding: 0.45rem 0.9rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 150ms, border-color 150ms, color 150ms;
}
.admin-section-jump-nav-btn:hover {
background: rgba(201,168,76,0.14);
border-color: rgba(201,168,76,0.4);
color: #fff6de;
}
.admin-archive-helper { .admin-archive-helper {
background: rgba(201,168,76,0.05); background: rgba(201,168,76,0.05);
border: 1px solid rgba(201,168,76,0.15); border: 1px solid rgba(201,168,76,0.15);