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
10 changes: 8 additions & 2 deletions packages/shared/src/components/feeds/FeedNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useScrollTopClassName } from '../../hooks/useScrollTopClassName';
import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme';
import { webappUrl } from '../../lib/constants';
import NotificationsBell from '../notifications/NotificationsBell';
import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry';
import classed from '../../lib/classed';
import type { AllFeedPages } from '../../lib/query';
import { OtherFeedPage } from '../../lib/query';
Expand Down Expand Up @@ -66,6 +67,10 @@ function FeedNav(): ReactElement | null {
const { home, bookmarks } = useActiveNav(feedName);
const isMobile = useViewSize(ViewSize.MobileL);
const isBelowLaptop = !useViewSize(ViewSize.Laptop);
// Phones get the giveback entry via MobileFeedActions and laptop+ via the
// header/rail; the tablet feed header is the only gap, so it renders its own.
// JS-gated (not CSS) so it never mounts alongside the other placements.
const isTablet = isBelowLaptop && !isMobile;
const { value: feedChipsVariant } = useConditionalFeature({
feature: featureFeedChips,
shouldEvaluate: isBelowLaptop,
Expand Down Expand Up @@ -167,7 +172,7 @@ function FeedNav(): ReactElement | null {
tabListProps={{
className: {
indicator: '!w-6',
item: 'px-1 tablet:last-of-type:mr-12',
item: 'px-1 tablet:last-of-type:mr-24',
},
autoScrollActive: true,
}}
Expand Down Expand Up @@ -218,7 +223,8 @@ function FeedNav(): ReactElement | null {
<MyFeedHeading />
</StickyNavIconWrapper>
)}
<div className="hidden items-center bg-background-default tablet:absolute tablet:inset-y-0 tablet:right-0 tablet:flex laptop:hidden">
<div className="hidden items-center gap-2 bg-background-default tablet:absolute tablet:inset-y-0 tablet:right-0 tablet:flex laptop:hidden">
{isTablet && <GivebackGiftEntry compact />}
<NotificationsBell compact />
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions packages/shared/src/components/feeds/MobileFeedActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import Link from '../utilities/Link';
import { ReadingStreakButton } from '../streak/ReadingStreakButton';
import { Divider } from '../utilities';
import { useReadingStreak } from '../../hooks/streaks';
import { ButtonIconPosition, ButtonVariant } from '../buttons/common';
import { useAuthContext } from '../../contexts/AuthContext';
Expand All @@ -16,6 +15,7 @@ import { Button } from '../buttons/Button';
import { SettingsIcon } from '../icons';
import { RootPortal } from '../tooltips/Portal';
import { QuestHeaderButton } from '../header/QuestHeaderButton';
import { GivebackGiftEntry } from '../../features/giveback/components/GivebackGiftEntry';

const ProfileSettingsMenuMobile = dynamic(
() =>
Expand All @@ -37,7 +37,7 @@ export function MobileFeedActions(): ReactElement {
position={LogoPosition.Relative}
onLogoClick={() => router.push('/')}
/>
<span className="flex flex-row items-center gap-2">
<span className="flex flex-row items-center gap-1">
{isStreaksEnabled && streak && (
<ReadingStreakButton
isLoading={isLoading}
Expand All @@ -47,7 +47,7 @@ export function MobileFeedActions(): ReactElement {
/>
)}
<QuestHeaderButton compact />
<Divider className="bg-border-subtlest-tertiary" vertical />
<GivebackGiftEntry compact />
{user && (
<>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const Donut = ({
return (
<FlexCol
ref={ref}
className="items-center gap-6 tablet:flex-row tablet:gap-8"
className="items-start gap-6 tablet:flex-row tablet:items-center tablet:gap-8"
>
<div className="relative shrink-0">
<svg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface GivebackGiftButtonProps {
showLabel?: boolean;
label?: string;
tooltip?: string;
// Mobile header: flat + sized to match the compact quest button beside it.
compact?: boolean;
onClick?: () => void;
className?: string;
}
Expand All @@ -32,28 +34,31 @@ export const GivebackGiftButton = forwardRef(function GivebackGiftButton(
showLabel = false,
label = 'Giveback',
tooltip = 'Giveback',
compact = false,
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.
// Desktop header: a square icon button that matches the notification bell
// exactly (Float, w-10, centered, no side padding). Mobile header (compact):
// flat and sized to match the compact quest button that sits beside it.
if (!isRail) {
return (
<Tooltip content={tooltip} side="bottom">
<Button
ref={ref}
type="button"
variant={ButtonVariant.Float}
variant={compact ? ButtonVariant.Tertiary : ButtonVariant.Float}
size={compact ? ButtonSize.Small : undefined}
aria-label={tooltip}
onClick={onClick}
icon={<GiftIcon />}
icon={<GiftIcon size={compact ? IconSize.Small : undefined} />}
className={classNames(
'relative w-10 justify-center',
'relative justify-center',
compact ? 'size-8 !p-0' : 'w-10',
pressClass,
className,
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ interface ValuePop {
interface GivebackGiftDockProps {
variant?: GivebackGiftButtonVariant;
showLabel?: boolean;
// Mobile header: flat button + viewport-pinned prompt (see GivebackGiftButton
// and GivebackInvitePrompt).
compact?: boolean;
onOpenGiveback?: () => void;
// Override where the invite prompt opens (defaults follow the variant).
promptPlacement?: 'below' | 'above';
Expand All @@ -58,6 +61,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
{
variant = 'header',
showLabel = false,
compact = false,
onOpenGiveback,
promptPlacement,
promptAlign,
Expand All @@ -74,6 +78,10 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
// Bumps per show so a replacing prompt remounts (fresh timer + confetti).
const [promptSeq, setPromptSeq] = useState(0);
const timers = useRef<number[]>([]);
// The compact prompt is viewport-fixed, so anchor it just below the real gift
// instead of a hardcoded top offset (which misaligns with banners/safe areas).
const anchorRef = useRef<HTMLDivElement>(null);
const [promptTop, setPromptTop] = useState<number | null>(null);

const clearTimers = useCallback(() => {
timers.current.forEach((id) => window.clearTimeout(id));
Expand All @@ -96,6 +104,18 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
// unmounted component (production never calls reset()).
useEffect(() => clearTimers, [clearTimers]);

// Measure the gift's viewport position each time the compact prompt opens; the
// header is sticky, so a single read holds for the prompt's short lifetime.
useEffect(() => {
if (!compact || !prompt) {
return;
}
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) {
setPromptTop(Math.round(rect.bottom + 12));
}
}, [compact, prompt]);

const popGift = useCallback(() => {
setPopping(false);
window.requestAnimationFrame(() => setPopping(true));
Expand Down Expand Up @@ -154,7 +174,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
}, [onOpenGiveback]);

return (
<div className="relative inline-flex">
<div ref={anchorRef} className="relative inline-flex">
<span
className={classNames(
'relative inline-flex',
Expand All @@ -179,6 +199,7 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
<GivebackGiftButton
variant={variant}
showLabel={showLabel}
compact={compact}
onClick={handleOpen}
/>
)}
Expand Down Expand Up @@ -209,6 +230,8 @@ export const GivebackGiftDock = forwardRef(function GivebackGiftDock(
ctaLabel={prompt?.ctaLabel}
celebrate={prompt?.celebrate}
dropdown={isRail}
compact={compact}
compactTop={promptTop}
paused={giftHovered}
placement={promptPlacement ?? (isRail ? 'above' : 'below')}
align={promptAlign ?? (isRail ? 'start' : 'end')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { LogEvent } from '../../../lib/log';
interface GivebackGiftEntryProps {
variant?: GivebackGiftButtonVariant;
showLabel?: boolean;
// Flat, compact styling + viewport-pinned prompt for the mobile header
// placements. Set by the caller that mounts the entry on a mobile surface, so
// the styling follows the placement rather than being guessed from viewport.
compact?: boolean;
promptPlacement?: 'below' | 'above';
promptAlign?: 'start' | 'end';
// When the parent already evaluated `featureGiveback` (e.g. the rail, which
Expand All @@ -32,12 +36,15 @@ const LIVE_ACTIVITY_MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// The persistent giveback entry point. Gated on the same `featureGiveback` flag
// as the page, so it shows wherever giveback is enabled. It drives its dock from
// remote community activity: real approved actions pop a live "+$" jump and
// crossing a global milestone fires the celebratory popover. The header and the
// sidebar-v2 rail are mutually exclusive layouts, so only one entry is ever
// mounted and no cross-instance coordination is needed.
// crossing a global milestone fires the celebratory popover. Its mount points -
// the desktop header, the sidebar-v2 rail, and the mobile feed header (phone via
// MobileFeedActions, tablet via FeedNav) - are laid out to be mutually
// exclusive, so only one entry is ever mounted and no cross-instance
// coordination is needed. Adding a new mount point must preserve that.
export function GivebackGiftEntry({
variant = 'header',
showLabel = false,
compact = false,
promptPlacement,
promptAlign,
isFeatureEnabled,
Expand All @@ -48,10 +55,11 @@ export function GivebackGiftEntry({
const { logEvent } = useLogContext();
const dock = useRef<GivebackGiftDockHandle>(null);

// Desktop-only for now. The mobile placement is parked for a later PR, so the
// entry never shows on smaller viewports (header/rail are desktop anyway).
// The rail lives in the desktop sidebar, so it stays laptop-gated; the header
// variant also serves mobile, where it's the only giveback entry point.
const isLaptop = useViewSize(ViewSize.Laptop);
const baseGate = isAuthReady && isLoggedIn && isLaptop;
const baseGate =
isAuthReady && isLoggedIn && (variant === 'header' || isLaptop);
const hasExternalFlag = isFeatureEnabled !== undefined;
const { value: selfEnabled } = useConditionalFeature({
feature: featureGiveback,
Expand Down Expand Up @@ -85,6 +93,7 @@ export function GivebackGiftEntry({
ref={dock}
variant={variant}
showLabel={showLabel}
compact={compact}
onOpenGiveback={openGiveback}
promptPlacement={promptPlacement}
promptAlign={promptAlign}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface GivebackInvitePromptProps {
// 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;
// Mobile header: pin to the viewport (centered, below the header) instead of
// anchoring to the mid-header gift, so the wide card never overflows the edge.
compact?: boolean;
// Measured viewport `top` (px) for the compact prompt, so it sits just below
// the real gift. Falls back to a safe-area-aware offset until it's measured.
compactTop?: number | null;
autoDismissMs?: number;
// Externally pause the auto-dismiss (e.g. while the cursor is over the gift).
paused?: boolean;
Expand All @@ -51,6 +57,8 @@ export const GivebackInvitePrompt = ({
placement = 'below',
align = 'end',
dropdown = false,
compact = false,
compactTop = null,
autoDismissMs = 5000,
paused = false,
onClick,
Expand Down Expand Up @@ -112,23 +120,28 @@ export const GivebackInvitePrompt = ({
const content = (
<div
className={classNames(
'w-[25rem]',
dropdown
? 'fixed bottom-3 left-20 z-popup ml-2 max-w-[calc(100vw-6rem)]'
: classNames(
'absolute z-3 max-w-[calc(100vw-2rem)]',
isAbove ? 'bottom-full mb-3' : 'top-full mt-3',
align === 'end' ? 'right-0' : 'left-0',
),
dropdown &&
'fixed bottom-3 left-20 z-popup ml-2 w-[25rem] max-w-[calc(100vw-6rem)]',
!dropdown &&
compact &&
'fixed left-1/2 top-[calc(env(safe-area-inset-top,0px)+3.5rem)] z-popup w-[calc(100vw-2rem)] max-w-[25rem] -translate-x-1/2',
!dropdown &&
!compact &&
classNames(
'absolute z-3 w-[25rem] max-w-[calc(100vw-2rem)]',
isAbove ? 'bottom-full mb-3' : 'top-full mt-3',
align === 'end' ? 'right-0' : 'left-0',
),
className,
)}
style={compact && compactTop != null ? { top: compactTop } : undefined}
role="status"
>
{celebrate && (
<div
className={classNames(
'pointer-events-none absolute',
dropdown
dropdown || compact
? 'inset-x-0 top-0'
: [isAbove ? 'bottom-0' : 'top-0', giftSide],
)}
Expand All @@ -138,7 +151,7 @@ export const GivebackInvitePrompt = ({
)}

{/* Tail pointing to the gift (anchored mode only). */}
{!dropdown && (
{!dropdown && !compact && (
<div
className={classNames(
'absolute size-3 rotate-45 border border-border-subtlest-tertiary bg-background-popover',
Expand Down Expand Up @@ -226,7 +239,7 @@ export const GivebackInvitePrompt = ({
</div>
);

return dropdown ? <RootPortal>{content}</RootPortal> : content;
return dropdown || compact ? <RootPortal>{content}</RootPortal> : content;
};

export default GivebackInvitePrompt;
Loading