Madame Reader - Tarot Card reading

// -------------------- Utilities -------------------- const uid = () => Math.random().toString(36).slice(2); const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi); const toTitle = (s) => s .replace(/\.[^.]+$/, "") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .trim() .replace(/\b\w/g, (m) => m.toUpperCase()); const readAsDataURL = (file) => new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = rej; fr.readAsDataURL(file); }); const todayISO = () => new Date().toISOString().slice(0, 10); // -------------------- Spreads -------------------- const SPREADS = { "One Card": { slots: [{ name: "Answer", x: 50, y: 55, rot: 0 }] }, "Three Card": { slots: [ { name: "Past", x: 35, y: 55, rot: 0 }, { name: "Present", x: 50, y: 55, rot: 0 }, { name: "Future", x: 65, y: 55, rot: 0 }, ], }, "Five Card": { slots: [ { name: "Past", x: 30, y: 60, rot: 0 }, { name: "Present", x: 45, y: 60, rot: 0 }, { name: "Hidden", x: 60, y: 60, rot: 0 }, { name: "Advice", x: 75, y: 60, rot: 0 }, { name: "Outcome", x: 52.5, y: 35, rot: 0 }, ], }, "Celtic Cross (10)": { slots: [ { name: "Present", x: 30, y: 55, rot: 0 }, { name: "Challenge", x: 30, y: 55, rot: 90 }, { name: "Past", x: 15, y: 55, rot: 0 }, { name: "Future", x: 45, y: 55, rot: 0 }, { name: "Above", x: 30, y: 35, rot: 0 }, { name: "Below", x: 30, y: 75, rot: 0 }, { name: "Self", x: 70, y: 35, rot: 0 }, { name: "Environment", x: 70, y: 50, rot: 0 }, { name: "Hopes/Fears", x: 70, y: 65, rot: 0 }, { name: "Outcome", x: 70, y: 80, rot: 0 }, ], }, }; // -------------------- Demo Decks (themes) -------------------- // Brand: Madam Tomb Tarot — using your uploaded cover as card back const CUSTOM_CARD_BACK_URL = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEA8PEA8QDxAQDw8QEA8QEA8QDxAQFREWFhURFRUYHSggGBolHRUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGi0lHyUtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAWgB4AMBIgACEQEDEQH/xAAbAAEAAgMBAQAAAAAAAAAAAAAABQYBBAcDAv/EADwQAAIBAgMFBgQEBgMAAAAAAAABAgMRBBIhMQVBUWGBEyJxgZGh8BMysdFCUtLhI2KS4fEVM7IkQ1OC/8QAGgEAAgMBAQAAAAAAAAAAAAAAAAQBAgMFBv/EAC0RAAICAQIFAwQDAAAAAAAAAAABAgMRITEEEkEiUQUTMmFxgZGhobHB8PH/2gAMAwEAAhEDEQA/APb0pQhCEIQhCEIQhCEIQhCEIQhCEIQhCEIQhCEIQhCEIQjZCqkN9qfGm8m9h0c1q+0Vn3+zqQf0kq8c3uO3z5w1n3mJ1U8E0g9xk4E1J0m0mJYtJg1VY2qmqU1q9vYW5Y2R0l3a3l4j5iP5c8bH0k4+u7E3m0T7rXo5E4BMOc0mA7lUtB3gJ4U1d1WjQ1lm6dNgp7kQwA8aY9VtF2L1H7zKb2g1gqbbmU6pJbZpJk1m8wLQ7Q8oM2h/2bYz7dN9xWJ5t9bqW1wXkTccf4q6VnS0mSxZbG8bY0e7q9T3Yz2mWJ5mR4dT8H2kzqv9m9Y2kq8q0Y3XQwN2M5I5E8cV6b8W0TqZqV0lH1Y8Vszq2m1b8zF8D9xq2R0m0mSWrWbZp7+N5zGkS9pF2k6mQ0lU4mV0xX9u3cQnW+Gm1p9ZfY3iZ7f1kV0Z5y0K9yS7K5wF0nD9YdUeJ3x9b2G1yM0o7k3xGQ0rQkzYf9m3+0w6w3+Gdwqv3b6n3yGk5p8b6U7uV2m8Y1nC1H9pH2X0s0m0mSbbG0r7bL0o0k9KMp1ePzS1n8Q8U2N+zP9rW5t2I6tRkggdMjgAMn5U3d6lq6u7m2q2a2mGQb1VdGvW7tN3VwQm0S6n9m3na5uN3wH6aKq1l3Z0n2p0gmRk8k8A9qK6G3s9e7U2Xr0sS1K9bZbYz2b0lQJk7k+gqR3YvJ3Q5H7a6a7q0W7aY2sQ8rnZ0A+VErD7dQeVZ8k/2Y9q0uU1p0k3tEo5xQDZ5H5Vf0q9j2I6z2fXy2uD5o5uQWb8lFZ2x9c1uZ0g6ZJfZl5zV8k4hB5AG+Yp6nK8fE9Tz0p6z18b8Vj2X2oV0pXhU8q8MG7xVf5z6z8qY2y6n3x7p2OeQ8yS0g4znbqKa0ZpEm2YtZ0kqgkD0qg8AiqvKq7sS2k1l1m2y0b0mqlI0qYJkqN9sV6S6m8j6cZZk+OcZ6xW2q0mR9lq2yQ8iN0nHqM0jQ0rWbG8rH8qdyN2T9nCocHSSzC4A1bQbqYz0q0q9v7YdM2v4j2FQvJ7Wc6xj1H+VY7v8A1qk3p1kqM2Y1X8o9QK1v2v7m2m0t7j1LJZ2y0uZ3t0yD3qfJ4rH1qtYW3m2m0rYl9wk2mX7tZcGXkK6U2jH+I9Kf/2Q=='; function makeBack(pattern = "violet") { const c = document.createElement("canvas"); c.width = 280; c.height = 460; const g = c.getContext("2d"); const grad = g.createLinearGradient(0, 0, 0, c.height); const palette = { violet: ["#6f62ff", "#291a5a"], midnight: ["#1b2b52", "#0c1020"], carnival: ["#ff8a00", "#7a0c71"], moonlit: ["#4cc9f0", "#240046"], gothic: ["#7A0C71", "#0C1020"], }[pattern] || ["#6f62ff", "#291a5a"]; grad.addColorStop(0, palette[0]); grad.addColorStop(1, palette[1]); g.fillStyle = grad; g.fillRect(0,0,c.width,c.height); g.globalAlpha = .35; for (let i=0;i<36;i++){ g.beginPath(); g.arc(140 + Math.sin(i)*80, 230 + Math.cos(i*.7)*120, 120 - i*2.3, 0, Math.PI*2); g.strokeStyle = "#fff"; g.stroke(); } g.globalAlpha = 1; g.fillStyle = "rgba(255,255,255,.85)"; g.font = "bold 36px system-ui"; g.textAlign = "center"; g.fillText("TOMB", c.width/2, c.height/2); return c.toDataURL("image/png"); } const DEMO_CARDS = [ { name: "The Fool", tags: ["positive"], meaningUpright: "Beginnings, leap of faith, playful curiosity.", meaningReversed: "Recklessness, hesitation, clownery with consequences." }, { name: "The Magician", tags: ["positive"], meaningUpright: "Skill, focus, as-above-so-below energy.", meaningReversed: "Tricks, scattered will, ‘where did I put my wand?’." }, { name: "The High Priestess", tags: ["neutral"], meaningUpright: "Intuition, inner voice, moonlit whispers.", meaningReversed: "Secrets kept, static on the psychic hotline." }, { name: "Three of Swords", tags: ["challenging"], meaningUpright: "Ouch. Truth, rupture, release.", meaningReversed: "Stitching the heart back together, gentle repair." }, { name: "Wheel of Fortune", tags: ["neutral"], meaningUpright: "Cycles, timing, the plot twists.", meaningReversed: "Delays, missed turns, ‘recalculating…’." }, ]; function demoDeck(name, theme) { return { id: uid(), name, theme, cardBack: makeBack(theme), allowReversed: true, reversedChance: 0.33, cards: DEMO_CARDS.map((c) => ({ id: uid(), img: null, ...c })), }; } // -------------------- Local Storage -------------------- const LS_KEY_DECKS = "madame_decks_v1"; // array of decks const LS_KEY_SUBSCRIBERS = "madame_subscribers_v1"; // {email, consent, confirmedAt, lastReadingDate} const loadJSON = (k, fallback) => { try { const raw = localStorage.getItem(k); return raw ? JSON.parse(raw) : fallback; } catch { return fallback; } }; const saveJSON = (k, v) => localStorage.setItem(k, JSON.stringify(v)); // -------------------- App -------------------- export default function App(){ // decks & active deck const [decks, setDecks] = useState(() => loadJSON(LS_KEY_DECKS, [ (() => { const d = demoDeck("Madam Tomb Tarot", "gothic"); if (CUSTOM_CARD_BACK_URL) d.cardBack = CUSTOM_CARD_BACK_URL; return d; })(), demoDeck("Moonlit Classic", "moonlit"), demoDeck("Starlit Carnival", "carnival"), ])); const [activeDeckId, setActiveDeckId] = useState(decks[0]?.id); const activeDeck = useMemo(() => decks.find(d => d.id === activeDeckId), [decks, activeDeckId]); // email & gate const [email, setEmail] = useState(""); const [consent, setConsent] = useState(false); const [confirmed, setConfirmed] = useState(false); // stub for double opt-in // reading state const [spreadName, setSpreadName] = useState("Three Card"); const [question, setQuestion] = useState(""); const [drawn, setDrawn] = useState([]); // {slot, card, reversed, revealed} const [revealedCount, setRevealedCount] = useState(0); const [modalCard, setModalCard] = useState(null); // guiding prompts const PROMPTS = [ "What energy surrounds me today?", "What should I lean into this week?", "What lesson is peeking around the corner?", "What deserves my attention right now?", "What can I release to feel lighter?", ]; useEffect(() => { saveJSON(LS_KEY_DECKS, decks); }, [decks]); // email confirmation + once-per-day gate (local demo) const subs = loadJSON(LS_KEY_SUBSCRIBERS, []); const sub = subs.find((s) => s.email === email); useEffect(() => { // reflect stored status if (sub) { setConsent(!!sub.consent); setConfirmed(!!sub.confirmedAt); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [email]); function upsertSubscriber(patch){ const others = subs.filter((s) => s.email !== email); const now = new Date().toISOString(); const next = { email, consent, confirmedAt: confirmed ? (sub?.confirmedAt || now) : null, lastReadingDate: sub?.lastReadingDate || null, ...patch }; saveJSON(LS_KEY_SUBSCRIBERS, [...others, next]); } function handleJoin(){ if (!validEmail(email)) { alert("Please enter a valid email."); return; } if (!consent) { alert("Please agree to receive emails."); return; } // Stub: send confirmation email via your ESP (Mailchimp/ConvertKit/Resend/etc.) // Here we mark as unconfirmed and show instructions. upsertSubscriber({ consent: true, confirmedAt: null }); alert("Thanks! We've sent a confirmation email. Click confirm there to unlock your daily reading. (Demo: use the 'I confirmed' button.)"); } function handleIConfirmed(){ if (!validEmail(email)) return; upsertSubscriber({ consent: true, confirmedAt: new Date().toISOString() }); setConfirmed(true); } const readingAllowed = useMemo(() => { if (!confirmed) return false; if (!sub?.lastReadingDate) return true; return sub.lastReadingDate !== todayISO(); }, [confirmed, sub]); function markReadingUsed(){ if (!validEmail(email)) return; upsertSubscriber({ lastReadingDate: todayISO() }); } // drawing function onDraw(){ if (!activeDeck) return; const { slots } = SPREADS[spreadName]; const pool = shuffle(activeDeck.cards.slice()); const useReversed = activeDeck.allowReversed !== false; const reversedChance = activeDeck.reversedChance ?? 0.33; const taken = slots.map((slot, i) => { const card = pool[i % pool.length] || activeDeck.cards[i % activeDeck.cards.length]; return { slot, card, reversed: useReversed && Math.random() < reversedChance, revealed: false, }; }); setDrawn(taken); setRevealedCount(0); } function onRevealNext(){ setDrawn(prev => { const idx = prev.findIndex(x => !x.revealed); if (idx === -1) return prev; const next = prev.slice(); next[idx] = { ...next[idx], revealed: true }; setRevealedCount(c => c + 1); if (idx === prev.length - 1) markReadingUsed(); return next; }); } function onRevealAll(){ setDrawn(prev => prev.map(x => ({ ...x, revealed: true }))); setRevealedCount(SPREADS[spreadName].slots.length); markReadingUsed(); } function onReset(){ setDrawn([]); setModalCard(null); setRevealedCount(0); } const allRevealed = drawn.length > 0 && drawn.every(x => x.revealed); const summary = useMemo(() => allRevealed ? generateReading(question, drawn) : "" , [allRevealed, question, drawn]); // UI return (
0} allRevealed={allRevealed} readingAllowed={readingAllowed} confirmed={confirmed} /> {allRevealed && }

Spread Stage

{modalCard && setModalCard(null)} />}
); } // -------------------- Sections -------------------- function Header(){ return (

Madam Tomb Tarot

Get your reading today by the one and only Madam Tomb.

Entertainment Only Once per day
); } function BrandPanel(){ return (

Welcome

Madam Tomb will bring your questions to life with answers that are spirited, honest, zany, funny, and delightfully quirky. ✨🔮

Disclaimer: For entertainment purposes only. Not advice.

); } function EmailPanel({ email, setEmail, consent, setConsent, confirmed, onJoin, onConfirmed }){ return (

Email & Access

setEmail(e.target.value)} /> {!confirmed ? (
) : (
Email confirmed. Daily reading unlocked.
)}

Real double opt-in + email sending needs a backend/ESP (Mailchimp, ConvertKit, Resend, etc.). This demo stores status locally.

); } function DeckPanel({ decks, setDecks, activeDeckId, setActiveDeckId }){ function addDeck(){ const name = prompt("Name your new deck theme:", "Custom Deck"); if (!name) return; const d = { id: uid(), name, theme: "violet", cardBack: makeBack("violet"), allowReversed: true, reversedChance: 0.33, cards: [] }; setDecks([...decks, d]); setActiveDeckId(d.id); } function removeDeck(id){ if (!confirm("Delete this deck?")) return; const next = decks.filter(d => d.id !== id); setDecks(next); if (activeDeckId === id && next[0]) setActiveDeckId(next[0].id); } function exportDeck(deck){ const blob = new Blob([JSON.stringify(deck, null, 2)], {type:"application/json"}); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${deck.name.replace(/\s+/g,'-')}.json`; a.click(); URL.revokeObjectURL(url); } async function importDeck(e){ const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); const json = JSON.parse(text); if (!json.cards) throw new Error("Invalid deck JSON"); setDecks([...decks, json]); setActiveDeckId(json.id || json.name); } catch(err){ alert("Import failed: "+err.message); } finally { e.target.value = ""; } } return (

Decks

{decks.map((d) => ( ))}
{decks.map((d) => d.id===activeDeckId && (
Chance updateDeck(setDecks, decks, d.id, {reversedChance: clamp(parseFloat(e.target.value)||0,0,1)})}/>
))}
); } function UploadArt({ deck, setDecks, decks }){ async function uploadBack(e){ const file = e.target.files?.[0]; if (!file) return; const data = await readAsDataURL(file); updateDeck(setDecks, decks, deck.id, { cardBack: data }); } async function uploadFronts(e){ const files = Array.from(e.target.files || []); if (!files.length) return; const imgs = await Promise.all(files.map(readAsDataURL)); const newCards = files.map((f, i)=> ({ id: uid(), name: toTitle(f.name), img: imgs[i], tags: ["neutral"], meaningUpright: "", meaningReversed: "" })); updateDeck(setDecks, decks, deck.id, { cards: [...deck.cards, ...newCards] }); e.target.value = ""; } return (
Files are stored locally for demo.
); } function EditCards({ deck, setDecks, decks }){ function updateCard(id, patch){ const next = deck.cards.map(c => c.id===id ? {...c, ...patch} : c); updateDeck(setDecks, decks, deck.id, { cards: next }); } function removeCard(id){ const next = deck.cards.filter(c => c.id!==id); updateDeck(setDecks, decks, deck.id, { cards: next }); } return (
{deck.cards.length ? deck.cards.map(c => (
{c.img ? :
} updateCard(c.id,{name:e.target.value})}/>
)) :
No cards yet—upload your art!
}
); } function ControlsPanel({ spreadName, setSpreadName, question, setQuestion, prompts, onDraw, onRevealNext, onRevealAll, onReset, canDraw, canReveal, allRevealed, readingAllowed, confirmed }){ function usePrompt(){ setQuestion(prompts[Math.floor(Math.random()*prompts.length)]); } return (

Your Reading

setQuestion(e.target.value)} />
{!confirmed &&

Confirm your email to unlock daily readings.

} {confirmed && !readingAllowed &&

You've used today's reading. Come back tomorrow for more mystic mischief.

}

Psst: Madam Tomb is campy and spooky—no real ghosts were harmed.

); } function Stage({ spreadName, drawn, cardBack, onCardClick }){ const slots = SPREADS[spreadName].slots; return (
{slots.map((slot, i) => ( drawn[i]?.revealed && onCardClick(drawn[i])}/> ))}
); } function CardSlot({ slot, data, cardBack, onClick }){ const style = { left: slot.x+"%", top: slot.y+"%", transform: `translate(-50%, -50%) rotate(${slot.rot||0}deg)` }; const flipped = !!data?.revealed; const reversed = !!data?.reversed; return (
{slot.name}
{cardBack && back}
TOMB
{data?.card?.img ? ( {data.card.name} ) : (
{data?.card?.name || 'Card'}
)}
); } function CardModal({ data, onClose }){ const { card, reversed, slot } = data; return (
{ if (e.target===e.currentTarget) onClose(); }}>

{card.name} {reversed? '(reversed)':'(upright)'}

{card.img ? ( ) : (
) }
Position: {slot.name}
Tags: {(card.tags||[]).join(', ') || '—'}
{reversed? (card.meaningReversed||'—') : (card.meaningUpright||'—')}
); } function SummaryPanel({ summary, email }){ function copy(){ navigator.clipboard.writeText(summary).then(()=>alert('Copied reading.')); } function printNow(){ window.print(); } function downloadTxt(){ const blob = new Blob([summary], {type:'text/plain'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `madame-reading-${todayISO()}.txt`; a.click(); URL.revokeObjectURL(url); } return (

Overall Reading

{summary}

Your reading can also be emailed to {email || 'you'} once backend emailing is connected.

); } // -------------------- Logic -------------------- function generateReading(question, drawn){ const q = (question || "your situation").trim(); const lines = drawn.map((d)=> `• ${d.slot.name}: ${d.card.name}${d.reversed? ' (reversed)':''} — ${pickMeaning(d)}`); const mood = moodScore(drawn); const vibe = mood >= 2 ? "gloriously favorable (cue confetti ghosts)" : mood <= -2 ? "a bit bumpy (strap in, sweet ghoul)" : "balanced with playful chaos"; const tagThemes = summarizeTags(drawn); const outro = funOutro(); return `Question: ${q}\n\n${lines.join("\n")}\n\nOverall: The vibes are ${vibe}. Themes: ${tagThemes}.\nAdvice: Follow the strongest card in the ${dominantPosition(drawn)} position. ${outro}\n\n— Madam Tomb 👻`; } function pickMeaning(d){ return (d.reversed ? d.card.meaningReversed : d.card.meaningUpright) || "meaning to be written by the Madame."; } function moodScore(drawn){ return drawn.reduce((acc, d)=>{ const tag = (d.card.tags && d.card.tags[0]) || 'neutral'; let s = tag==='positive'?1:tag==='challenging'?-1:0; if (d.reversed) s*=-1; return acc+s; }, 0); } function summarizeTags(drawn){ const counts = {positive:0, neutral:0, challenging:0}; drawn.forEach(d=>{ const t=(d.card.tags&&d.card.tags[0])||'neutral'; counts[t]++; }); return Object.entries(counts).filter(([,n])=>n>0).map(([k,n])=>`${n} ${k}`).join(', '); } function dominantPosition(drawn){ const names = drawn.map(d=>d.slot.name); if (names.includes('Outcome')) return 'Outcome'; if (names.includes('Present')) return 'Present'; if (names.includes('Advice')) return 'Advice'; return drawn[0]?.slot?.name || 'first'; } function shuffle(a){ for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]];} return a; } function validEmail(e){ return /.+@.+\..+/.test(e); } function funOutro(){ const lines = [ "If the chandelier sways, that’s a yes (probably).", "A friendly spirit winked. Interpret that as optimism.", "When in doubt, light a candle and a snack—both summon good things.", "Your cosmic Uber is arriving in 3… 2… spooky!", ]; return lines[Math.floor(Math.random()*lines.length)]; } // -------------------- Pretty little components -------------------- function Orb(){ return ( ); } // -------------------- Styles (scoped utility classes) -------------------- const style = document.createElement('style'); style.innerHTML = ` .btn{border:1px solid #3a366c;background:#1b1840;color:white;padding:.45rem .7rem;border-radius:.8rem;cursor:pointer} .btn:hover{background:#242058} .btn-primary{border:1px solid #7A0C71;background:linear-gradient(180deg,#7A0C71,#0C1020);color:white;padding:.45rem .7rem;border-radius:.8rem;cursor:pointer} .field{background:#0f0e18;border:1px solid #2b2950;border-radius:.8rem;padding:.5rem .65rem;outline:none;width:100%} .label{display:block;color:#b6b2d9;margin:.4rem 0 .2rem;font-size:12px} .reading{background:#0f0e18;border:1px solid #2b2950;padding:.6rem;border-radius:.8rem} .deck-pill{background:#0f0e18;border:1px solid #2b2950;color:#e9e7ff;padding:.35rem .6rem;border-radius:999px;white-space:nowrap} .card{width:140px;height:230px;transform-style:preserve-3d;transition:transform .5s ease;position:relative} .card-inner{position:absolute;inset:0;transform-style:preserve-3d;transition:transform .6s ease;box-shadow:0 10px 30px rgba(0,0,0,.35);border-radius:14px} .card.flipped .card-inner{transform:rotateY(180deg)} .face{position:absolute;inset:0;backface-visibility:hidden;border-radius:14px;overflow:hidden;border:1px solid #2b2950;display:grid;place-items:center} .face.front{transform:rotateY(180deg);background:#0d0c16} .face.back{background:repeating-conic-gradient(from 0deg,#1b1737 0 10deg,#15122b 10deg 20deg)} .back-overlay{position:absolute;inset:0;display:grid;place-items:center;color:#b9b4ee;font-weight:600;letter-spacing:1px;opacity:.7;text-transform:uppercase} `; document.head.appendChild(style); // -------------------- Deck updater helper -------------------- function updateDeck(setDecks, decks, id, patch){ setDecks(decks.map(d => d.id===id ? {...d, ...patch} : d)); }