// -------------------- 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.map((d) => (
))}
{decks.map((d) => d.id===activeDeckId && (
))}
);
}
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 &&

}
TOMB
{data?.card?.img ? (

) : (
{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));
}