From 9059b7653398f61b652c2aa3f031f04a50a71887 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 16:21:41 +0300 Subject: [PATCH 1/2] feat(giveback): gift entry point with live activity + invite prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the giveback header + new-layout rail gift entry point — an acquisition hook that pulls people into /giveback. Gated entirely behind the existing `featureGiveback` flag (off in production until turned on per cohort), so main behavior is unchanged when the flag is off. - GivebackGiftButton: calm resting gift (header/rail variants), press feedback - GivebackGiftDock: composition point + imperative API (pulseActivity, showPrompt, reset), bare "+$" community dollar jumps, glow bloom - GivebackInvitePrompt: mascot-fronted invite card with a hover-pausing auto-dismiss countdown ring (driven by useTimedAnimation) - GivebackConfettiBurst + motion classes in base.css (bounce:0, reduced-motion safe) - GivebackGiftEntry: flag- + desktop-gated container, wired into HeaderButtons and the SidebarDesktopV2 rail gift (falls back to Invite friends when off) - Storybook: resting states, header states, and an interactive live playground The money pulses + invite-prompt cadence are driven by a local timer as a clearly-marked PLACEHOLDER — to be replaced by live backend community-activity signals (and the hardcoded figures dropped) before the experiment graduates. Co-Authored-By: Claude Opus 4.8 --- .../src/components/layout/HeaderButtons.tsx | 2 + .../components/sidebar/SidebarDesktopV2.tsx | 36 ++- .../components/GivebackConfettiBurst.tsx | 116 +++++++++ .../components/GivebackGiftButton.tsx | 100 ++++++++ .../giveback/components/GivebackGiftDock.tsx | 203 +++++++++++++++ .../giveback/components/GivebackGiftEntry.tsx | 130 ++++++++++ .../components/GivebackInvitePrompt.tsx | 232 ++++++++++++++++++ .../giveback/components/GivebackMascot.tsx | 3 +- .../giveback/givebackInvitePrompts.ts | 53 ++++ packages/shared/src/lib/image.ts | 5 + packages/shared/src/lib/log.ts | 2 + packages/shared/src/styles/base.css | 143 +++++++++++ .../giveback/GivebackGiftButton.stories.tsx | 94 +++++++ .../giveback/GivebackGiftShowcase.stories.tsx | 181 ++++++++++++++ .../giveback/GivebackHeaderStates.stories.tsx | 120 +++++++++ 15 files changed, 1413 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackGiftButton.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackGiftDock.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx create mode 100644 packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx create mode 100644 packages/shared/src/features/giveback/givebackInvitePrompts.ts create mode 100644 packages/storybook/stories/features/giveback/GivebackGiftButton.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx create mode 100644 packages/storybook/stories/features/giveback/GivebackHeaderStates.stories.tsx diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index 313def886e7..6f6fae46642 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -8,6 +8,7 @@ import classed from '../../lib/classed'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; import { QuestHeaderButton } from '../header/QuestHeaderButton'; +import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry'; interface HeaderButtonsProps { additionalButtons?: ReactNode; @@ -42,6 +43,7 @@ export function HeaderButtons({ + {additionalButtons} diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 066b1959b63..f024d2739c7 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -116,6 +116,9 @@ import { useCanPurchaseCores } from '../../hooks/useCoresFeature'; import { useSquadNavigation } from '../../hooks'; import { useAddBookmarkFolder } from '../../hooks/bookmark/useAddBookmarkFolder'; import { useStreakRingState } from '../../hooks/streaks/useStreakRingState'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureGiveback } from '../../lib/featureManagement'; +import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry'; import { FeedbackWidget } from '../feedback'; import { HorizontalSeparator } from '../utilities'; import { Typography, TypographyType } from '../typography/Typography'; @@ -258,18 +261,39 @@ const RailHoverCard = ({ ); }; -// Theme toggling now lives in the profile dropdown (ThemeSection, matching -// production). The rail slot is reused for a quick "Invite friends" shortcut. -const SidebarInviteButton = (): ReactElement => ( - - - +const railGiftLink = (label: string, href: string): ReactElement => ( + + + ); +// Theme toggling now lives in the profile dropdown (ThemeSection, matching +// production). When the giveback experiment is on, the rail gift becomes the +// giveback entry point — carrying the live money jumps + invite prompt via +// GivebackGiftEntry — and otherwise falls back to the "Invite friends" +// shortcut. +const SidebarInviteButton = (): ReactElement => { + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { value: givebackEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate: isAuthReady && isLoggedIn, + }); + + if (givebackEnabled) { + return ( + + {railGiftLink('Giveback', `${webappUrl}giveback`)} + + ); + } + + return railGiftLink('Invite friends', `${settingsUrl}/invite`); +}; + const supportItems: ProfileSectionItemProps[] = [ { title: 'Get the mobile app', diff --git a/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx new file mode 100644 index 00000000000..89f57429fdb --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx @@ -0,0 +1,116 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useEffect, useMemo } from 'react'; + +// Money-forward palette: gold + green lead (cash/coins), brand accents fill in. +const CONFETTI_COLORS = [ + 'var(--theme-accent-cheese-default)', + 'var(--theme-accent-avocado-default)', + 'var(--theme-accent-cabbage-default)', + 'var(--theme-accent-bacon-default)', +]; + +interface ConfettiPiece { + id: string; + dx: number; + dy: number; + rotate: number; + delayMs: number; + durationMs: number; + color: string; +} + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +// Fan the pieces mostly upward and outward, then let gravity pull them down — +// a quick celebratory pop rather than a full-screen confetti dump. +const buildPieces = (count: number, spread: number): ConfettiPiece[] => { + return Array.from({ length: count }, (_, index) => { + const angle = Math.PI + (Math.PI * (index + 0.5)) / count; // upper half + const distance = spread * (0.6 + Math.random() * 0.6); + const dx = Math.cos(angle) * distance; + const dy = Math.sin(angle) * distance + spread * (0.35 + Math.random()); + + return { + id: `giveback-confetti-${index.toString()}`, + dx, + dy, + rotate: (Math.random() - 0.5) * 540, + delayMs: Math.round(Math.random() * 90), + durationMs: 760 + Math.round(Math.random() * 320), + color: CONFETTI_COLORS[index % CONFETTI_COLORS.length], + }; + }); +}; + +export interface GivebackConfettiBurstProps { + // Bump to replay the burst (each new value spawns a fresh set of pieces). + trigger: number; + pieceCount?: number; + spread?: number; + onDone?: () => void; + className?: string; +} + +export const GivebackConfettiBurst = ({ + trigger, + pieceCount = 18, + spread = 88, + onDone, + className, +}: GivebackConfettiBurstProps): ReactElement | null => { + const reduced = prefersReducedMotion(); + + const pieces = useMemo( + () => (reduced ? [] : buildPieces(pieceCount, spread)), + // trigger intentionally re-seeds the pieces on each replay. + // eslint-disable-next-line react-hooks/exhaustive-deps + [trigger, pieceCount, spread, reduced], + ); + + useEffect(() => { + if (!onDone) { + return undefined; + } + const longest = pieces.reduce( + (max, piece) => Math.max(max, piece.delayMs + piece.durationMs), + reduced ? 200 : 0, + ); + const timer = window.setTimeout(onDone, longest + 80); + return () => window.clearTimeout(timer); + }, [pieces, onDone, reduced, trigger]); + + if (!pieces.length) { + return null; + } + + return ( +
+ {pieces.map((piece) => ( + + ))} +
+ ); +}; + +export default GivebackConfettiBurst; diff --git a/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx b/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx new file mode 100644 index 00000000000..5ba438bc7fe --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx @@ -0,0 +1,100 @@ +import type { ForwardedRef, ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { GiftIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; + +export type GivebackGiftButtonVariant = 'header' | 'rail'; + +export interface GivebackGiftButtonProps { + variant?: GivebackGiftButtonVariant; + showLabel?: boolean; + label?: string; + tooltip?: string; + onClick?: () => void; + className?: string; +} + +const pressClass = 'transition-transform duration-150 ease-out active:scale-90'; + +// The persistent giveback entry point. Calm at rest — a plain gift, no ambient +// progress meter and no notification badge. Ref-forwarding so the dock can +// anchor money jumps and the milestone glow to the icon. +export const GivebackGiftButton = forwardRef(function GivebackGiftButton( + { + variant = 'header', + showLabel = false, + label = 'Giveback', + tooltip = 'Giveback', + onClick, + className, + }: GivebackGiftButtonProps, + ref: ForwardedRef, +): ReactElement { + const isRail = variant === 'rail'; + + // Header: a square icon button that matches the notification bell exactly + // (Float, w-10, centered, no side padding) so it's not wider than its + // neighbours. + if (!isRail) { + return ( + + + ); + + if (showLabel) { + return railButton; + } + + return ( + + {railButton} + + ); +}); + +export default GivebackGiftButton; diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx new file mode 100644 index 00000000000..ac796ffebd5 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -0,0 +1,203 @@ +import type { ForwardedRef, ReactElement, ReactNode } from 'react'; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import type { GivebackGiftButtonVariant } from './GivebackGiftButton'; +import { GivebackGiftButton } from './GivebackGiftButton'; +import { GivebackInvitePrompt } from './GivebackInvitePrompt'; +import type { GivebackInvitePromptData } from '../givebackInvitePrompts'; + +// Imperative API the header/rail wiring drives. This surface is an acquisition +// hook — everything here is generic + community-framed to pull people into +// /giveback, never a personal reward notice. +export interface GivebackGiftDockHandle { + // Ambient community money landing in the pot (social proof), e.g. "+$8". + // This bare dollar jump on the gift is the engagement/attention mechanism. + pulseActivity: (label: string) => void; + // The full invite bubble (rotating messages). + showPrompt: (prompt: GivebackInvitePromptData) => void; + reset: () => void; +} + +interface ValuePop { + id: string; + label: string; + // Horizontal nudge (px) off the gift centre so it reads beside the icon and + // rapid pops don't stack exactly on top of each other. + dx: number; +} + +interface GivebackGiftDockProps { + variant?: GivebackGiftButtonVariant; + showLabel?: boolean; + onOpenGiveback?: () => void; + // Override where the invite prompt opens (defaults follow the variant). + promptPlacement?: 'below' | 'above'; + promptAlign?: 'start' | 'end'; + // Custom anchor (e.g. the sidebar's own styled gift link). When provided it + // replaces the built-in gift button; the money/prompt overlays anchor to it. + children?: ReactNode; +} + +const GIFT_POP_MS = 380; +const VALUE_POP_LIFETIME_MS = 2000; +const MILESTONE_TOAST_DELAY_MS = 180; + +let popCounter = 0; + +export const GivebackGiftDock = forwardRef(function GivebackGiftDock( + { + variant = 'header', + showLabel = false, + onOpenGiveback, + promptPlacement, + promptAlign, + children, + }: GivebackGiftDockProps, + ref: ForwardedRef, +): ReactElement { + const isRail = variant === 'rail'; + const [pops, setPops] = useState([]); + const [popping, setPopping] = useState(false); + const [glowKey, setGlowKey] = useState(0); + const [giftHovered, setGiftHovered] = useState(false); + const [prompt, setPrompt] = useState(null); + // Bumps per show so a replacing prompt remounts (fresh timer + confetti). + const [promptSeq, setPromptSeq] = useState(0); + const timers = useRef([]); + + const track = useCallback((id: number) => { + timers.current.push(id); + }, []); + + const popGift = useCallback(() => { + setPopping(false); + window.requestAnimationFrame(() => setPopping(true)); + track(window.setTimeout(() => setPopping(false), GIFT_POP_MS + 40)); + }, [track]); + + const pulseActivity = useCallback( + (label: string) => { + popCounter += 1; + const id = `giveback-pop-${popCounter.toString()}`; + // Bias to the right of the icon with a little spread. + const dx = Math.round(Math.random() * 18) + 6; + setPops((current) => [...current, { id, label, dx }]); + popGift(); + track( + window.setTimeout(() => { + setPops((current) => current.filter((pop) => pop.id !== id)); + }, VALUE_POP_LIFETIME_MS), + ); + }, + [popGift, track], + ); + + const showPrompt = useCallback( + (next: GivebackInvitePromptData) => { + if (next.celebrate) { + setGlowKey((current) => current + 1); + } + popGift(); + track( + window.setTimeout(() => { + setPromptSeq((current) => current + 1); + setPrompt(next); + }, MILESTONE_TOAST_DELAY_MS), + ); + }, + [popGift, track], + ); + + const reset = useCallback(() => { + timers.current.forEach((id) => window.clearTimeout(id)); + timers.current = []; + setPops([]); + setPopping(false); + setPrompt(null); + }, []); + + useImperativeHandle(ref, () => ({ pulseActivity, showPrompt, reset }), [ + pulseActivity, + showPrompt, + reset, + ]); + + // Opening giveback (clicking the gift or the toast CTA) navigates to the page, + // so dismiss the prompt — the user is already there, no need to keep nagging. + const handleOpen = useCallback(() => { + setPrompt(null); + onOpenGiveback?.(); + }, [onOpenGiveback]); + + return ( +
+ setGiftHovered(true)} + onMouseLeave={() => setGiftHovered(false)} + // A custom anchor (rail link) navigates on its own; still dismiss the + // toast when it's clicked (capture phase — the link stays interactive). + onClickCapture={children ? () => setPrompt(null) : undefined} + > + {/* Soft glow bloom on a celebratory community moment. */} + {glowKey > 0 && ( + + )} + {children ?? ( + + )} + + + {/* Community money landing in the pot — bare green numerals that pop in + beside the gift and drift up (Polymarket-style real-time jump). Offset + to the right of the icon per-pop so they stay legible, not on the + glyph. */} +
+ {pops.map((pop) => ( + + {pop.label} + + ))} +
+ + setPrompt(null)} + /> +
+ ); +}); + +export default GivebackGiftDock; diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx new file mode 100644 index 00000000000..f930241abdf --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -0,0 +1,130 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import type { GivebackGiftButtonVariant } from './GivebackGiftButton'; +import type { GivebackGiftDockHandle } from './GivebackGiftDock'; +import { GivebackGiftDock } from './GivebackGiftDock'; +import { givebackInvitePrompts } from '../givebackInvitePrompts'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureGiveback } from '../../../lib/featureManagement'; +import { webappUrl } from '../../../lib/constants'; +import { LogEvent } from '../../../lib/log'; + +interface GivebackGiftEntryProps { + variant?: GivebackGiftButtonVariant; + showLabel?: boolean; + promptPlacement?: 'below' | 'above'; + promptAlign?: 'start' | 'end'; + // Custom anchor (e.g. the sidebar's own styled gift link). + children?: ReactNode; +} + +// Rotate a different opening message on each load. +let promptCursor = 0; +// Only one mounted entry runs the ambient cadence, so header + rail can't both +// fire (whichever mounts first claims it). +let cadenceClaimed = false; + +// PLACEHOLDER CADENCE — the money pulses and the invite prompt are currently +// driven on a local timer as a stand-in. Before this graduates past the +// experiment, replace this timer with live backend community-activity signals +// (real amounts landing in the pot) and drop the hardcoded figures in +// `givebackInvitePrompts`. This whole surface is gated behind `featureGiveback`, +// so it stays off in production until the experiment is turned on per cohort. +const FIRST_PULSE_MS = 3500; +const PULSE_INTERVAL_MS = 22000; +const PROMPT_MS = 6000; +const PULSE_AMOUNTS = [1, 2, 3, 5, 8]; + +const randomPulse = (): string => + `+$${PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]}`; + +// The persistent giveback entry point. Gated on the same `featureGiveback` flag +// as the page, so it shows wherever giveback is enabled. It drives its dock from +// the ambient cadence, so the money jumps and invite prompts play on the real +// gift (header or sidebar). +export function GivebackGiftEntry({ + variant = 'header', + showLabel = false, + promptPlacement, + promptAlign, + children, +}: GivebackGiftEntryProps): ReactElement | null { + const router = useRouter(); + const { isLoggedIn, isAuthReady } = useAuthContext(); + const { logEvent } = useLogContext(); + const dock = useRef(null); + + // Desktop-only for now — the mobile placement is parked for a later PR, so + // the entry never shows on smaller viewports (header/rail are desktop anyway). + const isLaptop = useViewSize(ViewSize.Laptop); + const shouldEvaluate = isAuthReady && isLoggedIn && isLaptop; + const { value: isEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate, + }); + const show = shouldEvaluate && isEnabled; + + // Ambient cadence (claimed by a single instance). See PLACEHOLDER note above. + useEffect(() => { + if (!show || cadenceClaimed) { + return undefined; + } + cadenceClaimed = true; + + const timeouts: number[] = []; + timeouts.push( + window.setTimeout(() => { + dock.current?.pulseActivity(randomPulse()); + }, FIRST_PULSE_MS), + ); + timeouts.push( + window.setTimeout(() => { + const prompt = + givebackInvitePrompts[promptCursor % givebackInvitePrompts.length]; + promptCursor += 1; + dock.current?.showPrompt(prompt); + logEvent({ + event_name: LogEvent.ViewGivebackPrompt, + extra: JSON.stringify({ prompt: prompt.id }), + }); + }, PROMPT_MS), + ); + const pulse = window.setInterval(() => { + dock.current?.pulseActivity(randomPulse()); + }, PULSE_INTERVAL_MS); + + return () => { + timeouts.forEach((id) => window.clearTimeout(id)); + window.clearInterval(pulse); + cadenceClaimed = false; + }; + }, [show, logEvent]); + + if (!show) { + return null; + } + + const openGiveback = () => { + logEvent({ event_name: LogEvent.ClickGivebackGiftEntry }); + router.push(`${webappUrl}giveback`); + }; + + return ( + + {children} + + ); +} + +export default GivebackGiftEntry; diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx new file mode 100644 index 00000000000..d363be41b90 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -0,0 +1,232 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useTimedAnimation } from '../../../hooks/useTimedAnimation'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { MiniCloseIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { RootPortal } from '../../../components/tooltips/Portal'; +import { cloudinaryCharmGiveback } from '../../../lib/image'; +import { GivebackConfettiBurst } from './GivebackConfettiBurst'; + +export interface GivebackInvitePromptProps { + open: boolean; + eyebrow?: string; + headline?: string; + body?: string; + ctaLabel?: string; + // Festive community moment — confetti bursts from the gift. + celebrate?: boolean; + // Opens above the gift (rail, bottom-left) instead of below it (header). + placement?: 'below' | 'above'; + // Horizontal edge the tail points to — matches where the gift sits. + align?: 'start' | 'end'; + // Open like a rail dropdown: portaled + fixed at the same left margin as the + // support/settings/profile menus, instead of anchored to the gift with a tail. + dropdown?: boolean; + autoDismissMs?: number; + // Externally pause the auto-dismiss (e.g. while the cursor is over the gift). + paused?: boolean; + onClick?: () => void; + onClose?: () => void; + className?: string; +} + +// A playful, community-framed invitation fronted by the daily.dev mascot. It's +// an acquisition hook, not a personal reward notice — it leads with social +// proof + the honest trade and always ends in a clear way into /giveback. The +// close button carries the auto-dismiss countdown ring (drains as it nears +// exit), so there's no bulky progress bar. +export const GivebackInvitePrompt = ({ + open, + eyebrow = 'Raised together', + headline = 'The community is funding real-world causes', + body = 'All from everyday daily.dev activity. Pick the causes you care about.', + ctaLabel = 'Join in', + celebrate = false, + placement = 'below', + align = 'end', + dropdown = false, + autoDismissMs = 5000, + paused = false, + onClick, + onClose, + className, +}: GivebackInvitePromptProps): ReactElement | null => { + // Bumps every time the prompt opens, so the confetti restarts even when one + // prompt replaces another. + const [runId, setRunId] = useState(0); + const [hovered, setHovered] = useState(false); + + // Keep onClose fresh without re-arming the timer every render. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const handleEnd = useCallback(() => onCloseRef.current?.(), []); + + const { timer, startAnimation, pauseAnimation, resumeAnimation } = + useTimedAnimation({ + autoEndAnimation: true, + outAnimationDuration: 150, + onAnimationEnd: handleEnd, + }); + + // Arm the countdown when the prompt opens (once — not on every render). + useEffect(() => { + if (!open) { + return undefined; + } + setRunId((current) => current + 1); + startAnimation(autoDismissMs); + return () => pauseAnimation(); + }, [open, autoDismissMs, startAnimation, pauseAnimation]); + + // Pause the countdown while the pointer rests on the gift or the toast, and + // resume from where it left off (never restart) when it leaves. + const isPaused = paused || hovered; + useEffect(() => { + if (!open) { + return; + } + if (isPaused) { + pauseAnimation(); + } else { + resumeAnimation(); + } + }, [isPaused, open, pauseAnimation, resumeAnimation]); + + if (!open) { + return null; + } + + const isAbove = placement === 'above'; + // Countdown ring: drains 0 -> 100 as the remaining time elapses, and freezes + // while paused (the timer stops ticking). + const remaining = autoDismissMs > 0 ? (timer / autoDismissMs) * 100 : 0; + const dashoffset = Math.min(100, Math.max(0, 100 - remaining)); + const giftSide = align === 'end' ? 'right-6' : 'left-6'; + + const content = ( +
+ {celebrate && ( +
+ +
+ )} + + {/* Tail pointing to the gift (anchored mode only). */} + {!dropdown && ( +
+ )} + +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Close button (top-right corner) with the auto-dismiss countdown ring. */} + + + {/* Left: full-width message + CTA. */} +
+
+ + {eyebrow} + +

+ {headline} +

+

+ {body} +

+
+ + +
+ + {/* The Giveback charm, bobbing on a soft glow. Its artwork sits on black, + so mix-blend-screen drops the black on the dark card. */} +
+ + daily.dev Giveback charm +
+
+
+ ); + + return dropdown ? {content} : content; +}; + +export default GivebackInvitePrompt; diff --git a/packages/shared/src/features/giveback/components/GivebackMascot.tsx b/packages/shared/src/features/giveback/components/GivebackMascot.tsx index be73c936313..3bce6caf828 100644 --- a/packages/shared/src/features/giveback/components/GivebackMascot.tsx +++ b/packages/shared/src/features/giveback/components/GivebackMascot.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; +import { cloudinaryCharmGiveback } from '../../../lib/image'; // The daily.dev charm, themed as a "wish-granting genie" for Giveback: you make // a wish (pick a cause / take an action) and daily.dev grants it. const GIVEBACK_CHARM_IMAGE = { - src: 'https://media.daily.dev/image/upload/s--d1dldAty--/f_auto,q_auto/v1780848838/public/daily.dev%20Charm%20-%20Giveback%20(1)', + src: cloudinaryCharmGiveback, alt: 'daily.dev charm celebrating community impact for the Giveback campaign', }; diff --git a/packages/shared/src/features/giveback/givebackInvitePrompts.ts b/packages/shared/src/features/giveback/givebackInvitePrompts.ts new file mode 100644 index 00000000000..44ade98a2d2 --- /dev/null +++ b/packages/shared/src/features/giveback/givebackInvitePrompts.ts @@ -0,0 +1,53 @@ +// Copy for the header/rail gift entry point. This surface is an ACQUISITION +// hook, not a personal tracker: most people never contribute, and we don't +// track per-user interactions here. Every prompt is generic + community-framed +// and exists to pull the user into /giveback. Voice follows the giveback +// "honest trade" system: you help us grow, we redirect the budget to causes you +// pick, you never pay. Org rules: "daily.dev" lowercase; never state a user +// count (money-raised figures are fine). + +export interface GivebackInvitePromptData { + id: string; + // Small kicker above the headline (e.g. a live/community tag). + eyebrow?: string; + headline: string; + body: string; + ctaLabel: string; + // Festive community moment — fires confetti + glow. Use sparingly. + celebrate?: boolean; +} + +// A rotating set so the header stays fresh and hits different motivations: +// social proof, how-it-works/no-cost, cause spotlight, and a plain invite. +export const givebackInvitePrompts: GivebackInvitePromptData[] = [ + { + id: 'community-raised', + eyebrow: 'Raised together', + headline: 'The community just crossed $12,340 for good causes', + body: 'All of it funded by everyday daily.dev activity, not out of anyone’s pocket.', + ctaLabel: 'Join in', + celebrate: true, + }, + { + id: 'how-it-works', + eyebrow: 'No cost to you', + headline: 'Turn your daily.dev activity into real donations', + body: 'You help us grow, we redirect the budget to causes you pick. You never pay a cent.', + ctaLabel: 'See how it works', + }, + { + id: 'cause-funded', + eyebrow: 'Just funded', + headline: 'Dev scholarships just got a boost 🎓', + body: 'Choose which real-world causes your daily.dev activity backs.', + ctaLabel: 'Pick your causes', + celebrate: true, + }, + { + id: 'invite', + eyebrow: 'Two clicks', + headline: 'Give back while you read', + body: 'Developers are turning their daily habit into donations right now. Add yours.', + ctaLabel: 'Start giving back', + }, +]; diff --git a/packages/shared/src/lib/image.ts b/packages/shared/src/lib/image.ts index 470e859c74b..a711d0c56ba 100644 --- a/packages/shared/src/lib/image.ts +++ b/packages/shared/src/lib/image.ts @@ -474,3 +474,8 @@ export const cloudinaryCharmNoPosts = export const cloudinaryCharmNotEnoughTags = 'https://media.daily.dev/image/upload/s--0PIPx07_--/f_auto,q_auto/v1781529338/public/daily.dev%20Charm%20-%20no%20enoght%20tags%20(1)'; + +// The Giveback charm (genie-themed). Artwork sits on solid black — render with +// `mix-blend-screen` on a dark surface so the black drops out. +export const cloudinaryCharmGiveback = + 'https://media.daily.dev/image/upload/s--d1dldAty--/f_auto,q_auto/v1780848838/public/daily.dev%20Charm%20-%20Giveback%20(1)'; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 27d9ad8f9b4..716836cd76b 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -516,6 +516,8 @@ export enum LogEvent { ViewGivebackFunnelStep = 'view giveback funnel step', CompleteGivebackFunnel = 'complete giveback funnel', ClickGivebackHowItWorks = 'click giveback how it works', + ClickGivebackGiftEntry = 'click giveback gift entry', + ViewGivebackPrompt = 'view giveback prompt', // Daily homepage DailyFeedback = 'daily feedback', } diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index beb4d42a9e0..d6f972b47a0 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1048,6 +1048,149 @@ meter::-webkit-meter-bar { will-change: transform, opacity; } + /* Giveback gift celebrations. Each confetti piece flies from the gift's + center to its own (x, y) offset while spinning and fading — offsets and + rotation are handed in per piece via CSS vars. */ + @keyframes giveback-confetti-piece { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.2) rotate(0deg); + } + + 14% { + opacity: 1; + transform: translate(-50%, -50%) scale(1) rotate(30deg); + } + + 100% { + opacity: 0; + transform: translate( + calc(-50% + var(--giveback-confetti-x)), + calc(-50% + var(--giveback-confetti-y)) + ) + scale(0.85) rotate(var(--giveback-confetti-r)); + } + } + + .giveback-confetti-piece { + width: 0.5rem; + height: 0.5rem; + border-radius: 1px; + background: currentColor; + animation: giveback-confetti-piece 0.9s cubic-bezier(0.12, 0.62, 0.28, 1) + forwards; + will-change: transform, opacity; + } + + /* Real-time dollar jump beside the gift: a bare "+$" figure pops in, holds + legibly while drifting up a little, then fades. Slow enough to read; the + horizontal offset is set per-pop so it sits next to the icon, not on it. */ + @keyframes giveback-value-rise { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.9); + } + + 16% { + opacity: 1; + transform: translateY(0) scale(1); + } + + 70% { + opacity: 1; + transform: translateY(-14px) scale(1); + } + + 100% { + opacity: 0; + transform: translateY(-24px) scale(1); + } + } + + .giveback-value-rise { + animation: giveback-value-rise 1.8s cubic-bezier(0.22, 0.61, 0.36, 1) + forwards; + will-change: transform, opacity; + } + + /* Milestone toast: signature enter — rise + de-blur + fade, from the top + anchor (the gift), no overshoot. */ + @keyframes giveback-toast-in { + 0% { + opacity: 0; + transform: translateY(-8px); + filter: blur(8px); + } + + 100% { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } + } + + .giveback-toast-in { + transform-origin: top center; + animation: giveback-toast-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both; + will-change: transform, opacity, filter; + } + + /* Subtle "received" reaction on the gift when money lands — a settle up to + peak with no dip below rest (critically damped, no wobble). */ + @keyframes giveback-gift-pop { + 0% { + transform: scale(1); + } + + 35% { + transform: scale(1.12); + } + + 100% { + transform: scale(1); + } + } + + .giveback-gift-pop { + animation: giveback-gift-pop 0.38s cubic-bezier(0.16, 1, 0.3, 1) both; + } + + /* Milestone: a soft radial glow blooms out from the gift and fades — a + warmer, classier beat than a hard expanding ring. */ + @keyframes giveback-glow-bloom { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.5); + } + + 30% { + opacity: 0.9; + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.9); + } + } + + .giveback-glow-bloom { + animation: giveback-glow-bloom 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards; + will-change: transform, opacity; + } + + @media (prefers-reduced-motion: reduce) { + .giveback-confetti-piece, + .giveback-value-rise, + .giveback-glow-bloom { + animation-duration: 0.01ms; + } + + .giveback-toast-in, + .giveback-gift-pop { + animation: none; + } + } + @keyframes quest-claimed-stamp { 0% { opacity: 0; diff --git a/packages/storybook/stories/features/giveback/GivebackGiftButton.stories.tsx b/packages/storybook/stories/features/giveback/GivebackGiftButton.stories.tsx new file mode 100644 index 00000000000..0cb6edb1f29 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackGiftButton.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { GivebackGiftButton } from '@dailydotdev/shared/src/features/giveback/components/GivebackGiftButton'; +import { withGiveback } from './giveback.mocks'; + +// The persistent giveback entry point: a calm gift icon in the top header and, +// on the new layout, at the bottom-left of the rail. No ambient progress meter — +// the gift stays quiet at rest and only comes alive in the celebration moments. +const meta: Meta = { + title: 'Features/Giveback/Entry points/Gift button', + component: GivebackGiftButton, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Resting states for the header/rail gift icon. Calm at rest — just the gift, no progress meter and no notification badge. Press feedback scales down.', + }, + }, + }, + decorators: [withGiveback()], + argTypes: { + variant: { control: 'inline-radio', options: ['header', 'rail'] }, + showLabel: { control: 'boolean' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const HeaderFrame = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ daily.dev +
+ {children} + + +
+
+); + +const RailFrame = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+
+ {['Feed', 'Explore', 'Bookmarks'].map((item) => ( +
+ + {item} +
+ ))} +
+
+ {children} +
+
+); + +export const Playground: Story = { + args: { + variant: 'header', + }, +}; + +export const Placement: Story = { + parameters: { controls: { disable: true } }, + render: () => ( +
+
+ Header + + + +
+
+ Rail, labelled + + + +
+
+ ), +}; diff --git a/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx b/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx new file mode 100644 index 00000000000..51856070863 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx @@ -0,0 +1,181 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useEffect, useRef, useState } from 'react'; +import type { GivebackGiftDockHandle } from '@dailydotdev/shared/src/features/giveback/components/GivebackGiftDock'; +import { GivebackGiftDock } from '@dailydotdev/shared/src/features/giveback/components/GivebackGiftDock'; +import { givebackInvitePrompts } from '@dailydotdev/shared/src/features/giveback/givebackInvitePrompts'; +import { useCountUp } from '@dailydotdev/shared/src/features/giveback/useGivebackMotion'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { withGiveback } from './giveback.mocks'; + +const ControlButton = ({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}): React.ReactElement => ( + +); + +// Mirrors the real old-layout header (MainLayoutHeader → HeaderButtons): full +// width, bottom border, h-16, logo left, and the action cluster right in the +// real order — opportunity, quests, [giveback gift], notifications, profile. +// The neighbours are Float-button-sized placeholders; the gift is the real +// component so it can be validated in its true header context. +const HeaderBar = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ daily.dev +
+ + + {children} + + +
+
+); + +const LivePlayground = ({ + variant, +}: { + variant: 'header' | 'rail'; +}): React.ReactElement => { + const dock = useRef(null); + const promptIndex = useRef(0); + const [raisedToday, setRaisedToday] = useState(12340); + const [ambient, setAmbient] = useState(false); + const raised = useCountUp(raisedToday, true, 700); + + const cyclePrompt = () => { + const next = givebackInvitePrompts[promptIndex.current]; + promptIndex.current = + (promptIndex.current + 1) % givebackInvitePrompts.length; + dock.current?.showPrompt(next); + }; + + // Ambient community activity — money keeps landing on its own (social proof). + useEffect(() => { + if (!ambient) { + return undefined; + } + const tick = () => { + const amount = [1, 2, 3, 5, 8, 12][Math.floor(Math.random() * 6)]; + setRaisedToday((current) => current + amount); + dock.current?.pulseActivity(`+$${amount}`); + }; + const interval = window.setInterval(tick, 1600); + return () => window.clearInterval(interval); + }, [ambient]); + + const dockNode = ( + + ); + + return ( +
+ {variant === 'header' ? ( + {dockNode} + ) : ( +
+
+ {['Feed', 'Explore', 'Bookmarks'].map((item) => ( +
+ + {item} +
+ ))} +
+
+ {dockNode} +
+
+ )} + +
+ + This entry point is an acquisition hook — its job is to pull people + into the giveback page. Everything is generic + community-framed, not a + personal tracker. + +
+ + + Community raised today:{' '} + + ${raised.toLocaleString('en-US')} + + +
+ +
+ + Show invite prompt → + + setAmbient((v) => !v)}> + {ambient ? 'Stop' : 'Start'} community activity + + { + dock.current?.reset(); + setRaisedToday(12340); + setAmbient(false); + promptIndex.current = 0; + }} + > + Reset + +
+ + + “Show invite prompt” cycles through the message variants (social proof, + how-it-works, cause spotlight, plain invite). Celebratory ones bloom + + confetti. + +
+
+ ); +}; + +const meta: Meta = { + title: 'Features/Giveback/Entry points/Live playground', + decorators: [withGiveback()], + parameters: { + layout: 'centered', + controls: { disable: true }, + docs: { + description: { + component: + 'The gift entry point as an acquisition hook. Community money lands on the gift in real time — bare Polymarket-style dollar jumps (no chip) that flash on the button and leap up. A rotating generic invite prompt (with a visible auto-dismiss countdown) draws the eye into /giveback. No personal tracking. Motion is bounce:0 per the Jakub Krehel craft playbook.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Header: Story = { + render: () => , +}; + +export const Rail: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/features/giveback/GivebackHeaderStates.stories.tsx b/packages/storybook/stories/features/giveback/GivebackHeaderStates.stories.tsx new file mode 100644 index 00000000000..e5434b6ed59 --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackHeaderStates.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { GivebackGiftButton } from '@dailydotdev/shared/src/features/giveback/components/GivebackGiftButton'; +import { GivebackInvitePrompt } from '@dailydotdev/shared/src/features/giveback/components/GivebackInvitePrompt'; +import { givebackInvitePrompts } from '@dailydotdev/shared/src/features/giveback/givebackInvitePrompts'; +import { withGiveback } from './giveback.mocks'; + +// A stand-in for the old-layout top header so the gift can be judged in its real +// neighbourhood (logo left, action cluster right). +const Header = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ daily.dev +
+ + {children} + + +
+
+); + +const Label = ({ text }: { text: string }): React.ReactElement => ( + {text} +); + +const meta: Meta = { + title: 'Features/Giveback/Entry points/Header states', + parameters: { + layout: 'fullscreen', + controls: { disable: true }, + docs: { + description: { + component: + 'Static poses of the header gift entry on the old layout, for review: resting icon, a community dollar jump, and the invite toast (celebration + plain).', + }, + }, + }, + decorators: [withGiveback()], +}; + +export default meta; + +type Story = StoryObj; + +export const AllStates: Story = { + render: () => ( +
+
+
+ +
+
+ +
+
+ +
+
+
+ ), +}; From 33f84af6594332f7762bea9e1f7c0bf0ca1cd062 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 16:40:10 +0300 Subject: [PATCH 2/2] fix(giveback): log rail gift clicks + reuse reduced-motion hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from self-review of the entry point: - The sidebar rail gift navigates via its anchor href, so it never went through the dock's open handler and ClickGivebackGiftEntry was never logged for rail clicks (header logged, rail didn't). Route the rail anchor's capture-phase click through handleOpen so the event fires; the entry only calls router.push for the built-in header button to avoid double-navigating the rail anchor. - GivebackConfettiBurst had a private copy of a prefers-reduced-motion check that read the media query once and never reacted to changes. Reuse the existing (now exported) usePrefersReducedMotion from useGivebackMotion, which subscribes to changes — dropping the duplicated helper. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackConfettiBurst.tsx | 8 ++------ .../src/features/giveback/components/GivebackGiftDock.tsx | 7 ++++--- .../features/giveback/components/GivebackGiftEntry.tsx | 6 +++++- .../shared/src/features/giveback/useGivebackMotion.ts | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx index 89f57429fdb..608071a974b 100644 --- a/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx +++ b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; +import { usePrefersReducedMotion } from '../useGivebackMotion'; // Money-forward palette: gold + green lead (cash/coins), brand accents fill in. const CONFETTI_COLORS = [ @@ -19,11 +20,6 @@ interface ConfettiPiece { color: string; } -const prefersReducedMotion = (): boolean => - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches; - // Fan the pieces mostly upward and outward, then let gravity pull them down — // a quick celebratory pop rather than a full-screen confetti dump. const buildPieces = (count: number, spread: number): ConfettiPiece[] => { @@ -61,7 +57,7 @@ export const GivebackConfettiBurst = ({ onDone, className, }: GivebackConfettiBurstProps): ReactElement | null => { - const reduced = prefersReducedMotion(); + const reduced = usePrefersReducedMotion(); const pieces = useMemo( () => (reduced ? [] : buildPieces(pieceCount, spread)), diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index ac796ffebd5..610ad30a2c4 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -144,9 +144,10 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( )} onMouseEnter={() => setGiftHovered(true)} onMouseLeave={() => setGiftHovered(false)} - // A custom anchor (rail link) navigates on its own; still dismiss the - // toast when it's clicked (capture phase — the link stays interactive). - onClickCapture={children ? () => setPrompt(null) : undefined} + // A custom anchor (rail link) navigates on its own via its href; on click + // (capture phase, so the link stays interactive) still run handleOpen to + // dismiss the toast and log the entry click, matching the header button. + onClickCapture={children ? handleOpen : undefined} > {/* Soft glow bloom on a celebratory community moment. */} {glowKey > 0 && ( diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index f930241abdf..caac67c029f 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -110,7 +110,11 @@ export function GivebackGiftEntry({ const openGiveback = () => { logEvent({ event_name: LogEvent.ClickGivebackGiftEntry }); - router.push(`${webappUrl}giveback`); + // The rail passes its own anchor (with an href) as children, so it navigates + // itself — only the built-in header button needs an explicit push here. + if (!children) { + router.push(`${webappUrl}giveback`); + } }; return ( diff --git a/packages/shared/src/features/giveback/useGivebackMotion.ts b/packages/shared/src/features/giveback/useGivebackMotion.ts index 5afeefbd846..25ce5d5d458 100644 --- a/packages/shared/src/features/giveback/useGivebackMotion.ts +++ b/packages/shared/src/features/giveback/useGivebackMotion.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; -const usePrefersReducedMotion = (): boolean => { +export const usePrefersReducedMotion = (): boolean => { const [reduced, setReduced] = useState(false); useEffect(() => {