From 283b172b5d5d4fd4947afadf950192c3269cfced Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 1 Jul 2026 17:59:40 +0300 Subject: [PATCH 01/19] feat(giveback): add 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 entry point: a calm gift icon that surfaces community momentum and pulls people into /giveback. - GivebackGiftButton: calm resting gift (header/rail variants), press feedback - GivebackGiftDock: composition point + imperative API (pulseActivity, showPrompt) - Polymarket-style bare "+$" dollar jumps beside the gift (community social proof) - GivebackInvitePrompt: wide, compact, mascot-fronted invite card with a countdown ring on the close button (rotating generic community messaging) - GivebackConfettiBurst + motion classes in base.css (bounce:0, reduced-motion safe) - GivebackGiftEntry: container gated on featureGiveback, wired into HeaderButtons - Storybook playgrounds (gift button states + interactive live playground) Note: the money pulses + prompt cadence are a review placeholder driven by a local timer — to be replaced by real backend community-activity signals. Co-Authored-By: Claude Opus 4.8 --- .../src/components/layout/HeaderButtons.tsx | 2 + .../components/GivebackConfettiBurst.tsx | 116 +++++++++++ .../components/GivebackGiftButton.tsx | 77 +++++++ .../giveback/components/GivebackGiftDock.tsx | 175 ++++++++++++++++ .../giveback/components/GivebackGiftEntry.tsx | 108 ++++++++++ .../components/GivebackInvitePrompt.tsx | 193 ++++++++++++++++++ .../giveback/givebackInvitePrompts.ts | 57 ++++++ packages/shared/src/lib/log.ts | 2 + packages/shared/src/styles/base.css | 161 +++++++++++++++ .../giveback/GivebackGiftButton.stories.tsx | 94 +++++++++ .../giveback/GivebackGiftShowcase.stories.tsx | 180 ++++++++++++++++ 11 files changed, 1165 insertions(+) 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 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/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..6cabbfe8266 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx @@ -0,0 +1,77 @@ +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; +} + +// 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'; + + const button = ( + + ); + + if (isRail && showLabel) { + return button; + } + + return ( + + {button} + + ); +}); + +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..e4450956c34 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -0,0 +1,175 @@ +import type { ForwardedRef, ReactElement } 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; +} + +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, + }: GivebackGiftDockProps, + ref: ForwardedRef, +): ReactElement { + const isRail = variant === 'rail'; + const [pops, setPops] = useState([]); + const [popping, setPopping] = useState(false); + const [glowKey, setGlowKey] = useState(0); + const [prompt, setPrompt] = useState(null); + 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(() => 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, + ]); + + return ( +
+ + {/* Soft glow bloom on a celebratory community moment. */} + {glowKey > 0 && ( + + )} + + + + {/* 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..52369de1567 --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } 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 { 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; +} + +// Rotate a different opening message on each load. +let promptCursor = 0; + +// Demo cadence for the review build. NOTE: the money pulses and the invite +// prompt are driven on a timer here as a placeholder — in the real wiring these +// come from live backend community-activity signals, not a local timer. +const FIRST_PULSE_MS = 3500; +const PULSE_INTERVAL_MS = 22000; +const PROMPT_MS = 6000; +const PULSE_AMOUNTS = [1, 2, 3, 5, 8]; + +// The persistent giveback entry point. Gated on the same `featureGiveback` flag +// as the page (on in development), so it shows wherever giveback is enabled. +export function GivebackGiftEntry({ + variant = 'header', + showLabel = false, +}: GivebackGiftEntryProps): ReactElement | null { + const router = useRouter(); + const { isLoggedIn, isAuthReady } = useAuthContext(); + const { logEvent } = useLogContext(); + const dock = useRef(null); + + const shouldEvaluate = isAuthReady && isLoggedIn; + const { value: isEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate, + }); + const show = shouldEvaluate && isEnabled; + + // Only the header instance drives the ambient cadence, so a second mount (the + // rail) never double-fires prompts. + const driveCadence = show && variant === 'header'; + + useEffect(() => { + if (!driveCadence) { + return undefined; + } + + const timeouts: number[] = []; + timeouts.push( + window.setTimeout(() => { + dock.current?.pulseActivity(`+$${PULSE_AMOUNTS[2]}`); + }, 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(() => { + const amount = + PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]; + dock.current?.pulseActivity(`+$${amount}`); + }, PULSE_INTERVAL_MS); + + return () => { + timeouts.forEach((id) => window.clearTimeout(id)); + window.clearInterval(pulse); + }; + }, [driveCadence, logEvent]); + + if (!show) { + return null; + } + + const openGiveback = () => { + logEvent({ event_name: LogEvent.ClickGivebackGiftEntry }); + router.push(`${webappUrl}giveback`); + }; + + return ( + + ); +} + +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..43752b6c47b --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -0,0 +1,193 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { MiniCloseIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { cloudinaryCharmInviteFriends } 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; + // Pulsing "live" dot in the eyebrow (social-proof signal). + live?: 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'; + autoDismissMs?: number; + 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, + live = false, + placement = 'below', + align = 'end', + autoDismissMs = 5000, + onClick, + onClose, + className, +}: GivebackInvitePromptProps): ReactElement | null => { + // Bumps every time the prompt opens, so the confetti and the countdown ring + // restart even when one prompt replaces another. + const [runId, setRunId] = useState(0); + + useEffect(() => { + if (!open) { + return undefined; + } + setRunId((current) => current + 1); + if (!autoDismissMs || !onClose) { + return undefined; + } + const timer = window.setTimeout(onClose, autoDismissMs); + return () => window.clearTimeout(timer); + }, [open, autoDismissMs, onClose]); + + if (!open) { + return null; + } + + const isAbove = placement === 'above'; + const giftSide = align === 'end' ? 'right-6' : 'left-6'; + + return ( +
+ {celebrate && ( +
+ +
+ )} + + {/* Tail pointing to the gift. */} +
+ +
+ {/* The daily.dev mascot, bobbing on a soft cabbage glow. */} +
+ + daily.dev mascot +
+ +
+
+ {live && ( + + + + + )} + + {eyebrow} + +
+

+ {headline} +

+

+ {body} +

+
+ + {/* Right rail: the countdown-ring close on top, the CTA below. */} +
+ + + +
+
+
+ ); +}; + +export default GivebackInvitePrompt; diff --git a/packages/shared/src/features/giveback/givebackInvitePrompts.ts b/packages/shared/src/features/giveback/givebackInvitePrompts.ts new file mode 100644 index 00000000000..bc90a0bd177 --- /dev/null +++ b/packages/shared/src/features/giveback/givebackInvitePrompts.ts @@ -0,0 +1,57 @@ +// 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; + // Shows the pulsing "live" dot in the eyebrow (social-proof signal). + live?: 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, + live: 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', + live: true, + }, +]; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 8a6241478d8..39653e02e6a 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -512,6 +512,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..ea968f8fa2b 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1048,6 +1048,167 @@ 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; + } + + /* Auto-dismiss countdown ring around the invite prompt's close (X) button — + the stroke drains away as the time elapses (pathLength/dasharray = 100). + Duration is set inline to match autoDismissMs. */ + @keyframes giveback-dismiss-ring { + from { + stroke-dashoffset: 0; + } + + to { + stroke-dashoffset: 100; + } + } + + .giveback-dismiss-ring { + animation: giveback-dismiss-ring linear forwards; + } + + @media (prefers-reduced-motion: reduce) { + .giveback-confetti-piece, + .giveback-value-rise, + .giveback-glow-bloom { + animation-duration: 0.01ms; + } + + .giveback-toast-in, + .giveback-gift-pop, + .giveback-dismiss-ring { + 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..2b9a0fbaaee --- /dev/null +++ b/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx @@ -0,0 +1,180 @@ +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'; + +const ControlButton = ({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}): React.ReactElement => ( + +); + +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', + 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: () => ( +
+ +
+ ), +}; From 40387754cdb711ea92bd42ab9b281bacfeeba0cf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 1 Jul 2026 18:20:48 +0300 Subject: [PATCH 02/19] feat(giveback): point new-layout rail gift to giveback The V2 sidebar gift icon linked to the referral/invite page. When the giveback experiment is on for the user it now links to /giveback instead, falling back to invite otherwise. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 066b1959b63..793a624116f 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -116,6 +116,8 @@ 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 { FeedbackWidget } from '../feedback'; import { HorizontalSeparator } from '../utilities'; import { Typography, TypographyType } from '../typography/Typography'; @@ -259,16 +261,29 @@ 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 => ( - - - - - - - -); +// production). The rail gift points to giveback when that experiment is on for +// the user, and falls back to the "Invite friends" shortcut otherwise. +const SidebarInviteButton = (): ReactElement => { + const { isAuthReady, isLoggedIn } = useAuthContext(); + const { value: givebackEnabled } = useConditionalFeature({ + feature: featureGiveback, + shouldEvaluate: isAuthReady && isLoggedIn, + }); + + const target = givebackEnabled + ? { href: `${webappUrl}giveback`, label: 'Giveback' } + : { href: `${settingsUrl}/invite`, label: 'Invite friends' }; + + return ( + + + + + + + + ); +}; const supportItems: ProfileSectionItemProps[] = [ { From be6542c5bda839ac62d34526d16f593747bbb588 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 09:50:37 +0300 Subject: [PATCH 03/19] refactor(giveback): restructure invite toast layout - Remove the pulsing green "live" dot from the toast (and the unused `live` prop) - Swap the cabbage ring for a plain subtle gray border - Full-width left column: eyebrow, headline, body, and CTA below the text - Move the mascot to the bottom-right, below the close (X) button Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackGiftDock.tsx | 1 - .../components/GivebackInvitePrompt.tsx | 79 ++++++++----------- .../giveback/givebackInvitePrompts.ts | 4 - 3 files changed, 35 insertions(+), 49 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index e4450956c34..96b44fd3c71 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -162,7 +162,6 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( body={prompt?.body} ctaLabel={prompt?.ctaLabel} celebrate={prompt?.celebrate} - live={prompt?.live} placement={isRail ? 'above' : 'below'} align={isRail ? 'start' : 'end'} onClick={onOpenGiveback} diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 43752b6c47b..375fd6b2761 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -20,8 +20,6 @@ export interface GivebackInvitePromptProps { ctaLabel?: string; // Festive community moment — confetti bursts from the gift. celebrate?: boolean; - // Pulsing "live" dot in the eyebrow (social-proof signal). - live?: 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. @@ -44,7 +42,6 @@ export const GivebackInvitePrompt = ({ body = 'All from everyday daily.dev activity. Pick the causes you care about.', ctaLabel = 'Join in', celebrate = false, - live = false, placement = 'below', align = 'end', autoDismissMs = 5000, @@ -108,42 +105,35 @@ export const GivebackInvitePrompt = ({ )} /> -
- {/* The daily.dev mascot, bobbing on a soft cabbage glow. */} -
- - daily.dev mascot -
- -
-
- {live && ( - - - - - )} - +
+ {/* Left: full-width message + CTA. */} +
+
+ {eyebrow} +

+ {headline} +

+

+ {body} +

-

- {headline} -

-

- {body} -

+ +
- {/* Right rail: the countdown-ring close on top, the CTA below. */} -
+ {/* Right rail: countdown-ring close on top, mascot tucked below it. */} +
- +
+ + daily.dev mascot +
diff --git a/packages/shared/src/features/giveback/givebackInvitePrompts.ts b/packages/shared/src/features/giveback/givebackInvitePrompts.ts index bc90a0bd177..c41ac81f920 100644 --- a/packages/shared/src/features/giveback/givebackInvitePrompts.ts +++ b/packages/shared/src/features/giveback/givebackInvitePrompts.ts @@ -15,8 +15,6 @@ export interface GivebackInvitePromptData { ctaLabel: string; // Festive community moment — fires confetti + glow. Use sparingly. celebrate?: boolean; - // Shows the pulsing "live" dot in the eyebrow (social-proof signal). - live?: boolean; } // A rotating set so the header stays fresh and hits different motivations: @@ -29,7 +27,6 @@ export const givebackInvitePrompts: GivebackInvitePromptData[] = [ body: 'All of it funded by everyday daily.dev activity — not out of anyone’s pocket.', ctaLabel: 'Join in', celebrate: true, - live: true, }, { id: 'how-it-works', @@ -52,6 +49,5 @@ export const givebackInvitePrompts: GivebackInvitePromptData[] = [ headline: 'Give back while you read', body: 'Developers are turning their daily habit into donations right now. Add yours.', ctaLabel: 'Start giving back', - live: true, }, ]; From e789180a46c8ff3df5c65e4d5219d9c968e6c7a8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 09:58:25 +0300 Subject: [PATCH 04/19] feat(giveback): add opt-in QA panel for the gift entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating bottom-right panel (top z-index) to drive the entry point manually for review on a preview deploy or locally. Opt in with ?giveback_debug — never shows for real users. Buttons trigger the money jump, each invite prompt variant, and reset, all on the live header gift. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackDevPanel.tsx | 89 +++++++++++++++++++ .../giveback/components/GivebackGiftEntry.tsx | 21 +++-- 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/features/giveback/components/GivebackDevPanel.tsx diff --git a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx new file mode 100644 index 00000000000..268c03baeed --- /dev/null +++ b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx @@ -0,0 +1,89 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import type { GivebackGiftDockHandle } from './GivebackGiftDock'; +import { givebackInvitePrompts } from '../givebackInvitePrompts'; + +const PULSE_AMOUNTS = [1, 2, 3, 5, 8, 12]; + +interface GivebackDevPanelProps { + dock: RefObject; +} + +// A floating QA panel for testing the gift entry point on a preview deploy (or +// locally). Opt-in via the `?giveback_debug` query param so it never shows for +// real users. Sits above everything, bottom-right; each button drives the gift +// dock so you can watch the money jump, the invite prompts, and the celebration +// on the header entry in real time. +export function GivebackDevPanel({ + dock, +}: GivebackDevPanelProps): ReactElement { + const [open, setOpen] = useState(true); + + const pulse = () => { + const amount = + PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]; + dock.current?.pulseActivity(`+$${amount}`); + }; + + return ( +
+
+ + 🧪 Giveback QA + + +
+ + {open && ( +
+ + {givebackInvitePrompts.map((prompt) => ( + + ))} + +
+ )} +
+ ); +} + +export default GivebackDevPanel; diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index 52369de1567..0f366b8d08f 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/router'; import type { GivebackGiftButtonVariant } from './GivebackGiftButton'; import type { GivebackGiftDockHandle } from './GivebackGiftDock'; import { GivebackGiftDock } from './GivebackGiftDock'; +import { GivebackDevPanel } from './GivebackDevPanel'; import { givebackInvitePrompts } from '../givebackInvitePrompts'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useLogContext } from '../../../contexts/LogContext'; @@ -95,13 +96,21 @@ export function GivebackGiftEntry({ router.push(`${webappUrl}giveback`); }; + // Opt-in QA panel (append ?giveback_debug to the URL) for driving the entry + // point manually on a preview deploy. Only the header instance renders it. + const showDevPanel = + variant === 'header' && router.query.giveback_debug !== undefined; + return ( - + <> + + {showDevPanel && } + ); } From 14354bcf027b303113ed6547e7ade10b6a8dd474 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 10:45:14 +0300 Subject: [PATCH 05/19] fix(giveback): let ?giveback_debug force-show the entry on preview The feature flag defaults off in production/preview builds, so the QA panel never rendered there. The debug param now force-shows the gift entry and panel regardless of the flag (still requires login). Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackGiftEntry.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index 0f366b8d08f..f23ac49353a 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -40,12 +40,15 @@ export function GivebackGiftEntry({ const { logEvent } = useLogContext(); const dock = useRef(null); + // QA override: `?giveback_debug` force-shows the entry (and its panel) even + // when the feature flag is off, so it's testable on a preview deploy. + const debug = router.query.giveback_debug !== undefined; const shouldEvaluate = isAuthReady && isLoggedIn; const { value: isEnabled } = useConditionalFeature({ feature: featureGiveback, shouldEvaluate, }); - const show = shouldEvaluate && isEnabled; + const show = shouldEvaluate && (isEnabled || debug); // Only the header instance drives the ambient cadence, so a second mount (the // rail) never double-fires prompts. @@ -98,8 +101,7 @@ export function GivebackGiftEntry({ // Opt-in QA panel (append ?giveback_debug to the URL) for driving the entry // point manually on a preview deploy. Only the header instance renders it. - const showDevPanel = - variant === 'header' && router.query.giveback_debug !== undefined; + const showDevPanel = variant === 'header' && debug; return ( <> From d037831b5dcf0215681fead0561e5a99c62121d3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 10:49:12 +0300 Subject: [PATCH 06/19] style(giveback): white CTA, taller mascot, lighter text weights - Toast CTA is now the neutral (white) primary button, not cabbage - Mascot is taller (bottom-anchored) for more presence - Lighter font weights on the eyebrow (medium) and headline (semibold) - Drop the em dash from the community-raised prompt copy Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackInvitePrompt.tsx | 18 ++++++++---------- .../features/giveback/givebackInvitePrompts.ts | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 375fd6b2761..3e5a8bdd95b 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { Button, - ButtonColor, ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; @@ -107,12 +106,12 @@ export const GivebackInvitePrompt = ({
{/* Left: full-width message + CTA. */} -
+
- + {eyebrow} -

+

{headline}

@@ -123,7 +122,6 @@ export const GivebackInvitePrompt = ({

- {/* Right rail: countdown-ring close on top, mascot tucked below it. */} -
+ {/* Right rail: countdown-ring close on top, mascot below it. */} +
-
+
daily.dev mascot
diff --git a/packages/shared/src/features/giveback/givebackInvitePrompts.ts b/packages/shared/src/features/giveback/givebackInvitePrompts.ts index c41ac81f920..44ade98a2d2 100644 --- a/packages/shared/src/features/giveback/givebackInvitePrompts.ts +++ b/packages/shared/src/features/giveback/givebackInvitePrompts.ts @@ -24,7 +24,7 @@ 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.', + body: 'All of it funded by everyday daily.dev activity, not out of anyone’s pocket.', ctaLabel: 'Join in', celebrate: true, }, From 38ee6005e7a7490454d0458d71dbc196f0bbc801 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 10:53:09 +0300 Subject: [PATCH 07/19] fix(giveback): make QA panel reliably visible - Portal the QA panel to document.body so its fixed positioning isn't trapped by a transformed header ancestor - Detect ?giveback_debug from window.location.search (router.query can be empty during static render/hydration) Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackDevPanel.tsx | 93 ++++++++++--------- .../giveback/components/GivebackGiftEntry.tsx | 14 ++- 2 files changed, 59 insertions(+), 48 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx index 268c03baeed..434591cf42b 100644 --- a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx @@ -5,6 +5,7 @@ import { ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; +import { RootPortal } from '../../../components/tooltips/Portal'; import type { GivebackGiftDockHandle } from './GivebackGiftDock'; import { givebackInvitePrompts } from '../givebackInvitePrompts'; @@ -31,58 +32,60 @@ export function GivebackDevPanel({ }; return ( -
-
- - 🧪 Giveback QA - - -
- - {open && ( -
- - {givebackInvitePrompts.map((prompt) => ( + {open ? '–' : '+'} + +
+ + {open && ( +
- ))} - -
- )} -
+ {givebackInvitePrompts.map((prompt) => ( + + ))} + +
+ )} +
+ ); } diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index f23ac49353a..73564fbc474 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import type { GivebackGiftButtonVariant } from './GivebackGiftButton'; import type { GivebackGiftDockHandle } from './GivebackGiftDock'; @@ -41,8 +41,16 @@ export function GivebackGiftEntry({ const dock = useRef(null); // QA override: `?giveback_debug` force-shows the entry (and its panel) even - // when the feature flag is off, so it's testable on a preview deploy. - const debug = router.query.giveback_debug !== undefined; + // when the feature flag is off, so it's testable on a preview deploy. Read + // from the URL directly on the client — router.query can be empty during + // static render/hydration. + const [debug, setDebug] = useState(false); + useEffect(() => { + if (typeof window !== 'undefined') { + setDebug(window.location.search.includes('giveback_debug')); + } + }, []); + const shouldEvaluate = isAuthReady && isLoggedIn; const { value: isEnabled } = useConditionalFeature({ feature: featureGiveback, From e3b43e625554bf1cb6183830fc80c73a117d37d0 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 10:57:29 +0300 Subject: [PATCH 08/19] style(giveback): enlarge toast mascot, narrow content column Co-Authored-By: Claude Opus 4.8 --- .../features/giveback/components/GivebackInvitePrompt.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 3e5a8bdd95b..7d7d87e812e 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -162,15 +162,15 @@ export const GivebackInvitePrompt = ({ -
+
daily.dev mascot
From 62a2e93491e490298f4fd5648ee7c5491e001961 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 11:05:43 +0300 Subject: [PATCH 09/19] style(giveback): bigger centered mascot, uniform toast padding Enlarge the mascot ~20%, center both columns vertically and move the close button to an absolute corner so the padding is equal on all sides. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackInvitePrompt.tsx | 89 +++++++++---------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 7d7d87e812e..1284bc930dd 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -104,9 +104,40 @@ export const GivebackInvitePrompt = ({ )} /> -
+
+ {/* Close button (top-right corner) with the auto-dismiss countdown ring. */} + + {/* Left: full-width message + CTA. */} -
+
{eyebrow} @@ -130,49 +161,17 @@ export const GivebackInvitePrompt = ({
- {/* Right rail: countdown-ring close on top, mascot below it. */} -
- - -
- - daily.dev mascot -
+ {/* The daily.dev mascot, bobbing on a soft cabbage glow. */} +
+ + daily.dev mascot
From ac3a8b826e27ca0fc5671167dead05d62d429b72 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 11:11:38 +0300 Subject: [PATCH 10/19] fix(giveback): make QA panel work on the new layout The panel lived inside the header entry, which the new layout doesn't render. Make it a self-contained harness (its own live gift preview + controls) mounted globally in MainLayout, so ?giveback_debug shows it on any layout. Add prompt placement overrides so the preview's toast opens on-screen from the corner. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/MainLayout.tsx | 2 + .../giveback/components/GivebackDevPanel.tsx | 58 +++++++++++++------ .../giveback/components/GivebackGiftDock.tsx | 9 ++- .../giveback/components/GivebackGiftEntry.tsx | 20 ++----- 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index e3ab6d50043..98432064e76 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -40,6 +40,7 @@ import { isExtension } from '../lib/func'; import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { useRecordRecentPages } from '../hooks/useRecentPages'; import { isSidebarSettingsPath } from './sidebar/sidebarCategory'; +import { GivebackDevPanel } from '../features/giveback/components/GivebackDevPanel'; import { HomepageTopBanners, useHomepageTopBannersVisibility, @@ -302,6 +303,7 @@ function MainLayoutComponent({ + diff --git a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx index 434591cf42b..5a2b4ec4faf 100644 --- a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx @@ -1,29 +1,39 @@ -import type { ReactElement, RefObject } from 'react'; -import React, { useState } from 'react'; +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Button, ButtonSize, ButtonVariant, } from '../../../components/buttons/Button'; import { RootPortal } from '../../../components/tooltips/Portal'; +import { useAuthContext } from '../../../contexts/AuthContext'; import type { GivebackGiftDockHandle } from './GivebackGiftDock'; +import { GivebackGiftDock } from './GivebackGiftDock'; import { givebackInvitePrompts } from '../givebackInvitePrompts'; const PULSE_AMOUNTS = [1, 2, 3, 5, 8, 12]; -interface GivebackDevPanelProps { - dock: RefObject; -} - -// A floating QA panel for testing the gift entry point on a preview deploy (or -// locally). Opt-in via the `?giveback_debug` query param so it never shows for -// real users. Sits above everything, bottom-right; each button drives the gift -// dock so you can watch the money jump, the invite prompts, and the celebration -// on the header entry in real time. -export function GivebackDevPanel({ - dock, -}: GivebackDevPanelProps): ReactElement { +// A self-contained QA panel for reviewing the gift entry point on any layout / +// preview deploy. Opt in with the `?giveback_debug` query param (so it never +// shows for real users); it mounts globally, independent of the header, and +// carries its OWN live gift preview so the money jump, invite prompts and +// celebration are visible even on the new layout where the header entry is +// hidden. Sits above everything, bottom-right. +export function GivebackDevPanel(): ReactElement | null { + const { isLoggedIn } = useAuthContext(); + const [enabled, setEnabled] = useState(false); const [open, setOpen] = useState(true); + const dock = useRef(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + setEnabled(window.location.search.includes('giveback_debug')); + } + }, []); + + if (!enabled || !isLoggedIn) { + return null; + } const pulse = () => { const amount = @@ -34,7 +44,7 @@ export function GivebackDevPanel({ return (
@@ -52,7 +62,21 @@ export function GivebackDevPanel({
{open && ( -
+ <> + {/* Live preview of the entry point — the prompt opens above/left so + it stays on screen from the bottom-right corner. */} +
+ + Live preview + + +
+ -
+ )}
diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index 96b44fd3c71..58e9049b045 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -36,6 +36,9 @@ interface GivebackGiftDockProps { variant?: GivebackGiftButtonVariant; showLabel?: boolean; onOpenGiveback?: () => void; + // Override where the invite prompt opens (defaults follow the variant). + promptPlacement?: 'below' | 'above'; + promptAlign?: 'start' | 'end'; } const GIFT_POP_MS = 380; @@ -49,6 +52,8 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( variant = 'header', showLabel = false, onOpenGiveback, + promptPlacement, + promptAlign, }: GivebackGiftDockProps, ref: ForwardedRef, ): ReactElement { @@ -162,8 +167,8 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( body={prompt?.body} ctaLabel={prompt?.ctaLabel} celebrate={prompt?.celebrate} - placement={isRail ? 'above' : 'below'} - align={isRail ? 'start' : 'end'} + placement={promptPlacement ?? (isRail ? 'above' : 'below')} + align={promptAlign ?? (isRail ? 'start' : 'end')} onClick={onOpenGiveback} onClose={() => setPrompt(null)} /> diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index 73564fbc474..7e62c47abe4 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'next/router'; import type { GivebackGiftButtonVariant } from './GivebackGiftButton'; import type { GivebackGiftDockHandle } from './GivebackGiftDock'; import { GivebackGiftDock } from './GivebackGiftDock'; -import { GivebackDevPanel } from './GivebackDevPanel'; import { givebackInvitePrompts } from '../givebackInvitePrompts'; import { useAuthContext } from '../../../contexts/AuthContext'; import { useLogContext } from '../../../contexts/LogContext'; @@ -107,20 +106,13 @@ export function GivebackGiftEntry({ router.push(`${webappUrl}giveback`); }; - // Opt-in QA panel (append ?giveback_debug to the URL) for driving the entry - // point manually on a preview deploy. Only the header instance renders it. - const showDevPanel = variant === 'header' && debug; - return ( - <> - - {showDevPanel && } - + ); } From 6c26c4294671d136748aafc17a5f588d79eb1c78 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 11:33:17 +0300 Subject: [PATCH 11/19] feat(giveback): wire live interactions onto the sidebar gift - The new-layout rail gift is now the giveback entry: GivebackGiftEntry wraps the rail gift link (via a new dock `children` anchor) so money jumps, the invite prompt and the celebration play on the actual sidebar icon - QA panel now drives whichever entry is mounted (header or rail) through a small window event bus, instead of a separate preview - Ambient cadence claimed by a single instance so header/rail can't double-fire - Toast mascot bottom-anchored + enlarged to remove the bottom gap Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 43 +++++++---- .../giveback/components/GivebackDevPanel.tsx | 50 ++++--------- .../giveback/components/GivebackGiftDock.tsx | 29 ++++---- .../giveback/components/GivebackGiftEntry.tsx | 71 ++++++++++++++----- .../components/GivebackInvitePrompt.tsx | 8 +-- .../src/features/giveback/givebackQa.ts | 19 +++++ 6 files changed, 135 insertions(+), 85 deletions(-) create mode 100644 packages/shared/src/features/giveback/givebackQa.ts diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 793a624116f..7f57aeb0521 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -118,6 +118,7 @@ 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'; @@ -260,29 +261,43 @@ const RailHoverCard = ({ ); }; +const railGiftLink = (label: string, href: string): ReactElement => ( + + + + + + + +); + // Theme toggling now lives in the profile dropdown (ThemeSection, matching -// production). The rail gift points to giveback when that experiment is on for -// the user, and falls back to the "Invite friends" shortcut otherwise. +// 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, }); + const [debug, setDebug] = useState(false); + useEffect(() => { + if (typeof window !== 'undefined') { + setDebug(window.location.search.includes('giveback_debug')); + } + }, []); - const target = givebackEnabled - ? { href: `${webappUrl}giveback`, label: 'Giveback' } - : { href: `${settingsUrl}/invite`, label: 'Invite friends' }; + if (givebackEnabled || debug) { + return ( + + {railGiftLink('Giveback', `${webappUrl}giveback`)} + + ); + } - return ( - - - - - - - - ); + return railGiftLink('Invite friends', `${settingsUrl}/invite`); }; const supportItems: ProfileSectionItemProps[] = [ diff --git a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx index 5a2b4ec4faf..7756803ce82 100644 --- a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx +++ b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Button, ButtonSize, @@ -7,23 +7,18 @@ import { } from '../../../components/buttons/Button'; import { RootPortal } from '../../../components/tooltips/Portal'; import { useAuthContext } from '../../../contexts/AuthContext'; -import type { GivebackGiftDockHandle } from './GivebackGiftDock'; -import { GivebackGiftDock } from './GivebackGiftDock'; import { givebackInvitePrompts } from '../givebackInvitePrompts'; +import { emitGivebackQa } from '../givebackQa'; -const PULSE_AMOUNTS = [1, 2, 3, 5, 8, 12]; - -// A self-contained QA panel for reviewing the gift entry point on any layout / -// preview deploy. Opt in with the `?giveback_debug` query param (so it never -// shows for real users); it mounts globally, independent of the header, and -// carries its OWN live gift preview so the money jump, invite prompts and -// celebration are visible even on the new layout where the header entry is -// hidden. Sits above everything, bottom-right. +// A floating QA panel for reviewing the gift entry point on any layout / preview +// deploy. Opt in with the `?giveback_debug` query param (never shows for real +// users). It drives the REAL mounted gift entry (header or sidebar) via the QA +// event bus, so the money jump, invite prompts and celebration play on the +// actual gift icon — not a separate preview. export function GivebackDevPanel(): ReactElement | null { const { isLoggedIn } = useAuthContext(); const [enabled, setEnabled] = useState(false); const [open, setOpen] = useState(true); - const dock = useRef(null); useEffect(() => { if (typeof window !== 'undefined') { @@ -35,12 +30,6 @@ export function GivebackDevPanel(): ReactElement | null { return null; } - const pulse = () => { - const amount = - PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]; - dock.current?.pulseActivity(`+$${amount}`); - }; - return (
- {/* Live preview of the entry point — the prompt opens above/left so - it stays on screen from the bottom-right corner. */} -
- - Live preview - - -
- + + Plays on the live gift icon + - {givebackInvitePrompts.map((prompt) => ( + {givebackInvitePrompts.map((prompt, index) => ( diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index 58e9049b045..d1a08cc4e0c 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -1,4 +1,4 @@ -import type { ForwardedRef, ReactElement } from 'react'; +import type { ForwardedRef, ReactElement, ReactNode } from 'react'; import React, { forwardRef, useCallback, @@ -39,6 +39,9 @@ interface GivebackGiftDockProps { // 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; @@ -54,6 +57,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( onOpenGiveback, promptPlacement, promptAlign, + children, }: GivebackGiftDockProps, ref: ForwardedRef, ): ReactElement { @@ -117,10 +121,10 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( ]); return ( -
+
@@ -132,23 +136,20 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( className="giveback-glow-bloom bg-accent-cabbage-default/50 pointer-events-none absolute left-1/2 top-1/2 size-16 rounded-full blur-lg" /> )} - + {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) => ( + `+$${PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]}`; + // The persistent giveback entry point. Gated on the same `featureGiveback` flag -// as the page (on in development), so it shows wherever giveback is enabled. +// as the page (on in development), so it shows wherever giveback is enabled. It +// drives its dock from the ambient cadence and from QA-panel events, 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); - // QA override: `?giveback_debug` force-shows the entry (and its panel) even - // when the feature flag is off, so it's testable on a preview deploy. Read - // from the URL directly on the client — router.query can be empty during - // static render/hydration. + // QA override: `?giveback_debug` force-shows the entry even when the feature + // flag is off, so it's testable on a preview deploy. Read from the URL + // directly — router.query can be empty during static render/hydration. const [debug, setDebug] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { @@ -57,19 +73,38 @@ export function GivebackGiftEntry({ }); const show = shouldEvaluate && (isEnabled || debug); - // Only the header instance drives the ambient cadence, so a second mount (the - // rail) never double-fires prompts. - const driveCadence = show && variant === 'header'; + // Manual QA-panel triggers, played on this real entry. + useEffect(() => { + if (!show || typeof window === 'undefined') { + return undefined; + } + const handler = (event: Event) => { + const action = (event as CustomEvent).detail; + if (action.type === 'pulse') { + dock.current?.pulseActivity(randomPulse()); + } else if (action.type === 'prompt') { + const prompt = + givebackInvitePrompts[action.index % givebackInvitePrompts.length]; + dock.current?.showPrompt(prompt); + } else if (action.type === 'reset') { + dock.current?.reset(); + } + }; + window.addEventListener(GIVEBACK_QA_EVENT, handler); + return () => window.removeEventListener(GIVEBACK_QA_EVENT, handler); + }, [show]); + // Ambient cadence (claimed by a single instance). useEffect(() => { - if (!driveCadence) { + if (!show || cadenceClaimed) { return undefined; } + cadenceClaimed = true; const timeouts: number[] = []; timeouts.push( window.setTimeout(() => { - dock.current?.pulseActivity(`+$${PULSE_AMOUNTS[2]}`); + dock.current?.pulseActivity(randomPulse()); }, FIRST_PULSE_MS), ); timeouts.push( @@ -84,18 +119,16 @@ export function GivebackGiftEntry({ }); }, PROMPT_MS), ); - const pulse = window.setInterval(() => { - const amount = - PULSE_AMOUNTS[Math.floor(Math.random() * PULSE_AMOUNTS.length)]; - dock.current?.pulseActivity(`+$${amount}`); + dock.current?.pulseActivity(randomPulse()); }, PULSE_INTERVAL_MS); return () => { timeouts.forEach((id) => window.clearTimeout(id)); window.clearInterval(pulse); + cadenceClaimed = false; }; - }, [driveCadence, logEvent]); + }, [show, logEvent]); if (!show) { return null; @@ -112,7 +145,11 @@ export function GivebackGiftEntry({ variant={variant} showLabel={showLabel} onOpenGiveback={openGiveback} - /> + promptPlacement={promptPlacement} + promptAlign={promptAlign} + > + {children} + ); } diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 1284bc930dd..4444e245991 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -161,16 +161,16 @@ export const GivebackInvitePrompt = ({
- {/* The daily.dev mascot, bobbing on a soft cabbage glow. */} -
+ {/* The daily.dev mascot, bottom-anchored and bobbing on a soft glow. */} +
daily.dev mascot
diff --git a/packages/shared/src/features/giveback/givebackQa.ts b/packages/shared/src/features/giveback/givebackQa.ts new file mode 100644 index 00000000000..194878436fe --- /dev/null +++ b/packages/shared/src/features/giveback/givebackQa.ts @@ -0,0 +1,19 @@ +// Lightweight event bus so the QA panel can drive whichever gift entry is +// actually mounted (header on the old layout, rail on the new one) instead of +// carrying its own preview. Window events keep the panel decoupled from the +// entry's dock ref. +export const GIVEBACK_QA_EVENT = 'giveback:qa'; + +export type GivebackQaAction = + | { type: 'pulse' } + | { type: 'prompt'; index: number } + | { type: 'reset' }; + +export const emitGivebackQa = (action: GivebackQaAction): void => { + if (typeof window === 'undefined') { + return; + } + window.dispatchEvent( + new CustomEvent(GIVEBACK_QA_EVENT, { detail: action }), + ); +}; From c082b35598a054e47ed6118731ee9f657db3720e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 11:57:22 +0300 Subject: [PATCH 12/19] style(giveback): open the rail invite prompt like a rail dropdown For the sidebar (rail) entry, portal the invite prompt and fix it at the same placement as the support/settings/profile menus (left-20 bottom-3 ml-2, z-popup) instead of anchoring it to the gift with a tail. Also fixes rail overflow clipping. Header entry keeps its anchored placement. Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackGiftDock.tsx | 1 + .../components/GivebackInvitePrompt.tsx | 47 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index d1a08cc4e0c..52033f8c830 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -168,6 +168,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( body={prompt?.body} ctaLabel={prompt?.ctaLabel} celebrate={prompt?.celebrate} + dropdown={isRail} placement={promptPlacement ?? (isRail ? 'above' : 'below')} align={promptAlign ?? (isRail ? 'start' : 'end')} onClick={onOpenGiveback} diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx index 4444e245991..c07174a0278 100644 --- a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx +++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx @@ -8,6 +8,7 @@ import { } from '../../../components/buttons/Button'; import { MiniCloseIcon } from '../../../components/icons'; import { IconSize } from '../../../components/Icon'; +import { RootPortal } from '../../../components/tooltips/Portal'; import { cloudinaryCharmInviteFriends } from '../../../lib/image'; import { GivebackConfettiBurst } from './GivebackConfettiBurst'; @@ -23,6 +24,9 @@ export interface GivebackInvitePromptProps { 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; onClick?: () => void; onClose?: () => void; @@ -43,6 +47,7 @@ export const GivebackInvitePrompt = ({ celebrate = false, placement = 'below', align = 'end', + dropdown = false, autoDismissMs = 5000, onClick, onClose, @@ -71,12 +76,17 @@ export const GivebackInvitePrompt = ({ const isAbove = placement === 'above'; const giftSide = align === 'end' ? 'right-6' : 'left-6'; - return ( + const content = (
)} - {/* Tail pointing to the gift. */} -
+ {/* Tail pointing to the gift (anchored mode only). */} + {!dropdown && ( +
+ )}
{/* Close button (top-right corner) with the auto-dismiss countdown ring. */} @@ -176,6 +189,8 @@ export const GivebackInvitePrompt = ({
); + + return dropdown ? {content} : content; }; export default GivebackInvitePrompt; From d0bbb59a0945233e2a1dfb97a76a384c9e5b5207 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 14:29:36 +0300 Subject: [PATCH 13/19] fix(giveback): pause the toast countdown on hover, don't restart it - Drive the auto-dismiss with useTimedAnimation (the app's standard toast timer) so the countdown ring is JS-driven and stays in sync - Pause the countdown while the cursor rests on the gift OR the toast, and resume from where it left off (never from scratch) - Stop re-arming the timer every render (onClose was an unstable dependency) via a ref, and remount a replacing prompt (fresh timer + confetti) - Remove the dead CSS-driven ring keyframe Co-Authored-By: Claude Opus 4.8 --- .../giveback/components/GivebackGiftDock.tsx | 14 ++++- .../components/GivebackInvitePrompt.tsx | 59 +++++++++++++++---- packages/shared/src/styles/base.css | 20 +------ 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx index 52033f8c830..0a7ff139ffc 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx @@ -65,7 +65,10 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( 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) => { @@ -101,7 +104,12 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( setGlowKey((current) => current + 1); } popGift(); - track(window.setTimeout(() => setPrompt(next), MILESTONE_TOAST_DELAY_MS)); + track( + window.setTimeout(() => { + setPromptSeq((current) => current + 1); + setPrompt(next); + }, MILESTONE_TOAST_DELAY_MS), + ); }, [popGift, track], ); @@ -127,6 +135,8 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock( 'relative inline-flex', popping && 'giveback-gift-pop', )} + onMouseEnter={() => setGiftHovered(true)} + onMouseLeave={() => setGiftHovered(false)} > {/* Soft glow bloom on a celebratory community moment. */} {glowKey > 0 && ( @@ -162,6 +172,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
void; onClose?: () => void; className?: string; @@ -49,31 +52,61 @@ export const GivebackInvitePrompt = ({ align = 'end', dropdown = false, autoDismissMs = 5000, + paused = false, onClick, onClose, className, }: GivebackInvitePromptProps): ReactElement | null => { - // Bumps every time the prompt opens, so the confetti and the countdown ring - // restart even when one prompt replaces another. + // 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); - if (!autoDismissMs || !onClose) { - return undefined; + 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(); } - const timer = window.setTimeout(onClose, autoDismissMs); - return () => window.clearTimeout(timer); - }, [open, autoDismissMs, onClose]); + }, [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 = ( @@ -117,7 +150,11 @@ export const GivebackInvitePrompt = ({ /> )} -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > {/* Close button (top-right corner) with the auto-dismiss countdown ring. */}
- {/* The daily.dev mascot, bottom-anchored and bobbing on a soft glow. */} -
+ {/* 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 mascot
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/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)'; From 98ec03c90038da335eac325cac120bb0dc393b7c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 15:52:04 +0300 Subject: [PATCH 17/19] chore(giveback): keep the gift entry desktop-only for now Mobile placement is parked for a later PR, so gate the entry on laptop+ to avoid an incidental appearance on non-feed mobile headers. Co-Authored-By: Claude Opus 4.8 --- .../src/features/giveback/components/GivebackGiftEntry.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx index 2c197c7b5c9..858c41db01b 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx @@ -9,6 +9,7 @@ import type { GivebackQaAction } from '../givebackQa'; import { GIVEBACK_QA_EVENT } from '../givebackQa'; 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'; @@ -66,7 +67,10 @@ export function GivebackGiftEntry({ } }, []); - const shouldEvaluate = isAuthReady && isLoggedIn; + // 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, From 3b6d2ef6739f2e84c70ad1cd656aea0aa1c82edf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 16:00:34 +0300 Subject: [PATCH 18/19] fix(giveback): wrap Live playground story in providers + faithful old-layout header The Live playground rendered the real GivebackGiftDock (Tooltip -> useQueryClient) without a QueryClientProvider, so it errored at runtime. Add the withGiveback decorator and make the mock header match the real old-layout header (h-16, border-b, real action-cluster order) so the entry is validated in context. Co-Authored-By: Claude Opus 4.8 --- .../giveback/GivebackGiftShowcase.stories.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx b/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx index 2b9a0fbaaee..51856070863 100644 --- a/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx +++ b/packages/storybook/stories/features/giveback/GivebackGiftShowcase.stories.tsx @@ -9,6 +9,7 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { withGiveback } from './giveback.mocks'; const ControlButton = ({ children, @@ -26,19 +27,26 @@ const ControlButton = ({ ); +// 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 = ({ @@ -147,6 +155,7 @@ const LivePlayground = ({ const meta: Meta = { title: 'Features/Giveback/Entry points/Live playground', + decorators: [withGiveback()], parameters: { layout: 'centered', controls: { disable: true }, @@ -164,17 +173,9 @@ export default meta; type Story = StoryObj; export const Header: Story = { - render: () => ( -
- -
- ), + render: () => , }; export const Rail: Story = { - render: () => ( -
- -
- ), + render: () => , }; From 01a004c6ec1dbbd42369eb01e950dda63add4f42 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 2 Jul 2026 16:11:21 +0300 Subject: [PATCH 19/19] style(giveback): make the header gift a square icon button like the bell Match NotificationsBell exactly (Float, w-10, centered, no side padding) so the gift isn't wider than the other header buttons. Co-Authored-By: Claude Opus 4.8 --- .../components/GivebackGiftButton.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx b/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx index 6cabbfe8266..5ba438bc7fe 100644 --- a/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx +++ b/packages/shared/src/features/giveback/components/GivebackGiftButton.tsx @@ -21,6 +21,8 @@ export interface GivebackGiftButtonProps { 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. @@ -37,25 +39,46 @@ export const GivebackGiftButton = forwardRef(function GivebackGiftButton( ): ReactElement { const isRail = variant === 'rail'; - const button = ( + // 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 (isRail && showLabel) { - return button; + if (showLabel) { + return railButton; } return ( - - {button} + + {railButton} ); });