Compare commits

...
3 Commits
6 changed files with 768 additions and 852 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.25.2", "version": "2.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.25.2", "version": "2.28.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.25.2", "version": "2.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+324 -454
View File
@@ -978,124 +978,35 @@
} }
/* ── About ── */ /* ── About ── */
.section-about {
background: #0d0d0d;
padding: 5rem 0;
}
.section-about .section-inner {
max-width: 1160px;
}
.about-inner {
display: grid;
grid-template-columns: minmax(340px, 420px) 1fr;
gap: 4.5rem;
align-items: start;
}
.about-photo {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.about-photo img {
display: block;
width: 100%;
border-radius: 14px;
box-shadow:
0 12px 50px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(201, 168, 76, 0.15);
}
.about-photo-divider {
width: 86%;
height: 1px;
margin: 0.3rem auto 0.45rem;
background: linear-gradient(
90deg,
rgba(201, 168, 76, 0),
rgba(201, 168, 76, 0.6) 20%,
rgba(201, 168, 76, 0.9) 50%,
rgba(201, 168, 76, 0.6) 80%,
rgba(201, 168, 76, 0)
);
}
.about-nate-copy {
padding: 0;
}
.about-nate-copy .eyebrow {
margin-bottom: 0.3rem;
}
.about-nate-rule {
width: 170px;
height: 1px;
background: linear-gradient(90deg, var(--brand-gold), rgba(201, 168, 76, 0.18));
margin-bottom: 0.8rem;
}
.about-nate-copy p {
font-family: var(--brand-font-body);
font-weight: 400;
font-size: 1.2rem;
line-height: 1.7;
color: var(--brand-warm-white);
margin: 0 0 1rem;
padding: 0;
opacity: 0.9;
}
.about-nate-cta {
display: inline-block;
font-family: var(--brand-font-body);
font-size: 0.95rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--brand-gold);
text-decoration: none;
margin-top: 0.35rem;
padding-bottom: 0.1rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.45);
transition: color 180ms ease, border-color 180ms ease;
}
.about-nate-cta:hover {
color: var(--brand-warm-white);
border-bottom-color: rgba(246, 232, 186, 0.85);
}
.about-verse-art {
display: block;
width: 100%;
margin-top: 1.1rem;
border-radius: 14px;
box-shadow:
0 12px 50px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(201, 168, 76, 0.15);
}
.about-text h2 {
font-family: var(--brand-font-heading);
font-size: clamp(1.8rem, 3vw, 2.4rem);
color: var(--brand-warm-white);
margin: 0.5rem 0 1.2rem;
font-weight: 700;
}
.about-text p {
font-family: var(--brand-font-body);
font-weight: 400;
font-size: 1.2rem;
line-height: 1.7;
color: var(--brand-warm-white);
margin: 0 0 1rem;
padding: 0;
opacity: 0.9;
}
/* ── Homepage Newsletter ── */ /* ── Homepage Newsletter ── */
.section-newsletter { .section-newsletter {
@@ -3827,138 +3738,41 @@
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.section-contact {
background: #090909;
border-top: 1px solid rgba(201, 168, 76, 0.18);
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
padding: 5rem 0;
}
.contact-inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2.5rem;
align-items: start;
}
.contact-card {
background: #111111;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 16px;
padding: 2.3rem;
max-width: 500px;
}
.contact-card .eyebrow {
margin: 0 0 0.45rem;
}
.contact-card h2 {
margin: 0 0 1.75rem;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: clamp(1.45rem, 2vw, 1.9rem);
line-height: 1.3;
font-weight: 600;
}
.contact-profile {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.75rem;
}
.contact-profile-photo {
width: 108px;
height: 108px;
border-radius: 999px;
object-fit: cover;
object-position: center 22%;
border: 2px solid var(--brand-gold);
flex-shrink: 0;
box-shadow:
0 0 0 4px rgba(201, 168, 76, 0.16),
0 10px 28px rgba(0, 0, 0, 0.42);
}
.contact-profile-name {
margin: 0 0 0.15rem;
font-family: var(--brand-font-body);
font-size: 1.45rem;
font-weight: 600;
color: var(--brand-warm-white);
}
.contact-profile-role {
margin: 0;
font-family: var(--brand-font-body);
font-size: 0.95rem;
color: var(--brand-gold);
}
.contact-quote {
border-left: 2px solid var(--brand-gold);
padding-left: 1rem;
margin: 0 0 1.75rem;
}
.contact-quote p {
margin: 0;
font-family: var(--brand-font-body);
font-size: 1.35rem;
font-style: italic;
font-weight: 500;
color: #d4c4a0;
line-height: 1.6;
}
.contact-intro {
margin: 0 0 1rem;
font-family: var(--brand-font-body);
font-size: 1rem;
line-height: 1.6;
color: #9a9994;
}
.contact-points {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.contact-point {
display: flex;
align-items: center;
gap: 0.65rem;
color: #8f8e89;
font-family: var(--brand-font-body);
font-size: 0.92rem;
}
.contact-point svg {
color: var(--brand-gold);
flex: 0 0 auto;
}
.contact-scripture {
margin-top: 1.75rem;
padding-top: 1.5rem;
border-top: 1px solid #2c2116;
}
.contact-scripture p {
margin: 0;
font-family: var(--brand-font-body);
font-size: 0.88rem;
font-style: italic;
line-height: 1.6;
color: #a09888;
}
.contact-scripture-ref {
white-space: nowrap;
}
.contact-form { .contact-form {
background: #121212; background: #121212;
@@ -5768,11 +5582,7 @@ a.study-track-card:hover {
opacity: 0.9; opacity: 0.9;
} }
.section-download-library {
background:
radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%),
var(--brand-black);
}
.download-library-head { .download-library-head {
max-width: 760px; max-width: 760px;
@@ -5788,74 +5598,21 @@ a.study-track-card:hover {
margin: 0.85rem auto 0; margin: 0.85rem auto 0;
} }
.download-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin: 0 auto 2rem;
max-width: 760px;
}
.download-tag-chip {
background: transparent;
border: 1px solid rgba(201, 168, 76, 0.3);
border-radius: 999px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-family: var(--brand-font-body);
font-size: 0.88rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.35rem 1rem;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.download-tag-chip:hover {
background: rgba(201, 168, 76, 0.1);
border-color: rgba(201, 168, 76, 0.55);
color: var(--brand-warm-white);
}
.download-tag-chip--active {
background: rgba(201, 168, 76, 0.15);
border-color: var(--brand-gold);
color: var(--brand-gold);
}
.download-clear-link {
background: none;
border: none;
cursor: pointer;
font-family: var(--brand-font-body);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-muted);
padding: 0.35rem 0.5rem;
transition: color 140ms ease;
text-decoration: underline;
text-underline-offset: 3px;
}
.download-clear-link:hover {
color: var(--brand-warm-white);
}
.download-empty-state {
text-align: center;
color: var(--brand-muted);
font-family: var(--brand-font-body);
padding: 2rem 0;
}
.download-filter-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
/* Related episodes on download detail pages */ /* Related episodes on download detail pages */
.download-related-episodes { .download-related-episodes {
@@ -5987,118 +5744,36 @@ a.study-track-card:hover {
padding: 4rem 0; padding: 4rem 0;
} }
.books-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.book-card {
display: flex;
gap: 1.25rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
}
.book-card-cover {
width: 80px;
height: 110px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.book-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.book-card-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
line-height: 1.3;
}
.book-card-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.book-card-btn {
align-self: flex-start;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
}
/* ── Recommended Resources section ── */ /* ── Recommended Resources section ── */
.recommended-resources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
}
.recommended-resource-card {
display: flex;
gap: 1.25rem;
align-items: flex-start;
text-decoration: none;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.recommended-resource-card:hover {
border-color: rgba(201, 168, 76, 0.35);
}
.recommended-resource-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.recommended-resource-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recommended-resource-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
}
.recommended-resource-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.recommended-resource-action {
font-size: 0.85rem;
color: var(--brand-gold);
font-weight: 600;
}
/* ── Interior page banner ── */ /* ── Interior page banner ── */
.section-page-banner { .section-page-banner {
@@ -6132,18 +5807,9 @@ a.study-track-card:hover {
} }
/* ── Featured download card ── */ /* ── Featured download card ── */
.resource-download-card--featured {
margin-bottom: 1.5rem;
border-color: rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.04);
}
.resource-link-image--featured {
width: 80px !important;
height: 110px !important;
border-radius: 6px !important;
object-fit: cover;
}
.featured-badge { .featured-badge {
display: inline-block; display: inline-block;
@@ -6161,37 +5827,13 @@ a.study-track-card:hover {
} }
/* ── Books + Recommended Resources tabs ── */ /* ── Books + Recommended Resources tabs ── */
.books-resources-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.15);
padding-bottom: 0;
}
.books-resources-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.6rem 1.1rem;
margin-bottom: -1px;
font-family: var(--brand-font-body);
font-size: 0.92rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease;
letter-spacing: 0.02em;
}
.books-resources-tab:hover {
color: rgba(255, 255, 255, 0.85);
}
.books-resources-tab--active {
color: var(--brand-gold);
border-bottom-color: var(--brand-gold);
}
/* ── Study Guide section ── */ /* ── Study Guide section ── */
.section-guide { .section-guide {
@@ -7936,15 +7578,9 @@ a.study-track-card:hover {
justify-content: center; justify-content: center;
} }
.about-inner {
grid-template-columns: 1fr;
gap: 2rem;
}
.about-photo {
max-width: 260px;
margin: 0 auto;
}
.series-card { .series-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -8000,19 +7636,11 @@ a.study-track-card:hover {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.contact-inner {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.contact-card {
max-width: 100%;
padding: 1.45rem;
}
.contact-quote p {
font-size: 1.2rem;
}
.header-logo-img { .header-logo-img {
height: 40px; height: 40px;
@@ -12973,14 +12601,14 @@ a.study-track-card:hover {
.em-webhook-add-title { font-size: 0.85rem; font-weight: 600; color: #c0bcd4; margin: 0 0 0.75rem; } .em-webhook-add-title { font-size: 0.85rem; font-weight: 600; color: #c0bcd4; margin: 0 0 0.75rem; }
/* ── Ko-fi support section ───────────────────────────────────────────────── */ /* ── Ko-fi support section ───────────────────────────────────────────────── */
.section-kofi-support { background: var(--brand-black-2, #0f0f0c); padding: 4.5rem 1.5rem; text-align: center; border-top: 1px solid var(--brand-border, #2a2518); border-bottom: 1px solid var(--brand-border, #2a2518); }
.kofi-support-inner { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 1.25rem; }
.kofi-support-inner .eyebrow { color: var(--brand-gold, #c9a84c); }
.kofi-support-inner h2 { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 2.2rem; font-weight: 600; color: var(--brand-warm-white, #f0ead8); margin: 0; }
.kofi-support-desc { font-size: 1rem; line-height: 1.75; color: var(--brand-muted, #b0a48c); max-width: 460px; margin: 0; }
.kofi-qr-wrap { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; margin: 0.5rem 0; }
.kofi-qr-img { width: 180px; height: 180px; border-radius: 8px; border: 1px solid var(--brand-border, #2a2518); }
.kofi-qr-caption { font-size: 0.78rem; color: var(--brand-muted, #b0a48c); letter-spacing: 0.03em; }
.kofi-btn { display: inline-block; background: transparent; color: var(--brand-gold, #c9a84c); border: 1px solid var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; padding: 0.7rem 1.75rem; border-radius: 4px; text-decoration: none; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; } .kofi-btn { display: inline-block; background: transparent; color: var(--brand-gold, #c9a84c); border: 1px solid var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; padding: 0.7rem 1.75rem; border-radius: 4px; text-decoration: none; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; }
.kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); } .kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); }
@@ -13777,9 +13405,7 @@ a.study-track-card:hover {
} }
/* About copy was set larger than body text everywhere else */ /* About copy was set larger than body text everywhere else */
.about-inner p {
font-size: 1.0625rem;
}
/* Nothing readable below 12px */ /* Nothing readable below 12px */
.study-module-lesson, .study-module-lesson,
@@ -13830,7 +13456,251 @@ a.study-track-card:hover {
font-size: clamp(1.4rem, 2.4vw, 1.625rem); font-size: clamp(1.4rem, 2.4vw, 1.625rem);
} }
.about-inner p {
font-size: 1.0625rem;
}
} }
/* ── Resources page (tabs + compact grids) ─────────────────────────────── */
.res-hub { padding: 2rem 0 3.5rem; }
.res-tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.res-tab {
font: inherit; font-size: 0.95rem; cursor: pointer; padding: 0.5rem 1.1rem; border-radius: 999px;
color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border);
transition: border-color 150ms, color 150ms, background 150ms;
}
.res-tab span { opacity: 0.6; margin-left: 0.25rem; font-size: 0.85em; }
.res-tab:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.res-tab--on, .res-tab--on:hover { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); }
.res-featured { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1.75rem; }
.res-feature {
display: flex; gap: 1rem; align-items: center; padding: 1rem; text-decoration: none; color: inherit;
background: linear-gradient(135deg, rgba(201, 168, 76, 0.12), rgba(201, 168, 76, 0.03));
border: 1px solid var(--brand-gold-dark); border-radius: 14px; transition: transform 150ms, border-color 150ms;
}
.res-feature:hover { transform: translateY(-2px); border-color: var(--brand-gold); }
.res-feature-img { width: 84px; height: 84px; flex-shrink: 0; object-fit: cover; border-radius: 10px; }
.res-feature-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.res-badge {
align-self: flex-start; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
color: var(--brand-black); background: var(--brand-gold); padding: 0.1rem 0.55rem; border-radius: 999px;
}
.res-tags { display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
.res-chip {
font: inherit; font-size: 0.82rem; cursor: pointer; padding: 0.25rem 0.8rem; border-radius: 999px;
color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border);
}
.res-chip:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.res-chip--on { background: rgba(201, 168, 76, 0.18); border-color: var(--brand-gold-dark); color: var(--brand-gold-light); }
.res-empty { color: var(--brand-muted); padding: 1rem 0; }
.res-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.res-card, .res-book {
display: flex; gap: 0.9rem; align-items: flex-start; padding: 0.95rem; text-decoration: none; color: inherit;
background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px;
transition: border-color 150ms, transform 150ms;
}
.res-card:hover { border-color: var(--brand-gold-dark); transform: translateY(-2px); }
.res-card-img { width: 64px; height: 64px; flex-shrink: 0; object-fit: cover; border-radius: 8px; }
.res-book-cover { width: 76px; flex-shrink: 0; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); }
.res-card-body, .res-book-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; flex: 1; }
.res-title { font-family: var(--brand-font-heading); font-size: 1.25rem; font-weight: 600; line-height: 1.25; color: var(--brand-warm-white); overflow-wrap: anywhere; }
.res-desc { margin: 0; font-size: 0.92rem; line-height: 1.55; color: var(--brand-muted); }
.res-clamped { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.res-card-tags { font-size: 0.75rem; color: var(--brand-gold-dark); letter-spacing: 0.03em; }
.res-action { margin-top: 0.15rem; font-size: 0.85rem; color: var(--brand-gold); }
.res-more { align-self: flex-start; font: inherit; font-size: 0.82rem; padding: 0; background: none; border: 0; cursor: pointer; color: var(--brand-gold); text-decoration: underline; }
.res-book-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
.res-btn { font-size: 0.78rem !important; padding: 0.45rem 0.95rem !important; }
@media (max-width: 600px) {
.res-hub { padding: 1.25rem 0 2.5rem; }
.res-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-left: -0.25rem; padding: 0 0.25rem 0.25rem; }
.res-tab { flex-shrink: 0; }
.res-grid { grid-template-columns: 1fr; gap: 0.75rem; }
.res-feature { align-items: flex-start; }
.res-feature-img { width: 64px; height: 64px; }
.res-title { font-size: 1.1rem; }
.res-card, .res-book { padding: 0.8rem; gap: 0.75rem; }
.res-card-img { width: 52px; height: 52px; }
.res-book-cover { width: 64px; }
/* Shorter previews on phones; the tag chips above already cover tags */
.res-card .res-clamped, .res-feature .res-clamped { -webkit-line-clamp: 2 !important; }
.res-card-tags { display: none; }
}
/* ── Download page ─────────────────────────────────────────────────────── */
.dl-page { padding: 1.5rem 0 3.5rem; }
.dl-back { display: inline-block; margin-bottom: 1rem; font-size: 0.9rem; color: var(--brand-muted); text-decoration: none; }
.dl-back:hover { color: var(--brand-gold); }
.dl-card {
display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 2rem; align-items: start;
background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 16px; padding: 1.75rem;
}
.dl-art img { width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
.dl-body { min-width: 0; }
.dl-body .eyebrow { margin: 0 0 0.35rem; }
#root > :not(.admin-shell) .dl-card h1 { font-size: clamp(1.7rem, 3.2vw, 2.25rem); line-height: 1.15; margin: 0 0 0.75rem; }
.dl-summary { margin: 0 0 0.9rem; font-size: 1rem; line-height: 1.65; color: var(--brand-muted); white-space: pre-line; }
.dl-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.1rem; }
.dl-tag { font-size: 0.78rem; padding: 0.15rem 0.7rem; border-radius: 999px; border: 1px solid var(--brand-gold-dark); color: var(--brand-gold-light); }
.dl-form .study-download-form { margin-top: 0.4rem; }
.dl-form .study-download-grid label:nth-child(3) { grid-column: 1 / -1; }
/* The consent line is a sentence, not a field label */
.dl-form .study-download-form label.contact-consent {
flex-direction: row; align-items: flex-start; gap: 0.6rem;
text-transform: none; letter-spacing: normal; font-size: 0.9rem; font-weight: 400; color: var(--brand-muted);
}
.dl-form .study-download-form label.contact-consent input { margin-top: 0.25rem; flex-shrink: 0; }
.dl-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-top: 1.1rem; font-size: 0.88rem; color: var(--brand-muted); }
.dl-actions .btn-secondary { padding: 0.5rem 1.1rem; font-size: 0.8rem; }
@media (max-width: 700px) {
.dl-page { padding: 1rem 0 2.5rem; }
.dl-card { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1.1rem; }
.dl-art { display: flex; justify-content: center; }
.dl-art img { width: auto; max-width: 150px; max-height: 200px; }
.dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.7rem; }
.dl-form .study-download-form { padding: 0.85rem; }
.dl-form .study-download-form input[type='text'],
.dl-form .study-download-form input[type='email'] { min-width: 0; width: 100%; }
}
@media (max-width: 480px) {
.dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr); }
}
/* ── Contact page ──────────────────────────────────────────────────────── */
.cx-section { padding: 2.5rem 0 2rem; }
.cx-layout { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 2.5rem; align-items: start; }
.cx-side { position: sticky; top: 9rem; display: flex; flex-direction: column; gap: 1.1rem; }
.cx-profile { display: flex; align-items: center; gap: 0.9rem; }
.cx-photo { width: 68px; height: 68px; border-radius: 50%; object-fit: cover; border: 2px solid var(--brand-gold-dark); flex-shrink: 0; }
.cx-name { margin: 0; font-family: var(--brand-font-heading); font-size: 1.5rem; font-weight: 600; color: var(--brand-warm-white); line-height: 1.1; }
.cx-role { margin: 0.2rem 0 0; font-size: 0.88rem; color: var(--brand-muted); }
.cx-quote { margin: 0; padding: 0 0 0 1rem; border-left: 2px solid var(--brand-gold-dark); font-size: 1rem; line-height: 1.6; font-style: italic; color: var(--brand-warm-white); }
.cx-intro { margin: 0; font-size: 0.98rem; line-height: 1.6; color: var(--brand-muted); }
.cx-points { display: flex; flex-direction: column; gap: 0.5rem; }
.cx-point { display: flex; align-items: flex-start; gap: 0.6rem; font-size: 0.9rem; color: var(--brand-muted); }
.cx-point svg { margin-top: 0.2rem; flex-shrink: 0; color: var(--brand-gold); }
.cx-verse { margin: 0; font-size: 0.88rem; line-height: 1.55; color: var(--brand-muted); }
.cx-verse span { color: var(--brand-gold); white-space: nowrap; }
.cx-qa-link { align-self: flex-start; font-size: 0.9rem; color: var(--brand-gold); text-decoration: none; }
.cx-qa-link:hover { text-decoration: underline; }
.cx-form-card { background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 16px; padding: 1.75rem; }
.cx-form { display: flex; flex-direction: column; gap: 1.1rem; }
.cx-form label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.85rem; font-weight: 500; color: var(--brand-muted); }
.cx-form input[type='text'], .cx-form input[type='email'], .cx-form textarea {
font: inherit; font-size: 1rem; font-weight: 400; color: var(--brand-warm-white); background: var(--brand-black);
border: 1px solid var(--brand-border); border-radius: 8px; padding: 0.7rem 0.85rem; width: 100%; min-width: 0;
transition: border-color 150ms, box-shadow 150ms;
}
.cx-form textarea { resize: vertical; min-height: 9rem; line-height: 1.55; }
.cx-form input:focus, .cx-form textarea:focus { outline: none; border-color: var(--brand-gold); box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.16); }
.cx-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; }
.cx-count { align-self: flex-end; font-size: 0.75rem; color: var(--brand-muted); opacity: 0.8; }
.cx-types { border: 0; margin: 0; padding: 0; min-width: 0; }
.cx-types legend { padding: 0; margin-bottom: 0.55rem; font-size: 0.85rem; font-weight: 500; color: var(--brand-muted); }
.cx-type-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.cx-form .cx-type { position: relative; flex-direction: row; gap: 0; cursor: pointer; }
.cx-type input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.cx-type span {
font-size: 0.9rem; padding: 0.4rem 0.95rem; border-radius: 999px; color: var(--brand-muted);
border: 1px solid var(--brand-border); transition: border-color 150ms, background 150ms, color 150ms;
}
.cx-type:hover span { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.cx-type input:checked + span { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); font-weight: 600; }
.cx-type input:focus-visible + span { outline: 2px solid var(--brand-gold-light); outline-offset: 2px; }
.cx-hint { margin: 0.6rem 0 0; font-size: 0.88rem; color: var(--brand-muted); }
.cx-checks { display: flex; flex-direction: column; gap: 0.6rem; }
.cx-form label.cx-check { flex-direction: row; align-items: flex-start; gap: 0.6rem; font-size: 0.9rem; font-weight: 400; cursor: pointer; }
.cx-check input { margin-top: 0.22rem; flex-shrink: 0; accent-color: var(--brand-gold); }
.cx-submit { align-self: flex-start; justify-content: center; min-width: 200px; }
.cx-support { padding: 1rem 0 3.5rem; }
.cx-support-card {
display: flex; align-items: center; justify-content: space-between; gap: 2rem; padding: 1.5rem 1.75rem;
background: linear-gradient(135deg, rgba(201, 168, 76, 0.1), rgba(201, 168, 76, 0.02));
border: 1px solid var(--brand-gold-dark); border-radius: 16px;
}
.cx-support-copy { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; min-width: 0; }
.cx-support-copy .eyebrow { margin: 0; }
#root > :not(.admin-shell) .cx-support h2 { font-size: clamp(1.4rem, 2.6vw, 1.75rem); margin: 0; line-height: 1.2; }
.cx-support-copy p:not(.eyebrow) { margin: 0 0 0.4rem; font-size: 0.95rem; line-height: 1.6; color: var(--brand-muted); max-width: 52ch; }
.cx-support-qr { margin: 0; flex-shrink: 0; text-align: center; }
.cx-support-qr img { width: 120px; height: 120px; border-radius: 10px; background: #fff; padding: 6px; display: block; }
.cx-support-qr figcaption { margin-top: 0.4rem; font-size: 0.78rem; color: var(--brand-muted); }
@media (max-width: 900px) {
.cx-layout { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
.cx-side { position: static; order: 2; }
.cx-form-card { order: 1; }
}
@media (max-width: 600px) {
.cx-section { padding: 1.5rem 0 1rem; }
.cx-form-card { padding: 1.1rem; }
.cx-submit { align-self: stretch; }
.cx-support-card { flex-direction: column; align-items: flex-start; gap: 1.1rem; padding: 1.25rem; }
.cx-support-qr { align-self: center; }
}
@media (max-width: 480px) {
.cx-row { grid-template-columns: minmax(0, 1fr); }
}
/* ── About page ────────────────────────────────────────────────────────── */
.ab-section { padding: 2.5rem 0 1.5rem; }
.ab-intro { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 2.5rem; align-items: center; }
.ab-photo { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 16px; border: 1px solid var(--brand-gold-dark); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5); }
.ab-intro-copy { display: flex; flex-direction: column; gap: 0.9rem; align-items: flex-start; }
.ab-intro-copy .eyebrow { margin: 0; }
.ab-bio { margin: 0; font-size: 1.1rem; line-height: 1.75; color: var(--brand-warm-white); max-width: 60ch; }
.ab-cta-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.4rem; }
.ab-show {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 340px); gap: 2.5rem; align-items: center;
margin-top: 3rem; padding: 2rem; border-radius: 16px;
background: var(--brand-black-3); border: 1px solid var(--brand-border);
}
.ab-show-copy { display: flex; flex-direction: column; gap: 0.8rem; }
.ab-show-copy .eyebrow { margin: 0; }
#root > :not(.admin-shell) .ab-show-copy h2 { margin: 0; line-height: 1.15; }
.ab-show-copy p:not(.eyebrow) { margin: 0; font-size: 1.02rem; line-height: 1.7; color: var(--brand-muted); max-width: 60ch; }
.ab-verse-art { width: 100%; height: auto; border-radius: 12px; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); }
.ab-paths { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-top: 1.5rem; }
.ab-path {
display: flex; flex-direction: column; gap: 0.35rem; padding: 1.15rem; text-decoration: none; color: inherit;
background: transparent; border: 1px solid var(--brand-border); border-radius: 12px;
transition: border-color 150ms, transform 150ms;
}
.ab-path:hover { border-color: var(--brand-gold-dark); transform: translateY(-2px); }
.ab-path-title { font-family: var(--brand-font-heading); font-size: 1.35rem; font-weight: 600; color: var(--brand-warm-white); }
.ab-path-text { font-size: 0.92rem; line-height: 1.5; color: var(--brand-muted); }
.ab-path-go { margin-top: 0.2rem; font-size: 0.85rem; color: var(--brand-gold); }
@media (max-width: 900px) {
.ab-intro { grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 1.75rem; }
.ab-show { grid-template-columns: minmax(0, 1fr); padding: 1.5rem; }
.ab-verse-art { max-width: 420px; justify-self: center; }
.ab-paths { grid-template-columns: minmax(0, 1fr); gap: 0.6rem; }
.ab-path { padding: 0.9rem 1rem; }
.ab-path-go { display: none; }
}
@media (max-width: 600px) {
.ab-section { padding: 1.5rem 0 1rem; }
.ab-intro { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
.ab-photo { max-width: 200px; aspect-ratio: 1 / 1; }
.ab-bio { font-size: 1.02rem; }
.ab-show { margin-top: 2rem; padding: 1.15rem; gap: 1.25rem; }
.ab-cta-row .btn-primary, .ab-cta-row .btn-secondary { flex: 1 1 100%; justify-content: center; text-align: center; }
}
@media (max-width: 600px) {
.section-testimonials { padding-top: 2rem; padding-bottom: 2rem; }
.cx-support { padding-bottom: 2rem; }
}
+160 -351
View File
@@ -14,6 +14,7 @@ import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal' import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { HeaderAccount } from './components/HeaderAccount' import { HeaderAccount } from './components/HeaderAccount'
import { ResourcesHub } from './components/ResourcesHub'
import { BIBLE_BOOKS } from '../shared/bible-books.js' import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css' import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
@@ -636,56 +637,83 @@ function KofiSupportSection({ content }: { content: SiteContent }) {
const qrUrl = content.platformKofiQrUrl const qrUrl = content.platformKofiQrUrl
if (!kofiUrl && !qrUrl) return null if (!kofiUrl && !qrUrl) return null
return ( return (
<section className="section-kofi-support" aria-label="Support the show"> <section className="cx-support" aria-label="Support the show">
<div className="section-inner kofi-support-inner"> <div className="section-inner">
<p className="eyebrow">Support the Ministry</p> <div className="cx-support-card">
<h2>Help Keep the Word Going</h2> <div className="cx-support-copy">
<p className="kofi-support-desc"> <p className="eyebrow">Support the Ministry</p>
This podcast and study resources are listener-supported. If God has used this ministry in your life, <h2>Help Keep the Word Going</h2>
consider buying me a coffee — it goes directly toward keeping the teaching free for everyone. <p>
</p> This podcast and its study resources are listener-supported. If God has used this ministry in your life,
consider buying me a coffee. It goes straight toward keeping the teaching free for everyone.
</p>
{kofiUrl && (
<a href={kofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">
☕ Support on Ko-fi
</a>
)}
</div>
{qrUrl && ( {qrUrl && (
<div className="kofi-qr-wrap"> <figure className="cx-support-qr">
<img src={qrUrl} alt="Ko-fi donation QR code" className="kofi-qr-img" /> <img src={qrUrl} alt="Ko-fi donation QR code" />
<p className="kofi-qr-caption">Scan to give on Ko-fi</p> <figcaption>Scan to give on Ko-fi</figcaption>
</div> </figure>
)}
{kofiUrl && (
<a href={kofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">
☕ Support on Ko-fi
</a>
)} )}
</div>
</div> </div>
</section> </section>
) )
} }
function AboutSection({ content }: { content: SiteContent }) { function AboutSection({ content }: { content: SiteContent }) {
const paths = [
{ to: '/start-here', title: 'New here?', text: 'A short path through the show, one step at a time.' },
{ to: '/episodes', title: 'Listen', text: 'Every episode, newest first.' },
{ to: '/study', title: 'Study with us', text: 'Self-paced Bible studies with notes and discussion.' },
]
return ( return (
<section className="section-about" aria-label="About the show"> <section className="ab-section" aria-label="About the show">
<div className="section-inner about-inner"> <div className="section-inner">
<div className="about-photo"> <div className="ab-intro">
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" /> <img
<div className="about-photo-divider" aria-hidden="true" /> src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'}
<div className="about-nate-copy"> alt="Nate Emmert"
className="ab-photo"
/>
<div className="ab-intro-copy">
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p> <p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
<div className="about-nate-rule" aria-hidden="true" /> <p className="ab-bio">{content.aboutNate}</p>
<p>{content.aboutNate}</p> <div className="ab-cta-row">
<Link to="/episodes" className="about-nate-cta">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link> <Link to="/episodes" className="btn-primary">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
<Link to="/start-here" className="btn-secondary">New here? Start here</Link>
</div>
</div> </div>
</div> </div>
<div className="about-text">
{content.aboutShowEyebrow && <p className="eyebrow">{content.aboutShowEyebrow}</p>} <div className="ab-show">
<h2>{content.aboutShowHeading}</h2> <div className="ab-show-copy">
<p>{content.aboutShowP1}</p> {content.aboutShowEyebrow && <p className="eyebrow">{content.aboutShowEyebrow}</p>}
<p>{content.aboutShowP2}</p> <h2>{content.aboutShowHeading}</h2>
<div className="rule-divider" aria-hidden="true" /> <p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
</div>
<img <img
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'} src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
alt="Hebrews 4:12 verse artwork" alt="Hebrews 4:12 verse artwork"
className="about-verse-art" className="ab-verse-art"
loading="lazy"
/> />
</div> </div>
<div className="ab-paths">
{paths.map(p => (
<Link key={p.to} to={p.to} className="ab-path">
<span className="ab-path-title">{p.title}</span>
<span className="ab-path-text">{p.text}</span>
<span className="ab-path-go">Go →</span>
</Link>
))}
</div>
</div> </div>
</section> </section>
) )
@@ -693,64 +721,59 @@ function AboutSection({ content }: { content: SiteContent }) {
function ContactSection({ content }: { content: SiteContent }) { function ContactSection({ content }: { content: SiteContent }) {
const point = (text: string, icon: ReactElement) => (
<div className="cx-point">
{icon}
<span>{text}</span>
</div>
)
return ( return (
<section className="section-contact" id="contact" aria-label="Contact form"> <section className="cx-section" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner"> <div className="section-inner cx-layout">
<div className="contact-copy"> <aside className="cx-side">
<div className="contact-card"> <div className="cx-profile">
<div className="contact-profile"> <img
<img src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'} alt={content.contactName || 'Nate'}
alt={content.contactName || 'Nate'} className="cx-photo"
className="contact-profile-photo" />
/> <div>
<div className="contact-profile-meta"> <p className="cx-name">{content.contactName || 'Nate'}</p>
<p className="contact-profile-name">{content.contactName || 'Nate'}</p> <p className="cx-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
<p className="contact-profile-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
</div>
</div> </div>
{content.contactQuote && (
<blockquote className="contact-quote">
<p>"{content.contactQuote}"</p>
</blockquote>
)}
{content.contactIntro && (
<p className="contact-intro">{content.contactIntro}</p>
)}
<div className="contact-points" aria-label="Contact response details">
{content.contactPoint1 && (
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{content.contactPoint1}</span>
</div>
)}
{content.contactPoint2 && (
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{content.contactPoint2}</span>
</div>
)}
</div>
{content.contactVerse && (
<div className="contact-scripture">
<p>
"{content.contactVerse}" -{' '}
<span className="contact-scripture-ref">{content.contactVerseRef}</span>
</p>
</div>
)}
</div> </div>
{content.contactQuote && <blockquote className="cx-quote">&ldquo;{content.contactQuote}&rdquo;</blockquote>}
{content.contactIntro && <p className="cx-intro">{content.contactIntro}</p>}
{(content.contactPoint1 || content.contactPoint2) && (
<div className="cx-points" aria-label="Contact response details">
{content.contactPoint1 && point(content.contactPoint1, (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
))}
{content.contactPoint2 && point(content.contactPoint2, (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
))}
</div>
)}
{content.contactVerse && (
<p className="cx-verse">
&ldquo;{content.contactVerse}&rdquo; <span>{content.contactVerseRef}</span>
</p>
)}
<Link to="/questions" className="cx-qa-link">Browse answered questions →</Link>
</aside>
<div className="cx-form-card">
<ContactForm />
</div> </div>
<ContactForm />
</div> </div>
</section> </section>
) )
@@ -854,131 +877,6 @@ function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string;
) )
} }
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
const hasPrimaryGuide = !!(content.studyGuideTitle)
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
if (!hasPrimaryGuide && !content.secondSeries?.guide?.title && resources.length === 0) return null
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.studyGuideTitle}</span>
{content.studyGuideDescription && (
<p className="resource-link-description">{content.studyGuideDescription}</p>
)}
<span className="resource-link-action">Download free guide →</span>
</div>
</div>
</Link>
)}
{content.secondSeries?.guide?.title && (
<Link to="/downloads/second-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{content.secondSeries.imageUrl && (
<img
src={content.secondSeries.imageUrl}
alt={content.secondSeries.title}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
)}
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.secondSeries.guide.title}</span>
{content.secondSeries.guide.description && (
<p className="resource-link-description">{content.secondSeries.guide.description}</p>
)}
<span className="resource-link-action">Download free guide →</span>
</div>
</div>
</Link>
)}
{resources.length > 0 && (
<>
{allTags.length > 0 && (
<div className="download-filter-bar">
<div className="download-tag-filter">
<button
type="button"
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
All topics
</button>
{allTags.map(tag => (
<button
key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
{activeTag !== null && (
<button
type="button"
className="download-clear-link"
onClick={() => setActiveTag(null)}
>
Clear
</button>
)}
</div>
</div>
)}
{filteredResources.length > 0 ? (
<div className="resources-list">
{filteredResources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{resource.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page →</span>
</div>
</div>
</Link>
))}
</div>
) : (
<p className="download-empty-state">No resources tagged "{activeTag}".</p>
)}
</>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) { function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id) const resource = resolveDownloadPageResource(content, id)
@@ -1002,44 +900,57 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
} }
return ( return (
<main className="thanks-page download-detail-page" aria-label={resource.label}> <div className="site">
<div className="thanks-card download-detail-card"> <SiteHeader content={content} />
<p className="eyebrow">Resources</p> <SiteSearchBar content={content} />
<h1>{resource.label}</h1> <main className="dl-page" aria-label={resource.label}>
<p>{resource.summary}</p> <div className="section-inner">
<Link to="/resources" className="dl-back">← Back to Resources</Link>
<article className="dl-card">
{resource.imageUrl && (
<div className="dl-art">
<img src={resource.imageUrl} alt={resource.label} />
</div>
)}
<div className="dl-body">
<p className="eyebrow">Free download</p>
<h1>{resource.label}</h1>
{resource.summary && <p className="dl-summary">{resource.summary}</p>}
{resource.tags.length > 0 && (
<div className="dl-tags">
{resource.tags.map(tag => <span key={tag} className="dl-tag">{tag}</span>)}
</div>
)}
{resource.imageUrl && ( <div className="dl-form">
<div className="download-detail-art"> {/* The title is already on the page, so the button stays short */}
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" /> {resource.isPrimaryGuide
</div> ? <StudyDownloadForm buttonText="Download now" />
)} : <ResourceDownloadForm resourceId={resource.resourceId} buttonText="Download now" />}
</div>
{resource.tags.length > 0 && ( {(resource.amazonUrl || resource.appleBooksUrl) && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div> <div className="dl-actions">
)} <span>Or get it from:</span>
{resource.amazonUrl && (
<div className="download-detail-form-wrap"> <a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.isPrimaryGuide {resource.amazonLabel || 'Get it on Amazon'}
? <StudyDownloadForm buttonText={resource.buttonText} /> </a>
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />} )}
{resource.appleBooksUrl && (
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
</div>
)}
</div>
</article>
</div> </div>
</main>
<div className="download-detail-actions"> <SiteFooter content={content} />
{resource.amazonUrl && ( <AnalyticsConsentBanner content={content} />
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary"> </div>
{resource.amazonLabel || 'Get it on Amazon'}
</a>
)}
{resource.appleBooksUrl && (
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
<Link to="/resources" className="btn-secondary">Back to Resources</Link>
</div>
</div>
</main>
) )
} }
@@ -2341,107 +2252,6 @@ function NuggetsHostDetailPage({ content }: { content: SiteContent }) {
) )
} }
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
const [activeTab, setActiveTab] = useState<'books' | 'recommended'>('books')
const hasBooks = books.length > 0
const hasRecommended = recommended.length > 0
const showTabs = hasBooks && hasRecommended
if (!hasBooks && !hasRecommended) return <TruthForLifeWidget type="resourcead" />
const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended')
return (
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
<div className="section-inner">
{showTabs ? (
<div className="books-resources-tabs">
<button
type="button"
className={`books-resources-tab${tab === 'books' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('books')}
>
Reading List
</button>
<button
type="button"
className={`books-resources-tab${tab === 'recommended' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('recommended')}
>
Recommended Resources
</button>
</div>
) : (
<div className="download-library-head">
<p className="eyebrow">{tab === 'books' ? 'Reading List' : 'Recommended'}</p>
<h2 className="section-heading">{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}</h2>
</div>
)}
{tab === 'books' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Books worth your time.</h2>
</div>
)}
<div className="books-list">
{books.map(book => (
<div key={book.id} className="book-card">
{book.imageUrl && (
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
)}
<div className="book-card-meta">
<span className="book-card-title">{book.label}</span>
{book.description && <p className="book-card-description">{book.description}</p>}
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
{book.appleBooksUrl && (
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary book-card-btn">
{book.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'recommended' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Recommended Resources</h2>
</div>
)}
<div className="recommended-resources-list">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
)}
<div className="recommended-resource-meta">
<span className="recommended-resource-title">{item.label}</span>
{item.description && <p className="recommended-resource-description">{item.description}</p>}
<span className="recommended-resource-action">Learn more →</span>
</div>
</a>
))}
</div>
</>
)}
</div>
</section>
)
}
function ResourcesPage({ content }: { content: SiteContent }) { function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta( usePageMeta(
@@ -2453,9 +2263,8 @@ function ResourcesPage({ content }: { content: SiteContent }) {
<SiteHeader content={content} /> <SiteHeader content={content} />
<SiteSearchBar content={content} /> <SiteSearchBar content={content} />
<PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." /> <PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
<DownloadLibrarySection content={content} /> <ResourcesHub content={content} downloadPath={id => `/downloads/${buildCustomDownloadPageId(id)}`} fallback={<TruthForLifeWidget type="resourcead" />} />
<CustomBlocksSection content={content} page="downloads" /> <CustomBlocksSection content={content} page="downloads" />
<BooksAndResourcesSection content={content} />
<SiteFooter content={content} /> <SiteFooter content={content} />
<AnalyticsConsentBanner content={content} /> <AnalyticsConsentBanner content={content} />
</div> </div>
@@ -2710,8 +2519,8 @@ function AboutPage({ content }: { content: SiteContent }) {
<SiteSearchBar content={content} /> <SiteSearchBar content={content} />
<PageBanner eyebrow="About" title="About the Show" /> <PageBanner eyebrow="About" title="About the Show" />
<AboutSection content={content} /> <AboutSection content={content} />
<KofiSupportSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} /> <TestimonialsSection testimonials={content.testimonials ?? []} />
<KofiSupportSection content={content} />
<CustomBlocksSection content={content} page="about" /> <CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} /> <SiteFooter content={content} />
<AnalyticsConsentBanner content={content} /> <AnalyticsConsentBanner content={content} />
+70 -44
View File
@@ -3,6 +3,7 @@ import type { ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/ const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
const MAX_MESSAGE = 3000
interface ContactFields { interface ContactFields {
firstName: string firstName: string
@@ -12,6 +13,14 @@ interface ContactFields {
messageType: string messageType: string
} }
const TYPES: { value: string; label: string; hint: string; placeholder: string }[] = [
{ value: 'question', label: 'Bible question', hint: 'Ask anything about Scripture. Answers are posted on the Q&A page.', placeholder: 'What would you like to know?' },
{ value: 'community', label: 'Community question', hint: 'A question about a study discussion or the community.', placeholder: 'What’s on your mind?' },
{ value: 'testimony', label: 'Testimony', hint: 'Share what God is doing in your life. Every one is read.', placeholder: 'Tell your story…' },
{ value: 'topic', label: 'Topic request', hint: 'Suggest a passage or topic for a future episode.', placeholder: 'Which passage or topic?' },
{ value: 'general', label: 'General message', hint: 'Anything else you’d like to say.', placeholder: 'Write your message…' },
]
export default function ContactForm() { export default function ContactForm() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -42,6 +51,8 @@ export default function ContactForm() {
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({}) const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
const activeType = TYPES.find(t => t.value === fields.messageType) ?? TYPES[0]
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) { function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target const { name, value } = e.target
setFields(f => ({ ...f, [name]: value })) setFields(f => ({ ...f, [name]: value }))
@@ -59,10 +70,6 @@ export default function ContactForm() {
} }
} }
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setStatus('submitting') setStatus('submitting')
@@ -90,7 +97,7 @@ export default function ContactForm() {
} }
return ( return (
<form className="contact-form" onSubmit={handleSubmit} noValidate> <form className="cx-form" onSubmit={handleSubmit} noValidate>
<input <input
type="text" type="text"
className="contact-honeypot" className="contact-honeypot"
@@ -100,14 +107,37 @@ export default function ContactForm() {
value={honey} value={honey}
onChange={e => setHoney(e.target.value)} onChange={e => setHoney(e.target.value)}
/> />
<label>
First Name <fieldset className="cx-types">
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} /> <legend>What is this about?</legend>
</label> <div className="cx-type-row" role="radiogroup">
<label> {TYPES.map(t => (
Last Name <label key={t.value} className="cx-type">
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} /> <input
</label> type="radio"
name="messageType"
value={t.value}
checked={fields.messageType === t.value}
onChange={() => setFields(f => ({ ...f, messageType: t.value }))}
/>
<span>{t.label}</span>
</label>
))}
</div>
<p className="cx-hint" aria-live="polite">{activeType.hint}</p>
</fieldset>
<div className="cx-row">
<label>
First name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
</div>
<label> <label>
Email Email
<input <input
@@ -122,41 +152,37 @@ export default function ContactForm() {
/> />
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>} {fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
</label> </label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="community">Community Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label> <label>
Message Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} /> <textarea
</label> name="message"
{fields.messageType === 'question' && ( rows={6}
<label className="contact-consent"> required
<input maxLength={MAX_MESSAGE}
type="checkbox" placeholder={activeType.placeholder}
checked={notifyOnAnswer} value={fields.message}
onChange={e => setNotifyOnAnswer(e.target.checked)} onChange={handleChange}
/>
<span>Email me when this question is answered.</span>
</label>
)}
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/> />
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span> <span className="cx-count">{fields.message.length} / {MAX_MESSAGE}</span>
</label> </label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}> <div className="cx-checks">
{status === 'submitting' ? 'Sending…' : 'Send Message'} {fields.messageType === 'question' && (
<label className="cx-check">
<input type="checkbox" checked={notifyOnAnswer} onChange={e => setNotifyOnAnswer(e.target.checked)} />
<span>Email me when this question is answered.</span>
</label>
)}
<label className="cx-check">
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
</div>
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
<button type="submit" className="btn-primary cx-submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send message'}
</button> </button>
</form> </form>
) )
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent } from '../content'
type TabKey = 'downloads' | 'books' | 'recommended'
interface Props {
content: SiteContent
downloadPath: (resourceId: string) => string
// Shown when there are no downloads, books or recommended resources at all
fallback: ReactNode
}
// A description that stays short in the grid; long ones get a Read more toggle
function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) {
const [open, setOpen] = useState(false)
const long = text.length > threshold
return (
<>
<p
className={`${className}${long && !open ? ' res-clamped' : ''}`}
style={long && !open ? { WebkitLineClamp: lines } : undefined}
>
{text}
</p>
{long && (
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
{open ? 'Show less' : 'Read more'}
</button>
)}
</>
)
}
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
const links = content.customLinks ?? []
const resources = links.filter(l => l.placement === 'resources')
const books = links.filter(l => l.placement === 'books')
const recommended = links.filter(l => l.placement === 'recommended')
const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = []
if (content.studyGuideTitle) {
featured.push({
key: 'primary',
to: '/downloads/primary-guide',
title: content.studyGuideTitle,
description: content.studyGuideDescription ?? '',
image: content.seriesImageUrl || '/images/titus-cover.png',
alt: content.seriesTitle,
})
}
if (content.secondSeries?.guide?.title) {
featured.push({
key: 'second',
to: '/downloads/second-guide',
title: content.secondSeries.guide.title,
description: content.secondSeries.guide.description ?? '',
image: content.secondSeries.imageUrl ?? '',
alt: content.secondSeries.title,
})
}
const counts: Record<TabKey, number> = {
downloads: featured.length + resources.length,
books: books.length,
recommended: recommended.length,
}
const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0)
const fromHash = (): TabKey | null => {
const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : ''
return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null
}
const [picked, setPicked] = useState<TabKey | null>(fromHash)
const [activeTag, setActiveTag] = useState<string | null>(null)
// Links like /resources#books, and the browser's back/forward buttons, switch the tab too
useEffect(() => {
const onHash = () => setPicked(fromHash())
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
if (available.length === 0) return <>{fallback}</>
const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0]
const choose = (k: TabKey) => {
setPicked(k)
try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ }
}
const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag))
const labels: Record<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
return (
<section className="res-hub" aria-label="Resources">
<div className="section-inner">
{available.length > 1 && (
<div className="res-tabs" role="tablist" aria-label="Resource types">
{available.map(k => (
<button
key={k}
type="button"
role="tab"
id={`res-tab-${k}`}
aria-selected={tab === k}
aria-controls="res-panel"
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
onClick={() => choose(k)}
>
{labels[k]} <span>{counts[k]}</span>
</button>
))}
</div>
)}
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
{tab === 'downloads' && (
<>
{featured.length > 0 && (
<div className="res-featured">
{featured.map(f => (
<Link key={f.key} to={f.to} className="res-feature">
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
<div className="res-feature-body">
<span className="res-badge">Featured</span>
<span className="res-title">{f.title}</span>
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
<span className="res-action">Download free guide →</span>
</div>
</Link>
))}
</div>
)}
{tags.length > 0 && (
<div className="res-tags" role="group" aria-label="Filter by topic">
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
{tags.map(t => (
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
))}
</div>
)}
{shownResources.length > 0 ? (
<div className="res-grid">
{shownResources.map(r => (
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{r.label}</span>
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
<span className="res-action">Open download →</span>
</div>
</Link>
))}
</div>
) : (
resources.length > 0 && <p className="res-empty">No downloads tagged &quot;{activeTag}&quot;.</p>
)}
</>
)}
{tab === 'books' && (
<div className="res-grid">
{books.map(book => (
<div key={book.id} className="res-book">
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
<div className="res-book-body">
<span className="res-title">{book.label}</span>
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
<div className="res-book-actions">
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary res-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
{book.appleBooksUrl && (
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary res-btn">
{book.appleBooksLabel || 'Apple Books'}
</a>
)}
</div>
</div>
</div>
))}
</div>
)}
{tab === 'recommended' && (
<div className="res-grid">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{item.label}</span>
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
<span className="res-action">Visit →</span>
</div>
</a>
))}
</div>
)}
</div>
</div>
</section>
)
}