Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
283b172
feat(giveback): add gift entry point with live activity + invite prompt
tsahimatsliah Jul 1, 2026
4038775
feat(giveback): point new-layout rail gift to giveback
tsahimatsliah Jul 1, 2026
be6542c
refactor(giveback): restructure invite toast layout
tsahimatsliah Jul 2, 2026
e789180
feat(giveback): add opt-in QA panel for the gift entry point
tsahimatsliah Jul 2, 2026
14354bc
fix(giveback): let ?giveback_debug force-show the entry on preview
tsahimatsliah Jul 2, 2026
d037831
style(giveback): white CTA, taller mascot, lighter text weights
tsahimatsliah Jul 2, 2026
38ee600
fix(giveback): make QA panel reliably visible
tsahimatsliah Jul 2, 2026
e3b43e6
style(giveback): enlarge toast mascot, narrow content column
tsahimatsliah Jul 2, 2026
62a2e93
style(giveback): bigger centered mascot, uniform toast padding
tsahimatsliah Jul 2, 2026
ac3a8b8
fix(giveback): make QA panel work on the new layout
tsahimatsliah Jul 2, 2026
6c26c42
feat(giveback): wire live interactions onto the sidebar gift
tsahimatsliah Jul 2, 2026
c082b35
style(giveback): open the rail invite prompt like a rail dropdown
tsahimatsliah Jul 2, 2026
17df4b3
Merge branch 'main' into claude/lucid-saha-dd7639
tsahimatsliah Jul 2, 2026
1e18142
Merge branch 'main' into claude/lucid-saha-dd7639
tsahimatsliah Jul 2, 2026
d0bbb59
fix(giveback): pause the toast countdown on hover, don't restart it
tsahimatsliah Jul 2, 2026
76ad34d
Merge branch 'main' into claude/lucid-saha-dd7639
tsahimatsliah Jul 2, 2026
3d87c57
fix(giveback): dismiss the toast when the gift or CTA is clicked
tsahimatsliah Jul 2, 2026
71699a6
docs(giveback): add old-layout header states story for review
tsahimatsliah Jul 2, 2026
479941a
feat(giveback): use the Giveback charm image in the invite toast
tsahimatsliah Jul 2, 2026
98ec03c
chore(giveback): keep the gift entry desktop-only for now
tsahimatsliah Jul 2, 2026
3b6d2ef
fix(giveback): wrap Live playground story in providers + faithful old…
tsahimatsliah Jul 2, 2026
01a004c
style(giveback): make the header gift a square icon button like the bell
tsahimatsliah Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -302,6 +303,7 @@ function MainLayoutComponent({
<QuestUpdatesListener />
<PromptElement />
<Toast autoDismissNotifications={autoDismissNotifications} />
<GivebackDevPanel />
<BootPopups />
<SpotlightHost />
<StreakMilestonePopup />
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/layout/HeaderButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,7 @@ export function HeaderButtons({
<Container>
<OpportunityEntryButton />
<QuestHeaderButton />
<GivebackGiftEntry />
{additionalButtons}
<NotificationsBell />
<ProfileButton className="hidden laptop:flex" />
Expand Down
42 changes: 36 additions & 6 deletions packages/shared/src/components/sidebar/SidebarDesktopV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 => (
<Tooltip side="right" content="Invite friends">
<Link href={`${settingsUrl}/invite`} passHref>
<a aria-label="Invite friends" className={railButtonClass}>
const railGiftLink = (label: string, href: string): ReactElement => (
<Tooltip side="right" content={label}>
<Link href={href} passHref>
<a aria-label={label} className={railButtonClass}>
<GiftIcon size={IconSize.Small} aria-hidden />
</a>
</Link>
</Tooltip>
);

// 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 (
<GivebackGiftEntry variant="rail">
{railGiftLink('Giveback', `${webappUrl}giveback`)}
</GivebackGiftEntry>
);
}

return railGiftLink('Invite friends', `${settingsUrl}/invite`);
};

const supportItems: ProfileSectionItemProps[] = [
{
title: 'Get the mobile app',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-hidden
className={`pointer-events-none absolute inset-0 overflow-visible ${
className ?? ''
}`}
>
{pieces.map((piece) => (
<span
key={piece.id}
className="giveback-confetti-piece absolute left-1/2 top-1/2"
style={
{
color: piece.color,
animationDelay: `${piece.delayMs}ms`,
animationDuration: `${piece.durationMs}ms`,
'--giveback-confetti-x': `${piece.dx}px`,
'--giveback-confetti-y': `${piece.dy}px`,
'--giveback-confetti-r': `${piece.rotate}deg`,
} as CSSProperties
}
/>
))}
</div>
);
};

export default GivebackConfettiBurst;
Original file line number Diff line number Diff line change
@@ -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 (
<RootPortal>
<div
className="fixed bottom-4 right-4 flex w-60 flex-col gap-2 rounded-12 border border-border-subtlest-secondary bg-background-popover p-3 shadow-3"
style={{ zIndex: 2147483647 }}
>
<div className="flex items-center justify-between gap-2">
<span className="font-bold text-text-primary typo-caption1">
🧪 Giveback QA
</span>
<button
type="button"
aria-label={open ? 'Collapse panel' : 'Expand panel'}
onClick={() => setOpen((value) => !value)}
className="rounded grid size-5 place-items-center text-text-tertiary hover:bg-surface-float hover:text-text-primary"
>
{open ? '–' : '+'}
</button>
</div>

{open && (
<>
<span className="text-text-tertiary typo-caption2">
Plays on the live gift icon
</span>
<Button
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Primary}
onClick={() => emitGivebackQa({ type: 'pulse' })}
>
💸 Money jump
</Button>
{givebackInvitePrompts.map((prompt, index) => (
<Button
key={prompt.id}
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Secondary}
onClick={() => emitGivebackQa({ type: 'prompt', index })}
className="!justify-start"
>
{prompt.celebrate ? '🎉 ' : '💬 '}
{prompt.eyebrow ?? prompt.id}
</Button>
))}
<Button
type="button"
size={ButtonSize.XSmall}
variant={ButtonVariant.Subtle}
onClick={() => emitGivebackQa({ type: 'reset' })}
>
Reset
</Button>
</>
)}
</div>
</RootPortal>
);
}

export default GivebackDevPanel;
Loading
Loading