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/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..7f57aeb0521 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,45 @@ 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,
+ });
+ const [debug, setDebug] = useState(false);
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ setDebug(window.location.search.includes('giveback_debug'));
+ }
+ }, []);
+
+ if (givebackEnabled || debug) {
+ return (
+
+ {railGiftLink('Giveback', `${webappUrl}giveback`)}
+
+ );
+ }
+
+ return railGiftLink('Invite friends', `${settingsUrl}/invite`);
+};
+
const supportItems: ProfileSectionItemProps[] = [
{
title: 'Get the mobile app',
diff --git a/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx
new file mode 100644
index 00000000000..89f57429fdb
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackConfettiBurst.tsx
@@ -0,0 +1,116 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React, { useEffect, useMemo } from 'react';
+
+// Money-forward palette: gold + green lead (cash/coins), brand accents fill in.
+const CONFETTI_COLORS = [
+ 'var(--theme-accent-cheese-default)',
+ 'var(--theme-accent-avocado-default)',
+ 'var(--theme-accent-cabbage-default)',
+ 'var(--theme-accent-bacon-default)',
+];
+
+interface ConfettiPiece {
+ id: string;
+ dx: number;
+ dy: number;
+ rotate: number;
+ delayMs: number;
+ durationMs: number;
+ color: string;
+}
+
+const prefersReducedMotion = (): boolean =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+// Fan the pieces mostly upward and outward, then let gravity pull them down β
+// a quick celebratory pop rather than a full-screen confetti dump.
+const buildPieces = (count: number, spread: number): ConfettiPiece[] => {
+ return Array.from({ length: count }, (_, index) => {
+ const angle = Math.PI + (Math.PI * (index + 0.5)) / count; // upper half
+ const distance = spread * (0.6 + Math.random() * 0.6);
+ const dx = Math.cos(angle) * distance;
+ const dy = Math.sin(angle) * distance + spread * (0.35 + Math.random());
+
+ return {
+ id: `giveback-confetti-${index.toString()}`,
+ dx,
+ dy,
+ rotate: (Math.random() - 0.5) * 540,
+ delayMs: Math.round(Math.random() * 90),
+ durationMs: 760 + Math.round(Math.random() * 320),
+ color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
+ };
+ });
+};
+
+export interface GivebackConfettiBurstProps {
+ // Bump to replay the burst (each new value spawns a fresh set of pieces).
+ trigger: number;
+ pieceCount?: number;
+ spread?: number;
+ onDone?: () => void;
+ className?: string;
+}
+
+export const GivebackConfettiBurst = ({
+ trigger,
+ pieceCount = 18,
+ spread = 88,
+ onDone,
+ className,
+}: GivebackConfettiBurstProps): ReactElement | null => {
+ const reduced = prefersReducedMotion();
+
+ const pieces = useMemo(
+ () => (reduced ? [] : buildPieces(pieceCount, spread)),
+ // trigger intentionally re-seeds the pieces on each replay.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [trigger, pieceCount, spread, reduced],
+ );
+
+ useEffect(() => {
+ if (!onDone) {
+ return undefined;
+ }
+ const longest = pieces.reduce(
+ (max, piece) => Math.max(max, piece.delayMs + piece.durationMs),
+ reduced ? 200 : 0,
+ );
+ const timer = window.setTimeout(onDone, longest + 80);
+ return () => window.clearTimeout(timer);
+ }, [pieces, onDone, reduced, trigger]);
+
+ if (!pieces.length) {
+ return null;
+ }
+
+ return (
+
+ {pieces.map((piece) => (
+
+ ))}
+
+ );
+};
+
+export default GivebackConfettiBurst;
diff --git a/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx
new file mode 100644
index 00000000000..7756803ce82
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackDevPanel.tsx
@@ -0,0 +1,94 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useState } from 'react';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { RootPortal } from '../../../components/tooltips/Portal';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { givebackInvitePrompts } from '../givebackInvitePrompts';
+import { emitGivebackQa } from '../givebackQa';
+
+// 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);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ setEnabled(window.location.search.includes('giveback_debug'));
+ }
+ }, []);
+
+ if (!enabled || !isLoggedIn) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ π§ͺ Giveback QA
+
+
+
+
+ {open && (
+ <>
+
+ Plays on the live gift icon
+
+
+ {givebackInvitePrompts.map((prompt, index) => (
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+export default GivebackDevPanel;
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 (
+
+ }
+ className={classNames(
+ 'relative w-10 justify-center',
+ pressClass,
+ className,
+ )}
+ />
+
+ );
+ }
+
+ const railButton = (
+
+ );
+
+ if (showLabel) {
+ return railButton;
+ }
+
+ return (
+
+ {railButton}
+
+ );
+});
+
+export default GivebackGiftButton;
diff --git a/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx
new file mode 100644
index 00000000000..ac796ffebd5
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackGiftDock.tsx
@@ -0,0 +1,203 @@
+import type { ForwardedRef, ReactElement, ReactNode } from 'react';
+import React, {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+import type { GivebackGiftButtonVariant } from './GivebackGiftButton';
+import { GivebackGiftButton } from './GivebackGiftButton';
+import { GivebackInvitePrompt } from './GivebackInvitePrompt';
+import type { GivebackInvitePromptData } from '../givebackInvitePrompts';
+
+// Imperative API the header/rail wiring drives. This surface is an acquisition
+// hook β everything here is generic + community-framed to pull people into
+// /giveback, never a personal reward notice.
+export interface GivebackGiftDockHandle {
+ // Ambient community money landing in the pot (social proof), e.g. "+$8".
+ // This bare dollar jump on the gift is the engagement/attention mechanism.
+ pulseActivity: (label: string) => void;
+ // The full invite bubble (rotating messages).
+ showPrompt: (prompt: GivebackInvitePromptData) => void;
+ reset: () => void;
+}
+
+interface ValuePop {
+ id: string;
+ label: string;
+ // Horizontal nudge (px) off the gift centre so it reads beside the icon and
+ // rapid pops don't stack exactly on top of each other.
+ dx: number;
+}
+
+interface GivebackGiftDockProps {
+ variant?: GivebackGiftButtonVariant;
+ showLabel?: boolean;
+ onOpenGiveback?: () => void;
+ // Override where the invite prompt opens (defaults follow the variant).
+ promptPlacement?: 'below' | 'above';
+ promptAlign?: 'start' | 'end';
+ // Custom anchor (e.g. the sidebar's own styled gift link). When provided it
+ // replaces the built-in gift button; the money/prompt overlays anchor to it.
+ children?: ReactNode;
+}
+
+const GIFT_POP_MS = 380;
+const VALUE_POP_LIFETIME_MS = 2000;
+const MILESTONE_TOAST_DELAY_MS = 180;
+
+let popCounter = 0;
+
+export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
+ {
+ variant = 'header',
+ showLabel = false,
+ onOpenGiveback,
+ promptPlacement,
+ promptAlign,
+ children,
+ }: GivebackGiftDockProps,
+ ref: ForwardedRef,
+): ReactElement {
+ const isRail = variant === 'rail';
+ const [pops, setPops] = useState([]);
+ const [popping, setPopping] = useState(false);
+ const [glowKey, setGlowKey] = useState(0);
+ const [giftHovered, setGiftHovered] = useState(false);
+ const [prompt, setPrompt] = useState(null);
+ // Bumps per show so a replacing prompt remounts (fresh timer + confetti).
+ const [promptSeq, setPromptSeq] = useState(0);
+ const timers = useRef([]);
+
+ const track = useCallback((id: number) => {
+ timers.current.push(id);
+ }, []);
+
+ const popGift = useCallback(() => {
+ setPopping(false);
+ window.requestAnimationFrame(() => setPopping(true));
+ track(window.setTimeout(() => setPopping(false), GIFT_POP_MS + 40));
+ }, [track]);
+
+ const pulseActivity = useCallback(
+ (label: string) => {
+ popCounter += 1;
+ const id = `giveback-pop-${popCounter.toString()}`;
+ // Bias to the right of the icon with a little spread.
+ const dx = Math.round(Math.random() * 18) + 6;
+ setPops((current) => [...current, { id, label, dx }]);
+ popGift();
+ track(
+ window.setTimeout(() => {
+ setPops((current) => current.filter((pop) => pop.id !== id));
+ }, VALUE_POP_LIFETIME_MS),
+ );
+ },
+ [popGift, track],
+ );
+
+ const showPrompt = useCallback(
+ (next: GivebackInvitePromptData) => {
+ if (next.celebrate) {
+ setGlowKey((current) => current + 1);
+ }
+ popGift();
+ track(
+ window.setTimeout(() => {
+ setPromptSeq((current) => current + 1);
+ setPrompt(next);
+ }, MILESTONE_TOAST_DELAY_MS),
+ );
+ },
+ [popGift, track],
+ );
+
+ const reset = useCallback(() => {
+ timers.current.forEach((id) => window.clearTimeout(id));
+ timers.current = [];
+ setPops([]);
+ setPopping(false);
+ setPrompt(null);
+ }, []);
+
+ useImperativeHandle(ref, () => ({ pulseActivity, showPrompt, reset }), [
+ pulseActivity,
+ showPrompt,
+ reset,
+ ]);
+
+ // Opening giveback (clicking the gift or the toast CTA) navigates to the page,
+ // so dismiss the prompt β the user is already there, no need to keep nagging.
+ const handleOpen = useCallback(() => {
+ setPrompt(null);
+ onOpenGiveback?.();
+ }, [onOpenGiveback]);
+
+ return (
+
+ setGiftHovered(true)}
+ onMouseLeave={() => setGiftHovered(false)}
+ // A custom anchor (rail link) navigates on its own; still dismiss the
+ // toast when it's clicked (capture phase β the link stays interactive).
+ onClickCapture={children ? () => setPrompt(null) : undefined}
+ >
+ {/* Soft glow bloom on a celebratory community moment. */}
+ {glowKey > 0 && (
+
+ )}
+ {children ?? (
+
+ )}
+
+
+ {/* Community money landing in the pot β bare green numerals that pop in
+ beside the gift and drift up (Polymarket-style real-time jump). Offset
+ to the right of the icon per-pop so they stay legible, not on the
+ glyph. */}
+
+ {pops.map((pop) => (
+
+ {pop.label}
+
+ ))}
+
+
+ setPrompt(null)}
+ />
+
+ );
+});
+
+export default GivebackGiftDock;
diff --git a/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx
new file mode 100644
index 00000000000..858c41db01b
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackGiftEntry.tsx
@@ -0,0 +1,160 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { useEffect, useRef, useState } 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 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';
+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;
+
+// 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];
+
+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 (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 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') {
+ setDebug(window.location.search.includes('giveback_debug'));
+ }
+ }, []);
+
+ // 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 || debug);
+
+ // 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 (!show || cadenceClaimed) {
+ return undefined;
+ }
+ cadenceClaimed = true;
+
+ const timeouts: number[] = [];
+ timeouts.push(
+ window.setTimeout(() => {
+ dock.current?.pulseActivity(randomPulse());
+ }, FIRST_PULSE_MS),
+ );
+ timeouts.push(
+ window.setTimeout(() => {
+ const prompt =
+ givebackInvitePrompts[promptCursor % givebackInvitePrompts.length];
+ promptCursor += 1;
+ dock.current?.showPrompt(prompt);
+ logEvent({
+ event_name: LogEvent.ViewGivebackPrompt,
+ extra: JSON.stringify({ prompt: prompt.id }),
+ });
+ }, PROMPT_MS),
+ );
+ const pulse = window.setInterval(() => {
+ dock.current?.pulseActivity(randomPulse());
+ }, PULSE_INTERVAL_MS);
+
+ return () => {
+ timeouts.forEach((id) => window.clearTimeout(id));
+ window.clearInterval(pulse);
+ cadenceClaimed = false;
+ };
+ }, [show, logEvent]);
+
+ if (!show) {
+ return null;
+ }
+
+ const openGiveback = () => {
+ logEvent({ event_name: LogEvent.ClickGivebackGiftEntry });
+ router.push(`${webappUrl}giveback`);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default GivebackGiftEntry;
diff --git a/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx
new file mode 100644
index 00000000000..d363be41b90
--- /dev/null
+++ b/packages/shared/src/features/giveback/components/GivebackInvitePrompt.tsx
@@ -0,0 +1,232 @@
+import type { ReactElement } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { useTimedAnimation } from '../../../hooks/useTimedAnimation';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import { MiniCloseIcon } from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import { RootPortal } from '../../../components/tooltips/Portal';
+import { cloudinaryCharmGiveback } from '../../../lib/image';
+import { GivebackConfettiBurst } from './GivebackConfettiBurst';
+
+export interface GivebackInvitePromptProps {
+ open: boolean;
+ eyebrow?: string;
+ headline?: string;
+ body?: string;
+ ctaLabel?: string;
+ // Festive community moment β confetti bursts from the gift.
+ celebrate?: boolean;
+ // Opens above the gift (rail, bottom-left) instead of below it (header).
+ placement?: 'below' | 'above';
+ // Horizontal edge the tail points to β matches where the gift sits.
+ align?: 'start' | 'end';
+ // Open like a rail dropdown: portaled + fixed at the same left margin as the
+ // support/settings/profile menus, instead of anchored to the gift with a tail.
+ dropdown?: boolean;
+ autoDismissMs?: number;
+ // Externally pause the auto-dismiss (e.g. while the cursor is over the gift).
+ paused?: boolean;
+ onClick?: () => void;
+ onClose?: () => void;
+ className?: string;
+}
+
+// A playful, community-framed invitation fronted by the daily.dev mascot. It's
+// an acquisition hook, not a personal reward notice β it leads with social
+// proof + the honest trade and always ends in a clear way into /giveback. The
+// close button carries the auto-dismiss countdown ring (drains as it nears
+// exit), so there's no bulky progress bar.
+export const GivebackInvitePrompt = ({
+ open,
+ eyebrow = 'Raised together',
+ headline = 'The community is funding real-world causes',
+ body = 'All from everyday daily.dev activity. Pick the causes you care about.',
+ ctaLabel = 'Join in',
+ celebrate = false,
+ placement = 'below',
+ align = 'end',
+ dropdown = false,
+ autoDismissMs = 5000,
+ paused = false,
+ onClick,
+ onClose,
+ className,
+}: GivebackInvitePromptProps): ReactElement | null => {
+ // Bumps every time the prompt opens, so the confetti restarts even when one
+ // prompt replaces another.
+ const [runId, setRunId] = useState(0);
+ const [hovered, setHovered] = useState(false);
+
+ // Keep onClose fresh without re-arming the timer every render.
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+ const handleEnd = useCallback(() => onCloseRef.current?.(), []);
+
+ const { timer, startAnimation, pauseAnimation, resumeAnimation } =
+ useTimedAnimation({
+ autoEndAnimation: true,
+ outAnimationDuration: 150,
+ onAnimationEnd: handleEnd,
+ });
+
+ // Arm the countdown when the prompt opens (once β not on every render).
+ useEffect(() => {
+ if (!open) {
+ return undefined;
+ }
+ setRunId((current) => current + 1);
+ startAnimation(autoDismissMs);
+ return () => pauseAnimation();
+ }, [open, autoDismissMs, startAnimation, pauseAnimation]);
+
+ // Pause the countdown while the pointer rests on the gift or the toast, and
+ // resume from where it left off (never restart) when it leaves.
+ const isPaused = paused || hovered;
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ if (isPaused) {
+ pauseAnimation();
+ } else {
+ resumeAnimation();
+ }
+ }, [isPaused, open, pauseAnimation, resumeAnimation]);
+
+ if (!open) {
+ return null;
+ }
+
+ const isAbove = placement === 'above';
+ // Countdown ring: drains 0 -> 100 as the remaining time elapses, and freezes
+ // while paused (the timer stops ticking).
+ const remaining = autoDismissMs > 0 ? (timer / autoDismissMs) * 100 : 0;
+ const dashoffset = Math.min(100, Math.max(0, 100 - remaining));
+ const giftSide = align === 'end' ? 'right-6' : 'left-6';
+
+ const content = (
+
+ {celebrate && (
+
+
+
+ )}
+
+ {/* Tail pointing to the gift (anchored mode only). */}
+ {!dropdown && (
+
+ )}
+
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {/* Close button (top-right corner) with the auto-dismiss countdown ring. */}
+
+
+ {/* Left: full-width message + CTA. */}
+
+
+
+ {eyebrow}
+
+
+ {headline}
+
+
+ {body}
+
+
+
+
+
+
+ {/* The Giveback charm, bobbing on a soft glow. Its artwork sits on black,
+ so mix-blend-screen drops the black on the dark card. */}
+
+
+
+
+
+
+ );
+
+ 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/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 }),
+ );
+};
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 => (
+
+
+ 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.
+
+
+
+
+ β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: () => (
+