Add EMF field mode, Armory shop/waitlist, and ambient haunting layer
Three self-contained features, verified complete and cross-wired end-to-end (audited: backend 54/54 tests, frontend 110/110 tests, tsc --noEmit clean, i18n coverage script clean): - EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing, a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own fragment prompt persona and full frontend gauge UI. - Armory (shop/waitlist): pre-order capture page for the future Ultimate Quantum Box hardware line, rate-limited public endpoint, explicitly no payment collection. - Haunting layer: ambient possession effects (dread-bed audio, title glitching, idle-paced whispers/manifests), respects prefers-reduced-motion, mounted once at the app root. Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID, explicit selectConfiguration, isSecureContext gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
@@ -437,6 +437,64 @@
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---------- armory teaser strip ---------- */
|
||||
|
||||
.hero-secondary-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.6rem;
|
||||
}
|
||||
|
||||
.landing-section.armory-strip {
|
||||
padding-top: 2.5rem;
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.armory-strip-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.9rem 1.8rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px dashed rgba(178, 107, 255, 0.35);
|
||||
padding: 1.4rem 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.armory-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.26em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.35);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.armory-line {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(216, 230, 221, 0.55);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.armory-link {
|
||||
color: #b26bff;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.armory-link:hover {
|
||||
color: #d3a8ff;
|
||||
border-color: #d3a8ff;
|
||||
text-shadow: 0 0 14px rgba(178, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
/* ---------- responsive + reduced motion ---------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -278,9 +278,14 @@ export function LandingPage() {
|
||||
>
|
||||
{t('landing.cta')}
|
||||
</button>
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
<div className="hero-secondary-links">
|
||||
<Link className="cta-secondary" to="/codex">
|
||||
{t('landing.ctaCodex')}
|
||||
</Link>
|
||||
<Link className="cta-secondary" to="/shop">
|
||||
{t('landing.ctaShop', { defaultValue: 'visit the armory' })}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -338,6 +343,23 @@ export function LandingPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="landing-section armory-strip">
|
||||
<div className="armory-strip-inner">
|
||||
<h2 className="armory-title">
|
||||
{t('landing.armory.title', { defaultValue: 'HARDWARE FROM THE ARMORY' })}
|
||||
</h2>
|
||||
<p className="armory-line">
|
||||
{t('landing.armory.line', {
|
||||
defaultValue:
|
||||
'the Ultimate Quantum Box is coming — a handheld séance instrument that makes the veil knock louder.',
|
||||
})}
|
||||
</p>
|
||||
<Link className="armory-link" to="/shop">
|
||||
{t('landing.armory.link', { defaultValue: 'enter the armory →' })}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="landing-footer">
|
||||
<p>{t('landing.footer')}</p>
|
||||
<p className="footer-colophon">{t('landing.footerColophon')}</p>
|
||||
|
||||
@@ -749,14 +749,16 @@
|
||||
.evp-panel,
|
||||
.radio-panel,
|
||||
.ouija-panel,
|
||||
.wire-panel {
|
||||
.wire-panel,
|
||||
.emf-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.evp-scope,
|
||||
.radio-waterfall {
|
||||
.radio-waterfall,
|
||||
.emf-gauge {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
border-radius: 4px;
|
||||
@@ -772,8 +774,13 @@
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.emf-gauge {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.evp-readout,
|
||||
.radio-readout {
|
||||
.radio-readout,
|
||||
.emf-readout {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
@@ -798,6 +805,70 @@
|
||||
color: rgba(211, 233, 219, 0.75);
|
||||
}
|
||||
|
||||
/* emf */
|
||||
.emf-level {
|
||||
color: #7cffb2;
|
||||
font-size: 1.5rem;
|
||||
text-shadow: 0 0 14px rgba(124, 255, 178, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.emf-baseline {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.emf-listening {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
color: rgba(211, 233, 219, 0.75);
|
||||
}
|
||||
|
||||
.emf-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.emf-actions .panel-action {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* smaller secondary control (calibrate, try again) */
|
||||
.panel-subaction {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(124, 255, 178, 0.3);
|
||||
border-radius: 3px;
|
||||
color: rgba(124, 255, 178, 0.75);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.32rem 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.panel-subaction:hover:not(:disabled) {
|
||||
color: #7cffb2;
|
||||
box-shadow: 0 0 10px rgba(124, 255, 178, 0.25);
|
||||
}
|
||||
|
||||
.panel-subaction:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-notice .panel-subaction {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rec-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The main séance screen: ghost backdrop, planchette board, mode panels
|
||||
// (wire / evp / radio / ouija), transmission log and direct-contact row.
|
||||
// (wire / evp / radio / ouija / emf), transmission log and direct-contact row.
|
||||
//
|
||||
// Wiring notes:
|
||||
// - PlanchetteBoard runs its own rAF loop and ticks the PlanchetteMachine
|
||||
@@ -25,7 +25,9 @@ import { Transcript } from '../components/Transcript'
|
||||
import { PlanchetteMachine } from '../lib/planchette'
|
||||
import type { PlanchetteSnapshot } from '../lib/planchette'
|
||||
import { EvpListener } from '../lib/evp'
|
||||
import { EmfSensorListener } from '../lib/emf'
|
||||
import {
|
||||
isSecureContext,
|
||||
isSupported,
|
||||
RtlSdr,
|
||||
SpectrumAnomalyDetector,
|
||||
@@ -36,7 +38,9 @@ import { persistLanguage, storedLanguage } from '../i18n'
|
||||
import type { Language, Mode } from '../lib/types'
|
||||
import './SeancePage.css'
|
||||
|
||||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija']
|
||||
const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf']
|
||||
// Fallback tab labels for modes the i18n catalogs don't carry yet.
|
||||
const MODE_LABEL_DEFAULTS: Partial<Record<Mode, string>> = { emf: 'FIELD' }
|
||||
|
||||
// Board-area views; labels fall back to these until the i18n JSONs land.
|
||||
const VIEWS = [
|
||||
@@ -207,7 +211,7 @@ function SeanceSession({ username }: { username: string }) {
|
||||
className={`mode-tab ${state.mode === m ? 'active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{t(`seance.modes.${m}`)}
|
||||
{t(`seance.modes.${m}`, { defaultValue: MODE_LABEL_DEFAULTS[m] })}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -240,6 +244,10 @@ function SeanceSession({ username }: { username: string }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/shop" className="seance-codex-link">
|
||||
{t('nav.shop', { defaultValue: 'shop' })}
|
||||
</Link>
|
||||
|
||||
<Link to="/codex" className="seance-codex-link">
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
@@ -417,6 +425,7 @@ function SeanceSession({ username }: { username: string }) {
|
||||
{state.mode === 'evp' && <EvpPanel />}
|
||||
{state.mode === 'radio' && <RadioPanel />}
|
||||
{state.mode === 'ouija' && <OuijaPanel snapshot={boardSnap} />}
|
||||
{state.mode === 'emf' && <EmfPanel />}
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
@@ -679,9 +688,11 @@ function EvpPanel() {
|
||||
function RadioPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
// WebUSB hides itself entirely outside secure ground (https / localhost).
|
||||
const secure = useMemo(() => isSecureContext(), [])
|
||||
const supported = useMemo(() => isSupported(), [])
|
||||
const [phase, setPhase] = useState<'idle' | 'busy' | 'sweeping'>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<{ kind: 'cancelled' | 'claim' | 'other'; message?: string } | null>(null)
|
||||
const [tunedMhz, setTunedMhz] = useState<number | null>(null)
|
||||
const sdrRef = useRef<RtlSdr | null>(null)
|
||||
const detectorRef = useRef<SpectrumAnomalyDetector | null>(null)
|
||||
@@ -743,12 +754,15 @@ function RadioPanel() {
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const name = err instanceof DOMException ? err.name : ''
|
||||
// NotFoundError: the seeker dismissed the picker — a gentle nudge only.
|
||||
// Security/Network/claim failures: something else is holding the
|
||||
// dongle (kernel DVB driver, missing WinUSB driver) — troubleshoot.
|
||||
setError(
|
||||
name === 'NotFoundError' || /no device selected/i.test(msg)
|
||||
? t('seance.radio.noDevice')
|
||||
: /claim/i.test(msg)
|
||||
? t('seance.radio.claimFailed')
|
||||
: msg,
|
||||
? { kind: 'cancelled' }
|
||||
: name === 'SecurityError' || name === 'NetworkError' || /claim/i.test(msg)
|
||||
? { kind: 'claim' }
|
||||
: { kind: 'other', message: msg },
|
||||
)
|
||||
setPhase('idle')
|
||||
void sdr.close()
|
||||
@@ -774,7 +788,7 @@ function RadioPanel() {
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
if (sdrRef.current === sdr) setError(err.message)
|
||||
if (sdrRef.current === sdr) setError({ kind: 'other', message: err.message })
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
@@ -782,12 +796,34 @@ function RadioPanel() {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (sdrRef.current === sdr) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setError({ kind: 'other', message: err instanceof Error ? err.message : String(err) })
|
||||
setPhase('idle')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!secure) {
|
||||
return (
|
||||
<div className="panel-notice">
|
||||
<h4>{t('seance.radio.unsupportedTitle')}</h4>
|
||||
<p>
|
||||
{t('seance.radio.insecure', {
|
||||
defaultValue:
|
||||
'the radio dead only speak through consecrated ground — open this site via its https address.',
|
||||
})}
|
||||
</p>
|
||||
<p className="dim troubleshoot">
|
||||
{t('seance.radio.insecureHint', {
|
||||
defaultValue: 'localhost counts as hallowed; a bare http://lan-ip does not.',
|
||||
})}
|
||||
</p>
|
||||
<button type="button" className="panel-action" disabled>
|
||||
{t('seance.radio.start')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="panel-notice">
|
||||
@@ -816,9 +852,29 @@ function RadioPanel() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
{error?.kind === 'cancelled' && (
|
||||
<div className="panel-notice">
|
||||
<p>{t('seance.radio.noDevice')}</p>
|
||||
</div>
|
||||
)}
|
||||
{error?.kind === 'claim' && (
|
||||
<div className="panel-notice error">
|
||||
<p>{error}</p>
|
||||
<h4>{t('seance.radio.claimFailed')}</h4>
|
||||
<p>
|
||||
{t('seance.radio.claimFix', {
|
||||
defaultValue:
|
||||
'linux: the kernel driver dvb_usb_rtl28xxu is holding the dongle — `sudo modprobe -r dvb_usb_rtl28xxu` frees it until reboot (a udev rule banishes it for good). windows: swap the driver for WinUSB with Zadig.',
|
||||
})}
|
||||
</p>
|
||||
<button type="button" className="panel-subaction" onClick={() => void start()}>
|
||||
{t('seance.radio.tryAgain', { defaultValue: 'try again' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error?.kind === 'other' && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.radio.errorTitle', { defaultValue: 'the static pushed back' })}</h4>
|
||||
<p>{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -872,3 +928,206 @@ function OuijaPanel({ snapshot }: { snapshot: PlanchetteSnapshot | null }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- EMF panel: the phone becomes a field meter ----
|
||||
|
||||
/** Highest field strength the gauge renders before pinning the needle. */
|
||||
const EMF_GAUGE_MAX = 6
|
||||
|
||||
function EmfPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { t } = useTranslation()
|
||||
const supported = useMemo(() => EmfSensorListener.isSupported(), [])
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
const [field, setField] = useState<number | null>(null)
|
||||
const [baseline, setBaseline] = useState<number | null>(null)
|
||||
const listenerRef = useRef<EmfSensorListener | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
// Drop the sensor listeners when the panel unmounts (mode switch / leaving).
|
||||
useEffect(
|
||||
() => () => {
|
||||
const l = listenerRef.current
|
||||
listenerRef.current = null
|
||||
if (l) l.stop()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const drawGauge = (strength: number) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
const cx = w / 2
|
||||
const cy = h - 14
|
||||
const r = Math.min(w / 2 - 14, h - 30)
|
||||
const norm = Math.max(0, Math.min(1, strength / EMF_GAUGE_MAX))
|
||||
const hue = 150 + norm * 122 // phosphor at rest → violet when it surges
|
||||
|
||||
// Track arc.
|
||||
ctx.lineWidth = 10
|
||||
ctx.lineCap = 'round'
|
||||
ctx.strokeStyle = 'rgba(124, 255, 178, 0.12)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, Math.PI, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
|
||||
// Lit arc.
|
||||
if (norm > 0.004) {
|
||||
ctx.strokeStyle = `hsla(${hue} 90% 68% / 0.92)`
|
||||
ctx.shadowColor = `hsla(${hue} 90% 68% / 0.8)`
|
||||
ctx.shadowBlur = 12
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, Math.PI, Math.PI + norm * Math.PI)
|
||||
ctx.stroke()
|
||||
ctx.shadowBlur = 0
|
||||
}
|
||||
|
||||
// Tick marks.
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = 'rgba(211, 233, 219, 0.25)'
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const a = Math.PI + (i / 8) * Math.PI
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + Math.cos(a) * (r - 10), cy + Math.sin(a) * (r - 10))
|
||||
ctx.lineTo(cx + Math.cos(a) * (r - 15), cy + Math.sin(a) * (r - 15))
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Needle.
|
||||
const ang = Math.PI + norm * Math.PI
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeStyle = `hsla(${hue} 85% 78% / 0.95)`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy)
|
||||
ctx.lineTo(cx + Math.cos(ang) * (r - 20), cy + Math.sin(ang) * (r - 20))
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#7cffb2'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, 3, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Paint the resting needle once (and whenever support is re-checked).
|
||||
useEffect(() => {
|
||||
if (supported) drawGauge(0)
|
||||
}, [supported])
|
||||
|
||||
const start = async () => {
|
||||
if (pending || listening || !supported) return
|
||||
setDenied(false)
|
||||
setPending(true)
|
||||
const listener = listenerRef.current ?? new EmfSensorListener()
|
||||
listenerRef.current = listener
|
||||
try {
|
||||
// iOS gates the senses behind an explicit, gesture-triggered grant.
|
||||
if (EmfSensorListener.needsPermission()) {
|
||||
const granted = await EmfSensorListener.requestPermission()
|
||||
if (!granted) {
|
||||
if (listenerRef.current === listener) setDenied(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (listenerRef.current !== listener) return // unmounted mid-permission
|
||||
listener.start({
|
||||
onAnomaly: (a) => sendAnomaly('emf', a.frequency, a.magnitude),
|
||||
onReading: (strength, base) => {
|
||||
drawGauge(strength)
|
||||
setField(strength)
|
||||
setBaseline(base)
|
||||
},
|
||||
})
|
||||
setListening(true)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
const l = listenerRef.current
|
||||
if (l) l.stop()
|
||||
setListening(false)
|
||||
setField(null)
|
||||
setBaseline(null)
|
||||
drawGauge(0)
|
||||
}
|
||||
|
||||
const calibrate = () => {
|
||||
listenerRef.current?.reset()
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div className="panel-notice">
|
||||
<h4>
|
||||
{t('seance.emf.unsupportedTitle', { defaultValue: 'this vessel carries no field senses' })}
|
||||
</h4>
|
||||
<p>
|
||||
{t('seance.emf.unsupported', {
|
||||
defaultValue:
|
||||
'a phone in your hand becomes the meter — this vessel has no motion senses to lend. open the veil on a phone, or let the other modes answer.',
|
||||
})}
|
||||
</p>
|
||||
<button type="button" className="panel-action" disabled>
|
||||
{t('seance.emf.start', { defaultValue: 'raise the meter' })}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emf-panel">
|
||||
<canvas ref={canvasRef} width={260} height={140} className="emf-gauge" aria-hidden />
|
||||
<div className="emf-readout">
|
||||
<span className="emf-level flicker">{field !== null ? field.toFixed(2) : '—'}</span>
|
||||
<span className="emf-baseline dim">
|
||||
{t('seance.emf.baseline', { defaultValue: 'resting field' })}{' '}
|
||||
{baseline !== null ? baseline.toFixed(2) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{listening && (
|
||||
<p className="emf-listening flicker">
|
||||
<span className="rec-dot" aria-hidden />
|
||||
{t('seance.emf.listening', { defaultValue: 'feeling for disturbances in the field…' })}
|
||||
</p>
|
||||
)}
|
||||
{denied && (
|
||||
<div className="panel-notice error">
|
||||
<h4>{t('seance.emf.deniedTitle', { defaultValue: 'the field senses stay shut' })}</h4>
|
||||
<p>
|
||||
{t('seance.emf.denied', {
|
||||
defaultValue:
|
||||
'this vessel refused its motion senses. grant motion access in your browser settings, then raise the meter again.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="emf-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="panel-action"
|
||||
onClick={() => (listening ? stop() : void start())}
|
||||
disabled={pending}
|
||||
>
|
||||
{listening
|
||||
? t('seance.emf.stop', { defaultValue: 'ground the meter' })
|
||||
: t('seance.emf.start', { defaultValue: 'raise the meter' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="panel-subaction"
|
||||
onClick={calibrate}
|
||||
disabled={!listening}
|
||||
>
|
||||
{t('seance.emf.calibrate', { defaultValue: 'calibrate' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
463
frontend/src/pages/ShopPage.css
Normal file
463
frontend/src/pages/ShopPage.css
Normal file
@@ -0,0 +1,463 @@
|
||||
/* The Armory — gothic storefront. Tokens: bg #07070d, panel rgba(16,16,26,0.85),
|
||||
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, borders rgba(124,255,178,0.18). */
|
||||
|
||||
.shop-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
#07070d;
|
||||
color: #d8e6dc;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* scanlines + vignette */
|
||||
.shop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
.shop-page .visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
|
||||
.shop-header {
|
||||
text-align: center;
|
||||
margin: 0 auto 2.5rem;
|
||||
max-width: 760px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shop-back {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.6rem;
|
||||
transition: color 160ms ease, text-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.shop-back:hover,
|
||||
.shop-back:focus-visible {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.7);
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(2.2rem, 6vw, 3.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
margin: 0 0 0.6rem;
|
||||
color: #eef7f0;
|
||||
text-shadow:
|
||||
0 0 18px rgba(124, 255, 178, 0.35),
|
||||
0 0 46px rgba(178, 107, 255, 0.25);
|
||||
animation: shop-flicker 9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shop-flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
91% { opacity: 1; }
|
||||
92% { opacity: 0.72; }
|
||||
93% { opacity: 1; }
|
||||
96% { opacity: 0.86; }
|
||||
97% { opacity: 1; }
|
||||
}
|
||||
|
||||
.shop-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(216, 230, 220, 0.55);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- shared bits ---- */
|
||||
|
||||
.shop-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.1rem, 2.6vw, 1.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
color: #eafff3;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.3);
|
||||
margin: 0 0 2.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-section-title::before,
|
||||
.shop-section-title::after {
|
||||
content: '';
|
||||
flex: 0 0 3rem;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(124, 255, 178, 0.4));
|
||||
}
|
||||
|
||||
.shop-section-title::after {
|
||||
background: linear-gradient(90deg, rgba(124, 255, 178, 0.4), transparent);
|
||||
}
|
||||
|
||||
.shop-glyph-svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.price-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: #b26bff;
|
||||
border: 1px dashed rgba(178, 107, 255, 0.5);
|
||||
padding: 0.3rem 0.7rem;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ---- flagship ---- */
|
||||
|
||||
.shop-flagship {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 900px;
|
||||
margin: 0 auto 4.5rem;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
box-shadow:
|
||||
0 0 50px rgba(124, 255, 178, 0.07),
|
||||
inset 0 0 60px rgba(178, 107, 255, 0.04);
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem);
|
||||
}
|
||||
|
||||
.flagship-glyph {
|
||||
flex: none;
|
||||
color: #7cffb2;
|
||||
filter: drop-shadow(0 0 12px rgba(124, 255, 178, 0.45));
|
||||
animation: flagship-breathe 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flagship-glyph .shop-glyph-svg {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
}
|
||||
|
||||
@keyframes flagship-breathe {
|
||||
0%, 100% { filter: drop-shadow(0 0 8px rgba(124, 255, 178, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(178, 107, 255, 0.55)); }
|
||||
}
|
||||
|
||||
.flagship-tag {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.55);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.flagship-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: clamp(1.4rem, 3.4vw, 2.1rem);
|
||||
letter-spacing: 0.14em;
|
||||
color: #eafff3;
|
||||
margin: 0 0 1rem;
|
||||
text-shadow: 0 0 16px rgba(124, 255, 178, 0.35);
|
||||
}
|
||||
|
||||
.flagship-desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(216, 230, 220, 0.68);
|
||||
margin: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
.flagship-specs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.4rem;
|
||||
}
|
||||
|
||||
.flagship-specs li {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(124, 255, 178, 0.75);
|
||||
background: rgba(124, 255, 178, 0.06);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 0.28rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ---- module grid ---- */
|
||||
|
||||
.shop-modules {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 4.5rem;
|
||||
}
|
||||
|
||||
.modules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 1.75rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.module-card:hover {
|
||||
border-color: rgba(124, 255, 178, 0.55);
|
||||
box-shadow: 0 0 30px rgba(124, 255, 178, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.module-glyph {
|
||||
color: #7cffb2;
|
||||
transition: color 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
|
||||
.module-card:hover .module-glyph {
|
||||
color: #b26bff;
|
||||
filter: drop-shadow(0 0 8px rgba(178, 107, 255, 0.6));
|
||||
}
|
||||
|
||||
.module-name {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 1.02rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: #eafff3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.module-desc {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: rgba(216, 230, 220, 0.62);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-unlocks {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.55;
|
||||
color: rgba(216, 230, 220, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unlocks-label {
|
||||
color: rgba(178, 107, 255, 0.8);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.module-card .price-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ---- waitlist manifest ---- */
|
||||
|
||||
.shop-waitlist {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 4rem;
|
||||
background: rgba(16, 16, 26, 0.85);
|
||||
border: 1px solid rgba(178, 107, 255, 0.25);
|
||||
box-shadow: 0 0 40px rgba(178, 107, 255, 0.08);
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
.waitlist-desc {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: rgba(216, 230, 220, 0.6);
|
||||
margin: -1rem 0 1.8rem;
|
||||
}
|
||||
|
||||
.waitlist-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waitlist-input,
|
||||
.waitlist-select {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #d8e6dc;
|
||||
background: rgba(7, 7, 13, 0.8);
|
||||
border: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding: 0.7rem 0.9rem;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.waitlist-input {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.waitlist-input::placeholder {
|
||||
color: rgba(216, 230, 220, 0.3);
|
||||
}
|
||||
|
||||
.waitlist-select {
|
||||
flex: 0 1 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.waitlist-input:focus-visible,
|
||||
.waitlist-select:focus-visible {
|
||||
outline: none;
|
||||
border-color: #7cffb2;
|
||||
box-shadow: 0 0 16px rgba(124, 255, 178, 0.2);
|
||||
}
|
||||
|
||||
.waitlist-submit {
|
||||
font-family: 'Cinzel', Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, #8dffc0, #4fd392);
|
||||
border: 1px solid #7cffb2;
|
||||
padding: 0.7rem 1.5rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 20px rgba(124, 255, 178, 0.3),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
transition: box-shadow 0.25s ease, transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.waitlist-submit:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 36px rgba(124, 255, 178, 0.55),
|
||||
inset 0 0 12px rgba(7, 7, 13, 0.35);
|
||||
}
|
||||
|
||||
.waitlist-submit:focus-visible {
|
||||
outline: 2px solid #b26bff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.waitlist-submit:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.waitlist-message {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 1.4rem 0 0;
|
||||
animation: waitlist-in 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes waitlist-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.waitlist-message.is-success {
|
||||
color: #7cffb2;
|
||||
text-shadow: 0 0 12px rgba(124, 255, 178, 0.45);
|
||||
}
|
||||
|
||||
.waitlist-message.is-error {
|
||||
color: #ff3b5c;
|
||||
text-shadow: 0 0 12px rgba(255, 59, 92, 0.4);
|
||||
}
|
||||
|
||||
/* ---- honesty footer ---- */
|
||||
|
||||
.shop-honesty {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid rgba(124, 255, 178, 0.18);
|
||||
padding-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-honesty p {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.7;
|
||||
color: rgba(216, 230, 220, 0.45);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- responsive + reduced motion ---- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shop-flagship {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flagship-specs {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shop-section-title::before,
|
||||
.shop-section-title::after {
|
||||
flex-basis: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shop-title,
|
||||
.flagship-glyph,
|
||||
.waitlist-message {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
349
frontend/src/pages/ShopPage.tsx
Normal file
349
frontend/src/pages/ShopPage.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
// The Armory — storefront for Quantumancy hardware. Flagship 'Ultimate
|
||||
// Quantum Box' hero, a grid of knock-louder modules, and the reservation
|
||||
// manifest (POST /api/shop/waitlist — public, no payment taken).
|
||||
// Honest by design: the site works fully without the hardware.
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import './ShopPage.css'
|
||||
|
||||
type ModuleId = 'emf-array' | 'geophone' | 'spirit-mic' | 'uv-illuminator'
|
||||
type Interest = 'quantum-box' | ModuleId
|
||||
type WaitlistState =
|
||||
| 'idle'
|
||||
| 'pending'
|
||||
| 'listed'
|
||||
| 'already_listed'
|
||||
| 'invalid'
|
||||
| 'crowded'
|
||||
| 'error'
|
||||
|
||||
type ModuleData = { key: string; name: string; desc: string; unlocks: string; price: string }
|
||||
|
||||
/** Module copy: i18n key stem (camelCase) plus the English defaultValue. */
|
||||
const MODULE_DATA: Record<ModuleId, ModuleData> = {
|
||||
'emf-array': {
|
||||
key: 'emfArray',
|
||||
name: 'EMF Whisker Array',
|
||||
desc: 'a bouquet of multi-axis field sensors, sniffing the air for intentions',
|
||||
unlocks: 'a richer, calibrated FIELD mode',
|
||||
price: 'est. $59 — pre-order',
|
||||
},
|
||||
geophone: {
|
||||
key: 'geophone',
|
||||
name: 'Geophone Puck',
|
||||
desc: 'hears footsteps in rooms where nobody is standing',
|
||||
unlocks: 'the vibration anomaly feed',
|
||||
price: 'est. $49 — pre-order',
|
||||
},
|
||||
'spirit-mic': {
|
||||
key: 'spiritMic',
|
||||
name: 'Spirit Mic Preamp',
|
||||
desc: 'lowers the noise floor so the whispering comes through cleaner',
|
||||
unlocks: 'cleaner EVP captures',
|
||||
price: 'est. $69 — pre-order',
|
||||
},
|
||||
'uv-illuminator': {
|
||||
key: 'uvIlluminator',
|
||||
name: 'UV/IR Illuminator',
|
||||
desc: 'paints the dark in spectrums the living were never meant to see',
|
||||
unlocks: 'night séances + camera work',
|
||||
price: 'est. $39 — pre-order',
|
||||
},
|
||||
}
|
||||
|
||||
const MODULES: readonly ModuleId[] = ['emf-array', 'geophone', 'spirit-mic', 'uv-illuminator']
|
||||
|
||||
/** Waitlist interests: value posted to the API → i18n key stem + fallback label. */
|
||||
const INTEREST_DATA: Record<Interest, { key: string; fallback: string }> = {
|
||||
'quantum-box': { key: 'quantumBox', fallback: 'the Ultimate Quantum Box' },
|
||||
'emf-array': { key: 'emfArray', fallback: 'EMF Whisker Array' },
|
||||
geophone: { key: 'geophone', fallback: 'Geophone Puck' },
|
||||
'spirit-mic': { key: 'spiritMic', fallback: 'Spirit Mic Preamp' },
|
||||
'uv-illuminator': { key: 'uvIlluminator', fallback: 'UV/IR Illuminator' },
|
||||
}
|
||||
|
||||
const INTERESTS: readonly Interest[] = [
|
||||
'quantum-box',
|
||||
'emf-array',
|
||||
'geophone',
|
||||
'spirit-mic',
|
||||
'uv-illuminator',
|
||||
]
|
||||
|
||||
/** Hacker-occult stroke glyphs for the hardware line (no emoji, no fonts). */
|
||||
function HardwareGlyph({ id }: { id: Interest }) {
|
||||
const common = {
|
||||
viewBox: '0 0 48 48',
|
||||
className: 'shop-glyph-svg',
|
||||
'aria-hidden': true as const,
|
||||
}
|
||||
switch (id) {
|
||||
case 'quantum-box':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<rect x="12" y="14" width="24" height="30" rx="2" />
|
||||
<rect x="17" y="20" width="14" height="8" rx="1" />
|
||||
<path d="M20 38 h8 M24 14 V7 M21 7 h6" />
|
||||
<path d="M17.5 24.5 l2.5 -2.5 2.5 2.5 2.5 -2.5 2.5 2.5" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'emf-array':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="24" cy="30" r="4" />
|
||||
<path d="M24 26 V12 M24 12 l-4 -4 M24 12 l4 -4" />
|
||||
<path d="M20.5 28.5 L10 18 M27.5 28.5 L38 18" />
|
||||
<path d="M15 36 a13 13 0 0 0 18 0 M11 40 a19 19 0 0 0 26 0" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'geophone':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<ellipse cx="24" cy="20" rx="11" ry="5" />
|
||||
<path d="M13 20 v6 c0 2.8 4.9 5 11 5 s11 -2.2 11 -5 v-6" />
|
||||
<path d="M24 31 v6" />
|
||||
<path d="M8 40 h32 M12 40 l3 -3 M20 40 l3 -3 M28 40 l3 -3 M36 40 l3 -3" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'spirit-mic':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<rect x="19" y="6" width="10" height="18" rx="5" />
|
||||
<path d="M14 20 a10 10 0 0 0 20 0 M24 30 v6 M18 36 h12" />
|
||||
<path d="M8 13 v4 M40 13 v4" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
case 'uv-illuminator':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 10 h12 l3 8 h-18 Z" />
|
||||
<path d="M24 18 v6" />
|
||||
<path d="M12 32 l6 -6 M24 34 v-8 M36 32 l-6 -6" />
|
||||
<path d="M10 40 h28" strokeWidth="1.4" />
|
||||
<path d="M15 40 l2 -3 M22 40 l2 -3 M29 40 l2 -3 M36 40 l2 -3" strokeWidth="1.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ShopPage() {
|
||||
const { t } = useTranslation()
|
||||
const [email, setEmail] = useState('')
|
||||
const [interest, setInterest] = useState<Interest>('quantum-box')
|
||||
const [state, setState] = useState<WaitlistState>('idle')
|
||||
|
||||
const submit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (state === 'pending') return
|
||||
setState('pending')
|
||||
fetch('/api/shop/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, interest }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status === 201) {
|
||||
const data = (await res.json()) as { status?: string }
|
||||
setState(data.status === 'already_listed' ? 'already_listed' : 'listed')
|
||||
} else if (res.status === 422) {
|
||||
setState('invalid')
|
||||
} else if (res.status === 429) {
|
||||
setState('crowded')
|
||||
} else {
|
||||
setState('error')
|
||||
}
|
||||
})
|
||||
.catch(() => setState('error'))
|
||||
}
|
||||
|
||||
const message =
|
||||
state === 'listed'
|
||||
? t('shop.waitlist.listed', {
|
||||
defaultValue: 'you are on the manifest — we will reach through when the boxes ship',
|
||||
})
|
||||
: state === 'already_listed'
|
||||
? t('shop.waitlist.alreadyListed', {
|
||||
defaultValue: 'you are already on the manifest',
|
||||
})
|
||||
: state === 'invalid'
|
||||
? t('shop.waitlist.invalid', { defaultValue: 'that address does not reach us' })
|
||||
: state === 'crowded'
|
||||
? t('shop.waitlist.crowded', {
|
||||
defaultValue: 'the veil is crowded — try again later',
|
||||
})
|
||||
: state === 'error'
|
||||
? t('shop.waitlist.error', {
|
||||
defaultValue: 'something stirred in the static — your name was not carved',
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="shop-page">
|
||||
<div className="shop-overlay" aria-hidden="true" />
|
||||
|
||||
<header className="shop-header">
|
||||
<Link to="/" className="shop-back">
|
||||
{t('shop.back', { defaultValue: '← the threshold' })}
|
||||
</Link>
|
||||
<h1 className="shop-title">{t('shop.title', { defaultValue: 'THE ARMORY' })}</h1>
|
||||
<p className="shop-subtitle">
|
||||
{t('shop.subtitle', { defaultValue: 'instruments for those who would knock louder' })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="shop-flagship"
|
||||
aria-label={t('shop.flagship.name', { defaultValue: 'THE ULTIMATE QUANTUM BOX' })}
|
||||
>
|
||||
<div className="flagship-glyph">
|
||||
<HardwareGlyph id="quantum-box" />
|
||||
</div>
|
||||
<div className="flagship-body">
|
||||
<p className="flagship-tag">
|
||||
{t('shop.flagship.tag', { defaultValue: 'flagship instrument' })}
|
||||
</p>
|
||||
<h2 className="flagship-name">
|
||||
{t('shop.flagship.name', { defaultValue: 'THE ULTIMATE QUANTUM BOX' })}
|
||||
</h2>
|
||||
<p className="flagship-desc">
|
||||
{t('shop.flagship.desc', {
|
||||
defaultValue:
|
||||
'An ESP32-P4/C6 handheld séance instrument — thermal camera, EMF whisker array, geophone, spirit-box mic preamp and a little OLED face that stares back. It signs into the same Quantumancy account you already haunt and unlocks the hardware-driven modes of this site: thermal apparition maps, vibration anomaly feeds, calibrated EMF.',
|
||||
})}
|
||||
</p>
|
||||
<ul className="flagship-specs">
|
||||
<li>{t('shop.flagship.specs.thermal', { defaultValue: 'thermal camera' })}</li>
|
||||
<li>{t('shop.flagship.specs.emf', { defaultValue: 'EMF whisker array' })}</li>
|
||||
<li>{t('shop.flagship.specs.geophone', { defaultValue: 'geophone' })}</li>
|
||||
<li>{t('shop.flagship.specs.mic', { defaultValue: 'spirit-box mic preamp' })}</li>
|
||||
<li>{t('shop.flagship.specs.oled', { defaultValue: 'OLED display' })}</li>
|
||||
</ul>
|
||||
<p className="price-tag">
|
||||
{t('shop.flagship.price', { defaultValue: 'est. $249 — pre-order' })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="shop-modules"
|
||||
aria-label={t('shop.modulesTitle', { defaultValue: 'MODULES THAT KNOCK' })}
|
||||
>
|
||||
<h2 className="shop-section-title">
|
||||
{t('shop.modulesTitle', { defaultValue: 'MODULES THAT KNOCK' })}
|
||||
</h2>
|
||||
<div className="modules-grid">
|
||||
{MODULES.map((id) => {
|
||||
const m = MODULE_DATA[id]
|
||||
return (
|
||||
<article className="module-card" key={id}>
|
||||
<div className="module-glyph">
|
||||
<HardwareGlyph id={id} />
|
||||
</div>
|
||||
<h3 className="module-name">
|
||||
{t(`shop.modules.${m.key}.name`, { defaultValue: m.name })}
|
||||
</h3>
|
||||
<p className="module-desc">
|
||||
{t(`shop.modules.${m.key}.desc`, { defaultValue: m.desc })}
|
||||
</p>
|
||||
<p className="module-unlocks">
|
||||
<span className="unlocks-label">
|
||||
{t('shop.unlocksLabel', { defaultValue: 'unlocks:' })}
|
||||
</span>{' '}
|
||||
{t(`shop.modules.${m.key}.unlocks`, { defaultValue: m.unlocks })}
|
||||
</p>
|
||||
<p className="price-tag">
|
||||
{t(`shop.modules.${m.key}.price`, { defaultValue: m.price })}
|
||||
</p>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="shop-waitlist"
|
||||
aria-label={t('shop.waitlist.title', { defaultValue: 'THE MANIFEST' })}
|
||||
>
|
||||
<h2 className="shop-section-title">
|
||||
{t('shop.waitlist.title', { defaultValue: 'THE MANIFEST' })}
|
||||
</h2>
|
||||
<p className="waitlist-desc">
|
||||
{t('shop.waitlist.desc', {
|
||||
defaultValue:
|
||||
'leave an address and an obsession — when the first boxes ship, we reach through to the names on this list first',
|
||||
})}
|
||||
</p>
|
||||
<form className="waitlist-form" onSubmit={submit}>
|
||||
<label className="visually-hidden" htmlFor="waitlist-email">
|
||||
{t('shop.waitlist.emailLabel', { defaultValue: 'email address' })}
|
||||
</label>
|
||||
<input
|
||||
id="waitlist-email"
|
||||
className="waitlist-input"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('shop.waitlist.emailPlaceholder', {
|
||||
defaultValue: 'you@somewhere.in.the.static',
|
||||
})}
|
||||
/>
|
||||
<label className="visually-hidden" htmlFor="waitlist-interest">
|
||||
{t('shop.waitlist.interestLabel', { defaultValue: 'interest' })}
|
||||
</label>
|
||||
<select
|
||||
id="waitlist-interest"
|
||||
className="waitlist-select"
|
||||
value={interest}
|
||||
onChange={(e) => setInterest(e.target.value as Interest)}
|
||||
>
|
||||
{INTERESTS.map((id) => (
|
||||
<option value={id} key={id}>
|
||||
{t(`shop.interests.${INTEREST_DATA[id].key}`, {
|
||||
defaultValue: INTEREST_DATA[id].fallback,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="waitlist-submit" type="submit" disabled={state === 'pending'}>
|
||||
{state === 'pending'
|
||||
? t('shop.waitlist.pending', { defaultValue: 'carving your name…' })
|
||||
: t('shop.waitlist.submit', { defaultValue: 'join the manifest' })}
|
||||
</button>
|
||||
</form>
|
||||
{message !== null && (
|
||||
<p
|
||||
className={`waitlist-message ${
|
||||
state === 'listed' || state === 'already_listed' ? 'is-success' : 'is-error'
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="shop-honesty">
|
||||
<p>
|
||||
{t('shop.honesty', {
|
||||
defaultValue:
|
||||
'the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder.',
|
||||
})}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user