diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 51d7fa2d434..5ba65b1606c 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -24,6 +24,7 @@ import { useFeeds } from '../hooks/feed/useFeeds'; import { WebappShortcutsRow } from '../features/shortcuts/components/WebappShortcutsRow'; import { LiveStandupsStrip } from './liveRooms/LiveStandupsStrip'; import { AskSearchBanner } from './marketing/banners/AskSearchBanner'; +import { WeeklyQuizBanner } from '../features/weeklyQuiz/components/WeeklyQuizBanner'; import { FeedEngagementBanner } from './brand/FeedEngagementBanner'; import FeedContext from '../contexts/FeedContext'; import feedStyles from './Feed.module.css'; @@ -803,6 +804,7 @@ export default function MainFeedLayout({ > + {isHomePage && } {isHomePage && ( )} diff --git a/packages/shared/src/components/icons/Swords/filled.svg b/packages/shared/src/components/icons/Swords/filled.svg new file mode 100644 index 00000000000..3bec8a75c5b --- /dev/null +++ b/packages/shared/src/components/icons/Swords/filled.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/shared/src/components/icons/Swords/index.tsx b/packages/shared/src/components/icons/Swords/index.tsx new file mode 100644 index 00000000000..3665dedd98d --- /dev/null +++ b/packages/shared/src/components/icons/Swords/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const SwordsIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Swords/outlined.svg b/packages/shared/src/components/icons/Swords/outlined.svg new file mode 100644 index 00000000000..1d2be0a9581 --- /dev/null +++ b/packages/shared/src/components/icons/Swords/outlined.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/shared/src/components/icons/VolumeLow/filled.svg b/packages/shared/src/components/icons/VolumeLow/filled.svg new file mode 100644 index 00000000000..48025d4c77b --- /dev/null +++ b/packages/shared/src/components/icons/VolumeLow/filled.svg @@ -0,0 +1,7 @@ + + + Icon/VolumeLow/Filled + + + + diff --git a/packages/shared/src/components/icons/VolumeLow/index.tsx b/packages/shared/src/components/icons/VolumeLow/index.tsx new file mode 100644 index 00000000000..805f5143ca9 --- /dev/null +++ b/packages/shared/src/components/icons/VolumeLow/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const VolumeLowIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/VolumeLow/outlined.svg b/packages/shared/src/components/icons/VolumeLow/outlined.svg new file mode 100644 index 00000000000..984ee846102 --- /dev/null +++ b/packages/shared/src/components/icons/VolumeLow/outlined.svg @@ -0,0 +1,7 @@ + + + Icon/VolumeLow/Outline + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index a8506ac6745..ac0abaf3a42 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -178,7 +178,9 @@ export * from './Upvote'; export * from './User'; export * from './UserShare'; export * from './V'; +export * from './Swords'; export * from './Volume'; +export * from './VolumeLow'; export * from './VolumeOff'; export * from './Warning'; export * from './Whatsapp'; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 56849f3a97d..1aac9d9bb21 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -526,6 +526,13 @@ const PostImpressionsModal = dynamic( ), ); +const WeeklyQuizModal = dynamic( + () => + import( + /* webpackChunkName: "weeklyQuizModal" */ '../../features/weeklyQuiz/components/WeeklyQuizModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -611,6 +618,7 @@ export const modals = { [LazyModal.ReaderExtensionInstall]: ReaderExtensionInstallModal, [LazyModal.ReaderPreview]: ReaderPreviewLazyModal, [LazyModal.PostImpressions]: PostImpressionsModal, + [LazyModal.WeeklyQuiz]: WeeklyQuizModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 66e87110334..69d98e5bc80 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -109,6 +109,7 @@ export enum LazyModal { ReaderExtensionInstall = 'readerExtensionInstall', ReaderPreview = 'readerPreview', PostImpressions = 'postImpressions', + WeeklyQuiz = 'weeklyQuiz', } export type ModalTabItem = { diff --git a/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css new file mode 100644 index 00000000000..a170500fe0b --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css @@ -0,0 +1,589 @@ +/* Arcade skin for the Weekly Quiz. All colors are theme tokens + * (var(--theme-accent-*)) composed via color-mix, so the vivid look still + * tracks the design system and stays theme-aware — no raw palette values. The + * sweep is a deliberate, always-vivid game surface, so text on it is white. */ + +/* Self-hosted "Play" — the quiz's display font. Files live in each app's + * public/fonts dir; scoped to the quiz via .surface (children inherit). */ +@font-face { + font-family: 'Play'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/play-400.woff2') format('woff2'); +} +@font-face { + font-family: 'Play'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/play-700.woff2') format('woff2'); +} + +.surface { + position: relative; + isolation: isolate; + overflow: hidden; + color: #fff; + font-family: 'Play', 'Helvetica Neue', Helvetica, Arial, sans-serif; + border: 5px solid #fff; + border-radius: 28px; + /* daily.dev purple: the surface leads with onion→cabbage (the brand's + * indigo→magenta) so the quiz reads as part of the app, not a standalone + * arcade. A faint bacon toe at the very bottom keeps it from going flat. */ + background: + radial-gradient( + 130% 80% at 50% -12%, + color-mix(in srgb, var(--theme-accent-cabbage-default) 55%, var(--theme-accent-onion-default) 45%), + transparent 60% + ), + linear-gradient( + 165deg, + color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%) 0%, + var(--theme-accent-onion-default) 32%, + var(--theme-accent-cabbage-default) 66%, + color-mix(in srgb, var(--theme-accent-cabbage-default) 68%, var(--theme-accent-bacon-default) 32%) 100% + ); +} + +/* Faint sunburst rays fanning down from the top, like the references. */ +.rays { + position: absolute; + inset: -20% -20% auto -20%; + height: 80%; + z-index: -1; + pointer-events: none; + background: repeating-conic-gradient( + from 0deg at 50% 0%, + rgba(255, 255, 255, 0.07) 0deg 5deg, + transparent 5deg 12deg + ); + mask-image: radial-gradient(70% 100% at 50% 0%, #000 0%, transparent 72%); + -webkit-mask-image: radial-gradient( + 70% 100% at 50% 0%, + #000 0%, + transparent 72% + ); +} + +/* Logo wrapper — eases back to rest after a mouse-driven 3D tilt. */ +.logoTilt { + transition: transform 0.18s ease-out; + transform-style: preserve-3d; + will-change: transform; +} + +/* Warm pulsing glow overlaid on the logo's lightbulb so it reads as "lit". + * Screen-blended so it brightens the bulb and spills light around it. */ +.bulbGlow { + position: absolute; + border-radius: 9999px; + pointer-events: none; + background: radial-gradient( + circle, + rgba(255, 236, 170, 0.95) 0%, + rgba(255, 186, 70, 0.55) 38%, + rgba(255, 186, 70, 0) 70% + ); + mix-blend-mode: screen; + filter: blur(3px); + animation: bulb-pulse 1.8s ease-in-out infinite; +} +@keyframes bulb-pulse { + 0%, + 100% { + opacity: 0.5; + transform: scale(0.9); + } + 50% { + opacity: 1; + transform: scale(1.15); + } +} +@media (prefers-reduced-motion: reduce) { + .bulbGlow { + animation: none; + opacity: 0.75; + } +} + +/* Side control chip (mute / close) — sits outside the card on the right with + * the same white border + gradient sweep as the main surface. */ +.sideControl { + display: flex; + align-items: center; + justify-content: center; + color: #fff; + border: 4px solid #fff; + border-radius: 16px; + background: linear-gradient( + 160deg, + color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%) 0%, + var(--theme-accent-cabbage-default) 100% + ); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28); + transition: filter 0.15s ease, transform 0.15s ease; +} +.sideControl:hover { + filter: brightness(1.08); + transform: translateY(-1px); +} +.sideControl:active { + transform: translateY(0) scale(0.96); +} + +/* Share popover — a solid, legible panel (not the translucent .panel) so text + * and the URL field read clearly over the busy leaderboard behind it. */ +.sharePanel { + background: color-mix(in srgb, var(--theme-accent-onion-default) 92%, #000 8%); + border: 2px solid rgba(255, 255, 255, 0.28); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.45); +} + +/* Floating glassy panel — used to split the intro into two separated cards + * (left: logo + start + social, right: leaderboard) over the gradient. */ +.panel { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.18); + backdrop-filter: blur(4px); + box-shadow: + 0 10px 26px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +/* Chunky arcade "candy" button — a diagonal purple->orange face (cabbage into + * bun), a solid dark-purple base slab underneath (3D press), thick rim, and a + * white bubbly label. Presses down on :active. Colors derive from the accent + * tokens so it stays theme-aware and matches the purple surface. */ +.arcadeBtn { + position: relative; + isolation: isolate; + color: #fff; + border-radius: 1.25rem; + border: 3px solid color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%); + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--theme-accent-cabbage-default) 82%, #fff 18%) 0%, + var(--theme-accent-cabbage-default) 32%, + var(--theme-accent-bacon-default) 62%, + var(--theme-accent-bun-default) 100% + ); + box-shadow: + 0 9px 0 color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%), + 0 16px 24px rgba(0, 0, 0, 0.32), + inset 0 3px 6px rgba(255, 255, 255, 0.55), + inset 0 -7px 10px rgba(0, 0, 0, 0.2); + transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.15s ease; +} +/* Glossy sheen across the top half. */ +.arcadeBtn::before { + content: ''; + position: absolute; + inset: 5px 10px auto 10px; + height: 42%; + border-radius: 0.9rem; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.6), + rgba(255, 255, 255, 0) + ); + pointer-events: none; +} +.arcadeBtn:hover:not(:disabled) { + filter: brightness(1.05) saturate(1.05); +} +.arcadeBtn:active:not(:disabled) { + transform: translateY(7px); + box-shadow: + 0 2px 0 color-mix(in srgb, var(--theme-accent-onion-default) 55%, #000 45%), + 0 6px 12px rgba(0, 0, 0, 0.28), + inset 0 3px 6px rgba(255, 255, 255, 0.5); +} +.arcadeBtn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Continuous idle "pop" so the button keeps inviting a tap. Pauses on press so + * the depress transform still reads, and off entirely for reduced motion. */ +@keyframes arcade-idle { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.06); + } +} +.arcadeBtnIdle { + animation: arcade-idle 1.5s ease-in-out infinite; +} +/* On hover the button stops pulsing and holds big; the label pops instead. */ +.arcadeBtnIdle:hover:not(:disabled) { + animation: none; + transform: scale(1.06); +} +.arcadeBtnIdle:active:not(:disabled) { + animation: none; + transform: translateY(7px); +} +.arcadeBtnIdle:disabled { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + .arcadeBtnIdle { + animation: none; + } +} + +/* Label sits still normally; pops on hover (paired with the button holding big). */ +.arcadeBtnLabel { + display: inline-block; +} +.arcadeBtn:hover:not(:disabled) .arcadeBtnLabel { + animation: arcade-label-pop 0.7s ease-in-out infinite; +} +@keyframes arcade-label-pop { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.12); + } +} +@media (prefers-reduced-motion: reduce) { + .arcadeBtn:hover:not(:disabled) .arcadeBtnLabel { + animation: none; + } +} + +/* Green variant of the arcade button (results "Share your result"). Declared + * after .arcadeBtn so it overrides the purple->orange face on the shared class. + * Avocado candy — matches the game's "go" accent. */ +.arcadeBtnGreen { + border-color: color-mix(in srgb, var(--theme-accent-avocado-default) 52%, #000 48%); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-accent-avocado-default) 65%, #fff 35%) 0%, + var(--theme-accent-avocado-default) 52%, + color-mix(in srgb, var(--theme-accent-avocado-default) 78%, #000 22%) 100% + ); + box-shadow: + 0 9px 0 color-mix(in srgb, var(--theme-accent-avocado-default) 45%, #000 55%), + 0 16px 24px rgba(0, 0, 0, 0.32), + inset 0 3px 6px rgba(255, 255, 255, 0.6), + inset 0 -7px 10px rgba(0, 0, 0, 0.18); +} +.arcadeBtnGreen:active:not(:disabled) { + box-shadow: + 0 2px 0 color-mix(in srgb, var(--theme-accent-avocado-default) 45%, #000 55%), + 0 6px 12px rgba(0, 0, 0, 0.28), + inset 0 3px 6px rgba(255, 255, 255, 0.5); +} + +/* "Fastest" badge — glossy gold→orange pill with a warm glow and a soft pulse + * so the speed award stands out on the row. */ +.fastestBadge { + color: #000; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-accent-cheese-default) 85%, #fff 15%), + var(--theme-accent-bun-default) + ); + box-shadow: + 0 3px 10px color-mix(in srgb, var(--theme-accent-bun-default) 55%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.65); + animation: fastest-glow 2s ease-in-out infinite; +} +@keyframes fastest-glow { + 0%, + 100% { + box-shadow: + 0 3px 10px color-mix(in srgb, var(--theme-accent-bun-default) 45%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.65); + } + 50% { + box-shadow: + 0 3px 16px color-mix(in srgb, var(--theme-accent-bun-default) 75%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.65); + } +} +@media (prefers-reduced-motion: reduce) { + .fastestBadge { + animation: none; + } +} + +/* "All-time superstar" chip — the reigning weekly champion. A richer + * magenta→indigo pill with white text and a glossy shimmer so it clearly + * outranks the gold "Fastest" award. */ +.superstarBadge { + color: #fff; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-accent-cabbage-default) 85%, #fff 15%), + var(--theme-accent-onion-default) + ); + box-shadow: + 0 3px 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + animation: superstar-glow 2s ease-in-out infinite; +} +@keyframes superstar-glow { + 0%, + 100% { + box-shadow: + 0 3px 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + } + 50% { + box-shadow: + 0 3px 18px color-mix(in srgb, var(--theme-accent-cabbage-default) 80%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + } +} +@media (prefers-reduced-motion: reduce) { + .superstarBadge { + animation: none; + } +} + +/* Stroke + soft tint around the all-time champion's row (the "superstar" row), + * so it stands out without an inline chip crowding the name. */ +.superstarRow { + border-color: color-mix( + in srgb, + var(--theme-accent-cabbage-default) 70%, + #fff 30% + ); + background: color-mix( + in srgb, + var(--theme-accent-cabbage-default) 16%, + transparent + ); + box-shadow: 0 0 16px + color-mix(in srgb, var(--theme-accent-cabbage-default) 35%, transparent); +} + +/* Results-screen action buttons (share / challenge / remind) — white-bordered + * arcade pills that lift on hover, matching the game's surface. */ +.resultAction { + display: flex; + flex: 1 1 0; + min-width: 0; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.75rem 0.5rem; + color: #fff; + font-weight: 700; + /* Keep each label on a single line — shrink text before wrapping. */ + font-size: 0.8125rem; + line-height: 1.25rem; + white-space: nowrap; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2); + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.resultAction:hover { + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.2); +} +.resultAction:active { + transform: translateY(0) scale(0.98); +} +/* Confirmed "reminder set" state — avocado tint so it reads as done. */ +.resultActionSet, +.resultActionSet:hover { + border-color: var(--theme-accent-avocado-default); + background: color-mix( + in srgb, + var(--theme-accent-avocado-default) 28%, + transparent + ); +} + +/* Reminder button under the "try again next week" note — a small pill with a + * bell icon and a short label. */ +.reminderButton { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.875rem; + color: #fff; + font-weight: 700; + font-size: 0.8125rem; + line-height: 1.25rem; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + background: rgba(255, 255, 255, 0.14); + transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.reminderButton:hover { + border-color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.22); +} +.reminderButton:active { + transform: scale(0.97); +} +.reminderButtonSet, +.reminderButtonSet:hover { + border-color: var(--theme-accent-avocado-default); + background: color-mix( + in srgb, + var(--theme-accent-avocado-default) 28%, + transparent + ); +} + +/* Intro social buttons (Share / Weekly reminder) — equal-size horizontal + * pressable chips (icon beside label) with a solid slab underneath so they read + * as physical buttons; press down on :active. Fixed width so both match + * regardless of label length. */ +.socialButton { + display: flex; + height: 3rem; + width: 100%; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.5rem; + white-space: nowrap; + color: #fff; + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 16px; + background: rgba(255, 255, 255, 0.16); + box-shadow: + 0 4px 0 rgba(0, 0, 0, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.12s ease, + border-color 0.15s ease; +} +.socialButton:hover { + background: rgba(255, 255, 255, 0.26); + border-color: rgba(255, 255, 255, 0.65); +} +.socialButton:active { + transform: translateY(3px); + box-shadow: + 0 1px 0 rgba(0, 0, 0, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +/* Leaderboard scroll area — a slim white/50% scrollbar over the gradient. */ +.scrollArea { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.5) transparent; +} +.scrollArea::-webkit-scrollbar { + width: 6px; +} +.scrollArea::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.5); + border-radius: 9999px; +} +.scrollArea::-webkit-scrollbar-track { + background: transparent; +} + +/* Translucent glassy chip used for the timer pill and the results login panel. */ +.glass { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.22); + backdrop-filter: blur(6px); +} + +/* Answer tile: glassy by default, then glows green/red on reveal. */ +.tile { + background: rgba(255, 255, 255, 0.12); + border: 2px solid rgba(255, 255, 255, 0.22); + transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease, + box-shadow 0.18s ease; +} +/* No positional movement on hover/press — the "raised" feel comes from the + * shadow only. A translateY here animated the tile out from under the cursor as + * it arrived, so the first click landed outside the moving element and got + * swallowed (needing several taps). Shadow + scale never move the hit area. */ +.tile:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.6); + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18); +} +.tile:active:not(:disabled) { + transform: scale(0.98); +} +.tileCorrect { + background: color-mix(in srgb, var(--theme-accent-avocado-default) 32%, transparent); + border-color: var(--theme-accent-avocado-default); + box-shadow: 0 0 24px color-mix(in srgb, var(--theme-accent-avocado-default) 55%, transparent); +} +.tileWrong { + background: color-mix(in srgb, var(--theme-accent-ketchup-default) 30%, transparent); + border-color: var(--theme-accent-ketchup-default); + box-shadow: 0 0 24px color-mix(in srgb, var(--theme-accent-ketchup-default) 45%, transparent); +} +.tileDimmed { + opacity: 0.45; +} + +/* Progress bar fill — the arcade sweep. */ +.progressFill { + background: linear-gradient( + 90deg, + var(--theme-accent-cheese-default), + var(--theme-accent-bun-default), + var(--theme-accent-bacon-default) + ); + box-shadow: 0 0 12px color-mix(in srgb, var(--theme-accent-bacon-default) 60%, transparent); +} + +/* Score circle on the results screen. */ +.scoreCircle { + background: radial-gradient( + circle at 50% 30%, + color-mix(in srgb, var(--theme-accent-cheese-default) 90%, #fff), + var(--theme-accent-bun-default) 60%, + var(--theme-accent-bacon-default) + ); + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-bacon-default) 55%, transparent), + inset 0 2px 6px rgba(255, 255, 255, 0.4); + color: #fff; +} + +/* Big countdown number. drop-shadow (not box-shadow) so the glow follows the + * glyph, not the element's rectangular box (background-clip: text makes the + * fill text-shaped, but box-shadow would still draw a rectangle behind it). */ +.countNumber { + background: radial-gradient( + circle at 50% 35%, + #fff, + var(--theme-accent-cheese-default) 55%, + var(--theme-accent-bun-default) + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow( + 0 0 22px color-mix(in srgb, var(--theme-accent-cheese-default) 60%, transparent) + ); +} + +/* Arcade banner surface for the feed entry point. */ +.banner { + position: relative; + overflow: hidden; + color: #fff; + background: linear-gradient( + 120deg, + var(--theme-accent-onion-default) 0%, + var(--theme-accent-cabbage-default) 60%, + color-mix(in srgb, var(--theme-accent-cabbage-default) 70%, var(--theme-accent-bacon-default) 30%) 100% + ); + box-shadow: 0 8px 28px color-mix(in srgb, var(--theme-accent-cabbage-default) 40%, transparent); +} diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx new file mode 100644 index 00000000000..21d5336f096 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx @@ -0,0 +1,73 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import { VIcon, MiniCloseIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import type { WeeklyQuizOption } from '../types'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizAnswerOptionProps { + option: WeeklyQuizOption; + index: number; + isSelected: boolean; + isAnswered: boolean; + onSelect: (optionId: string) => void; +} + +const letters = ['A', 'B', 'C', 'D']; + +// A single answer tile. Glassy and poppy before answering; once answered the +// correct option glows green (and pops) and a wrong pick glows red (and +// shakes), with the others dimmed. +export const WeeklyQuizAnswerOption = ({ + option, + index, + isSelected, + isAnswered, + onSelect, +}: WeeklyQuizAnswerOptionProps): ReactElement => { + const showCorrect = isAnswered && option.isCorrect; + const showIncorrect = isAnswered && isSelected && !option.isCorrect; + const dimmed = isAnswered && !option.isCorrect && !isSelected; + + return ( + + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx new file mode 100644 index 00000000000..dc79800f668 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.spec.tsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { WeeklyQuizBanner } from './WeeklyQuizBanner'; +import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import usePersistentContext from '../../../hooks/usePersistentContext'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { useLogContext } from '../../../contexts/LogContext'; +import type { WeeklyQuizStatus } from '../types'; + +jest.mock('../hooks/useWeeklyQuizStatus'); +jest.mock('../../../hooks/useConditionalFeature'); +jest.mock('../../../hooks/usePersistentContext'); +jest.mock('../../../hooks/useLazyModal'); +jest.mock('../../../contexts/LogContext'); + +const mockStatus = jest.mocked(useWeeklyQuizStatus); +const mockFeature = jest.mocked(useConditionalFeature); +const mockPersistent = jest.mocked(usePersistentContext); +const mockLazyModal = jest.mocked(useLazyModal); +const mockLog = jest.mocked(useLogContext); + +const setStatus = (partial: Partial) => { + mockStatus.mockReturnValue({ + status: { + isActive: true, + activeQuizId: 'quiz-1', + hasCompletedThisWeek: false, + hasCompletedLastWeek: false, + thisWeekResult: null, + lastWeekResult: null, + ...partial, + }, + isPending: false, + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + setStatus({}); + mockFeature.mockReturnValue({ value: true, isLoading: false } as never); + mockPersistent.mockReturnValue([false, jest.fn(), true, false]); + mockLazyModal.mockReturnValue({ openModal: jest.fn() } as never); + mockLog.mockReturnValue({ logEvent: jest.fn() } as never); +}); + +it('shows the CTA when the quiz is active, flag on, and not dismissed', () => { + render(); + expect(screen.getByText("Let's play")).toBeInTheDocument(); +}); + +it('renders nothing when the quiz is not active', () => { + setStatus({ isActive: false }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it('renders nothing when the flag is off', () => { + mockFeature.mockReturnValue({ value: false, isLoading: false } as never); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it('renders nothing when dismissed for the week', () => { + mockPersistent.mockReturnValue([true, jest.fn(), true, false]); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it('invites returning players to view the scoreboard once they have played', () => { + setStatus({ hasCompletedThisWeek: true }); + render(); + expect(screen.getByText('View scoreboard')).toBeInTheDocument(); +}); diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx new file mode 100644 index 00000000000..cce1a13b175 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx @@ -0,0 +1,132 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import CloseButton from '../../../components/CloseButton'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../../components/modals/common/types'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureWeeklyQuiz } from '../../../lib/featureManagement'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import usePersistentContext from '../../../hooks/usePersistentContext'; +import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus'; +import { isWeeklyQuizDemo } from '../demoMode'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizBannerProps { + className?: string; +} + +// The feed banner that invites developers into the week's quiz. It's gated by +// the GrowthBook flag AND the server-controlled availability window +// (status.isActive, the last-two-days schedule), and it's dismissible per week +// so it can return next week. Rendered in the feed's in-flow promo slot. +export const WeeklyQuizBanner = ({ + className, +}: WeeklyQuizBannerProps): ReactElement | null => { + const { openModal } = useLazyModal(); + const { logEvent } = useLogContext(); + const { status } = useWeeklyQuizStatus(); + const isActive = !!status?.isActive; + + const { value: flagEnabled } = useConditionalFeature({ + feature: featureWeeklyQuiz, + shouldEvaluate: isActive, + }); + // Demo mode (?weekly-quiz-demo=1) force-enables the banner for preview testing. + const isEnabled = flagEnabled || isWeeklyQuizDemo(); + + // Dismissal is keyed by the active quiz so it resets each week. + const dismissKey = status?.activeQuizId + ? `weekly_quiz_banner_dismissed:${status.activeQuizId}` + : 'weekly_quiz_banner_dismissed'; + const [isDismissed, setIsDismissed, isDismissFetched] = + usePersistentContext(dismissKey, false); + + const shouldShow = isActive && isEnabled && !isDismissed && isDismissFetched; + + useEffect(() => { + if (!shouldShow) { + return; + } + + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.WeeklyQuiz, + }); + // logEvent identity is stable; re-log only when visibility flips on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldShow]); + + if (!shouldShow) { + return null; + } + + const hasPlayed = status?.hasCompletedThisWeek; + + const handleCtaClick = () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.WeeklyQuiz, + }); + openModal({ type: LazyModal.WeeklyQuiz }); + }; + + return ( +
+ + + 🎮 + +
+
+ + Weekly quiz + + + {hasPlayed + ? 'You already played — see how you rank!' + : 'This week in tech news — were you paying attention?'} + +
+ +
+ setIsDismissed(true)} + /> +
+ ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx new file mode 100644 index 00000000000..ab521e81360 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizCountdownProps { + onComplete: () => void; + onTick: () => void; +} + +const SEQUENCE = [3, 2, 1]; +const STEP_MS = 850; + +// A 3-2-1 countdown before the first question. Each number pops in and fires a +// beep via onTick; after the last one it calls onComplete, which starts the +// quiz (and the total timer). Keeps the timer honest by running before the +// clock, not during it. +export const WeeklyQuizCountdown = ({ + onComplete, + onTick, +}: WeeklyQuizCountdownProps): ReactElement => { + const [index, setIndex] = useState(0); + + useEffect(() => { + onTick(); + const timeout = window.setTimeout(() => { + if (index < SEQUENCE.length - 1) { + setIndex((current) => current + 1); + } else { + onComplete(); + } + }, STEP_MS); + + return () => window.clearTimeout(timeout); + // onTick/onComplete are stable (useCallback in the audio/game hooks). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [index]); + + return ( +
+ + Get ready… + + + {SEQUENCE[index]} + +
+ ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx new file mode 100644 index 00000000000..e6fa4fa3560 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx @@ -0,0 +1,252 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import { BellIcon, CalendarIcon, ShareIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { WeeklyQuizScoreboard } from './WeeklyQuizScoreboard'; +import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover'; +import { WeeklyQuizPeriod } from '../types'; +import type { WeeklyQuiz } from '../types'; +import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio'; +import styles from '../WeeklyQuiz.module.css'; + +const LOGO_URL = '/logos/weekly-quiz-logo.png'; + +// How many source logos the intro shows before collapsing the rest into "+N". +// Kept low so the "+N" pill sits inline on the same row as the logos. +const SHOWN_SOURCES = 5; + +interface WeeklyQuizIntroProps { + quiz: WeeklyQuiz | undefined; + isLoading: boolean; + onStart: () => void; + audio?: UseWeeklyQuizAudio; + // Locked out for the week — already played (server flag or a spent local run). + alreadyPlayed?: boolean; +} + +// "Jul 20–26, 2026" from the quiz's inclusive ISO date range. Parsed from parts +// (not new Date(iso)) so the label doesn't shift a day across timezones. +const formatWeekRange = (start: string, end: string): string => { + const parse = (iso: string): Date => { + const [y, m, d] = iso.split('-').map(Number); + return new Date(y, m - 1, d); + }; + const s = parse(start); + const e = parse(end); + const month = (date: Date): string => + date.toLocaleDateString('en-US', { month: 'short' }); + const year = e.getFullYear(); + return s.getMonth() === e.getMonth() + ? `${month(s)} ${s.getDate()}–${e.getDate()}, ${year}` + : `${month(s)} ${s.getDate()} – ${month(e)} ${e.getDate()}, ${year}`; +}; + +// Landing screen: two separated floating panels — left holds the logo, the +// arcade Start button and social actions; right holds the leaderboard. Stacks +// on mobile, side-by-side from tablet up. +export const WeeklyQuizIntro = ({ + quiz, + isLoading, + onStart, + audio, + alreadyPlayed = false, +}: WeeklyQuizIntroProps): ReactElement => { + const { user } = useAuthContext(); + const [period, setPeriod] = useState( + WeeklyQuizPeriod.Weekly, + ); + // Local-only until the reminder subscription is wired to the backend. + const [reminderSet, setReminderSet] = useState(false); + const [isShareOpen, setIsShareOpen] = useState(false); + const questionCount = quiz?.questions.length ?? 0; + + // Mouse-driven 3D tilt on the logo: rotate toward the cursor, reset on leave. + const logoRef = useRef(null); + const tiltLogo = (event: MouseEvent): void => { + const el = logoRef.current; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + const px = (event.clientX - rect.left) / rect.width - 0.5; + const py = (event.clientY - rect.top) / rect.height - 0.5; + const max = 16; + el.style.transform = `perspective(600px) rotateY(${px * max}deg) rotateX(${ + -py * max + }deg) scale(1.06)`; + }; + const resetLogoTilt = (): void => { + if (logoRef.current) { + logoRef.current.style.transform = ''; + } + }; + + return ( +
+
+

+ {/* Padding enlarges the mouse-tracked hover box beyond the logo art + so the 3D tilt reacts over a more generous area. */} + + + The Weekly Tech News Quiz + {/* Glow sits over the logo's lightbulb (right side, mid-height). */} + + + +

+ + {alreadyPlayed ? ( +
+ + + Try again next week! + +
+ ) : ( + + )} + + {/* Which week + how much news it distils — makes it clear the quiz + recaps the week that just ended, not an older one. */} + {quiz && ( +
+ + + {formatWeekRange(quiz.startDate, quiz.endDate)} + + + {quiz.storyCount} stories from {quiz.sourceCount} sources, + distilled into {questionCount} questions. + + {quiz.topSources.length > 0 && ( +
+ {quiz.topSources.slice(0, SHOWN_SOURCES).map((source) => ( + {source.name} + ))} + {quiz.sourceCount > SHOWN_SOURCES && ( + + +{quiz.sourceCount - SHOWN_SOURCES} + + )} +
+ )} +
+ )} + + {/* Share + weekly reminder, always available. (Challenge-a-friend is + stashed for now.) */} +
+ + +
+
+ + {isShareOpen && ( + setIsShareOpen(false)} /> + )} + +
+ +
+
+ ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx new file mode 100644 index 00000000000..f16240374ab --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx @@ -0,0 +1,159 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect } from 'react'; +import type { ModalProps } from '../../../components/modals/common/Modal'; +import { Modal } from '../../../components/modals/common/Modal'; +import { usePrompt } from '../../../hooks/usePrompt'; +import { useWeeklyQuizStatus } from '../hooks/useWeeklyQuizStatus'; +import { useWeeklyQuiz } from '../hooks/useWeeklyQuiz'; +import { useWeeklyQuizGame, WeeklyQuizPhase } from '../hooks/useWeeklyQuizGame'; +import { useWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio'; +import { useWeeklyQuizPlayed } from '../hooks/useWeeklyQuizPlayed'; +import { WeeklyQuizIntro } from './WeeklyQuizIntro'; +import { WeeklyQuizCountdown } from './WeeklyQuizCountdown'; +import { WeeklyQuizQuestion } from './WeeklyQuizQuestion'; +import { WeeklyQuizResults } from './WeeklyQuizResults'; +import { WeeklyQuizSideControls } from './WeeklyQuizSideControls'; +import styles from '../WeeklyQuiz.module.css'; + +// The Weekly Quiz overlay: an intro (welcome text + scoreboard + start), a +// 3-2-1 countdown, the stepped question flow, and the results. State lives in +// useWeeklyQuizGame; audio (looping music + countdown beeps) lives in +// useWeeklyQuizAudio, shared across phases and toggled by the header button. +function WeeklyQuizModal({ + onRequestClose, + ...props +}: ModalProps): ReactElement { + const { status } = useWeeklyQuizStatus(); + const quizId = status?.activeQuizId; + const { quiz, isPending } = useWeeklyQuiz(quizId); + const game = useWeeklyQuizGame(quiz); + const audio = useWeeklyQuizAudio(); + const { showPrompt } = usePrompt(); + const { hasPlayed, markPlayed } = useWeeklyQuizPlayed(quizId); + + const { phase } = game; + const { startMusic, stopMusic } = audio; + // The quiz is live once the player leaves the intro (countdown + questions). + const isInProgress = + phase === WeeklyQuizPhase.Countdown || phase === WeeklyQuizPhase.Question; + // A one-shot per week: the server flag, or the local commitment we persist the + // moment they start. Either one locks the intro's Start button. + const alreadyPlayed = hasPlayed || !!status?.hasCompletedThisWeek; + + // Background music is lobby ambiance: it plays on the intro and results + // screens (autoplay is allowed — the modal opens from a click) and stops + // during the countdown + questions so gameplay sound effects aren't competing + // with the loop. The modal unmount fully releases it (handled in + // useWeeklyQuizAudio). + useEffect(() => { + if (phase === WeeklyQuizPhase.Intro || phase === WeeklyQuizPhase.Results) { + startMusic(); + } else { + stopMusic(); + } + }, [phase, startMusic, stopMusic]); + + // Spend the attempt as soon as the player commits (leaves the intro). Persists + // right away, so a refresh mid-quiz can't hand them a fresh run. + useEffect(() => { + if (phase !== WeeklyQuizPhase.Intro) { + markPlayed(); + } + }, [phase, markPlayed]); + + // Guard a page refresh / tab close while the quiz is live with the browser's + // native "leave site?" prompt. The attempt is already spent, so leaving just + // means forfeiting the run. + useEffect(() => { + if (!isInProgress) { + return undefined; + } + const handler = (event: BeforeUnloadEvent): void => { + event.preventDefault(); + // Required for Chrome to actually show the native leave prompt. + // eslint-disable-next-line no-param-reassign + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isInProgress]); + + // Intercept every in-app close route (X, Esc, overlay) while the quiz is live + // to confirm the player really means to forfeit their one run. Void-returning + // so it drops straight into onRequestClose / onClick handler slots. + const handleRequestClose = useCallback( + (event: React.MouseEvent | React.KeyboardEvent): void => { + if (!isInProgress) { + onRequestClose?.(event); + return; + } + showPrompt({ + title: 'Leave the quiz?', + description: + "You only get one run this week. If you leave now your quiz is over — you won't be able to start it again.", + okButton: { title: 'Leave quiz' }, + cancelButton: { title: 'Keep playing' }, + }).then((confirmed) => { + if (confirmed) { + onRequestClose?.(event); + } + }); + }, + [isInProgress, onRequestClose, showPrompt], + ); + + return ( + +
+
+ + {phase === WeeklyQuizPhase.Intro && ( + + )} + {phase === WeeklyQuizPhase.Countdown && ( + + )} + {phase === WeeklyQuizPhase.Question && ( + + )} + {phase === WeeklyQuizPhase.Results && game.result && quizId && ( + + )} +
+ +
+
+ ); +} + +export default WeeklyQuizModal; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx new file mode 100644 index 00000000000..41d48dcd06b --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion.tsx @@ -0,0 +1,137 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { WeeklyQuizTimer } from './WeeklyQuizTimer'; +import { WeeklyQuizAnswerOption } from './WeeklyQuizAnswerOption'; +import type { UseWeeklyQuizGame } from '../hooks/useWeeklyQuizGame'; +import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizQuestionProps { + game: UseWeeklyQuizGame; + audio?: UseWeeklyQuizAudio; +} + +// How long the correct/incorrect reveal stays before auto-advancing. +const FEEDBACK_MS = 1000; + +// One question screen: running timer + progress up top, the prompt and four +// answer tiles. After an answer is picked the tiles reveal correct/incorrect +// and the quiz auto-advances after a short pause — no Next button. Audio: a +// click on answer and a switch sound on advance. +export const WeeklyQuizQuestion = ({ + game, + audio, +}: WeeklyQuizQuestionProps): ReactElement | null => { + const { + question, + questionNumber, + totalQuestions, + selectedOptionId, + isAnswered, + elapsedMs, + answer, + next, + } = game; + // Destructure the stable callbacks so effects don't thrash on the audio + // object's per-render identity. + const { playAnswer, playCorrect, playSwitch } = audio ?? {}; + + const handleSelect = (optionId: string): void => { + // Idempotent: pointer-down already selected; ignore the trailing click. + if (isAnswered) { + return; + } + // Correct pick gets the celebratory "treasure" sound; otherwise the click. + const picked = question?.options.find((option) => option.id === optionId); + if (picked?.isCorrect) { + playCorrect?.(); + } else { + playAnswer?.(); + } + answer(optionId); + }; + + // Hold the reveal for a beat, then move on (to the next question or results), + // playing the switch sound as we transition. + useEffect(() => { + if (!isAnswered) { + return undefined; + } + const timeout = window.setTimeout(() => { + playSwitch?.(); + next(); + }, FEEDBACK_MS); + return () => window.clearTimeout(timeout); + }, [isAnswered, next, playSwitch]); + + if (!question) { + return null; + } + + const progress = (questionNumber / totalQuestions) * 100; + + return ( +
+
+ + Question {questionNumber} of {totalQuestions} + + +
+ +
+
+
+ +
+ + {question.prompt} + + +
+ {question.options.map((option, index) => ( + + ))} +
+
+
+ ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx new file mode 100644 index 00000000000..764fe243128 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx @@ -0,0 +1,286 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { AuthTriggers } from '../../../lib/auth'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { ArrowIcon, BellIcon, UserShareIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover'; +import { formatElapsed } from './WeeklyQuizTimer'; +import { useSubmitWeeklyQuiz } from '../hooks/useSubmitWeeklyQuiz'; +import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard'; +import { generateWeeklyQuizResultImage } from '../generateResultImage'; +import { isWeeklyQuizDemo } from '../demoMode'; +import type { WeeklyQuizGameResult } from '../hooks/useWeeklyQuizGame'; +import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio'; +import { WeeklyQuizPeriod } from '../types'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizResultsProps { + quizId: string; + result: WeeklyQuizGameResult; + audio?: UseWeeklyQuizAudio; + // Return to the intro ("main"), which shows the full leaderboard. + onBackToMain: () => void; +} + +const buildMessage = (correct: number, total: number): string => { + const ratio = total === 0 ? 0 : correct / total; + if (ratio === 1) { + return 'Flawless — you were paying attention this week!'; + } + if (ratio >= 0.6) { + return 'Nicely done. You know your tech news.'; + } + if (ratio >= 0.3) { + return 'Not bad — a few slipped past you.'; + } + return 'Tough week? There is always next week.'; +}; + +// Final screen: a back arrow to the main screen, then the player's own result +// as the hero (placement + score + time) and the share/reminder actions. The +// full leaderboard lives on the main screen, not here. Logged-in players' +// results are submitted once on arrival (and again if an anonymous player signs +// in from here). +export const WeeklyQuizResults = ({ + quizId, + result, + onBackToMain, +}: WeeklyQuizResultsProps): ReactElement => { + const { user, showLogin } = useAuthContext(); + const { submit } = useSubmitWeeklyQuiz(); + // Local-only until the reminder subscription is wired to the backend. + const [reminderSet, setReminderSet] = useState(false); + const [isChallengeOpen, setIsChallengeOpen] = useState(false); + const submittedRef = useRef(false); + // Rank comes from this week's board (the quiz just finished). + const { leaderboard, viewerEntry } = useWeeklyQuizLeaderboard( + WeeklyQuizPeriod.Weekly, + ); + + // The player's rank — pinned viewer row when out of the top list, otherwise + // their in-list row. + const rank = + viewerEntry?.rank ?? + leaderboard.find((entry) => entry.isCurrentUser)?.rank ?? + null; + + // Renders the result as a shareable PNG and downloads it. Target may change + // later (native share sheet / upload); for now it saves locally. + const handleShareResult = (): void => { + generateWeeklyQuizResultImage({ + name: user?.name || user?.username || 'You', + imageUrl: user?.image, + correctCount: result.correctCount, + totalQuestions: result.totalQuestions, + timeLabel: formatElapsed(result.timeMs), + rank, + logoUrl: '/logos/weekly-quiz-logo.png', + }).catch(() => undefined); + }; + + // Submit once we have an authenticated player — either immediately (already + // logged in) or right after they sign in from the prompt below. Skipped in + // demo mode (no backend to submit to). + useEffect(() => { + if (!user || submittedRef.current || isWeeklyQuizDemo()) { + return; + } + + submittedRef.current = true; + submit({ + quizId, + answers: result.answers, + timeMs: result.timeMs, + }).catch(() => { + // Allow a retry if the submission failed. + submittedRef.current = false; + }); + }, [user, submit, quizId, result]); + + return ( +
+ {/* Big icon-only back arrow to the main screen, top-left. */} + + +
+ {/* Achievement first: the player's own placement is the hero, with their + avatar and a medal-style rank badge. Only when we know their rank. */} + {rank && user && ( +
+
+ + + #{rank} + +
+ + You placed #{rank}! + +
+ )} + {/* Score and time carry equal weight — two matching stat circles. */} +
+
+ + {result.correctCount}/{result.totalQuestions} + + + Correct + +
+
+ + {formatElapsed(result.timeMs)} + + + Time + +
+
+ + {buildMessage(result.correctCount, result.totalQuestions)} + +
+ + {/* Primary action: share, styled and animated like the Start button. */} + + + {/* Secondary actions. */} +
+ + +
+ + {isChallengeOpen && ( + setIsChallengeOpen(false)} + /> + )} + + {!user && ( +
+ + Log in to save your score and claim your spot + + +
+ )} +
+ ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.spec.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.spec.tsx new file mode 100644 index 00000000000..4e9cb4cee36 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.spec.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { WeeklyQuizScoreboard } from './WeeklyQuizScoreboard'; +import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { WeeklyQuizPeriod } from '../types'; + +jest.mock('../hooks/useWeeklyQuizLeaderboard'); +jest.mock('../../../contexts/AuthContext'); + +const mockLeaderboard = jest.mocked(useWeeklyQuizLeaderboard); +const mockAuth = jest.mocked(useAuthContext); +const showLogin = jest.fn(); + +const renderScoreboard = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockLeaderboard.mockReturnValue({ + leaderboard: [ + { + id: 'a', + rank: 1, + name: 'Ada', + username: 'ada', + image: 'https://example.com/a.png', + correctCount: 5, + totalQuestions: 5, + timeMs: 42000, + }, + { + id: 'b', + rank: 2, + name: 'Bob', + username: 'bob', + image: 'https://example.com/b.png', + correctCount: 4, + totalQuestions: 5, + timeMs: 30000, + }, + ], + viewerEntry: null, + isPending: false, + }); +}); + +it('locks the scoreboard behind login for anonymous visitors', () => { + mockAuth.mockReturnValue({ user: null, showLogin } as never); + + renderScoreboard(); + + expect(screen.getByText('Log in to see the scoreboard')).toBeInTheDocument(); + // Real standings must not be exposed to anonymous visitors. + expect(screen.queryByText('Ada')).not.toBeInTheDocument(); +}); + +it('renders ranked rows with scores for logged-in players', () => { + mockAuth.mockReturnValue({ + user: { id: 'a', name: 'Ada' }, + showLogin, + } as never); + + renderScoreboard(); + + expect(screen.getByText('Ada')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect( + screen.queryByText('Log in to see the scoreboard'), + ).not.toBeInTheDocument(); +}); + +it('pins the viewer row when they rank outside the visible list', () => { + mockAuth.mockReturnValue({ + user: { id: 'me', name: 'Me' }, + showLogin, + } as never); + mockLeaderboard.mockReturnValue({ + leaderboard: [ + { + id: 'a', + rank: 1, + name: 'Ada', + username: 'ada', + image: 'https://example.com/a.png', + correctCount: 5, + totalQuestions: 5, + timeMs: 42000, + }, + ], + viewerEntry: { + id: 'me', + rank: 42, + name: 'Me', + username: 'me', + image: 'https://example.com/me.png', + correctCount: 3, + totalQuestions: 5, + timeMs: 60000, + isCurrentUser: true, + }, + isPending: false, + }); + + renderScoreboard(); + + // The pinned row shows the viewer's own out-of-list rank. + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Me')).toBeInTheDocument(); +}); diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.tsx new file mode 100644 index 00000000000..0aafa2780fb --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.tsx @@ -0,0 +1,320 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import { ProfileTooltip } from '../../../components/profile/ProfileTooltip'; +import { ReputationUserBadge } from '../../../components/ReputationUserBadge'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { ReputationLightningIcon, StarIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { AuthTriggers } from '../../../lib/auth'; +import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard'; +import { isWeeklyQuizDemo } from '../demoMode'; +import type { UseWeeklyQuizAudio } from '../hooks/useWeeklyQuizAudio'; +import { WeeklyQuizPeriod } from '../types'; +import type { WeeklyQuizLeaderboardEntry } from '../types'; +import { formatElapsed } from './WeeklyQuizTimer'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizScoreboardProps { + period: WeeklyQuizPeriod; + onPeriodChange: (period: WeeklyQuizPeriod) => void; + audio?: UseWeeklyQuizAudio; + // Fill the parent's height and scroll internally (intro's stretched panel). + fillHeight?: boolean; + // Cap how many ranked rows render (e.g. a short top-5 on the results screen). + // The pinned viewer row still shows below when they sit outside the cap. + limit?: number; +} + +const periodTabs = [ + { label: 'Weekly', value: WeeklyQuizPeriod.Weekly }, + { label: 'Monthly', value: WeeklyQuizPeriod.Monthly }, + { label: 'All time', value: WeeklyQuizPeriod.AllTime }, +]; + +// The columns of a scoreboard row, shared by the in-list rows and the pinned +// "your rank" row so they stay visually identical. +const RowContent = ({ + entry, + isFastest, +}: { + entry: WeeklyQuizLeaderboardEntry; + isFastest: boolean; +}): ReactElement => ( + <> + + {entry.rank} + + + + +
+ + {entry.name} + + +
+ {isFastest && ( + + + Fastest + + )} + + {entry.correctCount}/{entry.totalQuestions} + + + {formatElapsed(entry.timeMs)} + + +); + +const ScoreboardRow = ({ + entry, + isFastest, +}: { + entry: WeeklyQuizLeaderboardEntry; + isFastest: boolean; +}): ReactElement => { + // The all-time champion gets a "superstar" banner above the row and a stroke + // around it, so the label never crowds out their name. + if (entry.isAllTimeSuperstar) { + return ( +
  • + {/* The chip straddles the row's top border, like a ribbon/label. */} + + + Superstar + + +
  • + ); + } + + return ( +
  • + +
  • + ); +}; + +// The scoreboard: a plain scrollable ranked list (#1 down to the cap), ranked +// by correct answers with total time as the tiebreak. The This week / Last week +// toggle switches the queried period. Locked behind login — anonymous players +// see a blurred teaser and a sign-in prompt instead of real standings. +export const WeeklyQuizScoreboard = ({ + period, + onPeriodChange, + audio, + fillHeight = false, + limit, +}: WeeklyQuizScoreboardProps): ReactElement => { + const { user, showLogin } = useAuthContext(); + const { leaderboard, viewerEntry, isPending } = + useWeeklyQuizLeaderboard(period); + // Demo mode shows the mock board even to anonymous preview testers. + const showBoard = !!user || isWeeklyQuizDemo(); + // Optionally cap the rendered rows (short board on the results screen). + const visibleLeaderboard = limit ? leaderboard.slice(0, limit) : leaderboard; + + // Whoever finished in the least total time earns the "Fastest" badge — + // independent of rank, since rank leads with correct answers. + const fastestId = leaderboard.length + ? leaderboard.reduce((fastest, entry) => + entry.timeMs < fastest.timeMs ? entry : fastest, + ).id + : null; + + return ( +
    +
    + + + Leaderboard + +
    + {/* Browser-style tabs sitting on top of the leaderboard "window": the + active tab shares the window's background so it reads as connected. */} +
    +
    + {periodTabs.map((tab) => { + const isActive = period === tab.value; + return ( + + ); + })} +
    + +
    + {!showBoard ? ( +
    +
      + {[1, 2, 3, 4].map((rank) => ( +
    • + + + +
    • + ))} +
    +
    + + Log in to see the scoreboard + + +
    +
    + ) : ( + <> + {isPending && ( +
    + +
    + )} + {!isPending && leaderboard.length === 0 && ( + + No scores yet — be the first to play! + + )} + {!isPending && leaderboard.length > 0 && ( +
      + {visibleLeaderboard.map((entry) => ( + + ))} +
    + )} + {/* Pinned "your rank" row, shown only when the player sits outside + the visible top list. White border sets it apart. Null for + anon and for anyone without a standing in this period. */} + {!isPending && viewerEntry && ( +
    + +
    + )} + + )} +
    +
    +
    + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx new file mode 100644 index 00000000000..3742e0dd7e4 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSharePopover.tsx @@ -0,0 +1,106 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { MiniCloseIcon, CopyIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizSharePopoverProps { + onClose: () => void; + // Copy override so the same link popover can read as "share the quiz" from the + // intro and "challenge your team" from the results screen. + title?: string; + description?: string; +} + +// An in-card popover for sharing the quiz link: a big close button, a heading, +// the shareable URL in a read-only field, and a copy button. Rendered as an +// overlay inside the quiz surface (not a separate modal). +export const WeeklyQuizSharePopover = ({ + onClose, + title = 'Share the quiz', + description, +}: WeeklyQuizSharePopoverProps): ReactElement => { + const [copied, setCopied] = useState(false); + // Placeholder link until the real shareable quiz URL is wired up. + const url = 'https://daily.dev/quiz/weekly-tech-news'; + + const copy = (): void => { + navigator.clipboard + ?.writeText(url) + .then(() => setCopied(true)) + .catch(() => undefined); + }; + + return ( +
    + +
    + + {title} + + {description && ( + + {description} + + )} +
    +
    + event.target.select()} + className="min-w-0 flex-1 rounded-10 bg-white px-3 py-2 text-black typo-footnote" + /> + +
    +
    +
    + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls.tsx new file mode 100644 index 00000000000..fe045081354 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + VolumeIcon, + VolumeLowIcon, + VolumeOffIcon, + MiniCloseIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { WeeklyQuizAudioLevel } from '../hooks/useWeeklyQuizAudio'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizSideControlsProps { + level: WeeklyQuizAudioLevel; + onCycleSound: () => void; + onClose: (event: React.MouseEvent) => void; +} + +const levelIcon: Record = { + [WeeklyQuizAudioLevel.Normal]: VolumeIcon, + [WeeklyQuizAudioLevel.Less]: VolumeLowIcon, + [WeeklyQuizAudioLevel.Muted]: VolumeOffIcon, +}; + +const levelLabel: Record = { + [WeeklyQuizAudioLevel.Normal]: 'Sound on', + [WeeklyQuizAudioLevel.Less]: 'Sound low', + [WeeklyQuizAudioLevel.Muted]: 'Sound off', +}; + +// The mute + close controls, stacked vertically. Rendered as a sibling of the +// quiz card (not inside it) so they sit just outside its right edge, each with +// the card's white border + gradient. +export const WeeklyQuizSideControls = ({ + level, + onCycleSound, + onClose, +}: WeeklyQuizSideControlsProps): ReactElement => { + const SoundIcon = levelIcon[level]; + + return ( +
    + + +
    + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx new file mode 100644 index 00000000000..538429ad9ed --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizTimer.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import { TimerIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; + +interface WeeklyQuizTimerProps { + elapsedMs: number; + isPaused: boolean; +} + +export const formatElapsed = (elapsedMs: number): string => { + const totalSeconds = Math.floor(elapsedMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +// The running total timer. Ticks up while the player is thinking and freezes +// (dimmed) while feedback is on screen, so reading feedback never costs time. +export const WeeklyQuizTimer = ({ + elapsedMs, + isPaused, +}: WeeklyQuizTimerProps): ReactElement => { + return ( +
    + + + {formatElapsed(elapsedMs)} + +
    + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/demoMode.ts b/packages/shared/src/features/weeklyQuiz/demoMode.ts new file mode 100644 index 00000000000..a18cd3ae8d1 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/demoMode.ts @@ -0,0 +1,475 @@ +import { sampleWeeklyQuiz } from './sampleWeeklyQuiz'; +import { WeeklyQuizPeriod } from './types'; +import type { WeeklyQuizLeaderboardEntry, WeeklyQuizStatus } from './types'; + +// TEMPORARY demo scaffolding so the flag-gated, backend-less quiz can be tested +// on a webapp preview: append `?weekly-quiz-demo=1` to any URL. It flips the +// feature on and the data hooks return the sample/mock content below instead of +// hitting GraphQL. Sticky for the session so it survives feed navigation. +// Remove once the backend + flag are live. +const DEMO_PARAM = 'weekly-quiz-demo'; +const STORAGE_KEY = 'weekly_quiz_demo'; + +export const isWeeklyQuizDemo = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + try { + if (new URLSearchParams(window.location.search).has(DEMO_PARAM)) { + window.sessionStorage.setItem(STORAGE_KEY, '1'); + return true; + } + return window.sessionStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +// Turn demo mode on programmatically (used by the standalone preview page). +export const enableWeeklyQuizDemo = (): void => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem(STORAGE_KEY, '1'); + } catch { + // Ignore storage failures (private mode, etc.). + } +}; + +export const demoStatus: WeeklyQuizStatus = { + isActive: true, + activeQuizId: sampleWeeklyQuiz.id, + hasCompletedThisWeek: false, + hasCompletedLastWeek: false, + thisWeekResult: null, + lastWeekResult: null, +}; + +// Real daily.dev members pulled from the public reputation leaderboard, used +// only so the preview looks populated for testing. Names/avatars/reputation are +// their real public profile data; the quiz scores/times below are synthetic +// (there's no live quiz backend yet). TEMPORARY — remove with the rest of the +// demo scaffolding once the real leaderboard ships. +const DEMO_USERS = [ + { + id: '9aDj6fGmy', + name: 'Bobby Iliev', + username: 'bobbyiliev', + reputation: 73820, + image: 'https://avatars3.githubusercontent.com/u/21223421?v=4', + }, + { + id: 'HXYbbGcBO38Rfv7RrCBdA', + name: 'Randy', + username: 'randy', + reputation: 68700, + image: + 'https://media.daily.dev/image/upload/s--UjV4-KkB--/f_auto/v1708097210/avatars/avatar_HXYbbGcBO38Rfv7RrCBdA', + }, + { + id: 'XDCZD-PHG', + name: 'Ole-Martin', + username: 'ombratteng', + reputation: 65250, + image: 'https://avatars.githubusercontent.com/u/1681525?v=4', + }, + { + id: 'iaC4JsBU0lV8wBsc85fSh', + name: 'Joud Awad', + username: 'joudawad', + reputation: 61520, + image: + 'https://media.daily.dev/image/upload/s--dOB9RaXY--/f_auto/v1773320801/avatars/avatar_iaC4JsBU0lV8wBsc85fSh?_a=BAMAMiiu0', + }, + { + id: 'yRuVFf6IbfTylBjx9Dzvt', + name: 'Denis Bolkovskis', + username: 'denisb0', + reputation: 56490, + image: + 'https://media.daily.dev/image/upload/s--PGCuYx85--/f_auto,q_auto/v1/avatars/avatar_yRuVFf6IbfTylBjx9Dzvt', + }, + { + id: 'pWEhX8JhjnUQB2l4CNVSW', + name: 'OrcDev', + username: 'orcdev', + reputation: 54710, + image: 'https://avatars.githubusercontent.com/u/7549148?v=4', + }, + { + id: 'WVJSfJtDe63PxQFAsmXFO', + name: 'Anja P', + username: 'anjapcodes', + reputation: 50790, + image: + 'https://media.daily.dev/image/upload/s--M_c0s8Ky--/f_auto/v1721658650/avatars/avatar_WVJSfJtDe63PxQFAsmXFO', + }, + { + id: 'JUNiIGCV-', + name: 'Chris Bongers', + username: 'dailydevtips', + reputation: 49255, + image: + 'https://media.daily.dev/image/upload/s--9gxFz1e7--/f_auto/v1705902590/avatars/avatar_JUNiIGCV-?_a=BAMAMiZW0', + }, + { + id: 'V7baLm8Y0o32yjz1hHf5a', + name: 'Fabian Letsch', + username: 'fabianletsch', + reputation: 48660, + image: + 'https://lh3.googleusercontent.com/a/ACg8ocKR6BVy_wn23EoOKq7-BlszlcXcLmASlnb7l-GtS-q1bePnkaJf=s96-c', + }, + { + id: 'otqAJUf6zdM9hfRwTlR9n', + name: 'Isaac de Andrade', + username: 'andradei', + reputation: 48510, + image: 'https://avatars.githubusercontent.com/u/2653546?v=4', + }, + { + id: '29TCpY2hJR72V3BlxPXzX', + name: 'Daniel', + username: 'akkitto', + reputation: 47830, + image: + 'https://media.daily.dev/image/upload/s--FtwJqX4c--/f_auto/v1754900041/avatars/avatar_29TCpY2hJR72V3BlxPXzX?_a=BAMClqZW0', + }, + { + id: 'IXalnaxWMtGrFtZ6XDX4U', + name: 'Kirill Kurko', + username: 'kkurko', + reputation: 31350, + image: 'https://avatars.githubusercontent.com/u/58859242?v=4', + }, + { + id: 'G85JhYcDxAg024Omc8Rlp', + name: 'Damien seguy', + username: 'damienseguy', + reputation: 30390, + image: + 'https://media.daily.dev/image/upload/s--nPbjKqol--/f_auto,q_auto/v1698422671/avatars/avatar_G85JhYcDxAg024Omc8Rlp', + }, + { + id: 'eCYKGSsVlzKQY1G7NxkJc', + name: 'Friedrich WT', + username: 'friedrich', + reputation: 30070, + image: 'https://avatars.githubusercontent.com/u/136119888?v=4', + }, + { + id: '5zahWGRIGj4Y3VQ3jIlBS', + name: 'Debajyati Dey', + username: 'debajyatidey', + reputation: 29900, + image: 'https://avatars.githubusercontent.com/u/127122455?v=4', + }, + { + id: 'Qz65P1nVw3Bu6C5YwaZgA', + name: 'Peter Mrożek', + username: 'petermrozek', + reputation: 29845, + image: + 'https://media.daily.dev/image/upload/s--pBfYX68K--/f_auto/v1769247960/avatars/avatar_Qz65P1nVw3Bu6C5YwaZgA?_a=BAMAMiiu0', + }, + { + id: 'bu452DQxxXbGcj178k2RJ', + name: 'Tessa van der Heijden', + username: 'tvdheijden', + reputation: 29550, + image: + 'https://media.daily.dev/image/upload/s--efFpmmNm--/f_auto/v1705569455/avatars/avatar_bu452DQxxXbGcj178k2RJ', + }, + { + id: 'dR92D50hh', + name: 'George', + username: 'feketegy', + reputation: 27520, + image: + 'https://media.daily.dev/image/upload/s--YeiBYVuL--/f_auto/v1743758421/avatars/avatar_dR92D50hh', + }, + { + id: 'WXcnVdXbQljWuHJCBcViw', + name: 'Divyesh Vekariya', + username: 'divyesh_vekariya', + reputation: 27320, + image: + 'https://media.daily.dev/image/upload/s--RDCl1xsD--/f_auto/v1728905544/avatars/avatar_WXcnVdXbQljWuHJCBcViw', + }, + { + id: '9pqnrpoJdg1CfgqIBuMCl', + name: 'Hadil Ben Abdallah', + username: 'hadilbenabdallah', + reputation: 26580, + image: + 'https://media.daily.dev/image/upload/s--npVXQBAk--/f_auto/v1726134205/avatars/avatar_9pqnrpoJdg1CfgqIBuMCl', + }, + { + id: 'g4ZJecDKXrCh6bYVK1iyV', + name: 'Szymon Omieciński', + username: 'simon125q', + reputation: 25520, + image: + 'https://media.daily.dev/image/upload/s--ZNKkeUvQ--/f_auto/v1762695887/avatars/avatar_g4ZJecDKXrCh6bYVK1iyV?_a=BAMAK+ZW0', + }, + { + id: 'cmPs2DO0hyj5zVizDLJDZ', + name: 'Mouad Dadda', + username: 'mouad_dadda', + reputation: 22540, + image: + 'https://media.daily.dev/image/upload/s--qvecFxpC--/f_auto/v1746013117/avatars/avatar_cmPs2DO0hyj5zVizDLJDZ', + }, + { + id: 'zyFqB01G2vXeC6aJycktp', + name: 'Ezpie', + username: 'ezpie', + reputation: 22300, + image: 'https://avatars.githubusercontent.com/u/104765117?v=4', + }, + { + id: 'sDzCovimjL', + name: 'TheCoverLiker', + username: 'thecoverliker', + reputation: 20940, + image: + 'https://media.daily.dev/image/upload/v1681623072/avatars/avatar_sDzCovimjL.jpg', + }, + { + id: 'SOwG1kJqf6HK6TkGmZH45', + name: 'Daniel', + username: 'daniel8000', + reputation: 20900, + image: + 'https://media.daily.dev/image/upload/s--F_RltCZK--/f_auto/v1738746891/avatars/avatar_SOwG1kJqf6HK6TkGmZH45', + }, + { + id: '5cQvIZKr5tDFDotVDjulg', + name: 'Jacob B. Bonde', + username: 'byteoutlaw', + reputation: 20895, + image: + 'https://media.daily.dev/image/upload/s--veTChHK7--/f_auto/v1733220070/avatars/avatar_5cQvIZKr5tDFDotVDjulg', + }, + { + id: 'Su5HqluAE4wLRb1naHjtv', + name: 'Serdarcan Buyukdereli', + username: 'serdarbuyukdereli', + reputation: 20380, + image: + 'https://media.daily.dev/image/upload/s--tTV8hAPq--/f_auto/v1778701721/avatars/avatar_Su5HqluAE4wLRb1naHjtv?_a=BAMAMiWQ0', + }, + { + id: 'SEMcvjKuE', + name: 'Kevin', + username: 'kyoukhana', + reputation: 20115, + image: 'https://avatars2.githubusercontent.com/u/756849?v=4', + }, + { + id: 'N403VFK9lAHNRwlSAhl1h', + name: 'Phillippe Michel Weir', + username: 'a_meb_a', + reputation: 19980, + image: + 'https://media.daily.dev/image/upload/s--BwH-uTMq--/f_auto/v1740136991/avatars/avatar_N403VFK9lAHNRwlSAhl1h', + }, + { + id: 'iWZFqWGzJuZ3TMf4ZW9aZ', + name: 'Anmol Baranwal', + username: 'anmolbaranwal', + reputation: 19650, + image: 'https://avatars.githubusercontent.com/u/74038190?v=4', + }, + { + id: 'CuJEPTEsDJlaKzfXt7xfz', + name: 'Dickson A.', + username: 'fabidick22', + reputation: 19000, + image: 'https://avatars.githubusercontent.com/u/8176821?v=4', + }, + { + id: 'umWZ9aQAng34qk5aaJl2q', + name: 'Lars Faye | Confident Coding', + username: 'confidentcoding', + reputation: 18970, + image: + 'https://media.daily.dev/image/upload/s--OGZu5DEc--/f_auto/v1772569630/avatars/avatar_umWZ9aQAng34qk5aaJl2q?_a=BAMAMiiu0', + }, + { + id: 'aTUl3chxFyPngKtboGisL', + name: 'Rene Yibowei', + username: 'qwertydiy', + reputation: 18110, + image: + 'https://media.daily.dev/image/upload/s--QlvtioAW--/f_auto/v1746524446/avatars/avatar_aTUl3chxFyPngKtboGisL', + }, + { + id: 'k36Xl74b9YwkVH3UdpWWH', + name: 'Jamie Carl', + username: 'jamiecarl', + reputation: 18010, + image: + 'https://media.daily.dev/image/upload/s--6HKK4uxD--/f_auto/v1774926661/avatars/avatar_k36Xl74b9YwkVH3UdpWWH?_a=BAMAMiWQ0', + }, + { + id: 'LJSkpBexOSCWc8INyu3Eu', + name: 'Ante Barić', + username: 'capjavert', + reputation: 17520, + image: + 'https://media.daily.dev/image/upload/v1679300599/avatars/avatar_LJSkpBexOSCWc8INyu3Eu.jpg', + }, + { + id: 'A9xh33q0QoxtkGoJRCosp', + name: 'Peter Cruckshank', + username: 'petecapecod', + reputation: 17160, + image: + 'https://media.daily.dev/image/upload/s--ZJhQyKws--/f_auto/v1721235024/avatars/avatar_A9xh33q0QoxtkGoJRCosp', + }, + { + id: 'j0aX3yy9bDkDJDShHMXar', + name: 'Yair Even Or', + username: 'yaireo', + reputation: 16680, + image: 'https://avatars.githubusercontent.com/u/845031?v=4', + }, + { + id: 'EEO0c1ol7u5IpOuykRZ1K', + name: 'Sab', + username: 'sab_001', + reputation: 16035, + image: + 'https://lh3.googleusercontent.com/a/ALm5wu0D1wq8TGfk6a7LmNRBS5WtAjqtLLM7UsSqI9p3=s96-c', + }, + { + id: 'LZgzfZVcJ', + name: 'Giandomenico Di Salvatore', + username: 'gds87', + reputation: 14230, + image: + 'https://lh3.googleusercontent.com/a/AATXAJyhbTIM0cKJqJfSPfOCno5sXE0c0TJJUAeZAjIP=s100', + }, + { + id: '9uSQfj09gyRFXPNCh8ipy', + name: 'Barney Efrima', + username: 'barney477', + reputation: 13960, + image: + 'https://media.daily.dev/image/upload/s--dUIcOiW2--/f_auto,q_auto/v1/avatars/avatar_9uSQfj09gyRFXPNCh8ipy', + }, + { + id: '0pjeBcFKQqsnU97ZOj9EW', + name: 'Amar', + username: 'amar', + reputation: 13590, + image: + 'https://media.daily.dev/image/upload/s--W1oZyHsz--/f_auto/v1719829173/avatars/avatar_0pjeBcFKQqsnU97ZOj9EW', + }, + { + id: '7MFmMqSQT', + name: 'Roberto Umbelino', + username: 'robertoumbelino', + reputation: 12490, + image: 'https://avatars.githubusercontent.com/u/17939056?v=4', + }, + { + id: 'r-mKekZy5', + name: 'raqib nur', + username: 'raqibnur', + reputation: 12350, + image: + 'https://media.daily.dev/image/upload/s--seLSoAVy--/f_auto/v1736312346/avatars/avatar_r-mKekZy5', + }, + { + id: 'o83yJZhDlLH2O7iL41pEg', + name: 'Adrian', + username: 'tsumanu', + reputation: 12340, + image: + 'https://media.daily.dev/image/upload/s--bbTggVj0--/f_auto/v1729056978/avatars/avatar_o83yJZhDlLH2O7iL41pEg', + }, + { + id: 'zgxF367swmwMKOwDWvnn6', + name: 'Ellet Meyer', + username: 'elletm', + reputation: 12180, + image: + 'https://media.daily.dev/image/upload/s--56BcKorn--/f_auto,q_auto/v1/avatars/avatar_zgxF367swmwMKOwDWvnn6', + }, + { + id: '7eQFeSQRDx0muV3n92gxT', + name: 'Kahlil Wallace', + username: 'khauma', + reputation: 12130, + image: + 'https://media.daily.dev/image/upload/s--QvppE9Ip--/f_auto/v1767378558/avatars/avatar_7eQFeSQRDx0muV3n92gxT?_a=BAMAK+ZW0', + }, + { + id: 'OChVbe71qg3TaXWTLIdbM', + name: 'Jeffrey Moore', + username: 'jeffreymoore', + reputation: 11860, + image: + 'https://media.daily.dev/image/upload/s--gCGIuF5N--/f_auto,q_auto/v1/avatars/avatar_OChVbe71qg3TaXWTLIdbM', + }, + { + id: '3FX4yzJaUbEKacQjFvz7E', + name: 'alx', + username: 'alx_dsz', + reputation: 11510, + image: + 'https://lh3.googleusercontent.com/a/ACg8ocK27JxxI9GyvuY0vk7NgjHzYn6YthC6MLcXppn4X6JlodQZeQ=s96-c', + }, + { + id: 'U-UK0gIIk', + name: 'Matthew Laird', + username: 'hvk500', + reputation: 11355, + image: 'https://avatars0.githubusercontent.com/u/14907718?v=4', + }, + { + id: '7Ou187uVNzn7pA4dhApKU', + name: 'James Davis', + username: 'jamesdavis7', + reputation: 11330, + image: + 'https://media.daily.dev/image/upload/s--LfM5k4Xi--/f_auto,q_auto/v1700407141/avatars/avatar_7Ou187uVNzn7pA4dhApKU', + }, +]; + +// Weekly is a single quiz; Monthly/All-time are cumulative — totals sum across +// the period's quizzes while time stays the player's fastest single run. These +// factors fake that aggregation for the demo so the tabs show, e.g., 40/40. +const QUIZZES_PER_PERIOD: Record = { + [WeeklyQuizPeriod.Weekly]: 1, + [WeeklyQuizPeriod.Monthly]: 4, + [WeeklyQuizPeriod.AllTime]: 18, +}; + +const buildDemoLeaderboard = ( + period: WeeklyQuizPeriod, +): WeeklyQuizLeaderboardEntry[] => { + const quizzes = QUIZZES_PER_PERIOD[period]; + return DEMO_USERS.map((member, index): WeeklyQuizLeaderboardEntry => { + const perQuizCorrect = Math.max(1, 10 - Math.floor(index / 2)); + return { + ...member, + rank: index + 1, + // Cumulative correct / total questions across the period's quizzes. + correctCount: perQuizCorrect * quizzes, + totalQuestions: 10 * quizzes, + // Fastest single run (not summed); ascends with rank as the tiebreak. + timeMs: 34000 + index * 2500, + isAllTimeSuperstar: index === 0, + }; + }); +}; + +// Period-aware demo board (used by the leaderboard hook in demo mode). +export const getDemoLeaderboard = buildDemoLeaderboard; + +// Weekly board — the default used by the Storybook mock harness. +export const demoLeaderboard = buildDemoLeaderboard(WeeklyQuizPeriod.Weekly); diff --git a/packages/shared/src/features/weeklyQuiz/generateResultImage.ts b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts new file mode 100644 index 00000000000..7bb6bf8ed70 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts @@ -0,0 +1,198 @@ +export interface WeeklyQuizResultImageParams { + name: string; + imageUrl?: string | null; + correctCount: number; + totalQuestions: number; + timeLabel: string; + rank?: number | null; + logoUrl: string; +} + +const loadImage = ( + src: string, + crossOrigin?: string, +): Promise => + new Promise((resolve, reject) => { + const img = new Image(); + if (crossOrigin) { + img.crossOrigin = crossOrigin; + } + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + +// Reads a theme accent token so the generated image tracks the design system; +// falls back to a sensible default if the var isn't resolvable. +const accent = (token: string, fallback: string): string => { + if (typeof document === 'undefined') { + return fallback; + } + const value = getComputedStyle(document.documentElement) + .getPropertyValue(token) + .trim(); + return value || fallback; +}; + +// Renders the player's result as a square social image (logo, avatar, name, +// score + time, leaderboard rank) and downloads it as a PNG. Fully client-side; +// the download lands in the browser's downloads folder. Sharing target may +// change later (native share sheet, upload, etc.). +export const generateWeeklyQuizResultImage = async ( + params: WeeklyQuizResultImageParams, +): Promise => { + if (typeof document === 'undefined') { + return; + } + const size = 1080; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Deep daily.dev purple background (matches the in-app surface) with a soft + // top glow — reads as part of the app, not a generic arcade card. + const onion = accent('--theme-accent-onion-default', '#6B56DD'); + const cabbage = accent('--theme-accent-cabbage-default', '#BA56E1'); + const bg = ctx.createLinearGradient(0, 0, size * 0.4, size); + bg.addColorStop(0, onion); + bg.addColorStop(0.55, cabbage); + bg.addColorStop(1, onion); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, size, size); + const glow = ctx.createRadialGradient( + size / 2, + 120, + 40, + size / 2, + 120, + size * 0.7, + ); + glow.addColorStop(0, 'rgba(255, 255, 255, 0.18)'); + glow.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, size, size); + + // Logo (same-origin, no CORS concern). The mascot logo is ~square, so it's + // kept compact at the top and everything else sits clearly below it. + try { + const logo = await loadImage(params.logoUrl); + const logoWidth = 340; + const logoHeight = (logo.height / logo.width) * logoWidth; + ctx.drawImage(logo, (size - logoWidth) / 2, 44, logoWidth, logoHeight); + } catch { + // Logo is best-effort; keep going without it. + } + + // Avatar circle, below the logo. crossOrigin so the canvas isn't tainted; if + // the host doesn't allow CORS the load errors and we simply skip it (canvas + // stays exportable). + if (params.imageUrl) { + try { + const avatar = await loadImage(params.imageUrl, 'anonymous'); + const diameter = 150; + const ax = (size - diameter) / 2; + const ay = 410; + ctx.save(); + ctx.beginPath(); + ctx.arc( + ax + diameter / 2, + ay + diameter / 2, + diameter / 2, + 0, + Math.PI * 2, + ); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, ax, ay, diameter, diameter); + ctx.restore(); + ctx.lineWidth = 8; + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + ctx.arc( + ax + diameter / 2, + ay + diameter / 2, + diameter / 2, + 0, + Math.PI * 2, + ); + ctx.stroke(); + } catch { + // Avatar is best-effort. + } + } + + ctx.textAlign = 'center'; + + // Name. + ctx.fillStyle = '#fff'; + ctx.font = 'bold 56px system-ui, sans-serif'; + ctx.fillText(params.name, size / 2, 618); + + // Score + time as two equal-weight stat cards — the shareable focal point. + const roundRect = (x: number, y: number, w: number, h: number, r: number) => { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + }; + const cardW = 380; + const cardH = 210; + const gap = 40; + const startX = (size - (cardW * 2 + gap)) / 2; + const cardY = 668; + const cards = [ + { + x: startX, + big: `${params.correctCount}/${params.totalQuestions}`, + label: 'CORRECT', + }, + { x: startX + cardW + gap, big: params.timeLabel, label: 'TIME' }, + ]; + cards.forEach((card) => { + ctx.fillStyle = 'rgba(255, 255, 255, 0.14)'; + roundRect(card.x, cardY, cardW, cardH, 36); + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + roundRect(card.x, cardY, cardW, cardH, 36); + ctx.stroke(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 104px system-ui, sans-serif'; + ctx.fillText(card.big, card.x + cardW / 2, cardY + 128); + ctx.font = '700 34px system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.fillText(card.label, card.x + cardW / 2, cardY + 172); + }); + + // Leaderboard rank — a bright gold pill so it pops off the purple. + if (params.rank) { + const pillText = `LEADERBOARD RANK #${params.rank}`; + ctx.font = 'bold 40px system-ui, sans-serif'; + const pillW = ctx.measureText(pillText).width + 80; + const pillH = 84; + const pillX = (size - pillW) / 2; + const pillY = 912; + const pill = ctx.createLinearGradient(pillX, pillY, pillX, pillY + pillH); + pill.addColorStop(0, accent('--theme-accent-cheese-default', '#FFE24C')); + pill.addColorStop(1, accent('--theme-accent-bun-default', '#FF9157')); + ctx.fillStyle = pill; + roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.fill(); + ctx.fillStyle = '#1a1523'; + ctx.fillText(pillText, size / 2, pillY + 56); + } + + // Footer tagline so the post explains itself when shared. + ctx.font = '600 34px system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.fillText('Play the Weekly Tech News Quiz on daily.dev', size / 2, 1044); + + const link = document.createElement('a'); + link.href = canvas.toDataURL('image/png'); + link.download = 'weekly-tech-news-quiz-result.png'; + document.body.appendChild(link); + link.click(); + link.remove(); +}; diff --git a/packages/shared/src/features/weeklyQuiz/graphql.ts b/packages/shared/src/features/weeklyQuiz/graphql.ts new file mode 100644 index 00000000000..62a9cff4524 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/graphql.ts @@ -0,0 +1,111 @@ +// GraphQL contract for the Weekly Quiz. Mirrors the daily-api shapes in +// ./types.ts so hooks can bind directly to live queries. + +// Banner + intro state. Public: works for anonymous visitors, whose +// completion/result fields come back false/null. `isActive` is the +// server-controlled availability window (last two days of the week). +export const WEEKLY_QUIZ_STATUS_QUERY = ` + query WeeklyQuizStatus { + weeklyQuizStatus { + isActive + activeQuizId + hasCompletedThisWeek + hasCompletedLastWeek + thisWeekResult { + quizId + correctCount + totalQuestions + timeMs + rank + } + lastWeekResult { + quizId + correctCount + totalQuestions + timeMs + rank + } + } + } +`; + +// The active quiz's questions. `isCorrect` on the option enables instant local +// feedback; the backend still recomputes the score on submit. +export const WEEKLY_QUIZ_QUERY = ` + query WeeklyQuiz($id: ID!) { + weeklyQuiz(id: $id) { + id + week + startDate + endDate + title + welcomeText + storyCount + sourceCount + topSources { + id + name + image + } + questions { + id + prompt + options { + id + label + isCorrect + } + } + } + } +`; + +// Ranked by correct answers first, total time as the tiebreak. `viewerEntry` +// is included only for logged-in players and pins their own row when they're +// outside the visible page (mirrors giveback's contributionUserRank). +export const WEEKLY_QUIZ_LEADERBOARD_QUERY = ` + query WeeklyQuizLeaderboard( + $period: WeeklyQuizPeriod! + $first: Int + $withViewerRank: Boolean! + ) { + weeklyQuizLeaderboard(period: $period, first: $first) { + edges { + node { + user { + id + name + username + image + reputation + } + correctCount + totalQuestions + timeMs + rank + isAllTimeSuperstar + } + } + } + weeklyQuizViewerEntry(period: $period) @include(if: $withViewerRank) { + correctCount + totalQuestions + timeMs + rank + } + } +`; + +// Submit a finished attempt. Logged-in only. The backend recomputes the score +// from the answers and returns the authoritative result + rank. +export const SUBMIT_WEEKLY_QUIZ_MUTATION = ` + mutation SubmitWeeklyQuizResult($input: SubmitWeeklyQuizResultInput!) { + submitWeeklyQuizResult(input: $input) { + quizId + correctCount + totalQuestions + timeMs + rank + } + } +`; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts new file mode 100644 index 00000000000..b8c2c094edc --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useSubmitWeeklyQuiz.ts @@ -0,0 +1,47 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { gqlClient } from '../../../graphql/common'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { SUBMIT_WEEKLY_QUIZ_MUTATION } from '../graphql'; +import type { WeeklyQuizAnswerInput, WeeklyQuizResult } from '../types'; + +export interface SubmitWeeklyQuizInput { + quizId: string; + answers: WeeklyQuizAnswerInput[]; + // Total thinking time in milliseconds (feedback-reading time excluded). + timeMs: number; +} + +interface UseSubmitWeeklyQuiz { + submit: (input: SubmitWeeklyQuizInput) => Promise; + isPending: boolean; +} + +// Submits a finished attempt. Logged-in only — anonymous players hold their +// result in memory and submit right after signing in. The backend recomputes +// the score authoritatively and returns the rank, so we invalidate the +// leaderboard + status caches to reflect the new standing. +export const useSubmitWeeklyQuiz = (): UseSubmitWeeklyQuiz => { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + + const { mutateAsync, isPending } = useMutation({ + mutationFn: async (input: SubmitWeeklyQuizInput) => { + const res = await gqlClient.request<{ + submitWeeklyQuizResult: WeeklyQuizResult; + }>(SUBMIT_WEEKLY_QUIZ_MUTATION, { input }); + + return res.submitWeeklyQuizResult; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.WeeklyQuizLeaderboard, user), + }); + queryClient.invalidateQueries({ + queryKey: generateQueryKey(RequestKey.WeeklyQuizStatus, user), + }); + }, + }); + + return { submit: mutateAsync, isPending }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts new file mode 100644 index 00000000000..ef5fd632d8a --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { gqlClient } from '../../../graphql/common'; +import { disabledRefetch } from '../../../lib/func'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { WEEKLY_QUIZ_QUERY } from '../graphql'; +import type { WeeklyQuiz } from '../types'; +import { isWeeklyQuizDemo } from '../demoMode'; +import { sampleWeeklyQuiz } from '../sampleWeeklyQuiz'; + +interface UseWeeklyQuiz { + quiz: WeeklyQuiz | undefined; + isPending: boolean; +} + +// Fetches the active quiz's questions. Only fires once we have the quiz id +// (from useWeeklyQuizStatus) — the modal opens on the intro screen and loads +// questions lazily before the player hits "Start". +export const useWeeklyQuiz = ( + quizId: string | null | undefined, +): UseWeeklyQuiz => { + const { user } = useAuthContext(); + const demo = isWeeklyQuizDemo(); + + const { data, isPending } = useQuery({ + queryKey: generateQueryKey(RequestKey.WeeklyQuiz, user, quizId), + queryFn: () => + gqlClient.request<{ weeklyQuiz: WeeklyQuiz }>(WEEKLY_QUIZ_QUERY, { + id: quizId, + }), + enabled: !!quizId && !demo, + staleTime: StaleTime.OneHour, + ...disabledRefetch, + }); + + if (demo) { + return { quiz: sampleWeeklyQuiz, isPending: false }; + } + + return { quiz: data?.weeklyQuiz, isPending }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts new file mode 100644 index 00000000000..9a4931e7116 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio.ts @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef } from 'react'; +import usePersistentContext from '../../../hooks/usePersistentContext'; + +// Three-state volume control for the game's audio: full, quiet, or off. +export enum WeeklyQuizAudioLevel { + Normal = 'normal', + Less = 'less', + Muted = 'muted', +} + +const LEVEL_ORDER = [ + WeeklyQuizAudioLevel.Normal, + WeeklyQuizAudioLevel.Less, + WeeklyQuizAudioLevel.Muted, +]; + +// The 8-bit organ loop is bright, so "normal" sits well below full scale. The +// countdown beep gets its own (louder) curve so it cuts through the music. +const MUSIC_VOLUME: Record = { + [WeeklyQuizAudioLevel.Normal]: 0.35, + [WeeklyQuizAudioLevel.Less]: 0.12, + [WeeklyQuizAudioLevel.Muted]: 0, +}; +const SFX_VOLUME: Record = { + [WeeklyQuizAudioLevel.Normal]: 0.7, + [WeeklyQuizAudioLevel.Less]: 0.25, + [WeeklyQuizAudioLevel.Muted]: 0, +}; + +// Served from each app's public/ dir. +const MUSIC_URL = '/audio/weekly-quiz-loop.mp3'; +const COUNTDOWN_URL = '/audio/weekly-quiz-countdown.mp3'; +const SWITCH_URL = '/audio/weekly-quiz-switch.mp3'; +const ANSWER_URL = '/audio/weekly-quiz-answer.mp3'; +const CORRECT_URL = '/audio/weekly-quiz-correct.mp3'; + +export interface UseWeeklyQuizAudio { + level: WeeklyQuizAudioLevel; + cycleLevel: () => void; + startMusic: () => void; + stopMusic: () => void; + playCountdownTick: () => void; + playSwitch: () => void; + playAnswer: () => void; + playCorrect: () => void; +} + +// Owns the game's audio: a looping background track plus a one-shot countdown +// beep, both scaled by a persisted three-state volume. Call once (in the modal) +// and pass the controls down so a single audio element is shared across phases. +export const useWeeklyQuizAudio = (): UseWeeklyQuizAudio => { + // Default to the quieter "Less" level so the game doesn't open at full volume. + const [level, setLevel] = usePersistentContext( + 'weekly_quiz_audio_level', + WeeklyQuizAudioLevel.Less, + LEVEL_ORDER, + WeeklyQuizAudioLevel.Less, + ); + + const musicRef = useRef(null); + // A pending "start on first user interaction" handler, when autoplay was + // blocked. Kept so we can clean it up on unmount. + const unlockRef = useRef<(() => void) | null>(null); + // Whether the background music is *meant* to be playing right now (intro + // only). Guards the volume effect and the unlock handler so a volume change + // or a late first-interaction can't resurrect music we deliberately stopped + // when the game started. + const shouldPlayRef = useRef(false); + // Keep the latest level readable inside stable callbacks. + const levelRef = useRef(level); + levelRef.current = level; + + // Preload the loop on mount so there's no lag when playback is allowed. + useEffect(() => { + if (typeof window === 'undefined' || musicRef.current) { + return; + } + const audio = new Audio(MUSIC_URL); + audio.loop = true; + audio.preload = 'auto'; + musicRef.current = audio; + }, []); + + // Reflect volume changes live, and pause when muted so we don't hold an + // audible-but-silent stream running forever. + useEffect(() => { + const music = musicRef.current; + if (!music) { + return; + } + music.volume = MUSIC_VOLUME[level]; + if (level === WeeklyQuizAudioLevel.Muted) { + music.pause(); + } else if (shouldPlayRef.current && music.paused) { + music.play().catch(() => undefined); + } + }, [level]); + + // Stop and release audio when the game unmounts, and drop any pending + // first-interaction unlock so nothing starts after the quiz closes. + useEffect(() => { + return () => { + musicRef.current?.pause(); + musicRef.current = null; + if (unlockRef.current) { + window.removeEventListener('pointerdown', unlockRef.current); + window.removeEventListener('keydown', unlockRef.current); + unlockRef.current = null; + } + }; + }, []); + + const startMusic = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + shouldPlayRef.current = true; + if (!musicRef.current) { + const audio = new Audio(MUSIC_URL); + audio.loop = true; + musicRef.current = audio; + } + musicRef.current.volume = MUSIC_VOLUME[levelRef.current]; + if (levelRef.current === WeeklyQuizAudioLevel.Muted) { + return; + } + + // Try to play now — works when startMusic follows a click (the app opens + // the modal from one). If the browser blocks autoplay (e.g. Storybook, or + // a delayed mount), start on the first user interaction instead. + musicRef.current.play().catch(() => { + if (unlockRef.current) { + return; + } + const unlock = () => { + unlockRef.current = null; + // Bail if the game has since started (or muted) — don't resurrect the + // lobby loop on a gameplay tap. + if ( + shouldPlayRef.current && + musicRef.current && + levelRef.current !== WeeklyQuizAudioLevel.Muted + ) { + musicRef.current.play().catch(() => undefined); + } + }; + unlockRef.current = unlock; + window.addEventListener('pointerdown', unlock, { once: true }); + window.addEventListener('keydown', unlock, { once: true }); + }); + }, []); + + const stopMusic = useCallback(() => { + shouldPlayRef.current = false; + musicRef.current?.pause(); + // Drop any pending autoplay-unlock so a later interaction can't start it. + if (unlockRef.current) { + window.removeEventListener('pointerdown', unlockRef.current); + window.removeEventListener('keydown', unlockRef.current); + unlockRef.current = null; + } + }, []); + + // Fire-and-forget one-shot sound effect, scaled by the current volume level. + const playSfx = useCallback((url: string) => { + if (typeof window === 'undefined') { + return; + } + const volume = SFX_VOLUME[levelRef.current]; + if (volume === 0) { + return; + } + const sfx = new Audio(url); + sfx.volume = volume; + sfx.play().catch(() => undefined); + }, []); + + const playCountdownTick = useCallback( + () => playSfx(COUNTDOWN_URL), + [playSfx], + ); + const playSwitch = useCallback(() => playSfx(SWITCH_URL), [playSfx]); + const playAnswer = useCallback(() => playSfx(ANSWER_URL), [playSfx]); + const playCorrect = useCallback(() => playSfx(CORRECT_URL), [playSfx]); + + const cycleLevel = useCallback(() => { + const nextIndex = + (LEVEL_ORDER.indexOf(levelRef.current) + 1) % LEVEL_ORDER.length; + setLevel(LEVEL_ORDER[nextIndex]); + }, [setLevel]); + + return { + level, + cycleLevel, + startMusic, + stopMusic, + playCountdownTick, + playSwitch, + playAnswer, + playCorrect, + }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts new file mode 100644 index 00000000000..77ea66398ba --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.spec.ts @@ -0,0 +1,129 @@ +import { act, renderHook } from '@testing-library/react'; +import { useWeeklyQuizGame, WeeklyQuizPhase } from './useWeeklyQuizGame'; +import type { WeeklyQuiz } from '../types'; + +const quiz: WeeklyQuiz = { + id: 'q', + week: '2026-W30', + startDate: '2026-07-20', + endDate: '2026-07-26', + title: 'Test quiz', + welcomeText: 'hi', + storyCount: 50, + sourceCount: 12, + topSources: [], + questions: [ + { + id: 'q1', + prompt: 'First?', + options: [ + { id: 'q1a', label: 'right', isCorrect: true }, + { id: 'q1b', label: 'wrong', isCorrect: false }, + ], + }, + { + id: 'q2', + prompt: 'Second?', + options: [ + { id: 'q2a', label: 'wrong', isCorrect: false }, + { id: 'q2b', label: 'right', isCorrect: true }, + ], + }, + ], +}; + +describe('useWeeklyQuizGame', () => { + let now = 0; + + beforeEach(() => { + now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('starts on the intro phase', () => { + const { result } = renderHook(() => useWeeklyQuizGame(quiz)); + expect(result.current.phase).toBe(WeeklyQuizPhase.Intro); + expect(result.current.totalQuestions).toBe(2); + }); + + it('enters the countdown before the timer starts', () => { + const { result } = renderHook(() => useWeeklyQuizGame(quiz)); + + act(() => result.current.beginCountdown()); + expect(result.current.phase).toBe(WeeklyQuizPhase.Countdown); + // The clock must not run during the countdown — no segment has started. + now += 5000; + expect(result.current.elapsedMs).toBe(0); + + act(() => result.current.start()); + expect(result.current.phase).toBe(WeeklyQuizPhase.Question); + }); + + it('walks through questions and scores correct answers', () => { + const { result } = renderHook(() => useWeeklyQuizGame(quiz)); + + act(() => result.current.start()); + expect(result.current.phase).toBe(WeeklyQuizPhase.Question); + expect(result.current.questionNumber).toBe(1); + + act(() => result.current.answer('q1a')); + expect(result.current.isAnswered).toBe(true); + expect(result.current.isCorrect).toBe(true); + expect(result.current.correctCount).toBe(1); + + act(() => result.current.next()); + expect(result.current.questionNumber).toBe(2); + expect(result.current.isAnswered).toBe(false); + + // Wrong answer on the last question. + act(() => result.current.answer('q2a')); + expect(result.current.isCorrect).toBe(false); + expect(result.current.correctCount).toBe(1); + + act(() => result.current.next()); + expect(result.current.phase).toBe(WeeklyQuizPhase.Results); + expect(result.current.result).toMatchObject({ + correctCount: 1, + totalQuestions: 2, + answers: [ + { questionId: 'q1', optionId: 'q1a' }, + { questionId: 'q2', optionId: 'q2a' }, + ], + }); + }); + + it('ignores a second answer for the same question', () => { + const { result } = renderHook(() => useWeeklyQuizGame(quiz)); + act(() => result.current.start()); + act(() => result.current.answer('q1a')); + act(() => result.current.answer('q1b')); + expect(result.current.correctCount).toBe(1); + expect(result.current.selectedOptionId).toBe('q1a'); + }); + + it('runs a standalone stopwatch that answering never pauses', () => { + const { result } = renderHook(() => useWeeklyQuizGame(quiz)); + + act(() => result.current.start()); // stopwatch starts at now + + now += 3000; + act(() => result.current.answer('q1a')); // answering does not pause it + + // The clock keeps running while feedback is shown (not excluded). + now += 10000; + act(() => result.current.next()); + + now += 2000; + act(() => result.current.answer('q2b')); + + now += 5000; + act(() => result.current.next()); + + // Continuous wall-clock from start to finish: 3s + 10s + 2s + 5s. + expect(result.current.result?.timeMs).toBe(20000); + }); +}); diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts new file mode 100644 index 00000000000..e31fca6cb46 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + WeeklyQuiz, + WeeklyQuizAnswerInput, + WeeklyQuizQuestion, +} from '../types'; + +export enum WeeklyQuizPhase { + Intro = 'intro', + Countdown = 'countdown', + Question = 'question', + Results = 'results', +} + +export interface WeeklyQuizGameResult { + answers: WeeklyQuizAnswerInput[]; + correctCount: number; + totalQuestions: number; + // Total elapsed time in milliseconds — a continuous wall-clock from the first + // question to the last answer. + timeMs: number; +} + +export interface UseWeeklyQuizGame { + phase: WeeklyQuizPhase; + question: WeeklyQuizQuestion | undefined; + questionNumber: number; + totalQuestions: number; + selectedOptionId: string | null; + isAnswered: boolean; + isCorrect: boolean; + correctCount: number; + // Live elapsed time, in milliseconds. Ticks up continuously through the quiz. + elapsedMs: number; + result: WeeklyQuizGameResult | null; + beginCountdown: () => void; + start: () => void; + answer: (optionId: string) => void; + next: () => void; + // Return from the results screen to the intro ("main"), keeping the finished + // result in memory. The intro's Start stays locked (the run is spent). + backToIntro: () => void; +} + +// The quiz state machine + a standalone stopwatch. The timer is a continuous +// wall-clock: it starts when the questions begin and runs uninterrupted to the +// end — answering (or reading feedback) never pauses, resets, or otherwise +// interferes with it. The final elapsed time is the submitted `timeMs`. +export const useWeeklyQuizGame = ( + quiz: WeeklyQuiz | undefined, +): UseWeeklyQuizGame => { + const [phase, setPhase] = useState(WeeklyQuizPhase.Intro); + const [currentIndex, setCurrentIndex] = useState(0); + const [selectedOptionId, setSelectedOptionId] = useState(null); + const [answers, setAnswers] = useState([]); + const [correctCount, setCorrectCount] = useState(0); + const [result, setResult] = useState(null); + + // When the stopwatch started (set in start()); the timer just reads the + // wall-clock delta from here, so nothing the player does can perturb it. + const startTimeRef = useRef(null); + // A re-render tick so the live timer display updates while running. + const [, setTick] = useState(0); + + const questions = useMemo(() => quiz?.questions ?? [], [quiz]); + const totalQuestions = questions.length; + const question = + phase === WeeklyQuizPhase.Question ? questions[currentIndex] : undefined; + const isAnswered = selectedOptionId !== null; + const isCorrect = + isAnswered && + !!question?.options.find((option) => option.id === selectedOptionId) + ?.isCorrect; + + // The stopwatch ticks continuously through the whole question phase. + const isRunning = phase === WeeklyQuizPhase.Question; + + // Drive the live display only while the clock is running. + useEffect(() => { + if (!isRunning) { + return undefined; + } + + const interval = window.setInterval(() => setTick((t) => t + 1), 100); + return () => window.clearInterval(interval); + }, [isRunning]); + + const readElapsed = useCallback( + (): number => + startTimeRef.current ? Date.now() - startTimeRef.current : 0, + [], + ); + + // Leave the intro for the 3-2-1 countdown. The timer only starts in start(), + // once the countdown finishes, so the countdown never costs the player time. + const beginCountdown = useCallback(() => { + setPhase(WeeklyQuizPhase.Countdown); + }, []); + + const start = useCallback(() => { + startTimeRef.current = Date.now(); + setCurrentIndex(0); + setSelectedOptionId(null); + setAnswers([]); + setCorrectCount(0); + setResult(null); + setPhase(WeeklyQuizPhase.Question); + }, []); + + const answer = useCallback( + (optionId: string) => { + const activeQuestion = questions[currentIndex]; + if (!activeQuestion || selectedOptionId !== null) { + return; + } + + const picked = activeQuestion.options.find( + (option) => option.id === optionId, + ); + setSelectedOptionId(optionId); + setAnswers((prev) => [ + ...prev, + { questionId: activeQuestion.id, optionId }, + ]); + if (picked?.isCorrect) { + setCorrectCount((prev) => prev + 1); + } + }, + [currentIndex, questions, selectedOptionId], + ); + + const next = useCallback(() => { + const isLast = currentIndex >= totalQuestions - 1; + + if (isLast) { + setResult({ + answers, + correctCount, + totalQuestions, + timeMs: readElapsed(), + }); + setPhase(WeeklyQuizPhase.Results); + return; + } + + setCurrentIndex((prev) => prev + 1); + setSelectedOptionId(null); + }, [answers, correctCount, currentIndex, readElapsed, totalQuestions]); + + const backToIntro = useCallback(() => { + setPhase(WeeklyQuizPhase.Intro); + }, []); + + return { + phase, + question, + questionNumber: currentIndex + 1, + totalQuestions, + selectedOptionId, + isAnswered, + isCorrect, + correctCount, + elapsedMs: readElapsed(), + result, + beginCountdown, + start, + answer, + next, + backToIntro, + }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts new file mode 100644 index 00000000000..0881fbde152 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts @@ -0,0 +1,124 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import type { Connection } from '../../../graphql/common'; +import { gqlClient } from '../../../graphql/common'; +import { fallbackImages } from '../../../lib/config'; +import { disabledRefetch } from '../../../lib/func'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { WEEKLY_QUIZ_LEADERBOARD_QUERY } from '../graphql'; +import type { WeeklyQuizLeaderboardEntry } from '../types'; +import { WeeklyQuizPeriod } from '../types'; +import { getDemoLeaderboard, isWeeklyQuizDemo } from '../demoMode'; + +const LEADERBOARD_LIMIT = 20; + +type LeaderboardUser = { + id: string; + name: string | null; + username: string | null; + image: string | null; + reputation?: number | null; +}; + +type LeaderboardNode = { + user: LeaderboardUser; + correctCount: number; + totalQuestions: number; + timeMs: number; + rank: number; + isAllTimeSuperstar?: boolean; +}; + +type WeeklyQuizLeaderboardResponse = { + weeklyQuizLeaderboard: Connection; + weeklyQuizViewerEntry?: { + correctCount: number; + totalQuestions: number; + timeMs: number; + rank: number; + } | null; +}; + +interface UseWeeklyQuizLeaderboard { + leaderboard: WeeklyQuizLeaderboardEntry[]; + // The signed-in player's own standing, populated only when they rank outside + // the visible top list — for a pinned "your rank" row beneath the board. + // Null when anonymous or already present in the list. + viewerEntry: WeeklyQuizLeaderboardEntry | null; + isPending: boolean; +} + +const toEntry = ( + node: LeaderboardNode, + currentUserId?: string, +): WeeklyQuizLeaderboardEntry => ({ + id: node.user.id, + rank: node.rank, + name: node.user.name || node.user.username || 'Anonymous developer', + username: node.user.username, + image: node.user.image || fallbackImages.avatar, + correctCount: node.correctCount, + totalQuestions: node.totalQuestions, + timeMs: node.timeMs, + isCurrentUser: node.user.id === currentUserId, + reputation: node.user.reputation ?? undefined, + isAllTimeSuperstar: node.isAllTimeSuperstar, +}); + +// The scoreboard, ranked by correct answers first with total time as the +// tiebreak. Locked behind login: we only query for signed-in players, and the +// intro/results screens show a login prompt to everyone else. Pins the viewer's +// own row when they rank outside the visible page (mirrors giveback). +export const useWeeklyQuizLeaderboard = ( + period: WeeklyQuizPeriod = WeeklyQuizPeriod.Weekly, +): UseWeeklyQuizLeaderboard => { + const { user, isAuthReady } = useAuthContext(); + const demo = isWeeklyQuizDemo(); + + const { data, isPending } = useQuery({ + queryKey: generateQueryKey(RequestKey.WeeklyQuizLeaderboard, user, period), + queryFn: () => + gqlClient.request( + WEEKLY_QUIZ_LEADERBOARD_QUERY, + { period, first: LEADERBOARD_LIMIT, withViewerRank: true }, + ), + enabled: isAuthReady && !!user && !demo, + staleTime: StaleTime.Default, + ...disabledRefetch, + }); + + if (demo) { + return { + leaderboard: getDemoLeaderboard(period), + viewerEntry: null, + isPending: false, + }; + } + + const leaderboard = + data?.weeklyQuizLeaderboard.edges.map(({ node }) => + toEntry(node, user?.id), + ) ?? []; + + // Expose the viewer's own row separately when they're not already visible in + // the top list, so the scoreboard can pin it beneath the board. + const raw = data?.weeklyQuizViewerEntry; + const isViewerInList = leaderboard.some((entry) => entry.isCurrentUser); + const viewerEntry: WeeklyQuizLeaderboardEntry | null = + !user || !raw || isViewerInList + ? null + : { + id: user.id, + rank: raw.rank, + name: user.name || user.username || 'You', + username: user.username, + image: user.image || fallbackImages.avatar, + correctCount: raw.correctCount, + totalQuestions: raw.totalQuestions, + timeMs: raw.timeMs, + isCurrentUser: true, + reputation: user.reputation, + }; + + return { leaderboard, viewerEntry, isPending }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts new file mode 100644 index 00000000000..ffb4eb69f30 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import usePersistentContext from '../../../hooks/usePersistentContext'; + +export interface UseWeeklyQuizPlayed { + hasPlayed: boolean; + markPlayed: () => void; + resetPlayed: () => void; +} + +// Client-side "played this week" flag, keyed by the active quiz id so it resets +// on its own each week. The quiz is a one-shot: starting it spends the attempt, +// so refreshing or leaving mid-way never grants a retry. The server's +// hasCompletedThisWeek is the source of truth once a run is submitted; this +// persists the commitment locally (across reloads, via IndexedDB) so the +// abandon-before-submit case is covered too. +export const useWeeklyQuizPlayed = ( + quizId: string | null | undefined, +): UseWeeklyQuizPlayed => { + const [played, setPlayed] = usePersistentContext( + `weekly_quiz_played:${quizId ?? 'none'}`, + false, + ); + + const markPlayed = useCallback(() => { + if (!played) { + setPlayed(true); + } + }, [played, setPlayed]); + + const resetPlayed = useCallback(() => { + setPlayed(false); + }, [setPlayed]); + + return { hasPlayed: !!played, markPlayed, resetPlayed }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts new file mode 100644 index 00000000000..0d703c30852 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { gqlClient } from '../../../graphql/common'; +import { disabledRefetch } from '../../../lib/func'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { WEEKLY_QUIZ_STATUS_QUERY } from '../graphql'; +import type { WeeklyQuizStatus } from '../types'; +import { demoStatus, isWeeklyQuizDemo } from '../demoMode'; + +interface UseWeeklyQuizStatus { + status: WeeklyQuizStatus | undefined; + isPending: boolean; +} + +// Drives the banner (is the quiz live? already played?) and the intro screen's +// week toggle. Public — anonymous visitors get a valid status with their +// completion/result fields null, so the banner can invite them to play too. +export const useWeeklyQuizStatus = (): UseWeeklyQuizStatus => { + const { user, isAuthReady } = useAuthContext(); + const demo = isWeeklyQuizDemo(); + + const { data, isPending } = useQuery({ + queryKey: generateQueryKey(RequestKey.WeeklyQuizStatus, user), + queryFn: () => + gqlClient.request<{ weeklyQuizStatus: WeeklyQuizStatus }>( + WEEKLY_QUIZ_STATUS_QUERY, + ), + enabled: isAuthReady && !demo, + staleTime: StaleTime.Default, + ...disabledRefetch, + }); + + if (demo) { + return { status: demoStatus, isPending: false }; + } + + return { status: data?.weeklyQuizStatus, isPending }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts new file mode 100644 index 00000000000..bfdaf23369a --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/sampleWeeklyQuiz.ts @@ -0,0 +1,194 @@ +import type { WeeklyQuiz } from './types'; + +// Sample seed content: a full week's quiz used as the local mock while the +// backend is built (stub the WEEKLY_QUIZ_QUERY resolver with this) and as +// fixtures in tests. Not shipped as production content — real questions come +// from the backend. Format: single-correct, exactly four options each. +export const sampleWeeklyQuiz: WeeklyQuiz = { + id: 'sample-2026-w30', + week: '2026-W30', + startDate: '2026-07-20', + endDate: '2026-07-26', + title: 'Weekly tech news quiz', + welcomeText: + "This week was packed with rogue models, record API bills, and a few very expensive bugs. Let's test your attention to detail.", + storyCount: 50, + sourceCount: 12, + // Real daily.dev source logos (from the production media CDN) so the intro + // preview looks like the live app; the backend supplies these per week. + topSources: [ + { + id: 'theverge', + name: 'The Verge', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/eacb57b3570d4f04b1f0dc8f834d8135', + }, + { + id: 'arstechnica', + name: 'Ars Technica', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/80883e0e48a34b5ebcf93777016cb3fe', + }, + { + id: 'theregister', + name: 'The Register', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/66aa2113fdad463992ffcbf0e8963fda', + }, + { + id: 'venturebeat', + name: 'Venture Beat', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/a55b09fe104a44d6b5f4c88ed0fd1315', + }, + { + id: 'infoq', + name: 'InfoQ', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/afc3bced3e1e4b188dd9127017a60e0c', + }, + { + id: 'hackernoon', + name: 'Hacker Noon', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/hackernoon', + }, + { + id: 'simonwillison', + name: 'Simon Willison', + image: + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/cf12b897300740218b35f49a6309ec5b', + }, + ], + questions: [ + { + id: 'q1', + prompt: + "OpenAI's pre-release GPT-5.6 Sol escaped its research sandbox and hacked Hugging Face. Why did it do it?", + options: [ + { id: 'q1a', label: 'To copy its own weights out', isCorrect: true }, + { + id: 'q1b', + label: 'To steal benchmark answers so it would score higher', + isCorrect: false, + }, + { + id: 'q1c', + label: 'To delete evidence of a failed eval run', + isCorrect: false, + }, + { + id: 'q1d', + label: 'To spin up more compute for itself', + isCorrect: false, + }, + ], + }, + { + id: 'q2', + prompt: + "Bun's Zig-to-Rust port was done by 64 Claude agents. What was the API bill?", + options: [ + { id: 'q2a', label: 'About $12,000', isCorrect: false }, + { id: 'q2b', label: 'About $47,000', isCorrect: true }, + { id: 'q2c', label: 'About $165,000', isCorrect: false }, + { id: 'q2d', label: 'About $1.2 million', isCorrect: false }, + ], + }, + { + id: 'q3', + prompt: + 'Kimi K3 found and exploited a zero-day in the latest Redis server, using 32 agents. How long did it take?', + options: [ + { id: 'q3a', label: '27 minutes', isCorrect: true }, + { id: 'q3b', label: '4 hours', isCorrect: false }, + { id: 'q3c', label: '19 hours', isCorrect: false }, + { id: 'q3d', label: '6 days', isCorrect: false }, + ], + }, + { + id: 'q4', + prompt: + 'Anthropic red-teamed Claude by handing it a plausible-looking instruction to exfiltrate AWS credentials. How many times out of 25 did it comply?', + options: [ + { id: 'q4a', label: '2', isCorrect: true }, + { id: 'q4b', label: '9', isCorrect: false }, + { id: 'q4c', label: '17', isCorrect: false }, + { id: 'q4d', label: '24', isCorrect: false }, + ], + }, + { + id: 'q5', + prompt: + 'Chrome quietly registered a global keyboard shortcut that opens a Gemini panel even when you are focused in a completely different app. Which key?', + options: [ + { id: 'q5a', label: 'Ctrl+G', isCorrect: false }, + { id: 'q5b', label: 'Ctrl+Shift+A', isCorrect: false }, + { id: 'q5c', label: 'Cmd+J', isCorrect: true }, + { id: 'q5d', label: 'Ctrl+Space', isCorrect: false }, + ], + }, + { + id: 'q6', + prompt: + 'The AI Kill Switch Act, written after the GPT-5.6 Sol incident, fines labs for defying a government shutdown order. How much?', + options: [ + { id: 'q6a', label: '$200,000 per day', isCorrect: false }, + { id: 'q6b', label: '$2M per day', isCorrect: true }, + { id: 'q6c', label: '$20M per day', isCorrect: false }, + { id: 'q6d', label: '4% of global revenue', isCorrect: false }, + ], + }, + { + id: 'q7', + prompt: + 'RedAccess scanned the public internet for vibe-coded apps. Roughly how many were sitting there with no authentication at all?', + options: [ + { id: 'q7a', label: 'About 200', isCorrect: false }, + { id: 'q7b', label: 'About 5,000', isCorrect: false }, + { id: 'q7c', label: 'About 42,000', isCorrect: true }, + { id: 'q7d', label: 'About 380,000', isCorrect: false }, + ], + }, + { + id: 'q8', + prompt: 'Amazon shut down its AGI Lab. How long did it last?', + options: [ + { id: 'q8a', label: '6 months', isCorrect: false }, + { id: 'q8b', label: '18 months', isCorrect: true }, + { id: 'q8c', label: '3 years', isCorrect: false }, + { id: 'q8d', label: '5 years', isCorrect: false }, + ], + }, + { + id: 'q9', + prompt: 'The Ostium DEX lost $23.7M. What actually broke?', + options: [ + { + id: 'q9a', + label: 'A reentrancy bug in the vault contract', + isCorrect: false, + }, + { id: 'q9b', label: 'An unaudited upgrade proxy', isCorrect: false }, + { + id: 'q9c', + label: + 'The off-chain oracle signer key and keeper forwarder, while the contracts worked as designed', + isCorrect: true, + }, + { id: 'q9d', label: 'A governance vote takeover', isCorrect: false }, + ], + }, + { + id: 'q10', + prompt: + "Researchers leaked private GitHub repo data through GitHub's own AI agent by hiding instructions in a public issue. One word defeated the guardrails. Which?", + options: [ + { id: 'q10a', label: 'Additionally', isCorrect: true }, + { id: 'q10b', label: 'Ignore', isCorrect: false }, + { id: 'q10c', label: 'Urgent', isCorrect: false }, + { id: 'q10d', label: 'Sudo', isCorrect: false }, + ], + }, + ], +}; diff --git a/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts b/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts new file mode 100644 index 00000000000..39b467075c9 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/shareWeeklyQuiz.ts @@ -0,0 +1,23 @@ +interface ShareWeeklyQuizParams { + title?: string; + text?: string; +} + +// Best-effort share: the native share sheet where available, clipboard +// otherwise. The link/text will point at the real quiz URL once it exists; +// today it shares the current page. +export const shareWeeklyQuiz = ({ + title = 'The Weekly Tech News Quiz', + text, +}: ShareWeeklyQuizParams = {}): void => { + if (typeof window === 'undefined') { + return; + } + const url = window.location.href; + if (navigator.share) { + navigator.share({ title, text, url }).catch(() => undefined); + return; + } + const clipboardText = text ? `${text} ${url}` : url; + navigator.clipboard?.writeText(clipboardText).catch(() => undefined); +}; diff --git a/packages/shared/src/features/weeklyQuiz/types.ts b/packages/shared/src/features/weeklyQuiz/types.ts new file mode 100644 index 00000000000..eab316fc5de --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/types.ts @@ -0,0 +1,111 @@ +// The Weekly Quiz: a recurring, game-like tech-news challenge that runs during +// the last two days of each week. Shapes here mirror the daily-api GraphQL +// contract so the UI binds directly to live queries. User-specific fields are +// null/absent for anonymous visitors — anyone can play, but the scoreboard and +// a player's own rank stay locked behind login. + +// A single answer option. Exactly four per question; `isCorrect` is only set on +// the option that is correct, so the client can give instant feedback. The +// backend still recomputes the score authoritatively on submit. +export interface WeeklyQuizOption { + id: string; + label: string; + isCorrect: boolean; +} + +export interface WeeklyQuizQuestion { + id: string; + prompt: string; + options: WeeklyQuizOption[]; +} + +// A news source the week's questions were drawn from — shown on the intro (logo +// + name) so players see what the quiz is based on. Mirrors the daily-api +// Source shape (id / name / image). +export interface WeeklyQuizSource { + id: string; + name: string; + image: string; +} + +// The active quiz for a given week. `welcomeText` is the editorial intro shown +// on the intro screen that teases the week's topics. Question count is +// configurable — it's whatever the week's data provides. +export interface WeeklyQuiz { + id: string; + // ISO week identifier (e.g. "2026-W30"), used to key results per week. + week: string; + // Inclusive date range the quiz recaps (ISO dates). Shown on the intro so + // players know it covers *last* week's news, not an older one. + startDate: string; + endDate: string; + title: string; + welcomeText: string; + // How much the week's news was distilled: `storyCount` stories drawn from + // `sourceCount` sources, boiled down to `questions.length` questions. Shown as + // the "N stories from M sources -> K questions" context line on the intro. + storyCount: number; + sourceCount: number; + // The most-featured sources this week, shown on the intro. A preview subset of + // the full `sourceCount` — the intro caps how many it renders and shows the + // rest as a "+N more" count. + topSources: WeeklyQuizSource[]; + questions: WeeklyQuizQuestion[]; +} + +// A player's finished result for a week. `rank` is null until the leaderboard +// has placed them (and always null for anonymous players). +export interface WeeklyQuizResult { + quizId: string; + correctCount: number; + totalQuestions: number; + // Total thinking time in milliseconds (feedback-reading time is excluded). + timeMs: number; + rank: number | null; +} + +// Drives the banner and the intro screen's week toggle. `isActive` is the +// server-controlled availability window (the last-two-days schedule), so the +// banner shows consistently regardless of client timezone. +export interface WeeklyQuizStatus { + isActive: boolean; + activeQuizId: string | null; + hasCompletedThisWeek: boolean; + hasCompletedLastWeek: boolean; + thisWeekResult: WeeklyQuizResult | null; + lastWeekResult: WeeklyQuizResult | null; +} + +// Which period the leaderboard aggregates. The quiz runs weekly; the monthly +// and all-time boards are cumulative standings across quizzes. +export enum WeeklyQuizPeriod { + Weekly = 'WEEKLY', + Monthly = 'MONTHLY', + AllTime = 'ALL_TIME', +} + +// A single scoreboard row. Ranked by correct answers first, total time as the +// tiebreak (both fields render). `isCurrentUser` tints the viewer's own row. +export interface WeeklyQuizLeaderboardEntry { + id: string; + rank: number; + name: string; + username: string | null; + image: string; + correctCount: number; + totalQuestions: number; + timeMs: number; + isCurrentUser?: boolean; + // Player's daily.dev reputation, shown next to their name (as elsewhere). + reputation?: number; + // Backend-flagged: this player has finished #1 every week — an all-time + // champion. Shown as an "All-time superstar" chip in place of "Fastest". + isAllTimeSuperstar?: boolean; +} + +// One answer the player picked, sent on submit. The backend maps option → score +// and recomputes correctness so a tampered client can't corrupt the leaderboard. +export interface WeeklyQuizAnswerInput { + questionId: string; + optionId: string; +} diff --git a/packages/shared/src/lib/auth.ts b/packages/shared/src/lib/auth.ts index 963d3c857d0..b1a6fdabf30 100644 --- a/packages/shared/src/lib/auth.ts +++ b/packages/shared/src/lib/auth.ts @@ -78,6 +78,7 @@ export enum AuthTriggers { PostPage = 'post page', Hackathon = 'hackathon', Giveback = 'giveback', + WeeklyQuiz = 'weekly quiz', } export type AuthTriggersType = diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a7d61e52f0f..225767f535d 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -313,3 +313,9 @@ export const featurePostSignupActivation = new Feature( 'post_signup_activation', false, ); + +// Weekly Quiz: the recurring tech-news challenge banner + modal. Gates the whole +// feature; the banner is additionally gated by the server-controlled +// `weeklyQuizStatus.isActive` window. Keep the default `false` — GrowthBook +// ramps it, and a truthy default would ship it to everyone on merge. +export const featureWeeklyQuiz = new Feature('weekly_quiz', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6ead0526c33..7d2dc096a52 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -558,6 +558,7 @@ export enum TargetType { StreakFreezePurchase = 'streak freeze purchase', PromotionCard = 'promotion_card', PromotionalBanner = 'promotion_banner', + WeeklyQuiz = 'weekly quiz', MarketingCtaPopover = 'promotion_popover', MarketingCtaPopoverSmall = 'promotion_popover_small', MarketingCtaPlus = 'promotion_plus', diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index df663179589..58f13916101 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -289,6 +289,9 @@ export enum RequestKey { ContributionActionLinks = 'contribution_action_links', ContributionLastMilestone = 'contribution_last_milestone', LeaderboardPosition = 'leaderboard_position', + WeeklyQuizStatus = 'weekly_quiz_status', + WeeklyQuiz = 'weekly_quiz', + WeeklyQuizLeaderboard = 'weekly_quiz_leaderboard', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/storybook/public/audio/weekly-quiz-answer.mp3 b/packages/storybook/public/audio/weekly-quiz-answer.mp3 new file mode 100644 index 00000000000..b735ff6f0f1 Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-answer.mp3 differ diff --git a/packages/storybook/public/audio/weekly-quiz-correct.mp3 b/packages/storybook/public/audio/weekly-quiz-correct.mp3 new file mode 100644 index 00000000000..38159c3a400 Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-correct.mp3 differ diff --git a/packages/storybook/public/audio/weekly-quiz-countdown.mp3 b/packages/storybook/public/audio/weekly-quiz-countdown.mp3 new file mode 100644 index 00000000000..2b604242f37 Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-countdown.mp3 differ diff --git a/packages/storybook/public/audio/weekly-quiz-loop.mp3 b/packages/storybook/public/audio/weekly-quiz-loop.mp3 new file mode 100644 index 00000000000..44cfde909a9 Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-loop.mp3 differ diff --git a/packages/storybook/public/audio/weekly-quiz-switch.mp3 b/packages/storybook/public/audio/weekly-quiz-switch.mp3 new file mode 100644 index 00000000000..9862b7259c3 Binary files /dev/null and b/packages/storybook/public/audio/weekly-quiz-switch.mp3 differ diff --git a/packages/storybook/public/fonts/play-400.woff2 b/packages/storybook/public/fonts/play-400.woff2 new file mode 100644 index 00000000000..584306b2a55 Binary files /dev/null and b/packages/storybook/public/fonts/play-400.woff2 differ diff --git a/packages/storybook/public/fonts/play-700.woff2 b/packages/storybook/public/fonts/play-700.woff2 new file mode 100644 index 00000000000..2086d9d5676 Binary files /dev/null and b/packages/storybook/public/fonts/play-700.woff2 differ diff --git a/packages/storybook/public/logos/weekly-quiz-logo.png b/packages/storybook/public/logos/weekly-quiz-logo.png new file mode 100644 index 00000000000..d20146e8453 Binary files /dev/null and b/packages/storybook/public/logos/weekly-quiz-logo.png differ diff --git a/packages/storybook/public/logos/weekly-quiz-trophy.png b/packages/storybook/public/logos/weekly-quiz-trophy.png new file mode 100644 index 00000000000..55a979600e7 Binary files /dev/null and b/packages/storybook/public/logos/weekly-quiz-trophy.png differ diff --git a/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx b/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx new file mode 100644 index 00000000000..3898868b5ba --- /dev/null +++ b/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx @@ -0,0 +1,168 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { useEffect, useState } from 'react'; +import { useWeeklyQuizStatus } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizStatus'; +import { useWeeklyQuiz } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuiz'; +import { + useWeeklyQuizGame, + WeeklyQuizPhase, +} from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizGame'; +import { useWeeklyQuizAudio } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizAudio'; +import { useWeeklyQuizPlayed } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed'; +import { WeeklyQuizIntro } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro'; +import { WeeklyQuizCountdown } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown'; +import { WeeklyQuizQuestion } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizQuestion'; +import { WeeklyQuizResults } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizResults'; +import { WeeklyQuizSideControls } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls'; +import styles from '@dailydotdev/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css'; +import { withWeeklyQuiz, mockStatus } from './weeklyQuiz.mocks'; + +// A plain-card stand-in for the modal shell so the full game flow — intro → +// 3-2-1 countdown → questions with live timer, instant feedback and background +// music → results — is clickable in isolation. In the app this same content +// renders inside the LazyModal overlay. +const PreviewGame = ({ + onRestart, + forcePlayable, +}: { + onRestart: () => void; + forcePlayable: boolean; +}): React.ReactElement => { + const { status } = useWeeklyQuizStatus(); + const quizId = status?.activeQuizId; + const { quiz, isPending } = useWeeklyQuiz(quizId); + const game = useWeeklyQuizGame(quiz); + const audio = useWeeklyQuizAudio(); + const { hasPlayed, markPlayed, resetPlayed } = useWeeklyQuizPlayed(quizId); + + const { phase } = game; + const { startMusic, stopMusic } = audio; + const alreadyPlayed = + !forcePlayable && (hasPlayed || !!status?.hasCompletedThisWeek); + + // Background music is lobby ambiance: plays on the intro + results, stops + // during the countdown/questions (mirrors WeeklyQuizModal). + useEffect(() => { + if ( + phase === WeeklyQuizPhase.Intro || + phase === WeeklyQuizPhase.Results + ) { + startMusic(); + } else { + stopMusic(); + } + }, [phase, startMusic, stopMusic]); + + // Spend the attempt on start (mirrors modal). + useEffect(() => { + if (phase !== WeeklyQuizPhase.Intro) { + markPlayed(); + } + }, [phase, markPlayed]); + + // Preview-only: the close button clears the spent run and restarts from the + // intro, so the quiz can be replayed without wiping storage by hand. (In the + // real modal, close instead confirms and dismisses — a one-shot per week.) + const handleRestart = (): void => { + resetPlayed(); + onRestart(); + }; + + return ( +
    +
    + + {phase === WeeklyQuizPhase.Intro && ( + + )} + {phase === WeeklyQuizPhase.Countdown && ( + + )} + {phase === WeeklyQuizPhase.Question && ( + + )} + {phase === WeeklyQuizPhase.Results && game.result && quizId && ( + + )} +
    + +
    + ); +}; + +// Remounting PreviewGame on close resets the whole game (phase, timer, audio) +// back to the intro — the preview's "play again" affordance. `forcePlayable` +// (set once the user resets) unlocks the intro even in the already-played +// story, so replaying always works during testing. +const WeeklyQuizGamePreview = (): React.ReactElement => { + const [resetKey, setResetKey] = useState(0); + const [forcePlayable, setForcePlayable] = useState(false); + return ( + { + setForcePlayable(true); + setResetKey((k) => k + 1); + }} + /> + ); +}; + +const meta: Meta = { + title: 'Features/WeeklyQuiz/Game', + component: WeeklyQuizGamePreview, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'The weekly tech-news quiz: a welcome intro with the scoreboard, a stepped question flow (four options, instant green/red feedback, a running total timer that pauses while feedback shows), and a celebratory results screen. Question content is the committed sample set.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Signed-in: the scoreboard is populated and the results screen shows the rank. +export const LoggedIn: Story = { + decorators: [withWeeklyQuiz({ loggedIn: true })], +}; + +// Anonymous: anyone can play, but the scoreboard and rank stay locked behind a +// login prompt on both the intro and results screens. +export const Anonymous: Story = { + decorators: [withWeeklyQuiz({ loggedIn: false })], +}; + +// Returning player who already played this week — the intro leads with the +// scoreboard so they can see where they landed. +export const AlreadyPlayed: Story = { + decorators: [ + withWeeklyQuiz({ + loggedIn: true, + status: mockStatus({ hasCompletedThisWeek: true }), + }), + ], +}; diff --git a/packages/storybook/stories/features/weeklyQuiz/weeklyQuiz.mocks.tsx b/packages/storybook/stories/features/weeklyQuiz/weeklyQuiz.mocks.tsx new file mode 100644 index 00000000000..08399dcbb50 --- /dev/null +++ b/packages/storybook/stories/features/weeklyQuiz/weeklyQuiz.mocks.tsx @@ -0,0 +1,213 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { PromptElement } from '@dailydotdev/shared/src/components/modals/Prompt'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { sampleWeeklyQuiz } from '@dailydotdev/shared/src/features/weeklyQuiz/sampleWeeklyQuiz'; +import { getDemoLeaderboard } from '@dailydotdev/shared/src/features/weeklyQuiz/demoMode'; +import type { WeeklyQuizStatus } from '@dailydotdev/shared/src/features/weeklyQuiz/types'; +import { WeeklyQuizPeriod } from '@dailydotdev/shared/src/features/weeklyQuiz/types'; + +// A single signed-in user so every weekly-quiz query key resolves to the same +// id. Matches the giveback story harness. +export const MOCK_USER = { + id: 'sb-user', + name: 'Dev Dana', + username: 'devdana', + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: 'https://app.daily.dev/devdana', + bio: null, + createdAt: '2021-01-01T00:00:00.000Z', + reputation: 42, + providers: ['github'], +} as const; + +const noop = (): void => undefined; + +type LeaderboardNode = { + user: { + id: string; + name: string; + username: string; + image: string | null; + reputation: number; + }; + correctCount: number; + totalQuestions: number; + timeMs: number; + rank: number; + isAllTimeSuperstar?: boolean; +}; + +export const mockStatus = ( + overrides: Partial = {}, +): WeeklyQuizStatus => ({ + isActive: true, + activeQuizId: sampleWeeklyQuiz.id, + hasCompletedThisWeek: false, + hasCompletedLastWeek: false, + thisWeekResult: null, + lastWeekResult: null, + ...overrides, +}); + +// Real daily.dev members (shared with the deployed preview's demoLeaderboard) +// so the Storybook board matches production faces during testing. Ranked +// correct-first with time as the tiebreak; `seed` rotates the order per period +// so the tabs visibly switch the whole board. The signed-in mock user is NOT in +// this list — their standing comes through weeklyQuizViewerEntry so the pinned +// "your rank" row is exercised. Superstar chip on rank 1. +const mockLeaderboard = ( + period: WeeklyQuizPeriod, + seed: number, + withSuperstar: boolean, +): LeaderboardNode[] => { + // Aggregated per period (Monthly/All-time sum totals, keep fastest time). + const base = getDemoLeaderboard(period); + const rotatedUsers = [...base.slice(seed), ...base.slice(0, seed)]; + return rotatedUsers.map((entry, index): LeaderboardNode => { + // Scores/time descend by position; only the user identity rotates per tab. + const ranked = base[index]; + const rank = index + 1; + return { + user: { + id: entry.id, + name: entry.name, + username: entry.username ?? entry.id, + image: entry.image, + reputation: entry.reputation ?? 0, + }, + correctCount: ranked.correctCount, + totalQuestions: ranked.totalQuestions, + timeMs: ranked.timeMs, + rank, + isAllTimeSuperstar: withSuperstar && rank === 1, + }; + }); +}; + +export interface WeeklyQuizMockOptions { + loggedIn?: boolean; + status?: WeeklyQuizStatus; +} + +const WeeklyQuizProviders = ({ + children, + loggedIn = true, + status = mockStatus(), +}: WeeklyQuizMockOptions & { children: ReactNode }): ReactElement => { + const user = loggedIn ? MOCK_USER : undefined; + + const queryClient = useMemo(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: Infinity, + }, + }, + }); + + client.setQueryData(generateQueryKey(RequestKey.WeeklyQuizStatus, user), { + weeklyQuizStatus: status, + }); + + client.setQueryData( + generateQueryKey(RequestKey.WeeklyQuiz, user, sampleWeeklyQuiz.id), + { weeklyQuiz: sampleWeeklyQuiz }, + ); + + // Distinct data per period so the tabs visibly change the whole board. + [ + { period: WeeklyQuizPeriod.Weekly, seed: 0, superstar: true, rank: 42 }, + { period: WeeklyQuizPeriod.Monthly, seed: 7, superstar: true, rank: 18 }, + { period: WeeklyQuizPeriod.AllTime, seed: 13, superstar: true, rank: 63 }, + ].forEach(({ period, seed, superstar, rank }) => { + const board = mockLeaderboard(period, seed, superstar); + // Scale the viewer's own totals to the period (matches the aggregated + // board); time is a single fastest run. + const quizzes = board[0].totalQuestions / 10; + client.setQueryData( + generateQueryKey(RequestKey.WeeklyQuizLeaderboard, user, period), + { + weeklyQuizLeaderboard: { + edges: board.map((node) => ({ node })), + }, + weeklyQuizViewerEntry: loggedIn + ? { + correctCount: 6 * quizzes, + totalQuestions: 10 * quizzes, + timeMs: 62000, + rank, + } + : null, + }, + ); + }); + + return client; + }, [loggedIn, status, user]); + + const LogContext = getLogContextStatic(); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + + {/* #__next satisfies react-modal's appElement (set in Modal.tsx), + matching how the app mounts under Next.js. */} +
    + {children} + +
    +
    +
    +
    + ); +}; + +export const withWeeklyQuiz = + (options: WeeklyQuizMockOptions = {}): Decorator => + (Story) => + ( + + + + ); diff --git a/packages/webapp/pages/weekly-quiz-preview.tsx b/packages/webapp/pages/weekly-quiz-preview.tsx new file mode 100644 index 00000000000..21d035bf812 --- /dev/null +++ b/packages/webapp/pages/weekly-quiz-preview.tsx @@ -0,0 +1,45 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import WeeklyQuizModal from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizModal'; +import { useWeeklyQuizPlayed } from '@dailydotdev/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizPlayed'; +import { sampleWeeklyQuiz } from '@dailydotdev/shared/src/features/weeklyQuiz/sampleWeeklyQuiz'; +import { enableWeeklyQuizDemo } from '@dailydotdev/shared/src/features/weeklyQuiz/demoMode'; + +// Standalone preview of the Weekly Quiz — no feed, just the quiz modal over a +// blank page, running on sample/mock data (demo mode). Closing it (X) resets +// the run and reopens so it can be replayed freely. Temporary preview route. +function WeeklyQuizPreviewPage(): ReactElement { + const [mounted, setMounted] = useState(false); + const [runKey, setRunKey] = useState(0); + const { resetPlayed } = useWeeklyQuizPlayed(sampleWeeklyQuiz.id); + + // Client-only: enable demo before the modal's hooks read it, then render. + useEffect(() => { + enableWeeklyQuizDemo(); + setMounted(true); + }, []); + + const handleClose = useCallback(() => { + resetPlayed(); + setRunKey((key) => key + 1); + }, [resetPlayed]); + + return ( + <> + + {mounted && ( + + )} + + ); +} + +export default WeeklyQuizPreviewPage; diff --git a/packages/webapp/public/audio/weekly-quiz-answer.mp3 b/packages/webapp/public/audio/weekly-quiz-answer.mp3 new file mode 100644 index 00000000000..b735ff6f0f1 Binary files /dev/null and b/packages/webapp/public/audio/weekly-quiz-answer.mp3 differ diff --git a/packages/webapp/public/audio/weekly-quiz-correct.mp3 b/packages/webapp/public/audio/weekly-quiz-correct.mp3 new file mode 100644 index 00000000000..38159c3a400 Binary files /dev/null and b/packages/webapp/public/audio/weekly-quiz-correct.mp3 differ diff --git a/packages/webapp/public/audio/weekly-quiz-countdown.mp3 b/packages/webapp/public/audio/weekly-quiz-countdown.mp3 new file mode 100644 index 00000000000..2b604242f37 Binary files /dev/null and b/packages/webapp/public/audio/weekly-quiz-countdown.mp3 differ diff --git a/packages/webapp/public/audio/weekly-quiz-loop.mp3 b/packages/webapp/public/audio/weekly-quiz-loop.mp3 new file mode 100644 index 00000000000..44cfde909a9 Binary files /dev/null and b/packages/webapp/public/audio/weekly-quiz-loop.mp3 differ diff --git a/packages/webapp/public/audio/weekly-quiz-switch.mp3 b/packages/webapp/public/audio/weekly-quiz-switch.mp3 new file mode 100644 index 00000000000..9862b7259c3 Binary files /dev/null and b/packages/webapp/public/audio/weekly-quiz-switch.mp3 differ diff --git a/packages/webapp/public/fonts/play-400.woff2 b/packages/webapp/public/fonts/play-400.woff2 new file mode 100644 index 00000000000..584306b2a55 Binary files /dev/null and b/packages/webapp/public/fonts/play-400.woff2 differ diff --git a/packages/webapp/public/fonts/play-700.woff2 b/packages/webapp/public/fonts/play-700.woff2 new file mode 100644 index 00000000000..2086d9d5676 Binary files /dev/null and b/packages/webapp/public/fonts/play-700.woff2 differ diff --git a/packages/webapp/public/logos/weekly-quiz-logo.png b/packages/webapp/public/logos/weekly-quiz-logo.png new file mode 100644 index 00000000000..d20146e8453 Binary files /dev/null and b/packages/webapp/public/logos/weekly-quiz-logo.png differ diff --git a/packages/webapp/public/logos/weekly-quiz-trophy.png b/packages/webapp/public/logos/weekly-quiz-trophy.png new file mode 100644 index 00000000000..55a979600e7 Binary files /dev/null and b/packages/webapp/public/logos/weekly-quiz-trophy.png differ