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..608071a974b --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx @@ -0,0 +1,112 @@ +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 = [ + '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; +} + +// 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 = usePrefersReducedMotion(); + + 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..610ad30a2c4 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -0,0 +1,204 @@ +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 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 && ( + + )} + {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..caac67c029f --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -0,0 +1,134 @@ +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 }); + // 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 ( + + {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/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(() => { 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: () => ( +
+
+
+ +
+
+ +
+
+ +
+
+
+ ), +};