// ============================================================================ // SHOP CONTACT INFO (single source of truth for the whole page) // Verified 2026-07-29 against parkercoinshop.com, Yelp, and the BBB profile // for Parker Coin and Bullion, LLC. // ============================================================================ const CONTACT = { name: 'Parker Coin and Bullion', phone: '(303) 578-8288', phoneHref: 'tel:+13035788288', // The shop's email deliberately does NOT appear here or anywhere the browser // can read (harvester bots scrape pages and public JSON for addresses). The // contact form's recipient lives server-side in data/settings.json, which // the web server blocks from direct fetches; edit it via /admin/. street: '11020 S Pikes Peak Dr, Ste 107', cityLine: 'Parker, CO 80138', mapsUrl: 'https://maps.google.com/?q=11020+S+Pikes+Peak+Dr+Ste+107+Parker+CO+80138', hoursLine: 'Mon-Fri · 11am to 4:30pm', closedLine: 'Closed Sat & Sun', }; // Defaults for the owner-editable bits. The live values come from // data/content.json (edited through /admin/) and are merged over these, so a // missing or broken file changes nothing. const DEFAULT_CHIPS = ['American Silver Eagles', 'American Gold Eagles', 'Gold Buffalos', 'Canadian Maple Leafs', 'Silver rounds & bars', '90% U.S. silver coins', 'Morgan & Peace dollars', 'Rare & collectible coins', 'Estate collections']; const DEFAULT_BUY_NOTE = 'Please note: we do not buy jewelry or scrap gold.'; // ============================================================================ // NAV SCROLLING // The browser's native behavior:'smooth' moves at a fixed clip that reads as // an abrupt jump over long distances (hero down to contact). glideTo animates // the scroll itself: ease-in-out, duration scaled by distance, cancelled the // moment the visitor scrolls on their own, instant under reduced motion. // ============================================================================ let glideFrame = 0; const cancelGlide = () => cancelAnimationFrame(glideFrame); function glideTo(top) { cancelGlide(); if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { window.scrollTo(0, top); return; } const start = window.scrollY; const dist = top - start; if (Math.abs(dist) < 2) return; const duration = Math.min(1800, 750 + Math.abs(dist) * 0.4); window.addEventListener('wheel', cancelGlide, { passive: true, once: true }); window.addEventListener('touchstart', cancelGlide, { passive: true, once: true }); let t0 = null; const step = (ts) => { if (t0 === null) t0 = ts; const p = Math.min(1, (ts - t0) / duration); const eased = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; window.scrollTo(0, start + dist * eased); if (p < 1) glideFrame = requestAnimationFrame(step); }; glideFrame = requestAnimationFrame(step); } // ============================================================================ // LIVE SPOT PRICES // Provider: api.gold-api.com (free, no API key, CORS-open, 1 request per // metal). Fallback options if that service ever disappears: goldapi.io // (100 req/day free, key via x-access-token header) or metalpriceapi.com // (100 req/month free, key in the query string). // ============================================================================ const SPOT_CONFIG = { enabled: true, // Refresh cadence. The provider updates its numbers roughly once a minute, // so faster than 60000 buys nothing. One fetch per metal per refresh. pollMs: 60000, symbols: { gold: 'XAU', silver: 'XAG', platinum: 'XPT' }, url: (sym) => `https://api.gold-api.com/price/${sym}`, }; // Variation 1 v2: Refined Classic: fluid + atmospheric purple-blue fades + more photography // Performance-tuned: parallax uses refs + rAF (no React rerenders); reveals use one-shot IO + CSS class. const RefinedClassicV2 = () => { const t = window.PCB_TOKENS; const [hovered, setHovered] = React.useState(null); // spotPrices shape: { gold, silver, platinum, palladium, pctChange: {…} } | null const [spotPrices, setSpotPrices] = React.useState(null); const [spotLive, setSpotLive] = React.useState(false); // true once first successful fetch const [spotMisses, setSpotMisses] = React.useState(0); // consecutive failed polls before first success // Owner-editable content, fetched once and merged over the defaults above. const [siteContent, setSiteContent] = React.useState(null); React.useEffect(() => { fetch('data/content.json') .then(r => (r.ok ? r.json() : null)) .then(json => { if (json && typeof json === 'object') setSiteContent(json); }) .catch(() => {}); }, []); const content = siteContent || {}; const C = { ...CONTACT, hoursLine: content.hoursLine || CONTACT.hoursLine, closedLine: content.closedLine || CONTACT.closedLine, }; const CHIPS = (Array.isArray(content.chips) && content.chips.length) ? content.chips : DEFAULT_CHIPS; const BUY_NOTE = content.buyNote || DEFAULT_BUY_NOTE; const ANNOUNCEMENT = typeof content.announcement === 'string' ? content.announcement.trim() : ''; // Lead attribution: remember how this visitor arrived (a ?src= or utm link, // or an outside referrer), first touch wins for the tab. The value rides // along with the contact form so the shop can see which channel a lead // came from. const leadSource = React.useMemo(() => { try { const q = new URLSearchParams(window.location.search); const fromUrl = (q.get('src') || q.get('utm_source') || '').slice(0, 120); const stored = sessionStorage.getItem('pcb_src'); if (stored) return stored; const ref = document.referrer && document.referrer.indexOf(window.location.hostname) === -1 ? document.referrer : ''; const src = fromUrl || ref || 'direct'; sessionStorage.setItem('pcb_src', src); return src; } catch (e) { return 'direct'; } }, []); // ?item= arrives from a collectibles listing CTA: prefill the interest field // and land the visitor on the contact form. const itemParam = React.useMemo(() => { try { return (new URLSearchParams(window.location.search).get('item') || '').slice(0, 120); } catch (e) { return ''; } }, []); const heroParallaxRef = React.useRef(null); const goldBarsParallaxRef = React.useRef(null); const goldBarsSectionRef = React.useRef(null); // Spot price polling, driven by SPOT_CONFIG above. Each metal is fetched // independently so one failed symbol never blanks the others. React.useEffect(() => { if (!SPOT_CONFIG.enabled) return; const fetchSpot = async () => { const entries = Object.entries(SPOT_CONFIG.symbols); const results = await Promise.allSettled(entries.map(async ([key, sym]) => { const res = await fetch(SPOT_CONFIG.url(sym)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); if (typeof json.price !== 'number') throw new Error('no price field'); return [key, +json.price.toFixed(2)]; })); const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value); if (ok.length === 0) { console.warn('[Parker Coin] Spot price fetch failed for all metals'); // keep any previous data; count misses so the UI can stop saying // "Connecting" when the feed is truly unreachable setSpotMisses(m => m + 1); return; } setSpotPrices(prev => { const next = { gold: null, silver: null, platinum: null, pctChange: {}, ...(prev || {}) }; ok.forEach(([key, price]) => { next[key] = price; }); return next; }); setSpotLive(true); }; fetchSpot(); const id = setInterval(fetchSpot, SPOT_CONFIG.pollMs); return () => clearInterval(id); }, []); // Single rAF-driven scroll handler: writes transforms directly to DOM. Zero rerenders. React.useEffect(() => { let raf = 0; let pending = false; const update = () => { pending = false; const y = window.scrollY; if (heroParallaxRef.current) { heroParallaxRef.current.style.transform = `translate3d(0, ${y * 0.18}px, 0)`; } if (goldBarsParallaxRef.current && goldBarsSectionRef.current) { const rect = goldBarsSectionRef.current.getBoundingClientRect(); const offset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) * 0.12; goldBarsParallaxRef.current.style.transform = `translate3d(0, ${offset}px, 0)`; } }; const onScroll = () => { if (pending) return; pending = true; raf = requestAnimationFrame(update); }; window.addEventListener('scroll', onScroll, { passive: true }); update(); return () => { window.removeEventListener('scroll', onScroll); cancelAnimationFrame(raf); }; }, []); // One-shot reveal: IO toggles a class once, then disconnects. No React state churn. const Reveal = React.useMemo(() => ({ children, delay = 0 }) => { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); if (r.top < window.innerHeight * 0.95) { requestAnimationFrame(() => el.classList.add('is-revealed')); return; } const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { el.classList.add('is-revealed'); io.disconnect(); } }, { threshold: 0.05, rootMargin: '0px 0px -40px 0px' }); io.observe(el); return () => io.disconnect(); }, []); return (
{children}
); }, []); const METAL_META = [ { sym: 'Au', name: 'Gold', key: 'gold' }, { sym: 'Ag', name: 'Silver', key: 'silver' }, { sym: 'Pt', name: 'Platinum', key: 'platinum' }, ]; // Open/closed pill in the nav, computed live for the shop's timezone const shopStatus = (() => { const now = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/Denver' })); const day = now.getDay(); // 0 = Sunday .. 6 = Saturday const mins = now.getHours() * 60 + now.getMinutes(); const open = day >= 1 && day <= 5 && mins >= 11 * 60 && mins < 16 * 60 + 30; return open ? { open: true, label: 'Open now · until 4:30' } : { open: false, label: 'Mon-Fri · 11 to 4:30' }; })(); const NAV_LINKS = [ { label: 'Visit', id: 'section-visit' }, { label: 'What we buy', id: 'section-buy' }, { label: 'Estates', id: 'section-estates' }, { label: 'Sales tax', id: 'section-tax' }, { label: 'Contact', id: 'section-contact' }, ]; // Nav clicks land partway into each section's top padding so the heading // sits comfortably near the top of the viewport instead of a padding void. const scrollTo = (id) => { const el = document.getElementById(id); if (!el) return; const pad = parseFloat(getComputedStyle(el).paddingTop) || 0; const top = el.getBoundingClientRect().top + window.scrollY + pad * 0.45; glideTo(top); }; // Deep links: /#contact (used by the estate page) and ?item= land on the // contact form once the page has rendered. React.useEffect(() => { const hash = (window.location.hash || '').replace('#', ''); const target = hash === 'contact' ? 'section-contact' : hash && document.getElementById('section-' + hash) ? 'section-' + hash : itemParam ? 'section-contact' : null; if (!target) return; const tmr = setTimeout(() => scrollTo(target), 400); return () => clearTimeout(tmr); }, []); const wrap = { width: '100%', maxWidth: 1440, margin: '0 auto', padding: '0 clamp(20px, 5vw, 64px)' }; // Contact form. Sole path: POST to contact.php (ships with the site, mails // the message with photo attachments). On failure we show the phone number // instead of a mailto fallback: a mailto would put the shop's real address // into the page for spam harvesters, which is exactly what keeping it in the // guarded settings.json avoids. const [sent, setSent] = React.useState(null); // null | 'server' | 'error' const [photos, setPhotos] = React.useState([]); // [{ url, name }] const [photoErr, setPhotoErr] = React.useState(null); const handlePhotos = (e) => { photos.forEach(p => URL.revokeObjectURL(p.url)); const files = Array.from(e.target.files || []); const total = files.reduce((sum, f) => sum + f.size, 0); if (files.length > 4 || total > 8 * 1024 * 1024) { e.target.value = ''; setPhotos([]); setPhotoErr('Up to 4 photos and 8MB total, please.'); return; } setPhotoErr(null); setPhotos(files.map(f => ({ url: URL.createObjectURL(f), name: f.name }))); }; const handleFormSubmit = async (e) => { e.preventDefault(); const form = e.currentTarget; const data = new FormData(form); if (data.get('website')) { setSent('server'); return; } // honeypot try { const res = await fetch('contact.php', { method: 'POST', body: data }); if (res.ok) { const out = await res.json().catch(() => ({})); if (out.ok) { form.reset(); photos.forEach(p => URL.revokeObjectURL(p.url)); setPhotos([]); setSent('server'); return; } } } catch (err) { /* server unreachable; fall through to the notice */ } setSent('error'); }; const Aurora = ({ tone = 'violet', opacity = 0.5 }) => { const colors = tone === 'violet' ? ['rgba(82, 60, 140, 0.55)', 'rgba(45, 80, 160, 0.45)', 'rgba(140, 70, 130, 0.30)'] : ['rgba(45, 80, 160, 0.55)', 'rgba(82, 60, 140, 0.40)', 'rgba(212, 166, 74, 0.22)']; return (