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/components/widgets/SocialShareList.tsx b/packages/shared/src/components/widgets/SocialShareList.tsx index 61761d6626f..492cf3cb5c5 100644 --- a/packages/shared/src/components/widgets/SocialShareList.tsx +++ b/packages/shared/src/components/widgets/SocialShareList.tsx @@ -27,6 +27,11 @@ interface SocialShareListProps { onNativeShare(): void; onClickSocial(provider: ShareProvider): void; shortenUrl?: boolean; + // When set and the device can share files (mobile), the social buttons open + // the native share sheet with this image attached instead of the platform's + // web intent — the only client-side way to share a real image (web intents + // carry text + link only). Email/Copy keep their dedicated behavior. + nativeShareFile?: File | null; } export function SocialShareList({ @@ -39,6 +44,7 @@ export function SocialShareList({ onNativeShare, onClickSocial, shortenUrl = true, + nativeShareFile, }: SocialShareListProps): ReactElement { const { getShortUrl } = useGetShortUrl(); @@ -46,6 +52,20 @@ export function SocialShareList({ onClickSocial(provider); const isEmailShare = provider === ShareProvider.Email; + // On mobile, route the platform buttons through the native share sheet so + // the image rides along (web intents can't attach files). Called with no + // prior await to stay inside the tap gesture. Email keeps its mailto. + if ( + !isEmailShare && + nativeShareFile && + globalThis.navigator?.canShare?.({ files: [nativeShareFile] }) + ) { + navigator + .share({ text: `${description} ${link}`, files: [nativeShareFile] }) + .catch(() => undefined); + return; + } + const shortLink = shortenUrl ? await getShortUrl(link) : link; const shareLink = getShareLink({ provider, 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..9057ca5f25c --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css @@ -0,0 +1,840 @@ +/* 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; + /* Match daily.dev modals/cards: neutral surface. Font inherits the app's + * default sans stack (no more arcade "Play"). Text tokens are applied per + * element (see components). The border + 16px radius are set on the JSX so + * mobile can go full-bleed (edge-to-edge, no frame) while tablet+ keeps the + * card look. */ + color: var(--theme-text-primary); + background: var(--theme-background-default); +} + +/* 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; +} + +/* Animated brand aura behind the logo: a soft, slowly-rotating multicolor glow + * (onion → cabbage → bacon → bun) blurred into an aurora. Sits behind the logo + * art, adding life to the otherwise-neutral surface without arcade noise. */ +.logoAura { + position: absolute; + inset: -22%; + border-radius: 9999px; + background: conic-gradient( + from 0deg, + var(--theme-accent-onion-default), + var(--theme-accent-cabbage-default), + var(--theme-accent-bacon-default), + var(--theme-accent-bun-default), + var(--theme-accent-cabbage-default), + var(--theme-accent-onion-default) + ); + filter: blur(30px); + opacity: 0.5; + pointer-events: none; + animation: logo-aura-spin 14s linear infinite; +} +@keyframes logo-aura-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .logoAura { + animation: none; + } +} + +/* A few A4 sheets drifting down the surface behind the mascot — a calm, sparse + * loop. Fills the whole surface so sheets fall from the top edge; clipped by the + * surface's own overflow. */ +.papers { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} +.paper { + position: absolute; + top: 0; + /* How far each sheet travels before it exits. Desktop keeps the card-height + * fall; mobile overrides it to a full viewport height (see media query). */ + --paper-fall: 720px; + width: 30px; + height: 42px; /* ~A4 ratio */ + border-radius: 3px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35); + /* `both` fill-mode holds the 0% (hidden, above the edge) state during the + * per-sheet delay, so nothing sits frozen mid-air before its turn. */ + animation: paper-fall linear infinite both; + will-change: transform, opacity; +} +/* Faint ruled lines so each sheet reads as a page. */ +.paper::before { + content: ''; + position: absolute; + inset: 8px 6px auto 6px; + height: 2px; + background: rgba(0, 0, 0, 0.12); + box-shadow: + 0 6px 0 rgba(0, 0, 0, 0.1), + 0 12px 0 rgba(0, 0, 0, 0.08); +} +@keyframes paper-fall { + 0% { + transform: translateY(-40px) rotate(-8deg); + opacity: 0; + } + 12% { + opacity: 0.9; + } + 88% { + opacity: 0.9; + } + 100% { + transform: translateY(var(--paper-fall, 720px)) rotate(22deg); + opacity: 0; + } +} +/* Biased to the right half so the sheets fall behind the mascot. */ +.paper:nth-child(1) { + left: 60%; + animation-duration: 7.5s; + animation-delay: 0s; +} +.paper:nth-child(2) { + left: 72%; + animation-duration: 9s; + animation-delay: 2.4s; +} +.paper:nth-child(3) { + left: 84%; + animation-duration: 8.2s; + animation-delay: 4.3s; +} +.paper:nth-child(4) { + left: 92%; + animation-duration: 9.6s; + animation-delay: 1.2s; +} +/* Extra sheets that only join in when the Start button is hovered. */ +.paper:nth-child(5) { + left: 54%; + animation-duration: 8.8s; + animation-delay: 3.1s; +} +.paper:nth-child(6) { + left: 66%; + animation-duration: 7.8s; + animation-delay: 0.6s; +} +.paper:nth-child(7) { + left: 78%; + animation-duration: 9.2s; + animation-delay: 5s; +} +.paper:nth-child(8) { + left: 88%; + animation-duration: 8s; + animation-delay: 2s; +} +.paperExtra { + display: none; +} +/* Hovering Start makes it rain: reveal the extra sheets (the speed-up is a + * smooth playback-rate ramp driven in JS, so it eases rather than jumps). */ +.papers.papersBoost .paperExtra { + display: block; +} +/* Mobile: the quiz is full-bleed, so the paper rain covers the whole screen — + * sheets spread edge to edge (not biased behind the mascot) and fall a full + * viewport height. Desktop keeps the right-biased, card-height fall above. */ +@media (max-width: 655.98px) { + /* Span the whole viewport height (not just the intro's content box) so sheets + fall past the fold; the surface's overflow clips them at the screen edge. */ + .papers { + height: 100dvh; + bottom: auto; + } + .paper { + --paper-fall: 105vh; + } + .paper:nth-child(1) { + left: 8%; + } + .paper:nth-child(2) { + left: 30%; + } + .paper:nth-child(3) { + left: 52%; + } + .paper:nth-child(4) { + left: 74%; + } + .paper:nth-child(5) { + left: 19%; + } + .paper:nth-child(6) { + left: 41%; + } + .paper:nth-child(7) { + left: 63%; + } + .paper:nth-child(8) { + left: 88%; + } +} +@media (prefers-reduced-motion: reduce) { + .papers { + display: none; + } +} + +/* 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: var(--theme-text-primary); + border: 1px solid var(--theme-border-subtlest-secondary); + border-radius: 16px; + background: var(--theme-background-default); + box-shadow: var(--theme-shadow2); + 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 popover surface over the leaderboard. */ +.sharePanel { + background: var(--theme-background-popover); + border: 1px solid var(--theme-border-subtlest-secondary); + box-shadow: var(--theme-shadow2); +} + +/* The intro's two cards (left: logo + start + social, right: leaderboard) — + * standard daily.dev subtle card surface. */ +.panel { + background: var(--theme-background-subtle); + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +/* 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; + transition: transform 0.15s ease; +} +/* On hover the button stops pulsing and holds big; the label pops instead. */ +.arcadeBtnIdle:hover:not(:disabled) { + animation: none; + transform: scale(1.06); +} +.arcadeBtnIdle:hover:not(:disabled) :global(.btn-label) { + display: inline-block; + animation: arcade-label-pop 0.7s ease-in-out infinite; +} +.arcadeBtnIdle:active:not(:disabled) { + animation: none; + transform: scale(0.98); +} +.arcadeBtnIdle:disabled { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + .arcadeBtnIdle, + .arcadeBtnIdle:hover:not(:disabled) :global(.btn-label) { + 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: var(--theme-background-subtle); + border: 1px solid var(--theme-border-subtlest-tertiary); +} + +/* Answer tile: glassy by default, then glows green/red on reveal. */ +.tile { + background: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(255, 255, 255, 0.1); + 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.1); + border-color: rgba(255, 255, 255, 0.3); + 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-onion-default), + var(--theme-accent-cabbage-default) + ); + box-shadow: 0 0 12px color-mix(in srgb, var(--theme-accent-cabbage-default) 60%, transparent); +} + +/* Score circle on the results screen. */ +/* Deep purple gradient (matches the brand) — dark enough that the white + * score/time text reads clearly. */ +.scoreCircle { + background: radial-gradient( + circle at 50% 30%, + var(--theme-accent-cabbage-default), + var(--theme-accent-onion-default) 62%, + color-mix(in srgb, var(--theme-accent-onion-default) 62%, #000 38%) + ); + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent), + inset 0 2px 6px rgba(255, 255, 255, 0.25); + 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-cabbage-default) 55%, + var(--theme-accent-onion-default) + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow( + 0 0 22px color-mix(in srgb, var(--theme-accent-cabbage-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); +} + +/* One-shot celebratory confetti on the results screen — pieces fan out from the + * top, fall once, and fade. No loop. Hidden under reduced motion. */ +.confetti { + position: absolute; + inset: 0; + z-index: 20; + overflow: visible; + pointer-events: none; +} +.confettiPiece { + position: absolute; + left: 50%; + top: 0; + width: 9px; + height: 14px; + border-radius: 2px; + background: currentColor; + opacity: 0; + animation-name: weekly-quiz-confetti; + animation-timing-function: cubic-bezier(0.2, 0.6, 0.3, 1); + animation-iteration-count: 1; + animation-fill-mode: forwards; +} +@keyframes weekly-quiz-confetti { + 0% { + transform: translate(-50%, -50%) rotate(0deg); + opacity: 1; + } + 100% { + transform: translate( + calc(-50% + var(--confetti-dx)), + calc(-50% + var(--confetti-dy)) + ) + rotate(var(--confetti-r)); + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .confetti { + display: none; + } +} + +/* The player's own leaderboard row — the gold "bar" treatment lifted from the + * results badge, so their line pops off the board. Forces dark text/icons on + * the descendants (the row content uses light theme tokens by default). */ +.viewerHighlight { + border: 0; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-accent-cheese-default) 88%, #fff 12%), + var(--theme-accent-bun-default) + ); + box-shadow: 0 6px 22px + color-mix(in srgb, var(--theme-accent-bun-default) 45%, transparent); +} +.viewerHighlight.viewerHighlight :global(*) { + color: #1a1523 !important; +} + +/* Playful circular "share it" sticker floating over the share row. */ +.shareSticker { + background: linear-gradient( + 145deg, + var(--theme-accent-cabbage-default), + var(--theme-accent-onion-default) + ); + color: #fff; + box-shadow: 0 8px 24px + color-mix(in srgb, var(--theme-accent-cabbage-default) 45%, transparent); + transform: rotate(-8deg); + animation: weekly-quiz-sticker-wiggle 3.6s ease-in-out infinite; +} +@keyframes weekly-quiz-sticker-wiggle { + 0%, + 100% { + transform: rotate(-8deg) translateY(0); + } + 50% { + transform: rotate(-4deg) translateY(-4px); + } +} +@media (prefers-reduced-motion: reduce) { + .shareSticker { + animation: none; + } +} 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..9511f063dae --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizAnswerOption.tsx @@ -0,0 +1,75 @@ +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..e52bbbc4c2b --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizBanner.tsx @@ -0,0 +1,136 @@ +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/WeeklyQuizConfetti.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizConfetti.tsx new file mode 100644 index 00000000000..4a550462491 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizConfetti.tsx @@ -0,0 +1,72 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import styles from '../WeeklyQuiz.module.css'; + +// Brand-accent confetti colors. +const COLORS = [ + 'var(--theme-accent-cabbage-default)', + 'var(--theme-accent-onion-default)', + 'var(--theme-accent-cheese-default)', + 'var(--theme-accent-avocado-default)', + 'var(--theme-accent-bacon-default)', + 'var(--theme-accent-bun-default)', +]; + +const PIECE_COUNT = 46; + +interface Piece { + id: number; + leftPct: number; + dx: number; + dy: number; + rotate: number; + delayMs: number; + durationMs: number; + color: string; +} + +// A one-shot celebratory rain from the top of the results screen: pieces start +// spread across the full width and fall the length of the page once, then fade +// — no loop. Purely decorative; hidden entirely under prefers-reduced-motion +// (handled in CSS). +export const WeeklyQuizConfetti = (): ReactElement => { + const pieces = useMemo( + () => + Array.from({ length: PIECE_COUNT }, (_, index) => ({ + id: index, + // Spread the start position across the whole width. + leftPct: Math.random() * 100, + // Small horizontal drift; the fall does the work. + dx: (Math.random() - 0.5) * 180, + // Fall far enough to cover the tall results page. + dy: 360 + Math.random() * 680, + rotate: (Math.random() - 0.5) * 720, + delayMs: Math.round(Math.random() * 240), + durationMs: 1100 + Math.round(Math.random() * 900), + color: COLORS[index % COLORS.length], + })), + [], + ); + + return ( +
+ {pieces.map((piece) => ( + + ))} +
+ ); +}; 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..20554fa62fd --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizCountdown.tsx @@ -0,0 +1,70 @@ +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 = [5, 4, 3, 2, 1]; +const STEP_MS = 1000; + +// A 5-4-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]} + + + Think fast and answer quickly — speed and knowledge both count. + +
+ ); +}; 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..7b13681b980 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizIntro.tsx @@ -0,0 +1,335 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { BellIcon, CalendarIcon, ShareIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + ProfilePicture, + ProfileImageSize, +} from '../../../components/ProfilePicture'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { WeeklyQuizSharePopover } from './WeeklyQuizSharePopover'; +import type { WeeklyQuiz } from '../types'; +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". +const SHOWN_SOURCES = 5; + +interface WeeklyQuizIntroProps { + quiz: WeeklyQuiz | undefined; + isLoading: boolean; + onStart: () => void; + // 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}`; +}; + +// The week pill ("Jul 20–26, 2026"). Exported so it can ride on the surface's +// top line next to the daily.dev logo instead of in the intro body. +export const WeeklyQuizDateChip = ({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}): ReactElement => ( + + + {formatWeekRange(startDate, endDate)} + +); + +// Landing screen: a single focused column that pitches the challenge — logo, a +// hook line, the week's context, and the Start button. The leaderboard is not +// shown here; it lives on the results screen once you've played. +export const WeeklyQuizIntro = ({ + quiz, + isLoading, + onStart, + alreadyPlayed = false, +}: WeeklyQuizIntroProps): ReactElement => { + const { user } = useAuthContext(); + // Local-only until the reminder subscription is wired to the backend. + const [reminderSet, setReminderSet] = useState(false); + const [isShareOpen, setIsShareOpen] = useState(false); + // Hovering (or focusing) Start rains more paper, faster. + const [startHovered, setStartHovered] = 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 = ''; + } + }; + + // Smoothly ramp the falling papers' playback rate on Start hover (eases + // slow→fast instead of jumping). Setting playbackRate keeps each sheet's + // position continuous; only the speed changes. + const papersRef = useRef(null); + const rateRef = useRef(1); + useEffect(() => { + const el = papersRef.current; + const target = startHovered ? 3.4 : 1; + const from = rateRef.current; + if (!el || from === target) { + return undefined; + } + const durationMs = 650; + let raf = 0; + let startTs: number | null = null; + const tick = (now: number): void => { + if (startTs === null) { + startTs = now; + } + const progress = Math.min(1, (now - startTs) / durationMs); + const eased = 1 - (1 - progress) ** 2; // easeOutQuad + rateRef.current = from + (target - from) * eased; + el.getAnimations({ subtree: true }).forEach((animation) => { + // eslint-disable-next-line no-param-reassign + animation.playbackRate = rateRef.current; + }); + if (progress < 1) { + raf = window.requestAnimationFrame(tick); + } + }; + raf = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(raf); + }, [startHovered]); + + return ( +
+ {/* A few A4 papers drifting down the whole surface, behind the content. + Hovering Start reveals the extra sheets and speeds them all up. */} + + + + + + + + + + + + {/* Logo (on tablet, the right column). */} +

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

+ + {/* Content column (on tablet, the left side). */} +
+ {/* The challenge pitch — the focus of this screen. */} +
+ + The Tech News Quiz + + {/* Mobile only: the week pill sits under the title. On tablet+ it + rides the top bar instead (passed as the surface's headerRight). */} + {quiz && ( + + + + )} + {quiz?.welcomeText && ( + + {quiz.welcomeText} + + )} +
+ + {alreadyPlayed ? ( +
+ + + Try again next week! + +
+ ) : ( + + )} + + {/* How much news it distils (the week pill rides on the top bar). */} + {quiz && ( +
+ + {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 — one row. */} +
+ + +
+
+ + {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..bf156e33e78 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizModal.tsx @@ -0,0 +1,195 @@ +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, WeeklyQuizDateChip } from './WeeklyQuizIntro'; +import { WeeklyQuizCountdown } from './WeeklyQuizCountdown'; +import { WeeklyQuizQuestion } from './WeeklyQuizQuestion'; +import { WeeklyQuizResults } from './WeeklyQuizResults'; +import { WeeklyQuizSideControls } from './WeeklyQuizSideControls'; +import { WeeklyQuizSurface } from './WeeklyQuizSurface'; + +// 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 ( + +
+ + } + headerRight={ + phase === WeeklyQuizPhase.Intro && quiz ? ( + // Desktop only: on mobile the week pill moves above the title. + + + + ) : undefined + } + > + {phase === WeeklyQuizPhase.Intro && ( + + )} + {phase === WeeklyQuizPhase.Countdown && ( + + )} + {phase === WeeklyQuizPhase.Question && ( + + )} + {phase === WeeklyQuizPhase.Results && game.result && quizId && ( + + } + /> + )} + + {/* Desktop only: the external side column. On mobile the controls move + inside the surface header (above the mascot). */} +
+ +
+
+
+ ); +} + +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..000418d489a --- /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..91236198f70 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizResults.tsx @@ -0,0 +1,534 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useMemo, 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 { BellIcon, CopyIcon, DownloadIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { SocialShareButton } from '../../../components/widgets/SocialShareButton'; +import { SocialShareList } from '../../../components/widgets/SocialShareList'; +import { WeeklyQuizConfetti } from './WeeklyQuizConfetti'; +import { WeeklyQuizScoreboard } from './WeeklyQuizScoreboard'; +import { WeeklyQuizLogo } from './WeeklyQuizSurface'; +import { useSubmitWeeklyQuiz } from '../hooks/useSubmitWeeklyQuiz'; +import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard'; +import { useCountUp } from '../hooks/useCountUp'; +import { fallbackImages } from '../../../lib/config'; +import { + createWeeklyQuizResultImage, + 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; + // Mute + close controls, rendered on the right of the brand header (mobile + // only) so the header matches the intro's top bar. Desktop keeps the modal's + // external side column. + headerControls?: ReactNode; +} + +// A developer "level" keyed to how many answers the player got right — the +// BuzzFeed-style "who are you" headline for the result, best to worst. Each +// level carries the min score ratio that earns it, a matching one-liner and a +// celebratory GIF. GIF URLs are best-effort (open Giphy media); the GIF hides +// itself if it fails to load. Exported so a Storybook gallery can show them all. +export interface ResultTier { + minRatio: number; + title: string; + message: string; + gif: string; +} + +export const WEEKLY_QUIZ_TIERS: ResultTier[] = [ + { + minRatio: 1, + title: '10x AI Engineer', + message: 'Flawless. You ship faster than the models can hallucinate.', + gif: 'https://media.giphy.com/media/3ohzdIuqJoo8QdKlnW/giphy.gif', + }, + { + minRatio: 0.8, + title: 'Prompt Wizard', + message: 'Sharp. You clearly speak fluent context window.', + gif: 'https://media.giphy.com/media/3o7btPCcdNniyf0ArS/giphy.gif', + }, + { + minRatio: 0.6, + title: 'Autocomplete Hero', + message: 'Strong run. You finished more than you missed.', + gif: 'https://media.giphy.com/media/13GIgrGdslD9oQ/giphy.gif', + }, + { + minRatio: 0.4, + title: 'Tab Spammer', + message: 'Not bad. A few tabs too many, a few answers too few.', + gif: 'https://media.giphy.com/media/YQitE4YNQNahy/giphy.gif', + }, + { + minRatio: 0.2, + title: 'Copy-Paste Coder', + message: 'Rough patch. Time to read the docs, not just paste them.', + gif: 'https://media.giphy.com/media/26tn33aiTi1jkl6H6/giphy.gif', + }, + { + minRatio: 0, + title: 'Asked AI, Still Wrong', + message: 'Tough week. Even the AI could not save this one.', + gif: 'https://media.giphy.com/media/3oEjHV0z8S7WM4MwnK/giphy.gif', + }, +]; + +const getTier = (correct: number, total: number): ResultTier => { + const ratio = total === 0 ? 0 : correct / total; + return ( + WEEKLY_QUIZ_TIERS.find((tier) => ratio >= tier.minRatio) ?? + WEEKLY_QUIZ_TIERS[WEEKLY_QUIZ_TIERS.length - 1] + ); +}; + +// A fabricated-but-fun "better than X%" percentile, derived from the score. A +// perfect run never claims to beat everyone. +const getPercentile = (correct: number, total: number): number => { + const ratio = total === 0 ? 0 : correct / total; + return Math.min(99, Math.max(1, Math.round(ratio * 100))); +}; + +// A circular ring showing a single stat, BuzzFeed-style. `ratio` fills the arc +// (pass 1 for a full ring). Inline CSS-var strokes track light/dark without raw +// colors. +const StatRing = ({ + value, + label, + ratio, + colorVar, +}: { + value: string; + label: string; + ratio: number; + colorVar: string; +}): ReactElement => { + const radius = 42; + const circumference = 2 * Math.PI * radius; + const dashOffset = circumference * (1 - Math.min(1, Math.max(0, ratio))); + return ( +
+ + + + +
+ + {value} + + + {label} + +
+
+ ); +}; + +// Final screen: a BuzzFeed-style verdict — a developer level for the player +// (score ring on the left, level and copy on the right, with the numbers +// counting up on reveal), a matching GIF, then the share row, the "challenge a +// friend" link and the leaderboard (the player's own row gets the gold bar). +// Logged-in players' results are submitted once on arrival (and again if an +// anonymous player signs in from here). +export const WeeklyQuizResults = ({ + quizId, + result, + audio, + headerControls, +}: 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 [linkCopied, setLinkCopied] = useState(false); + // Best-effort GIF: hide the slot entirely if the open Giphy URL fails to load. + const [gifFailed, setGifFailed] = useState(false); + // Leaderboard period for the board shown at the bottom of this screen. + const [period, setPeriod] = useState( + WeeklyQuizPeriod.Weekly, + ); + const submittedRef = useRef(false); + // The premade result card as a File, pre-rendered on mount so the native + // Share sheet can attach it within the tap gesture (Web Share API level 2 — + // the only client-side way to send a custom image, since link-preview images + // are scraped from the URL's Open Graph tags server-side). State (not a ref) + // so the social buttons re-render to route through the native sheet once it + // is ready. + const [shareFile, setShareFile] = useState(null); + // Shareable quiz link. Points at daily.dev for now (a real, resolvable URL + // with proper link-preview metadata) until the dedicated quiz page ships. + const quizUrl = 'https://daily.dev'; + + const { correctCount, totalQuestions } = result; + // Odometer-style reveal: the score, its ring arc and the time count up on + // mount (snaps instantly under prefers-reduced-motion). + const animatedCorrect = Math.round( + useCountUp(correctCount, { durationMs: 900 }), + ); + const ratio = totalQuestions === 0 ? 0 : animatedCorrect / totalQuestions; + const tier = getTier(correctCount, totalQuestions); + const percentile = getPercentile(correctCount, totalQuestions); + const shareText = `I just scored ${correctCount}/${totalQuestions} on the daily.dev Weekly Tech News Quiz. Think you know this week's tech news better than me? Take the quiz:`; + + const copyLink = (): void => { + navigator.clipboard + ?.writeText(quizUrl) + .then(() => setLinkCopied(true)) + .catch(() => undefined); + }; + // Prefer sharing the premade result image itself (native share sheet, mobile) + // so it lands as a photo in WhatsApp/etc.; fall back to a text + link share. + const nativeShare = (): void => { + const file = shareFile; + const message = `${shareText} ${quizUrl}`; + const run = async (): Promise => { + if (file && navigator.canShare?.({ files: [file] })) { + await navigator.share({ text: message, files: [file] }); + return; + } + await navigator.share?.({ + title: 'daily.dev weekly quiz', + text: shareText, + url: quizUrl, + }); + }; + // Swallow user-dismissed / unsupported-share rejections. + run().catch(() => undefined); + }; + + // 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; + + // The premade result-card params, shared by the Download button and the + // native Share (falls back to the daily.dev placeholder avatar with no pic). + const imageParams = useMemo( + () => ({ + name: user?.name || user?.username || 'You', + imageUrl: user?.image || fallbackImages.avatar, + title: tier.title, + correctCount, + totalQuestions, + percentile, + rank, + gifUrl: tier.gif, + logoUrl: '/logos/weekly-quiz-logo.png', + brandLogoUrl: '/android-chrome-512x512.png', + }), + [ + user?.name, + user?.username, + user?.image, + tier.title, + tier.gif, + correctCount, + totalQuestions, + percentile, + rank, + ], + ); + + // Renders the result as a shareable PNG and downloads it. + const handleDownload = (): void => { + generateWeeklyQuizResultImage(imageParams, 2).catch(() => undefined); + }; + + // Pre-render the same card to a File on mount so the native Share sheet can + // attach it the instant the player taps Share (staying inside the gesture). + useEffect(() => { + let cancelled = false; + createWeeklyQuizResultImage(imageParams, 2) + .then((dataUrl) => { + if (cancelled || !dataUrl) { + return; + } + const [meta, base64] = dataUrl.split(','); + const mime = meta.match(/:(.*?);/)?.[1] ?? 'image/png'; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + setShareFile( + new File([bytes], 'weekly-tech-news-quiz.png', { type: mime }), + ); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [imageParams]); + + // 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 ( + // Mobile: edge-to-edge, no outer padding, so the results fill the screen + // like the intro. Tablet+: the padded two-panel card layout. +
+ {/* One-shot celebratory confetti when the results appear. */} + + + {/* Chunk 1 — your result: brand header, the verdict headline, GIF and + share row. A floating panel, distinct from the panel below. */} +
+ {/* daily.dev brand header — same markup as the intro's top bar (logo + left, controls right on mobile), broken out of the panel padding. */} +
+ + {headerControls && ( + {headerControls} + )} +
+ {/* Verdict: the score ring on the left, level and copy on the right. */} +
+ +
+ + {tier.title} + + + You scored better than {percentile}% of players. + + + {tier.message} + +
+
+ + {/* A 4:3 GIF that matches the verdict. Hidden if the open URL fails. */} + {!gifFailed && ( + setGifFailed(true)} + className="aspect-[4/3] w-full max-w-[640px] rounded-16 object-cover" + /> + )} + + {/* Share row — download the result image, then the standard socials, all + on a single line. */} +
+ {/* Playful sticker nudging the player to share. */} + + Had fun? Share it! + + + Share your result + +
+ } + variant={ButtonVariant.Primary} + onClick={handleDownload} + /> + undefined} + // No backend link-shortener in this context; share the link as-is. + shortenUrl={false} + // Mobile: route the social buttons through the native share sheet + // so the premade result image is attached as a photo. + nativeShareFile={shareFile} + /> +
+
+
+ + {/* Divider splitting the shareable result from the follow-on actions. */} + {/* Chunk 2 — keep playing: challenge, reminder and the leaderboard. + Its own floating panel below the result. */} +
+ {/* Challenge a friend — share the quiz link so they can try to beat you. */} +
+ + Challenge a friend ⚔️ + + + Send this link so they can take this week's quiz and try to + beat your score. + +
+ event.target.select()} + className="min-w-0 flex-1 rounded-10 bg-background-default px-3 py-2 text-text-primary typo-footnote" + /> + +
+
+ + {/* Weekly reminder. */} + + + {!user && ( +
+ + Log in to save your score and claim your spot + + +
+ )} + + {/* The leaderboard now lives here, at the end. */} + +
+
+ ); +}; 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..7f24a8b7168 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizScoreboard.tsx @@ -0,0 +1,377 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } 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 } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { AuthTriggers } from '../../../lib/auth'; +import { useWeeklyQuizLeaderboard } from '../hooks/useWeeklyQuizLeaderboard'; +import { useCountUp } from '../hooks/useCountUp'; +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, + animate, +}: { + entry: WeeklyQuizLeaderboardEntry; + isFastest: boolean; + // Count the score up once the board scrolls into view. + animate: boolean; +}): ReactElement => { + const score = Math.round(useCountUp(entry.correctCount, { start: animate })); + // Mobile shows just the first name so the row stays compact; tablet+ keeps + // the full name. + const firstName = entry.name.split(' ')[0]; + return ( + <> + + {entry.rank} + + + + +
+ + {firstName === entry.name ? ( + entry.name + ) : ( + <> + {firstName} + {entry.name} + + )} + + +
+ {isFastest && ( + + + Fastest + + )} + + {score}/{entry.totalQuestions} + + + {formatElapsed(entry.timeMs)} + + + ); +}; + +const ScoreboardRow = ({ + entry, + isFastest, + animate, +}: { + entry: WeeklyQuizLeaderboardEntry; + isFastest: boolean; + animate: 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. */} + + + Fastest + + +
  • + ); + } + + 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; + + // Count the row scores up once the board scrolls into view (it usually sits + // below the fold on the results screen). + const listRef = useRef(null); + const [inView, setInView] = useState(false); + useEffect(() => { + const el = listRef.current; + if (!el || typeof IntersectionObserver === 'undefined') { + setInView(true); + return undefined; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setInView(true); + observer.disconnect(); + } + }, + { threshold: 0.25 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [showBoard]); + + // 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..e098201ca3f --- /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-background-default px-3 py-2 text-text-primary 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..ff2024ec734 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls.tsx @@ -0,0 +1,95 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +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; + // 'stack' (default): a vertical column, sized to sit just outside the card's + // right edge on desktop. 'inline': a compact horizontal row for the mobile + // full-bleed header, where the controls live inside the surface. + layout?: 'stack' | 'inline'; +} + +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. In the default 'stack' layout they render as a +// vertical column sitting just outside the card's right edge on desktop, each +// with the card's white border + gradient. In the 'inline' layout they render +// as a compact horizontal row for the mobile full-bleed header, where they live +// inside the surface (above the mascot). +export const WeeklyQuizSideControls = ({ + level, + onCycleSound, + onClose, + layout = 'stack', +}: WeeklyQuizSideControlsProps): ReactElement => { + const SoundIcon = levelIcon[level]; + const inline = layout === 'inline'; + const iconSize = inline ? IconSize.XSmall : IconSize.Small; + const buttonClass = classNames( + styles.sideControl, + inline ? 'h-9 w-9' : 'h-11 w-11', + ); + + const soundButton = ( + + ); + const closeButton = ( + + ); + + // Desktop column leads with close (top); the mobile row keeps close on the + // far right, the conventional spot for a dismiss control. + return ( +
    + {inline ? ( + <> + {soundButton} + {closeButton} + + ) : ( + <> + {closeButton} + {soundButton} + + )} +
    + ); +}; diff --git a/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx new file mode 100644 index 00000000000..3b1cc24413a --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface.tsx @@ -0,0 +1,70 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import LogoIcon from '../../../svg/LogoIcon'; +import LogoText from '../../../svg/LogoText'; +import styles from '../WeeklyQuiz.module.css'; + +interface WeeklyQuizSurfaceProps { + children: ReactNode; + // The intro drops the light beams; every other screen keeps them. + showRays?: boolean; + // Element pinned to the far right of the top bar (the intro's week pill). + headerRight?: ReactNode; + // The mute + close controls. On mobile the surface goes full-bleed and hosts + // them itself (inline in the top bar, or overlaid when bare); on desktop the + // modal keeps its own external side column, so this stays mobile-only. + controls?: ReactNode; + // Render as a plain, frameless container — no card surface, top bar or beams. + // The results screen uses this and supplies its own panels + brand header. + bare?: boolean; +} + +// The daily.dev icon + wordmark lockup, as it appears through the game. +export const WeeklyQuizLogo = (): ReactElement => ( + + + + +); + +// The quiz card shared by every screen: the neutral gradient surface, the +// optional light beams, and a persistent daily.dev wordmark on the top bar. +export const WeeklyQuizSurface = ({ + children, + showRays = true, + headerRight, + controls, + bare = false, +}: WeeklyQuizSurfaceProps): ReactElement => { + if (bare) { + return ( + // `min-w-0` lets this flex item shrink to the viewport instead of being + // forced wider by the non-wrapping share row (flexbox min-width:auto). + // `overflow-x-clip` then contains the confetti drift without touching + // vertical scrolling. Mobile gets a solid fill so the results read as one + // full-screen surface (like the intro); desktop stays transparent so the + // panels float in the modal. The bare screen supplies its own top bar + // (and hosts the mobile controls there), so no header is rendered here. +
    + {children} +
    + ); + } + return ( +
    + {showRays && } +
    + +
    + {headerRight} + {/* Mobile-only: the controls live inside the bar, above the mascot. + Desktop keeps the modal's external side column instead. */} + {controls && {controls}} +
    +
    + {children} +
    + ); +}; 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..939fa636ce2 --- /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..5a4578f85eb --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/demoMode.ts @@ -0,0 +1,496 @@ +import { fallbackImages } from '../../lib/config'; +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); + +// A synthetic "your rank" row for the demo, so the preview's results screen +// shows a placement even though there's no real signed-in player. +export const getDemoViewerEntry = ( + period: WeeklyQuizPeriod, +): WeeklyQuizLeaderboardEntry => { + const quizzes = QUIZZES_PER_PERIOD[period]; + return { + id: 'demo-you', + rank: 42, + name: 'You', + username: 'you', + image: fallbackImages.avatar, + correctCount: 6 * quizzes, + totalQuestions: 10 * quizzes, + timeMs: 62000, + isCurrentUser: true, + reputation: 1240, + }; +}; diff --git a/packages/shared/src/features/weeklyQuiz/generateResultImage.ts b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts new file mode 100644 index 00000000000..4b76a624659 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/generateResultImage.ts @@ -0,0 +1,611 @@ +export type WeeklyQuizShareVariant = 1 | 2 | 3 | 4; + +export interface WeeklyQuizResultImageParams { + name: string; + imageUrl?: string | null; + // The player's developer "level" (e.g. "Tab Spammer") — the hero headline. + title: string; + correctCount: number; + totalQuestions: number; + // "better than N% of players" flourish. + percentile: number; + rank?: number | null; + // The tier GIF — drawn as a still frame into the canvas. + gifUrl?: string | null; + logoUrl: string; + // daily.dev brand logo (best-effort; same-origin). + brandLogoUrl?: string; +} + +const FONT = 'system-ui, sans-serif'; +const CHALLENGE = 'Think you can beat me?'; + +// The daily.dev lockup (icon + wordmark) exactly as the app renders it, inlined +// as a white SVG so it can be rasterised onto the canvas. Aspect ~118:20. +const DAILY_LOGO_SVG = ``; + +const dailyLogoDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + DAILY_LOGO_SVG, +)}`; + +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; + }); + +// Best-effort load: resolves null instead of throwing, so one missing asset +// (blocked avatar, dead GIF) never fails the whole render. +const loadSafe = async ( + src?: string | null, + crossOrigin?: string, +): Promise => { + if (!src) { + return null; + } + try { + return await loadImage(src, crossOrigin); + } catch { + return null; + } +}; + +const accent = (token: string, fallback: string): string => { + if (typeof document === 'undefined') { + return fallback; + } + const value = getComputedStyle(document.documentElement) + .getPropertyValue(token) + .trim(); + return value || fallback; +}; + +interface Assets { + // The daily.dev icon + wordmark lockup. + daily: HTMLImageElement | null; + avatar: HTMLImageElement | null; + gif: HTMLImageElement | null; +} + +type Ctx = CanvasRenderingContext2D; + +const roundRectPath = ( + ctx: Ctx, + x: number, + y: number, + w: number, + h: number, + r: number, +): void => { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); +}; + +// Shrinks the font until `text` fits within `maxWidth`. +const fitFont = ( + ctx: Ctx, + text: string, + weight: string, + startPx: number, + maxWidth: number, + minPx = 30, +): void => { + let px = startPx; + ctx.font = `${weight} ${px}px ${FONT}`; + while (ctx.measureText(text).width > maxWidth && px > minPx) { + px -= 2; + ctx.font = `${weight} ${px}px ${FONT}`; + } +}; + +// Draws an image cover-fit (center-cropped) into a rounded rectangle. +const drawCover = ( + ctx: Ctx, + img: HTMLImageElement, + x: number, + y: number, + w: number, + h: number, + r: number, +): void => { + ctx.save(); + roundRectPath(ctx, x, y, w, h, r); + ctx.clip(); + const imgRatio = img.width / img.height; + const boxRatio = w / h; + let dw = w; + let dh = h; + if (imgRatio > boxRatio) { + dh = h; + dw = h * imgRatio; + } else { + dw = w; + dh = w / imgRatio; + } + ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh); + ctx.restore(); +}; + +// Circular avatar with a white ring. +const drawAvatar = ( + ctx: Ctx, + img: HTMLImageElement, + cx: number, + cy: number, + diameter: number, +): void => { + const r = diameter / 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(img, cx - r, cy - r, diameter, diameter); + ctx.restore(); + ctx.lineWidth = 6; + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); +}; + +// Gold "rank #N" pill, centered on (cx, y-top). +const drawRankPill = (ctx: Ctx, rank: number, cx: number, y: number): void => { + const text = `LEADERBOARD #${rank}`; + ctx.font = `bold 34px ${FONT}`; + const w = ctx.measureText(text).width + 68; + const h = 70; + const x = cx - w / 2; + const grad = ctx.createLinearGradient(x, y, x, y + h); + grad.addColorStop(0, accent('--theme-accent-cheese-default', '#FFE24C')); + grad.addColorStop(1, accent('--theme-accent-bun-default', '#FF9157')); + ctx.fillStyle = grad; + roundRectPath(ctx, x, y, w, h, h / 2); + ctx.fill(); + ctx.fillStyle = '#1a1523'; + ctx.textAlign = 'center'; + ctx.fillText(text, cx, y + 46); +}; + +const drawBackground = (ctx: Ctx, w: number, h: number): void => { + const bg = ctx.createLinearGradient(0, 0, w, h); + bg.addColorStop(0, '#1b1a24'); + bg.addColorStop(1, '#0d0d12'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + const aura = (x: number, y: number, r: number, color: string): void => { + const g = ctx.createRadialGradient(x, y, 20, x, y, r); + g.addColorStop(0, color); + g.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); + }; + aura(w * 0.5, h * 0.2, w * 0.6, 'rgba(107, 86, 221, 0.4)'); + aura(w * 0.7, h * 0.32, w * 0.5, 'rgba(186, 86, 225, 0.26)'); +}; + +// Brand header — the daily.dev lockup + the game name, left-aligned at (x,y). +const drawBrandHeader = ( + ctx: Ctx, + assets: Assets, + x: number, + y: number, +): void => { + let cursor = x; + if (assets.daily) { + const hgt = 26; + const wid = (hgt * 118) / 20; + ctx.drawImage(assets.daily, x, y, wid, hgt); + cursor = x + wid + 22; + } + ctx.textAlign = 'left'; + ctx.font = `800 24px ${FONT}`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.fillText('WEEKLY TECH NEWS QUIZ', cursor, y + 20); +}; + +const drawFooter = (ctx: Ctx, w: number, y: number): void => { + ctx.font = `bold 34px ${FONT}`; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.fillText('Play at daily.dev', w / 2, y + 6); +}; + +// Two matching stat cards: score and rank (or the challenge when no rank). +const drawScoreCards = ( + ctx: Ctx, + params: WeeklyQuizResultImageParams, + w: number, + y: number, +): void => { + const cardW = 400; + const cardH = 180; + const gap = 36; + const startX = (w - (cardW * 2 + gap)) / 2; + const cards = [ + { + x: startX, + big: `${params.correctCount}/${params.totalQuestions}`, + label: 'CORRECT', + }, + { + x: startX + cardW + gap, + big: params.rank ? `#${params.rank}` : `${params.percentile}%`, + label: params.rank ? 'ON THE BOARD' : 'PERCENTILE', + }, + ]; + cards.forEach((card) => { + ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; + roundRectPath(ctx, card.x, y, cardW, cardH, 32); + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.28)'; + roundRectPath(ctx, card.x, y, cardW, cardH, 32); + ctx.stroke(); + ctx.textAlign = 'center'; + ctx.fillStyle = '#fff'; + ctx.font = `bold 92px ${FONT}`; + ctx.fillText(card.big, card.x + cardW / 2, y + 108); + ctx.font = `700 30px ${FONT}`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.82)'; + ctx.fillText(card.label, card.x + cardW / 2, y + 150); + }); +}; + +// Variant 1 — GIF poster: full-bleed GIF up top, verdict + stats below. +const renderPoster = ( + ctx: Ctx, + w: number, + h: number, + params: WeeklyQuizResultImageParams, + assets: Assets, +): void => { + const gifH = 620; + if (assets.gif) { + drawCover(ctx, assets.gif, 0, 0, w, gifH, 0); + } + // Scrim so the header + edge read over the GIF. + const scrim = ctx.createLinearGradient(0, gifH - 260, 0, gifH); + scrim.addColorStop(0, 'rgba(13,13,18,0)'); + scrim.addColorStop(1, 'rgba(13,13,18,1)'); + ctx.fillStyle = scrim; + ctx.fillRect(0, gifH - 260, w, 260); + drawBrandHeader(ctx, assets, 60, 54); + + ctx.textAlign = 'center'; + fitFont(ctx, params.title, 'bold', 92, w - 140); + ctx.fillStyle = '#fff'; + ctx.fillText(params.title, w / 2, gifH + 96); + ctx.font = `500 34px ${FONT}`; + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText( + `You scored better than ${params.percentile}% of players`, + w / 2, + gifH + 150, + ); + + drawScoreCards(ctx, params, w, gifH + 200); + + const rowY = gifH + 430; + if (assets.avatar) { + drawAvatar(ctx, assets.avatar, w / 2 - 150, rowY, 84); + } + ctx.textAlign = 'left'; + fitFont(ctx, params.name, 'bold', 44, 360); + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.fillText(params.name, w / 2 - 96, rowY + 14); + + drawFooter(ctx, w, h - 70); +}; + +// Variant 2 — centered card: brand, avatar, name, level, score, GIF thumb. +const renderCentered = ( + ctx: Ctx, + w: number, + h: number, + params: WeeklyQuizResultImageParams, + assets: Assets, +): void => { + ctx.textAlign = 'center'; + if (assets.daily) { + const hgt = 44; + const wid = (hgt * 118) / 20; + ctx.drawImage(assets.daily, (w - wid) / 2, 70, wid, hgt); + } + ctx.font = `800 30px ${FONT}`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; + ctx.fillText('WEEKLY TECH NEWS QUIZ', w / 2, 168); + if (assets.avatar) { + drawAvatar(ctx, assets.avatar, w / 2, 320, 132); + } + fitFont(ctx, params.name, 'bold', 40, w - 200); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(params.name, w / 2, 430); + fitFont(ctx, params.title, 'bold', 84, w - 120); + ctx.fillStyle = '#fff'; + ctx.fillText(params.title, w / 2, 520); + ctx.font = `500 32px ${FONT}`; + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText( + `You scored better than ${params.percentile}% of players`, + w / 2, + 568, + ); + drawScoreCards(ctx, params, w, 620); + if (assets.gif) { + const gw = 720; + const gh = 360; + drawCover(ctx, assets.gif, (w - gw) / 2, 850, gw, gh, 28); + } + drawFooter(ctx, w, h - 60); +}; + +// Variant 3 — split: verdict/stats on the left, GIF filling the right. +const renderSplit = ( + ctx: Ctx, + w: number, + h: number, + params: WeeklyQuizResultImageParams, + assets: Assets, +): void => { + const gifW = 430; + if (assets.gif) { + drawCover(ctx, assets.gif, w - gifW, 0, gifW, h, 0); + const scrim = ctx.createLinearGradient(w - gifW, 0, w - gifW + 200, 0); + scrim.addColorStop(0, 'rgba(13,13,18,1)'); + scrim.addColorStop(1, 'rgba(13,13,18,0)'); + ctx.fillStyle = scrim; + ctx.fillRect(w - gifW, 0, 200, h); + } + const leftW = w - gifW; + const pad = 70; + drawBrandHeader(ctx, assets, pad, 70); + ctx.textAlign = 'left'; + fitFont(ctx, params.title, 'bold', 96, leftW - pad * 2); + ctx.fillStyle = '#fff'; + ctx.fillText(params.title, pad, 360); + ctx.font = `500 34px ${FONT}`; + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText(`Better than ${params.percentile}% of players`, pad, 420); + + ctx.font = `bold 150px ${FONT}`; + ctx.fillStyle = '#fff'; + ctx.fillText(`${params.correctCount}/${params.totalQuestions}`, pad, 600); + ctx.font = `700 34px ${FONT}`; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText('CORRECT', pad, 650); + + if (params.rank) { + drawRankPill(ctx, params.rank, pad + 140, 720); + } + + const rowY = h - 120; + if (assets.avatar) { + drawAvatar(ctx, assets.avatar, pad + 42, rowY, 84); + } + fitFont(ctx, params.name, 'bold', 40, leftW - pad * 2 - 110); + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.fillText(params.name, pad + 100, rowY + 12); +}; + +// Variant 4 — ticket: header band, big score, dashed divider, GIF strip. +const renderTicket = ( + ctx: Ctx, + w: number, + h: number, + params: WeeklyQuizResultImageParams, + assets: Assets, +): void => { + const m = 70; + const cardX = m; + const cardY = m; + const cardW = w - m * 2; + const cardH = h - m * 2; + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + roundRectPath(ctx, cardX, cardY, cardW, cardH, 40); + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(255,255,255,0.16)'; + roundRectPath(ctx, cardX, cardY, cardW, cardH, 40); + ctx.stroke(); + + drawBrandHeader(ctx, assets, cardX + 50, cardY + 50); + + ctx.textAlign = 'center'; + fitFont(ctx, params.title, 'bold', 90, cardW - 120); + ctx.fillStyle = '#fff'; + ctx.fillText(params.title, w / 2, cardY + 230); + + ctx.font = `bold 190px ${FONT}`; + ctx.fillStyle = '#fff'; + ctx.fillText( + `${params.correctCount}/${params.totalQuestions}`, + w / 2, + cardY + 430, + ); + ctx.font = `700 34px ${FONT}`; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText('CORRECT ANSWERS', w / 2, cardY + 480); + + // Dashed divider. + ctx.strokeStyle = 'rgba(255,255,255,0.24)'; + ctx.lineWidth = 3; + ctx.setLineDash([14, 12]); + ctx.beginPath(); + ctx.moveTo(cardX + 50, cardY + 540); + ctx.lineTo(cardX + cardW - 50, cardY + 540); + ctx.stroke(); + ctx.setLineDash([]); + + if (assets.gif) { + const gw = cardW - 100; + const gh = 300; + drawCover(ctx, assets.gif, cardX + 50, cardY + 580, gw, gh, 24); + } + + const rowY = cardY + cardH - 90; + if (assets.avatar) { + drawAvatar(ctx, assets.avatar, cardX + 92, rowY, 84); + } + ctx.textAlign = 'left'; + fitFont(ctx, params.name, 'bold', 40, cardW - 400); + ctx.fillStyle = 'rgba(255,255,255,0.92)'; + ctx.fillText(params.name, cardX + 150, rowY + 4); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = `500 28px ${FONT}`; + ctx.fillText(CHALLENGE, cardX + 150, rowY + 40); + if (params.rank) { + drawRankPill(ctx, params.rank, cardX + cardW - 170, rowY - 30); + } +}; + +// Renders the result to a PNG data URL for the given layout variant. Returns +// null if the canvas can't be produced. All screens are 1080x1350 (portrait +// social) except the split layout, which is square. +export const createWeeklyQuizResultImage = async ( + params: WeeklyQuizResultImageParams, + variant: WeeklyQuizShareVariant = 1, +): Promise => { + if (typeof document === 'undefined') { + return null; + } + const w = 1080; + const h = variant === 3 ? 1080 : 1350; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + + const [daily, avatar, gif] = await Promise.all([ + loadSafe(dailyLogoDataUrl), + loadSafe(params.imageUrl, 'anonymous'), + loadSafe(params.gifUrl, 'anonymous'), + ]); + const assets: Assets = { daily, avatar, gif }; + + drawBackground(ctx, w, h); + const renderers = { + 1: renderPoster, + 2: renderCentered, + 3: renderSplit, + 4: renderTicket, + } as const; + renderers[variant](ctx, w, h, params, assets); + + try { + return canvas.toDataURL('image/png'); + } catch { + return null; + } +}; + +export interface WeeklyQuizOgImageParams { + title: string; + message: string; + // Score range that earns the level, e.g. "6–7/10" or "10/10". + rangeLabel: string; + gifUrl?: string | null; +} + +// A simple, generic social card: GIF on top, then the level and its score-range +// pill, then the one-liner. No personal data, so it can be pre-rendered per +// level and served as a shared link's og:image. Returns a PNG data URL. +export const createWeeklyQuizOgImage = async ( + params: WeeklyQuizOgImageParams, +): Promise => { + if (typeof document === 'undefined') { + return null; + } + const w = 1080; + const pad = 48; + const headerH = 28; + const gifY = pad + headerH + 26; + const gifW = w - pad * 2; + const gifH = Math.round((gifW * 9) / 16); + const titleBaseline = gifY + gifH + 82; + const messageBaseline = titleBaseline + 52; + const h = messageBaseline + pad; + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + + const [daily, gif] = await Promise.all([ + loadSafe(dailyLogoDataUrl), + loadSafe(params.gifUrl, 'anonymous'), + ]); + + // Solid dark card (the quiz is always dark). + ctx.fillStyle = '#1a1d25'; + ctx.fillRect(0, 0, w, h); + + // daily.dev + quiz title, top-left. + drawBrandHeader(ctx, { daily, avatar: null, gif: null }, pad, pad); + + if (gif) { + drawCover(ctx, gif, pad, gifY, gifW, gifH, 24); + } + + // Score-range pill, right-aligned on the title row. + ctx.font = `bold 30px ${FONT}`; + const pillPadX = 22; + const pillW = ctx.measureText(params.rangeLabel).width + pillPadX * 2; + const pillH = 54; + const pillX = w - pad - pillW; + const pillY = titleBaseline - 42; + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + roundRectPath(ctx, pillX, pillY, pillW, pillH, 14); + ctx.fill(); + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.fillText(params.rangeLabel, pillX + pillW / 2, pillY + 37); + + // Level title (fills the space left of the pill). + ctx.textAlign = 'left'; + fitFont(ctx, params.title, 'bold', 54, gifW - pillW - 32); + ctx.fillStyle = '#fff'; + ctx.fillText(params.title, pad, titleBaseline); + + // One-liner. + ctx.font = `400 30px ${FONT}`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.fillText(params.message, pad, messageBaseline); + + try { + return canvas.toDataURL('image/png'); + } catch { + return null; + } +}; + +// Renders and downloads the result image as a PNG. +export const generateWeeklyQuizResultImage = async ( + params: WeeklyQuizResultImageParams, + variant: WeeklyQuizShareVariant = 1, +): Promise => { + const url = await createWeeklyQuizResultImage(params, variant); + if (!url) { + return; + } + const link = document.createElement('a'); + link.href = url; + 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/useCountUp.ts b/packages/shared/src/features/weeklyQuiz/hooks/useCountUp.ts new file mode 100644 index 00000000000..d8b116cf394 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useCountUp.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseCountUpOptions { + durationMs?: number; + // Defer the animation until this flips true (e.g. scrolled into view). + start?: boolean; +} + +// Animates a number from 0 up to `target` once, easing out — the odometer feel +// borrowed from jakub.kr's subscriber counter. Honors prefers-reduced-motion by +// snapping straight to the target. Returns a float; round/format at the call +// site. +export const useCountUp = ( + target: number, + { durationMs = 800, start = true }: UseCountUpOptions = {}, +): number => { + const [value, setValue] = useState(0); + const frameRef = useRef(); + + useEffect(() => { + if (!start) { + return undefined; + } + + const prefersReduced = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + if (prefersReduced || target === 0 || typeof window === 'undefined') { + setValue(target); + return undefined; + } + + let startTs: number | null = null; + const tick = (ts: number): void => { + if (startTs === null) { + startTs = ts; + } + const progress = Math.min(1, (ts - startTs) / durationMs); + const eased = 1 - (1 - progress) ** 3; // easeOutCubic + setValue(target * eased); + if (progress < 1) { + frameRef.current = window.requestAnimationFrame(tick); + } else { + setValue(target); + } + }; + + frameRef.current = window.requestAnimationFrame(tick); + return () => { + if (frameRef.current) { + window.cancelAnimationFrame(frameRef.current); + } + }; + }, [target, durationMs, start]); + + return value; +}; 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..0c46997aa52 --- /dev/null +++ b/packages/shared/src/features/weeklyQuiz/hooks/useWeeklyQuizLeaderboard.ts @@ -0,0 +1,128 @@ +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, + getDemoViewerEntry, + 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: getDemoViewerEntry(period), + 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/shared/src/lib/share.spec.ts b/packages/shared/src/lib/share.spec.ts index 9064b349375..f2f1e5b2931 100644 --- a/packages/shared/src/lib/share.spec.ts +++ b/packages/shared/src/lib/share.spec.ts @@ -5,12 +5,19 @@ import { ShareProvider, addLogQueryParams, getShareLink } from './share'; describe('getShareLink tests', () => { const link = 'https://foo.bar'; const text = 'hello world'; - it('should return WhatsApp share link', () => { + it('should return WhatsApp share link with the text prepended to the link', () => { const result = getShareLink({ provider: ShareProvider.WhatsApp, link, text, }); + expect(result).toEqual( + `https://wa.me/?text=${encodeURIComponent(`${text} ${link}`)}`, + ); + }); + + it('should return WhatsApp share link with just the link when no text', () => { + const result = getShareLink({ provider: ShareProvider.WhatsApp, link }); expect(result).toEqual(`https://wa.me/?text=${encodeURIComponent(link)}`); }); diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts index b6bb78125b7..58d8b1844e6 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -12,8 +12,8 @@ export enum ShareProvider { Email = 'email', } -export const getWhatsappShareLink = (link: string): string => - `https://wa.me/?text=${encodeURIComponent(link)}`; +export const getWhatsappShareLink = (link: string, text?: string): string => + `https://wa.me/?text=${encodeURIComponent(text ? `${text} ${link}` : link)}`; export const getTwitterShareLink = (link: string, text: string): string => `http://twitter.com/share?url=${encodeURIComponent( link, @@ -57,7 +57,7 @@ export const getShareLink = ({ }: GetShareLinkParams): string => { switch (provider) { case ShareProvider.WhatsApp: - return getWhatsappShareLink(link); + return getWhatsappShareLink(link, text); case ShareProvider.Twitter: return getTwitterShareLink(link, text); case ShareProvider.Facebook: diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index d6f972b47a0..338f9d7e97c 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -680,6 +680,13 @@ html.light .invert .invert, @mixin light-mode; } +/* Force the dark theme on a subtree regardless of the app's theme. Redeclares + * the theme variables to their dark values on the element, so descendants + * inherit dark. Used by the Weekly Quiz, which is always dark. */ +.force-dark { + @mixin dark-mode; +} + .logo { width: 4.313rem; } diff --git a/packages/storybook/public/android-chrome-512x512.png b/packages/storybook/public/android-chrome-512x512.png new file mode 100644 index 00000000000..59da1e55dd4 Binary files /dev/null and b/packages/storybook/public/android-chrome-512x512.png differ 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..cc33d9fa6a4 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..81b58a2f73a 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..638b677bbb3 --- /dev/null +++ b/packages/storybook/stories/features/weeklyQuiz/WeeklyQuiz.stories.tsx @@ -0,0 +1,1151 @@ +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, + WeeklyQuizDateChip, +} 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, + WEEKLY_QUIZ_TIERS, +} from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizResults'; +import { WeeklyQuizSideControls } from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizSideControls'; +import { + WeeklyQuizSurface, + WeeklyQuizLogo, +} from '@dailydotdev/shared/src/features/weeklyQuiz/components/WeeklyQuizSurface'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + TimerIcon, + DownloadIcon, + TwitterIcon, + WhatsappIcon, + FacebookIcon, + RedditIcon, + LinkedInIcon, + CopyIcon, + BellIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { SocialShareButton } from '@dailydotdev/shared/src/components/widgets/SocialShareButton'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import quizStyles from '@dailydotdev/shared/src/features/weeklyQuiz/WeeklyQuiz.module.css'; +import { + createWeeklyQuizResultImage, + createWeeklyQuizOgImage, +} from '@dailydotdev/shared/src/features/weeklyQuiz/generateResultImage'; +import { fallbackImages } from '@dailydotdev/shared/src/lib/config'; +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(); + }; + + const controls = ( + + ); + + return ( + // Mobile: break out of the decorator's padding so the surface is edge-to- + // edge and fills the screen. Tablet+: the centered card returns. +
    + + + + ) : undefined + } + > + {phase === WeeklyQuizPhase.Intro && ( + + )} + {phase === WeeklyQuizPhase.Countdown && ( + + )} + {phase === WeeklyQuizPhase.Question && ( + + )} + {phase === WeeklyQuizPhase.Results && game.result && quizId && ( + + )} + + {/* Desktop only: the external side column. On mobile the controls move + inside the surface header (above the mascot). */} +
    + +
    +
    + ); +}; + +// 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); + }} + /> + ); +}; + +// Jumps straight to the results screen with a mock finished result, so the end +// screen can be reviewed without playing through the whole quiz. +const ResultsPreview = (): React.ReactElement => { + const { status } = useWeeklyQuizStatus(); + const audio = useWeeklyQuizAudio(); + return ( + // Mirror the game-flow wrapper: full-bleed on mobile, centered card at + // tablet+, with the mute/close controls inside the surface on mobile. +
    + + undefined} + /> + } + /> + +
    + ); +}; + +// A gallery of every score level with its headline, one-liner and GIF, so the +// full set of results personas can be reviewed at a glance. +const ScoreTiersGallery = (): React.ReactElement => ( +
    + {WEEKLY_QUIZ_TIERS.map((tier, index) => { + const low = Math.round(tier.minRatio * 10); + const high = + index === 0 + ? 10 + : Math.round(WEEKLY_QUIZ_TIERS[index - 1].minRatio * 10) - 1; + const range = low === high ? `${low}/10` : `${low}–${high}/10`; + return ( +
    + +
    + + {tier.title} + + + {range} + +
    + + {tier.message} + +
    + ); + })} +
    +); + +const MASCOT = '/logos/weekly-quiz-logo.png'; +const CABBAGE_GRADIENT = { + background: + 'linear-gradient(140deg, var(--theme-accent-cabbage-default), var(--theme-accent-onion-default))', +}; + +// A faint stand-in for surrounding feed cards, to show placement in context. +const SkeletonCard = (): React.ReactElement => ( +
    +
    +
    +
    +
    +); + +// Wraps each concept with a numbered label + a short note on the approach. +const PromptFrame = ({ + n, + title, + note, + children, +}: { + n: number; + title: string; + note: string; + children: React.ReactNode; +}): React.ReactElement => ( +
    +
    + + {n} + + {title} + {note} +
    + {children} +
    +); + +// Six distinct ways to surface the quiz in the feed — each with a clear press +// target. Exploration mockups (not wired to the modal); pick one to productize. +const FeedPromptsGallery = (): React.ReactElement => ( +
    +
    + {/* 1 — Inline hero banner */} + +
    + +
    + + The Weekly Tech News Quiz + + + 10 questions on this week's biggest tech news. + +
    + +
    +
    + + {/* 2 — Slim pill strip */} + +
    + + + Weekly Tech News Quiz + · 10 questions · ends Sun + + +
    +
    + + {/* 3 — Post-card tile */} + +
    +
    + +
    +
    + + Weekly Tech News Quiz + + + Test your attention to detail. + + +
    +
    +
    + + {/* 4 — Floating action button over the feed */} + +
    +
    + +
    +
    + + +
    +
    +
    + + {/* 5 — Competitive ring teaser */} + +
    +
    + ?/10 +
    +
    + + You haven't played this week + + + See how you stack up against the leaderboard. + +
    + +
    +
    + + {/* 6 — Countdown urgency strip */} + +
    +
    + + + This week's quiz ends in 1d 6h + + +
    +
    +
    +
    +
    + + + {/* 7 — Notification-page item */} + +
    +
    + + +
    +
    + + The Weekly Tech News Quiz is live. + + {' '} + 10 questions on this week's biggest stories — can you ace it? + + + 2h ago + +
    +
    +
    +
    +
    +); + +// Sample result used to render the downloadable share-image variants. +const SHARE_SAMPLE = { + name: 'Dev Dana', + imageUrl: fallbackImages.avatar, + title: 'Autocomplete Hero', + correctCount: 6, + totalQuestions: 10, + percentile: 60, + rank: 42, + gifUrl: WEEKLY_QUIZ_TIERS.find((tier) => tier.minRatio === 0.6)?.gif, + logoUrl: '/logos/weekly-quiz-logo.png', + brandLogoUrl: '/android-chrome-512x512.png', +}; +const SHARE_VARIANT_LABELS = [ + '1 · GIF poster', + '2 · Centered card', + '3 · Split', + '4 · Ticket stub', +]; + +// Renders all four downloadable share-image layouts (canvas → PNG) side by side +// so the best one can be picked. +const ShareImageVariants = (): React.ReactElement => { + const [urls, setUrls] = useState>([]); + useEffect(() => { + let active = true; + Promise.all( + ([1, 2, 3, 4] as const).map((variant) => + createWeeklyQuizResultImage(SHARE_SAMPLE, variant), + ), + ).then((result) => { + if (active) { + setUrls(result); + } + }); + return () => { + active = false; + }; + }, []); + + return ( +
    +
    + {SHARE_VARIANT_LABELS.map((label, index) => ( +
    + + {label} + + {urls[index] ? ( + {label} + ) : ( +
    + Rendering… +
    + )} +
    + ))} +
    +
    + ); +}; + +// One generic social card per level — the simple image a shared link previews. +const rangeLabelFor = (index: number): string => { + const low = Math.round(WEEKLY_QUIZ_TIERS[index].minRatio * 10); + const high = + index === 0 + ? 10 + : Math.round(WEEKLY_QUIZ_TIERS[index - 1].minRatio * 10) - 1; + return low === high ? `${low}/10` : `${low}–${high}/10`; +}; + +const OgScoreCards = (): React.ReactElement => { + const [urls, setUrls] = useState>([]); + useEffect(() => { + let active = true; + Promise.all( + WEEKLY_QUIZ_TIERS.map((tier, index) => + createWeeklyQuizOgImage({ + title: tier.title, + message: tier.message, + rangeLabel: rangeLabelFor(index), + gifUrl: tier.gif, + }), + ), + ).then((result) => { + if (active) { + setUrls(result); + } + }); + return () => { + active = false; + }; + }, []); + + return ( +
    +
    + {WEEKLY_QUIZ_TIERS.map((tier, index) => ( +
    + + {rangeLabelFor(index)} · {tier.title} + + {urls[index] ? ( + {tier.title} + ) : ( +
    + Rendering… +
    + )} +
    + ))} +
    +
    + ); +}; + +// --- Mobile welcome-screen layout concepts ------------------------------- + +const SourceDots = (): React.ReactElement => ( +
    + {['V', 'a', 'A', 'VB', 'IQ'].map((source) => ( + + {source} + + ))} + +7 +
    +); + +const DatePill = (): React.ReactElement => ( + + Jul 20–26 + +); + +const Phone = ({ + label, + note, + children, +}: { + label: string; + note: string; + children: React.ReactNode; +}): React.ReactElement => ( +
    + {label} + {note} +
    + {children} +
    +
    +); + +// A — Stacked: everything centered in a single scroll, mascot leading. +const MobileLayoutA = (): React.ReactElement => ( +
    +
    + + +
    + +
    + + The Tech News Quiz + + + This week: rogue models, record API bills, and a few very expensive + bugs. + +
    + + 50 stories · 12 sources · 10 questions + +
    + +
    + +
    + Share + Weekly reminder +
    +
    +); + +// B — CTA-first: pitch up top, the Start button lands above the fold. +const MobileLayoutB = (): React.ReactElement => ( +
    +
    + + +
    +
    + + + The Tech News Quiz + +
    + + 10 quick questions on this week's biggest tech news. Speed and + knowledge both count. + + +
    + + 50 stories · 12 sources + + +
    +
    +); + +// C — Immersive: full-bleed mascot hero, content, a sticky Start at the bottom. +const MobileLayoutC = (): React.ReactElement => ( +
    +
    + + +
    + + +
    +
    +
    + + The Tech News Quiz + + + This week: rogue models, record API bills, and a few very expensive + bugs. Let's test your attention to detail. + + + 50 stories · 12 sources · 10 questions + + +
    +
    + +
    + Share + Reminder +
    +
    +
    +); + +const MobileIntroLayouts = (): React.ReactElement => ( +
    +
    + + + + + + + + + +
    +
    +); + +// The purple sweep used for the countdown number and the progress bar. +const PURPLE_FILL = { + background: + 'linear-gradient(90deg, var(--theme-accent-onion-default), var(--theme-accent-cabbage-default))', +}; + +// Countdown — matches Layout A's top bar, big purple number centered. +const MobileCountdown = (): React.ReactElement => ( +
    +
    + + +
    +
    + + Get ready… + + + 3 + + + Think fast and answer quickly — speed and knowledge both count. + +
    +
    +); + +const QUESTION_OPTIONS = [ + 'To copy its own weights out', + 'To steal benchmark answers so it would score higher', + 'To delete evidence of a failed eval run', + 'To spin up more compute for itself', +]; + +// Question — running header, progress bar, prompt, four stacked answer tiles. +const MobileQuestion = (): React.ReactElement => ( +
    +
    + +
    +
    + + Question 1 of 10 + + + 0:12 + +
    +
    +
    +
    + + OpenAI's pre-release GPT-5.6 Sol escaped its research sandbox and + hacked Hugging Face. Why did it do it? + +
    + {QUESTION_OPTIONS.map((option, index) => ( +
    + + {['A', 'B', 'C', 'D'][index]} + + + {option} + +
    + ))} +
    +
    +); + +const LEADERBOARD_ROWS = [ + { rank: 1, name: 'Bobby Iliev', score: '10/10', time: '0:34', you: false }, + { rank: 2, name: 'Randy', score: '10/10', time: '0:36', you: false }, + { rank: 3, name: 'Ole-Martin', score: '9/10', time: '0:39', you: false }, + { rank: 42, name: 'Dev Dana', score: '6/10', time: '1:02', you: true }, +]; + +// Final (results) — scrolls: brand header, verdict, GIF, share, leaderboard. +const MobileResults = (): React.ReactElement => ( +
    + {/* Chunk 1 — your result: brand header, verdict, GIF and share row. */} +
    +
    + +
    +
    +
    + 4/10 + Correct +
    +
    + + Tab Spammer + + + You scored better than 40% of players. + + + {WEEKLY_QUIZ_TIERS[3].message} + +
    +
    + + {/* Share row, with the same "Had fun?" sticker as the web screen. */} +
    + + Had fun? Share it! + + + Share your result + +
    + } + label="Download" + variant={ButtonVariant.Primary} + /> + } + label="X" + variant={ButtonVariant.Primary} + color={ButtonColor.Twitter} + /> + } + label="WhatsApp" + variant={ButtonVariant.Primary} + color={ButtonColor.WhatsApp} + /> + } + label="Facebook" + variant={ButtonVariant.Primary} + color={ButtonColor.Facebook} + /> + } + label="Reddit" + variant={ButtonVariant.Primary} + color={ButtonColor.Reddit} + /> + } + label="LinkedIn" + variant={ButtonVariant.Primary} + color={ButtonColor.LinkedIn} + /> +
    +
    +
    + + {/* Chunk 2 — keep playing: challenge, reminder and the leaderboard. */} +
    + {/* Challenge a friend — share the quiz link. */} +
    + + Challenge a friend ⚔️ + + + Send this link so they can take this week's quiz and try to beat + your score. + +
    + + +
    +
    + + {/* Weekly reminder. */} + + +
    + + 🏆 Leaderboard + + {LEADERBOARD_ROWS.map((row) => ( +
    + + {row.rank} + + + + {row.name} + + + {row.score} + + + {row.time} + +
    + ))} +
    +
    +
    +); + +const MobileScreens = (): React.ReactElement => ( +
    +
    + + + + + + + + + +
    +
    +); + +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 }), + }), + ], +}; + +// The results (end) screen on its own, with a mock finished result — so it can +// be reviewed without playing through the quiz. +export const Results: Story = { + render: () => , + decorators: [withWeeklyQuiz({ loggedIn: true })], +}; + +// Every score level side by side — headline, one-liner and GIF per tier. +export const ScoreTiers: Story = { + name: 'Score levels & GIFs', + render: () => , +}; + +// Ways to surface the quiz (feed + notifications), each with a press target. +export const FeedPrompts: Story = { + name: 'Entry-point concepts', + render: () => , +}; + +// The four downloadable share-image layouts, rendered as PNGs to compare. +export const ShareImages: Story = { + name: 'Share image (4 variants)', + render: () => , +}; + +// Per-score Open Graph cards (0..10) — the social link-preview images. +export const OgCards: Story = { + name: 'Social cards (per score)', + render: () => , +}; + +// Three welcome-screen layout concepts for mobile. +export const MobileIntro: Story = { + name: 'Mobile welcome (3 layouts)', + render: () => , +}; + +// Mobile countdown, question and final screens in the chosen (A) style. +export const MobileFlow: Story = { + name: 'Mobile screens (countdown / question / final)', + render: () => , +}; 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..cc33d9fa6a4 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..81b58a2f73a Binary files /dev/null and b/packages/webapp/public/logos/weekly-quiz-trophy.png differ