mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
577 lines
13 KiB
Svelte
577 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { fade, fly } from "svelte/transition";
|
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
|
import {
|
|
getVerseSnippet,
|
|
shareResult,
|
|
copyToClipboard as clipboardCopy,
|
|
} from "$lib/utils/share";
|
|
import Container from "./Container.svelte";
|
|
import CountdownTimer from "./CountdownTimer.svelte";
|
|
import StreakCounter from "./StreakCounter.svelte";
|
|
import ChapterGuess from "./ChapterGuess.svelte";
|
|
|
|
interface StatsData {
|
|
solveRank: number;
|
|
guessRank: number;
|
|
totalSolves: number;
|
|
averageGuesses: number;
|
|
tiedCount: number;
|
|
percentile: number;
|
|
}
|
|
|
|
interface WeightedMessage {
|
|
text: string;
|
|
weight: number;
|
|
}
|
|
|
|
let {
|
|
statsData,
|
|
correctBookId,
|
|
handleShare,
|
|
copyToClipboard,
|
|
copied = $bindable(false),
|
|
statsSubmitted,
|
|
guessCount,
|
|
reference,
|
|
onChapterGuessCompleted,
|
|
shareText,
|
|
verseText,
|
|
streak = 0,
|
|
streakPercentile = null,
|
|
}: {
|
|
statsData: StatsData | null;
|
|
correctBookId: string;
|
|
handleShare: () => void;
|
|
copyToClipboard: () => void;
|
|
copied: boolean;
|
|
statsSubmitted: boolean;
|
|
guessCount: number;
|
|
reference: string;
|
|
onChapterGuessCompleted: () => void;
|
|
shareText: string;
|
|
verseText: string;
|
|
streak?: number;
|
|
streakPercentile?: number | null;
|
|
} = $props();
|
|
|
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
|
let hasWebShare = $derived(
|
|
typeof navigator !== "undefined" && "share" in navigator,
|
|
);
|
|
let copySuccess = $state(false);
|
|
let bubbleCopied = $state(false);
|
|
let copyTracked = $state(false);
|
|
let showSnippetOption = $state(false);
|
|
let includeSnippet = $state(false);
|
|
|
|
let effectiveShareText = $derived(
|
|
includeSnippet
|
|
? (() => {
|
|
const snippet = getVerseSnippet(verseText);
|
|
const lines = shareText.split("\n");
|
|
return [
|
|
...lines.slice(0, -1),
|
|
snippet,
|
|
lines[lines.length - 1],
|
|
].join("\n");
|
|
})()
|
|
: shareText,
|
|
);
|
|
|
|
// List of congratulations messages with weights
|
|
const congratulationsMessages: WeightedMessage[] = [
|
|
{ text: "Congratulations!", weight: 10 },
|
|
{ text: "You got it!", weight: 1000 },
|
|
{ text: "Yup.", weight: 100 },
|
|
{ text: "Very nice!", weight: 1 },
|
|
];
|
|
|
|
// Function to select a random message based on weights
|
|
function getRandomCongratulationsMessage(): string {
|
|
// Special case for first try success
|
|
if (guessCount === 1) {
|
|
const n = Math.random();
|
|
if (n < 0.99) {
|
|
return "First try!";
|
|
} else {
|
|
return "Axios!";
|
|
}
|
|
}
|
|
|
|
const totalWeight = congratulationsMessages.reduce(
|
|
(sum, msg) => sum + msg.weight,
|
|
0,
|
|
);
|
|
let random = Math.random() * totalWeight;
|
|
|
|
for (const message of congratulationsMessages) {
|
|
random -= message.weight;
|
|
if (random <= 0) {
|
|
return message.text;
|
|
}
|
|
}
|
|
|
|
// Fallback to first message if something goes wrong
|
|
return congratulationsMessages[0].text;
|
|
}
|
|
|
|
// Generate the congratulations message
|
|
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-6">
|
|
<Container
|
|
class="w-full px-4 sm:px-6 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"
|
|
>
|
|
<div class="flex flex-col gap-3">
|
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
|
{congratulationsMessage} The verse is from
|
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
|
>{bookName}</span
|
|
>.
|
|
</p>
|
|
<p class="text-lg sm:text-xl md:text-2xl">
|
|
You guessed correctly after {guessCount}
|
|
{guessCount === 1 ? "guess" : "guesses"}.
|
|
</p>
|
|
<!-- {#if streak >= 7}
|
|
<p
|
|
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
|
>
|
|
Thank you for making BIBDLE part of your daily routine!
|
|
</p>
|
|
{/if} -->
|
|
</div>
|
|
</Container>
|
|
|
|
<!-- S++ Bonus Challenge for first try -->
|
|
{#if guessCount === 1}
|
|
<ChapterGuess
|
|
{reference}
|
|
bookId={correctBookId}
|
|
onCompleted={onChapterGuessCompleted}
|
|
/>
|
|
{/if}
|
|
|
|
<div class="flex flex-row gap-3 items-stretch w-full">
|
|
<div class="flex-2 min-w-0 flex flex-col">
|
|
<CountdownTimer />
|
|
</div>
|
|
{#if streak > 0}
|
|
<div class="flex-1 min-w-0 flex flex-col">
|
|
<StreakCounter {streak} {streakPercentile} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Statistics Display -->
|
|
{#if statsData}
|
|
<Container
|
|
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
|
>
|
|
<div
|
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
|
in:fade={{ delay: 800 }}
|
|
>
|
|
<!-- Solve Rank Column -->
|
|
<div class="flex flex-col">
|
|
<div
|
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
|
>
|
|
#{statsData.solveRank}
|
|
</div>
|
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
|
You were the {toOrdinal(statsData.solveRank)} person to solve
|
|
today
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Guess Rank Column -->
|
|
<div class="flex flex-col">
|
|
<div
|
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
|
>
|
|
{toOrdinal(statsData.guessRank)}
|
|
</div>
|
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
|
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
|
|
{statsData.totalSolves === 1
|
|
? "solve"
|
|
: "solves"}{statsData.tiedCount > 0
|
|
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
|
|
: ""}.<br />
|
|
{#if statsData.percentile <= 25}
|
|
<span class="font-bold">
|
|
(Top {statsData.percentile}%)
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Average Column -->
|
|
<div class="flex flex-col">
|
|
<div
|
|
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
|
>
|
|
{statsData.averageGuesses}
|
|
</div>
|
|
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
|
People solved after {statsData.averageGuesses}
|
|
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
|
|
average
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
{:else if !statsSubmitted}
|
|
<Container
|
|
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
|
>
|
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
|
</Container>
|
|
{/if}
|
|
|
|
<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="chat-window">
|
|
<!-- Received bubble: primary action (share / copy) -->
|
|
<div class="bubble-wrapper received-wrapper">
|
|
<button
|
|
class="bubble bubble-received"
|
|
class:success={copySuccess}
|
|
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
|
data-umami-event={hasWebShare
|
|
? "Share"
|
|
: "Copy to Clipboard"}
|
|
onclick={() => {
|
|
if (hasWebShare) {
|
|
(window as any).rybbit?.event("Share");
|
|
shareResult(effectiveShareText);
|
|
} else {
|
|
if (!copyTracked) {
|
|
(window as any).rybbit?.event(
|
|
"Copy to Clipboard",
|
|
);
|
|
copyTracked = true;
|
|
}
|
|
clipboardCopy(effectiveShareText);
|
|
copySuccess = true;
|
|
setTimeout(() => {
|
|
copySuccess = false;
|
|
}, 3000);
|
|
}
|
|
}}
|
|
>
|
|
{#if hasWebShare}
|
|
📤 Tap here to share
|
|
{:else if copySuccess}
|
|
✅ Copied!
|
|
{:else}
|
|
📋 Copy to clipboard
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Sent bubble: share text preview -->
|
|
<div class="bubble-wrapper">
|
|
<button
|
|
class="bubble bubble-sent"
|
|
aria-label="Copy to clipboard"
|
|
data-umami-event="Copy to Clipboard"
|
|
onclick={() => {
|
|
if (!copyTracked) {
|
|
(window as any).rybbit?.event("Copy to Clipboard");
|
|
copyTracked = true;
|
|
}
|
|
clipboardCopy(effectiveShareText);
|
|
showSnippetOption = true;
|
|
bubbleCopied = true;
|
|
setTimeout(() => {
|
|
bubbleCopied = false;
|
|
}, 2000);
|
|
}}>{effectiveShareText}</button
|
|
>
|
|
{#if hasWebShare}
|
|
<span class="copy-hint"
|
|
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
|
>
|
|
{:else}
|
|
<span class="copy-hint"
|
|
>{bubbleCopied ? "copied!" : ""}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showSnippetOption}
|
|
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
|
<span class="snippet-label">Show verse snippet in share?</span>
|
|
<button
|
|
class="snippet-toggle"
|
|
class:on={includeSnippet}
|
|
onclick={() => (includeSnippet = !includeSnippet)}
|
|
aria-pressed={includeSnippet}
|
|
aria-label="Show snippet in share"
|
|
>
|
|
<span class="toggle-thumb"></span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
:global(.fade-in) {
|
|
animation: fadeIn 0.5s ease-out;
|
|
}
|
|
|
|
/* ── Share card ── */
|
|
.share-card {
|
|
background: oklch(94% 0.028 298.626);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
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);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.share-card::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
|
opacity: 0.04;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Chat window ── */
|
|
.chat-window {
|
|
--sent-color: #0b93f6;
|
|
--received-color: #3a3a3c;
|
|
--bg: oklch(94% 0.028 298.626);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0 0.5rem 0;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
/* ── Bubble wrappers ── */
|
|
.bubble-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 2px;
|
|
}
|
|
|
|
.received-wrapper {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
/* ── Shared bubble base ── */
|
|
.bubble {
|
|
position: relative;
|
|
max-width: 255px;
|
|
margin-bottom: 0;
|
|
padding: 10px 20px;
|
|
line-height: 1.3;
|
|
word-wrap: break-word;
|
|
border-radius: 25px;
|
|
text-align: left;
|
|
white-space: pre-wrap;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition:
|
|
filter 80ms ease,
|
|
transform 80ms ease;
|
|
user-select: none;
|
|
}
|
|
|
|
/* ── Sent bubble (share text preview) ── */
|
|
.bubble-sent {
|
|
color: white;
|
|
background: var(--sent-color);
|
|
transform: rotate(-2deg);
|
|
}
|
|
|
|
.bubble-sent:hover {
|
|
background-color: #2ea8ff;
|
|
transform: rotate(-2deg) translateY(-2px);
|
|
}
|
|
|
|
.bubble-sent:hover::before {
|
|
background-color: #2ea8ff;
|
|
}
|
|
|
|
.bubble-sent:active {
|
|
background-color: #0878d4;
|
|
transform: rotate(-2deg) scale(0.97);
|
|
}
|
|
|
|
.bubble-sent:active::before {
|
|
background-color: #0878d4;
|
|
}
|
|
|
|
/* Sent tail: bottom-right */
|
|
.bubble-sent::before,
|
|
.bubble-sent::after {
|
|
position: absolute;
|
|
bottom: 0;
|
|
height: 25px;
|
|
content: "";
|
|
}
|
|
|
|
.bubble-sent::before {
|
|
width: 20px;
|
|
right: -7px;
|
|
background-color: var(--sent-color);
|
|
border-bottom-left-radius: 16px 14px;
|
|
}
|
|
|
|
.bubble-sent::after {
|
|
width: 26px;
|
|
right: -26px;
|
|
border-bottom-left-radius: 10px;
|
|
background-color: var(--bg);
|
|
}
|
|
|
|
/* ── Received bubble (action button) ── */
|
|
.bubble-received {
|
|
color: #f5f5f7;
|
|
background: var(--received-color);
|
|
transform: rotate(2deg);
|
|
padding: 14px 24px;
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
min-width: 14rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.bubble-received:hover {
|
|
background-color: #4a4a4e;
|
|
transform: rotate(2deg) translateY(-2px);
|
|
}
|
|
|
|
.bubble-received:hover::before {
|
|
background-color: #4a4a4e;
|
|
}
|
|
|
|
.bubble-received:active {
|
|
background-color: #2a2a2c;
|
|
transform: rotate(2deg) scale(0.97);
|
|
}
|
|
|
|
.bubble-received:active::before {
|
|
background-color: #2a2a2c;
|
|
}
|
|
|
|
.bubble-received.success {
|
|
background: #c7f7d4;
|
|
color: #155724;
|
|
}
|
|
|
|
/* Received tail: bottom-left (mirror of sent) */
|
|
.bubble-received::before,
|
|
.bubble-received::after {
|
|
position: absolute;
|
|
bottom: 0;
|
|
height: 25px;
|
|
content: "";
|
|
}
|
|
|
|
.bubble-received::before {
|
|
width: 20px;
|
|
left: -7px;
|
|
background-color: var(--received-color);
|
|
border-bottom-right-radius: 16px 14px;
|
|
}
|
|
|
|
.bubble-received::after {
|
|
width: 26px;
|
|
left: -26px;
|
|
border-bottom-right-radius: 10px;
|
|
background-color: var(--bg);
|
|
}
|
|
|
|
.bubble-received.success::before {
|
|
background-color: #c7f7d4;
|
|
}
|
|
|
|
/* ── Copy hints ── */
|
|
.copy-hint {
|
|
font-size: 0.68rem;
|
|
color: #444;
|
|
font-weight: 400;
|
|
letter-spacing: 0.01em;
|
|
padding-right: 32px;
|
|
transform: rotate(-2deg);
|
|
transform-origin: right center;
|
|
margin-top: -6px;
|
|
}
|
|
|
|
/* ── Snippet toggle row ── */
|
|
.snippet-toggle-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
padding: 0 0.25rem;
|
|
}
|
|
|
|
.snippet-label {
|
|
font-size: 0.72rem;
|
|
color: #666;
|
|
letter-spacing: 0.01em;
|
|
user-select: none;
|
|
}
|
|
|
|
.snippet-toggle {
|
|
position: relative;
|
|
width: 36px;
|
|
height: 20px;
|
|
border-radius: 10px;
|
|
background: #ccc;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background 200ms ease;
|
|
flex-shrink: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.snippet-toggle.on {
|
|
background: #34c759;
|
|
}
|
|
|
|
.toggle-thumb {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
transition: transform 200ms ease;
|
|
}
|
|
|
|
.snippet-toggle.on .toggle-thumb {
|
|
transform: translateX(16px);
|
|
}
|
|
</style>
|