Ask for name and email before sending visitors to Amazon or Apple Books (switchable in admin → Books); v2.29.0
Publish Container / build (push) Successful in 15m46s
Publish Container / build (push) Successful in 15m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c342f14bd
commit
9f52437a07
10 files changed
+277
-11
No files matched your search
@@ -59,3 +59,6 @@ data/episode-plays.json
|
|||||||
# Episode workflow progress — lives only in the server data volume
|
# Episode workflow progress — lives only in the server data volume
|
||||||
data/podcast-workflow.json
|
data/podcast-workflow.json
|
||||||
data/nuggets-workflow.json
|
data/nuggets-workflow.json
|
||||||
|
|
||||||
|
# Live download counters — server data volume only
|
||||||
|
data/download-counts.json
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.28.0",
|
"version": "2.29.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.28.0",
|
"version": "2.29.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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.28.0",
|
"version": "2.29.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -222,6 +222,81 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
|
||||||
|
// The destination always comes from the saved site content, never from the request.
|
||||||
|
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||||||
|
if (_honey) { res.json({ ok: true }); return }
|
||||||
|
|
||||||
|
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
|
||||||
|
res.status(400).json({ message: 'That link is not available.' }); return
|
||||||
|
}
|
||||||
|
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||||
|
res.status(400).json({ message: 'First name is required.' }); return
|
||||||
|
}
|
||||||
|
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||||||
|
res.status(400).json({ message: 'Last name is required.' }); return
|
||||||
|
}
|
||||||
|
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
||||||
|
res.status(400).json({ message: 'A valid email address is required.' }); return
|
||||||
|
}
|
||||||
|
|
||||||
|
const published = await loadSiteContentFile(DATA_FILE)
|
||||||
|
const content = published?.siteContent ?? {}
|
||||||
|
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
|
||||||
|
let label = ''
|
||||||
|
let url = ''
|
||||||
|
|
||||||
|
if (source === 'page:primary-guide') {
|
||||||
|
label = content.studyGuideTitle
|
||||||
|
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
|
||||||
|
} else if (source === 'page:second-guide') {
|
||||||
|
const guide = content.secondSeries?.guide
|
||||||
|
label = guide?.title
|
||||||
|
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('page:book-guide--')) {
|
||||||
|
const id = source.slice('page:book-guide--'.length)
|
||||||
|
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
|
||||||
|
label = guide?.title
|
||||||
|
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('page:custom--')) {
|
||||||
|
const id = source.slice('page:custom--'.length)
|
||||||
|
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
|
||||||
|
label = link?.label
|
||||||
|
url = pick(link?.amazonUrl, link?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('book:')) {
|
||||||
|
const id = source.slice('book:'.length)
|
||||||
|
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
|
||||||
|
label = book?.label
|
||||||
|
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = sanitizeUrl(url)
|
||||||
|
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
|
||||||
|
|
||||||
|
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||||
|
const trimmedEmail = email.trim()
|
||||||
|
const wantsSubscribe = subscribe !== false
|
||||||
|
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||||
|
|
||||||
|
addContactSubmission({
|
||||||
|
name,
|
||||||
|
email: trimmedEmail,
|
||||||
|
message: `Requested ${storeName} link: ${label || source}`,
|
||||||
|
messageType: 'general',
|
||||||
|
subscribe: wantsSubscribe,
|
||||||
|
})
|
||||||
|
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
|
||||||
|
incrementDownloadCount(`store:${source}:${store}`)
|
||||||
|
|
||||||
|
res.json({ ok: true, url: destination })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[store-link] request error:', err)
|
||||||
|
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ counts: state.downloadCounts })
|
res.json({ counts: state.downloadCounts })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5801,6 +5801,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<h2>Books</h2>
|
<h2>Books</h2>
|
||||||
<p>Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.</p>
|
<p>Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<label htmlFor="gate-store-links" style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
id="gate-store-links"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.gateStoreLinks !== false}
|
||||||
|
onChange={e => setForm(f => ({ ...f, gateStoreLinks: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Ask for name and email before sending visitors to Amazon or Apple Books
|
||||||
|
</label>
|
||||||
|
<p className="admin-field-help">
|
||||||
|
Applies to the Amazon and Apple Books buttons on the Reading List and on the study guide and download pages. Each visitor is added to your Inbox as a contact, and subscribed if they leave the box ticked. Turn it off to send visitors straight to the store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
|
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
|
||||||
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
|
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
|
||||||
<div key={book.id} className="admin-array-row">
|
<div key={book.id} className="admin-array-row">
|
||||||
|
|||||||
+28
@@ -13704,3 +13704,31 @@ a.study-track-card:hover {
|
|||||||
.cx-support { padding-bottom: 2rem; }
|
.cx-support { padding-bottom: 2rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Store link sign-up dialog (before Amazon / Apple Books) ─────────────── */
|
||||||
|
.sl-overlay { position: fixed; inset: 0; z-index: 400; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, 0.7); }
|
||||||
|
.sl-dialog {
|
||||||
|
width: min(460px, 100%); max-height: 100%; overflow-y: auto; padding: 1.5rem;
|
||||||
|
background: var(--brand-black-3); border: 1px solid var(--brand-gold-dark); border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
#root > :not(.admin-shell) .sl-dialog h3 { margin: 0 0 0.4rem; font-family: var(--brand-font-heading); font-size: 1.6rem; color: var(--brand-warm-white); }
|
||||||
|
.sl-lead { margin: 0 0 1rem; font-size: 0.95rem; line-height: 1.55; color: var(--brand-muted); }
|
||||||
|
.sl-dialog form { display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.sl-dialog label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; color: var(--brand-muted); }
|
||||||
|
.sl-dialog input[type='text'], .sl-dialog input[type='email'] {
|
||||||
|
font: inherit; font-size: 1rem; color: var(--brand-warm-white); background: var(--brand-black); width: 100%; min-width: 0;
|
||||||
|
border: 1px solid var(--brand-border); border-radius: 8px; padding: 0.65rem 0.8rem;
|
||||||
|
}
|
||||||
|
.sl-dialog input:focus { outline: none; border-color: var(--brand-gold); box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.16); }
|
||||||
|
.sl-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.75rem; }
|
||||||
|
.sl-dialog label.sl-check { flex-direction: row; align-items: flex-start; gap: 0.55rem; font-size: 0.88rem; font-weight: 400; cursor: pointer; }
|
||||||
|
.sl-check input { margin-top: 0.22rem; flex-shrink: 0; accent-color: var(--brand-gold); }
|
||||||
|
.sl-error { margin: 0; font-size: 0.9rem; color: #e07a6a; }
|
||||||
|
.sl-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.3rem; }
|
||||||
|
.sl-actions .btn-primary { flex: 1 1 auto; justify-content: center; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.sl-overlay { align-items: flex-end; padding: 0; }
|
||||||
|
.sl-dialog { border-radius: 16px 16px 0 0; padding: 1.25rem; }
|
||||||
|
.sl-row { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.sl-actions .btn-secondary { flex: 1 1 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
+5
-4
@@ -15,6 +15,7 @@ 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 { ResourcesHub } from './components/ResourcesHub'
|
||||||
|
import { StoreLink } from './components/StoreLink'
|
||||||
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'
|
||||||
@@ -933,14 +934,14 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="dl-actions">
|
<div className="dl-actions">
|
||||||
<span>Or get it from:</span>
|
<span>Or get it from:</span>
|
||||||
{resource.amazonUrl && (
|
{resource.amazonUrl && (
|
||||||
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
<StoreLink source={`page:${resource.id}`} store="amazon" href={resource.amazonUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
|
||||||
{resource.amazonLabel || 'Get it on Amazon'}
|
{resource.amazonLabel || 'Get it on Amazon'}
|
||||||
</a>
|
</StoreLink>
|
||||||
)}
|
)}
|
||||||
{resource.appleBooksUrl && (
|
{resource.appleBooksUrl && (
|
||||||
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
<StoreLink source={`page:${resource.id}`} store="apple" href={resource.appleBooksUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
|
||||||
{resource.appleBooksLabel || 'Get it on Apple Books'}
|
{resource.appleBooksLabel || 'Get it on Apple Books'}
|
||||||
</a>
|
</StoreLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { SiteContent } from '../content'
|
import type { SiteContent } from '../content'
|
||||||
|
import { StoreLink } from './StoreLink'
|
||||||
|
|
||||||
type TabKey = 'downloads' | 'books' | 'recommended'
|
type TabKey = 'downloads' | 'books' | 'recommended'
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: st
|
|||||||
|
|
||||||
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
|
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
|
||||||
const links = content.customLinks ?? []
|
const links = content.customLinks ?? []
|
||||||
|
// Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin)
|
||||||
|
const gated = content.gateStoreLinks !== false
|
||||||
const resources = links.filter(l => l.placement === 'resources')
|
const resources = links.filter(l => l.placement === 'resources')
|
||||||
const books = links.filter(l => l.placement === 'books')
|
const books = links.filter(l => l.placement === 'books')
|
||||||
const recommended = links.filter(l => l.placement === 'recommended')
|
const recommended = links.filter(l => l.placement === 'recommended')
|
||||||
@@ -174,14 +177,26 @@ export function ResourcesHub({ content, downloadPath, fallback }: Props) {
|
|||||||
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
|
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
|
||||||
<div className="res-book-actions">
|
<div className="res-book-actions">
|
||||||
{(book.amazonUrl || book.url) && (
|
{(book.amazonUrl || book.url) && (
|
||||||
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary res-btn">
|
<StoreLink
|
||||||
|
source={`book:${book.id}`}
|
||||||
|
store="amazon"
|
||||||
|
href={book.amazonUrl || book.url}
|
||||||
|
gated={gated}
|
||||||
|
className="btn-primary res-btn"
|
||||||
|
>
|
||||||
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
||||||
</a>
|
</StoreLink>
|
||||||
)}
|
)}
|
||||||
{book.appleBooksUrl && (
|
{book.appleBooksUrl && (
|
||||||
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary res-btn">
|
<StoreLink
|
||||||
|
source={`book:${book.id}`}
|
||||||
|
store="apple"
|
||||||
|
href={book.appleBooksUrl}
|
||||||
|
gated={gated}
|
||||||
|
className="btn-secondary res-btn"
|
||||||
|
>
|
||||||
{book.appleBooksLabel || 'Apple Books'}
|
{book.appleBooksLabel || 'Apple Books'}
|
||||||
</a>
|
</StoreLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const SAVED_KEY = 'vbn-store-link-contact'
|
||||||
|
|
||||||
|
interface Saved { firstName: string; lastName: string; email: string }
|
||||||
|
|
||||||
|
function readSaved(): Saved {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVED_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const d = JSON.parse(raw) as Partial<Saved>
|
||||||
|
return { firstName: d.firstName ?? '', lastName: d.lastName ?? '', email: d.email ?? '' }
|
||||||
|
}
|
||||||
|
} catch { /* private mode or bad data */ }
|
||||||
|
return { firstName: '', lastName: '', email: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Which guide or book this link belongs to (the server finds the real address from this)
|
||||||
|
source: string
|
||||||
|
store: 'amazon' | 'apple'
|
||||||
|
// The address used when the sign-up step is switched off
|
||||||
|
href: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
gated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A button that asks for name and email, then sends the visitor on to Amazon or Apple Books.
|
||||||
|
// With the gate switched off it is just a normal link.
|
||||||
|
export function StoreLink({ source, store, href, className, children, gated }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [fields, setFields] = useState<Saved>({ firstName: '', lastName: '', email: '' })
|
||||||
|
const [subscribe, setSubscribe] = useState(true)
|
||||||
|
const [honey, setHoney] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'sending' | 'error'>('idle')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null)
|
||||||
|
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const t = window.setTimeout(() => firstRef.current?.focus(), 30)
|
||||||
|
return () => { document.removeEventListener('keydown', onKey); window.clearTimeout(t) }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!gated) {
|
||||||
|
return <a href={href} target="_blank" rel="noopener noreferrer" className={className}>{children}</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setFields(readSaved())
|
||||||
|
setStatus('idle')
|
||||||
|
setError('')
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('sending')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/store-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source, store, ...fields, subscribe, _honey: honey }),
|
||||||
|
})
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string }
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
setError(data.message ?? 'Something went wrong. Please try again.')
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(SAVED_KEY, JSON.stringify(fields)) } catch { /* ignore */ }
|
||||||
|
window.location.assign(data.url)
|
||||||
|
} catch {
|
||||||
|
setError('Could not connect. Please try again later.')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (name: keyof Saved) => (e: React.ChangeEvent<HTMLInputElement>) => setFields(f => ({ ...f, [name]: e.target.value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className={className} onClick={openDialog}>{children}</button>
|
||||||
|
{open && (
|
||||||
|
<div className="sl-overlay" onMouseDown={e => { if (e.target === e.currentTarget) setOpen(false) }}>
|
||||||
|
<div className="sl-dialog" role="dialog" aria-modal="true" aria-labelledby={`sl-title-${source}-${store}`}>
|
||||||
|
<h3 id={`sl-title-${source}-${store}`}>One quick step</h3>
|
||||||
|
<p className="sl-lead">Tell us where to send updates, then we'll take you straight to {storeName}.</p>
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
|
||||||
|
<div className="sl-row">
|
||||||
|
<label>
|
||||||
|
First name
|
||||||
|
<input ref={firstRef} type="text" required autoComplete="given-name" value={fields.firstName} onChange={set('firstName')} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Last name
|
||||||
|
<input type="text" required autoComplete="family-name" value={fields.lastName} onChange={set('lastName')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" required autoComplete="email" value={fields.email} onChange={set('email')} />
|
||||||
|
</label>
|
||||||
|
<label className="sl-check">
|
||||||
|
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
|
||||||
|
<span>Subscribe me to updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||||
|
</label>
|
||||||
|
{status === 'error' && <p className="sl-error" role="alert">{error}</p>}
|
||||||
|
<div className="sl-actions">
|
||||||
|
<button type="submit" className="btn-primary" disabled={status === 'sending'}>
|
||||||
|
{status === 'sending' ? 'One moment…' : `Continue to ${storeName}`}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -396,6 +396,8 @@ export interface SiteContent {
|
|||||||
qaAnsweredEmailSignoff: string
|
qaAnsweredEmailSignoff: string
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
headerFollowLabel: string
|
headerFollowLabel: string
|
||||||
|
// Ask for name and email before sending visitors to Amazon or Apple Books (default on)
|
||||||
|
gateStoreLinks?: boolean
|
||||||
// ── Cookie banner ──
|
// ── Cookie banner ──
|
||||||
cookieBannerText: string
|
cookieBannerText: string
|
||||||
// ── Arrays ──
|
// ── Arrays ──
|
||||||
|
|||||||
Reference in new issue
Block a user