Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/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
36 changes: 30 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,39 @@ const RailHoverCard = ({
);
};

// Theme toggling now lives in the profile dropdown (ThemeSection, matching
// production). The rail slot is reused for a quick "Invite friends" shortcut.
const SidebarInviteButton = (): ReactElement => (
<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,
});

if (givebackEnabled) {
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,112 @@
import type { CSSProperties, ReactElement } from 'react';
import React, { useEffect, useMemo } from 'react';
import { usePrefersReducedMotion } from '../useGivebackMotion';

// Money-forward palette: gold + green lead (cash/coins), brand accents fill in.
const CONFETTI_COLORS = [
'var(--theme-accent-cheese-default)',
'var(--theme-accent-avocado-default)',
'var(--theme-accent-cabbage-default)',
'var(--theme-accent-bacon-default)',
];

interface ConfettiPiece {
id: string;
dx: number;
dy: number;
rotate: number;
delayMs: number;
durationMs: number;
color: string;
}

// Fan the pieces mostly upward and outward, then let gravity pull them down —
// a quick celebratory pop rather than a full-screen confetti dump.
const buildPieces = (count: number, spread: number): ConfettiPiece[] => {
return Array.from({ length: count }, (_, index) => {
const angle = Math.PI + (Math.PI * (index + 0.5)) / count; // upper half
const distance = spread * (0.6 + Math.random() * 0.6);
const dx = Math.cos(angle) * distance;
const dy = Math.sin(angle) * distance + spread * (0.35 + Math.random());

return {
id: `giveback-confetti-${index.toString()}`,
dx,
dy,
rotate: (Math.random() - 0.5) * 540,
delayMs: Math.round(Math.random() * 90),
durationMs: 760 + Math.round(Math.random() * 320),
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
};
});
};

export interface GivebackConfettiBurstProps {
// Bump to replay the burst (each new value spawns a fresh set of pieces).
trigger: number;
pieceCount?: number;
spread?: number;
onDone?: () => void;
className?: string;
}

export const GivebackConfettiBurst = ({
trigger,
pieceCount = 18,
spread = 88,
onDone,
className,
}: GivebackConfettiBurstProps): ReactElement | null => {
const reduced = usePrefersReducedMotion();

const pieces = useMemo(
() => (reduced ? [] : buildPieces(pieceCount, spread)),
// trigger intentionally re-seeds the pieces on each replay.
// eslint-disable-next-line react-hooks/exhaustive-deps
[trigger, pieceCount, spread, reduced],
);

useEffect(() => {
if (!onDone) {
return undefined;
}
const longest = pieces.reduce(
(max, piece) => Math.max(max, piece.delayMs + piece.durationMs),
reduced ? 200 : 0,
);
const timer = window.setTimeout(onDone, longest + 80);
return () => window.clearTimeout(timer);
}, [pieces, onDone, reduced, trigger]);

if (!pieces.length) {
return null;
}

return (
<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,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<HTMLButtonElement>,
): 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 (
<Tooltip content={tooltip} side="bottom">
<Button
ref={ref}
type="button"
variant={ButtonVariant.Float}
aria-label={tooltip}
onClick={onClick}
icon={<GiftIcon />}
className={classNames(
'relative w-10 justify-center',
pressClass,
className,
)}
/>
</Tooltip>
);
}

const railButton = (
<Button
ref={ref}
type="button"
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
onClick={onClick}
aria-label={tooltip}
className={classNames(
'relative',
pressClass,
showLabel && '!justify-start gap-3',
className,
)}
>
<GiftIcon size={IconSize.Small} className="text-text-primary" />
{showLabel && (
<span className="font-bold text-text-primary typo-callout">
{label}
</span>
)}
</Button>
);

if (showLabel) {
return railButton;
}

return (
<Tooltip content={tooltip} side="right">
{railButton}
</Tooltip>
);
});

export default GivebackGiftButton;
Loading
Loading