WIP new share menu

This commit is contained in:
George Powell
2026-02-21 00:38:09 -05:00
parent e592751a1c
commit 19646c72ca
3 changed files with 180 additions and 37 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { import {
getBookById, getBookById,
toOrdinal, toOrdinal,
@@ -35,6 +35,7 @@
guessCount, guessCount,
reference, reference,
onChapterGuessCompleted, onChapterGuessCompleted,
shareText,
} = $props(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -86,13 +87,13 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<Container <Container
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in" class="w-full px-2 sm:px-4 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
> >
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed"> <p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from {congratulationsMessage} The verse is from<br />
<span class="font-black text-3xl md:text-4xl">{bookName}</span>. <span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p> </p>
<p class="text-lg sm:text-xl md:text-2xl mt-4"> <p class="text-lg sm:text-xl md:text-2xl mt-2">
You guessed correctly after {guessCount} You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}. {guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75" <span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
@@ -100,16 +101,25 @@
> >
</p> </p>
<div class="flex justify-center mt-6"> {#if guessCount !== 1}
<p class="pt-6 big-text text-gray-700!">
{getNextGradeMessage(guessCount)}
</p>
{/if}
</Container>
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div>
<div class="share-buttons">
{#if hasWebShare} {#if hasWebShare}
<!-- mobile and arc in production -->
<button <button
onclick={handleShare} onclick={handleShare}
data-umami-event="Share" data-umami-event="Share"
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none" class="share-btn primary"
> >
📤 Share 📤 Click to share
</button> </button>
{:else}
<button <button
onclick={() => { onclick={() => {
copyToClipboard(); copyToClipboard();
@@ -119,34 +129,16 @@
}, 3000); }, 3000);
}} }}
data-umami-event="Copy to Clipboard" data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${ class={`share-btn primary ${copySuccess ? "success" : ""}`}
copySuccess
? "bg-white/30"
: "bg-white/70 hover:bg-white/80"
}`}
> >
{copySuccess ? "✅ Copied!" : "📋 Copy"} {copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
</button>
{:else}
<!-- dev mode and desktop browsers -->
<button
onclick={handleShare}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 ${
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "✅ Copied!" : "📋 Share"}
</button> </button>
{/if} {/if}
</div> </div>
<div class="chat-window">
{#if guessCount !== 1} <p class="bubble">{shareText}</p>
<p class="pt-6 big-text text-gray-700!"> </div>
{getNextGradeMessage(guessCount)} </div>
</p>
{/if}
</Container>
<!-- S++ Bonus Challenge for first try --> <!-- S++ Bonus Challenge for first try -->
{#if guessCount === 1} {#if guessCount === 1}
@@ -242,4 +234,130 @@
.fade-in { .fade-in {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
/* ── Share card ── */
.share-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 1.25rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.share-card-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8e8e93;
margin: 0;
}
/* ── Chat window ── */
.chat-window {
--sent-color: #0b93f6;
--bg: white;
display: flex;
justify-content: center;
padding: 0 1.5rem 0;
}
/* ── Bubble (from article technique) ── */
.bubble {
position: relative;
max-width: 255px;
margin-bottom: 8px;
padding: 10px 20px;
line-height: 1.3;
word-wrap: break-word;
border-radius: 25px;
text-align: left;
white-space: pre-wrap;
font-size: 0.9rem;
transform: rotate(-2deg);
color: white;
background: var(--sent-color);
}
.bubble::before,
.bubble::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble::before {
width: 20px;
right: -7px;
background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px;
}
.bubble::after {
width: 26px;
right: -26px;
border-bottom-left-radius: 10px;
background-color: var(--bg);
}
/* ── Share buttons ── */
.share-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.share-btn {
width: 100%;
padding: 1rem 1.5rem;
font-size: 1.1rem;
font-weight: 700;
border: 3px solid rgba(0, 0, 0, 0.25);
border-radius: 1rem;
cursor: pointer;
transition:
transform 80ms ease,
box-shadow 80ms ease,
opacity 80ms ease;
letter-spacing: 0.01em;
}
.share-btn:active {
transform: scale(0.97);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.share-btn.primary {
background: #7c3aed;
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
}
.share-btn.primary:hover {
opacity: 0.92;
}
.share-btn.primary.success {
background: #636363;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary {
background: #f2f2f7;
color: #1c1c1e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary:hover {
background: #e5e5ea;
}
</style> </style>

View File

@@ -1,6 +1,8 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game"; import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string { function getOrCreateAnonymousId(): string {
if (!browser) return ""; if (!browser) return "";
const key = "bibdle-anonymous-id"; const key = "bibdle-anonymous-id";
@@ -12,6 +14,9 @@ function getOrCreateAnonymousId(): string {
return id; return id;
} }
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
// system can track dependencies and re-run effects when they change.
export function createGamePersistence( export function createGamePersistence(
getDate: () => string, getDate: () => string,
getReference: () => string, getReference: () => string,
@@ -24,7 +29,8 @@ export function createGamePersistence(
let chapterGuessCompleted = $state(false); let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false); let chapterCorrect = $state(false);
// Initialize anonymous ID and load persisted flags // On mount (and if the user logs in/out), resolve the player's identity and
// restore per-day flags from localStorage.
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -36,6 +42,7 @@ export function createGamePersistence(
anonymousId = getOrCreateAnonymousId(); anonymousId = getOrCreateAnonymousId();
} }
// Tell Umami analytics which player this is so events are grouped correctly.
if ((window as any).umami) { if ((window as any).umami) {
(window as any).umami.identify(anonymousId); (window as any).umami.identify(anonymousId);
} }
@@ -43,8 +50,11 @@ export function createGamePersistence(
const date = getDate(); const date = getDate();
const reference = getReference(); const reference = getReference();
// Restore whether today's completion was already submitted to the server.
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true"; statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
// Restore the chapter bonus guess result. The stored value includes the
// chapter the player selected, so we can re-derive whether it was correct.
const chapterGuessKey = `bibdle-chapter-guess-${reference}`; const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null; chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
if (chapterGuessCompleted) { if (chapterGuessCompleted) {
@@ -58,7 +68,9 @@ export function createGamePersistence(
} }
}); });
// Load saved guesses from localStorage // On mount (and if the date or correct answer changes), load today's guesses
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
// each stored book ID against the correct answer.
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -72,13 +84,14 @@ export function createGamePersistence(
} }
let savedIds: string[] = JSON.parse(saved); let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds)); savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
guesses = savedIds guesses = savedIds
.map((bookId) => evaluateGuess(bookId, correctBookId)) .map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null); .filter((g): g is Guess => g !== null);
}); });
// Save guesses to localStorage whenever they change // Persist guesses to localStorage whenever they change. Only the book IDs are
// stored — the full Guess shape is re-derived on load (see effect above).
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const date = getDate(); const date = getDate();
@@ -88,12 +101,16 @@ export function createGamePersistence(
); );
}); });
// Called after stats are successfully submitted to the server so that
// returning to the page doesn't trigger a duplicate submission.
function markStatsSubmitted() { function markStatsSubmitted() {
if (!browser) return; if (!browser) return;
statsSubmitted = true; statsSubmitted = true;
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true"); localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
} }
// Marks the win as tracked for analytics. Returns true the first time (new
// win), false on subsequent calls so the analytics event fires exactly once.
function markWinTracked() { function markWinTracked() {
if (!browser) return; if (!browser) return;
const key = `bibdle-win-tracked-${getDate()}`; const key = `bibdle-win-tracked-${getDate()}`;
@@ -102,11 +119,16 @@ export function createGamePersistence(
return true; return true;
} }
// Returns true if the win has already been tracked in a previous render/session.
// Used to skip the animation delay when returning to an already-won game.
function isWinAlreadyTracked(): boolean { function isWinAlreadyTracked(): boolean {
if (!browser) return false; if (!browser) return false;
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true"; return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
} }
// Overwrites local state with the server's authoritative guess record.
// Called when a logged-in user opens the game on a new device so their
// progress from another device is restored.
function hydrateFromServer(guessIds: string[]) { function hydrateFromServer(guessIds: string[]) {
if (!browser) return; if (!browser) return;
const correctBookId = getCorrectBookId(); const correctBookId = getCorrectBookId();
@@ -116,6 +138,8 @@ export function createGamePersistence(
.filter((g): g is Guess => g !== null); .filter((g): g is Guess => g !== null);
} }
// Called by the WinScreen after the player submits their chapter bonus guess.
// Reads the result written to localStorage by WinScreen and updates reactive state.
function onChapterGuessCompleted() { function onChapterGuessCompleted() {
if (!browser) return; if (!browser) return;
chapterGuessCompleted = true; chapterGuessCompleted = true;

View File

@@ -304,6 +304,7 @@
guessCount={persistence.guesses.length} guessCount={persistence.guesses.length}
reference={dailyVerse.reference} reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted} onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
/> />
</div> </div>
{/if} {/if}