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
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent } from '../content'
|
||||
import { StoreLink } from './StoreLink'
|
||||
|
||||
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) {
|
||||
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 books = links.filter(l => l.placement === 'books')
|
||||
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} />}
|
||||
<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">
|
||||
<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')}
|
||||
</a>
|
||||
</StoreLink>
|
||||
)}
|
||||
{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'}
|
||||
</a>
|
||||
</StoreLink>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user