diff --git a/packages/shared/src/components/help/HelpWidget.module.css b/packages/shared/src/components/help/HelpWidget.module.css new file mode 100644 index 00000000000..a69be0c2910 --- /dev/null +++ b/packages/shared/src/components/help/HelpWidget.module.css @@ -0,0 +1,186 @@ +/* ── Card enter / exit ───────────────────────── */ +@keyframes helpCardSlideIn { + from { + opacity: 0; + transform: translateX(-8px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes helpCardSlideOut { + from { + opacity: 1; + transform: translateX(0) scale(1); + } + to { + opacity: 0; + transform: translateX(-6px) scale(0.97); + } +} + +.cardEnter { + animation: helpCardSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.cardExit { + animation: helpCardSlideOut 0.2s ease-in both; + pointer-events: none; +} + +/* ── Popover enter ──────────────────────────── */ +@keyframes helpPopoverSlideUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.popoverEnter { + animation: helpPopoverSlideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* ── Accent gradient bar ────────────────────── */ +.accentBar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient( + 90deg, + var(--theme-accent-cabbage-default), + var(--theme-accent-blueCheese-default), + var(--theme-accent-onion-default) + ); + background-size: 200% 100%; + animation: helpGradientShift 4s ease-in-out infinite; +} + +@keyframes helpGradientShift { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +/* ── Card hover ─────────────────────────────── */ +.card { + transition: box-shadow 0.3s ease, border-color 0.3s ease; +} + +.card:hover { + box-shadow: + 0 8px 30px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: var(--theme-border-subtlest-secondary); +} + +/* ── Icon soft glow ─────────────────────────── */ +.iconGlow { + position: absolute; + inset: -4px; + border-radius: 14px; + background: var(--theme-accent-cabbage-default); + opacity: 0.08; + filter: blur(8px); +} + +/* ── Inline sidebar card ────────────────────── */ +.inlineCard { + transition: + border-color 0.2s ease, + background-color 0.2s ease; +} + +.inlineAccent { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient( + 90deg, + var(--theme-accent-cabbage-default), + var(--theme-accent-blueCheese-default) + ); + background-size: 200% 100%; + animation: helpGradientShift 4s ease-in-out infinite; + border-radius: 12px 12px 0 0; +} + +/* ── Notification dot pulse ─────────────────── */ +@keyframes helpDotPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 var(--theme-accent-bacon-default); + } + 50% { + box-shadow: 0 0 0 4px transparent; + } +} + +.notificationDot { + animation: helpDotPulse 2s ease-in-out infinite; +} + +/* ── Popover highlight gradient (subtle) ────── */ +.popoverHighlight { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-accent-cabbage-default) 4%, transparent), + transparent + ); + pointer-events: none; +} + +/* ── CTA button shimmer ─────────────────────── */ +@keyframes helpCtaShimmer { + 0% { + background-position: -200% center; + } + 100% { + background-position: 200% center; + } +} + +.ctaButton { + position: relative; + overflow: hidden; +} + +.ctaButton::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 30%, + rgba(255, 255, 255, 0.12) 50%, + transparent 70% + ); + background-size: 200% 100%; + animation: helpCtaShimmer 3s ease-in-out infinite; + pointer-events: none; +} + +/* ── Tag subtle animation ───────────────────── */ +.tag { + transition: + background-color 0.2s ease, + color 0.2s ease; +} diff --git a/packages/shared/src/components/help/HelpWidget.tsx b/packages/shared/src/components/help/HelpWidget.tsx new file mode 100644 index 00000000000..d8acd0359f0 --- /dev/null +++ b/packages/shared/src/components/help/HelpWidget.tsx @@ -0,0 +1,594 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons/Arrow'; +import { MiniCloseIcon } from '../icons/MiniClose'; +import { SparkleIcon } from '../icons/Sparkle'; +import type { HelpGuideItem } from './mockHelpGuideData'; +import { mockHelpGuideItems } from './mockHelpGuideData'; +import styles from './HelpWidget.module.css'; + +type WidgetState = 'expanded' | 'minimized'; + +const COMPLETED_ITEMS_KEY = 'help_widget_completed'; +const WIDGET_STATE_KEY = 'help_widget_state'; + +function getCompletedIds(): string[] { + try { + return JSON.parse(localStorage.getItem(COMPLETED_ITEMS_KEY) || '[]'); + } catch { + return []; + } +} + +function saveCompletedIds(ids: string[]): void { + localStorage.setItem(COMPLETED_ITEMS_KEY, JSON.stringify(ids)); +} + +function getSavedState(): WidgetState { + return (localStorage.getItem(WIDGET_STATE_KEY) as WidgetState) || 'expanded'; +} + +interface HelpWidgetProps { + sidebarExpanded: boolean; +} + +export function HelpWidget({ + sidebarExpanded, +}: HelpWidgetProps): ReactElement | null { + const { user } = useAuthContext(); + const [widgetState, setWidgetState] = useState('expanded'); + const [completedIds, setCompletedIds] = useState([]); + const [activeIndex, setActiveIndex] = useState(0); + const [popoverOpen, setPopoverOpen] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const popoverRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + setCompletedIds(getCompletedIds()); + setWidgetState(getSavedState()); + }, []); + + const pendingItems = mockHelpGuideItems.filter( + (item) => !completedIds.includes(item.id), + ); + const pendingCount = pendingItems.length; + const activeItem = mockHelpGuideItems[activeIndex]; + + const isCompleted = activeItem + ? completedIds.includes(activeItem.id) + : false; + + const completeItem = useCallback( + (item: HelpGuideItem) => { + if (completedIds.includes(item.id)) { + return; + } + + const newCompleted = [...completedIds, item.id]; + setCompletedIds(newCompleted); + saveCompletedIds(newCompleted); + }, + [completedIds], + ); + + const handleCtaClick = useCallback( + (item: HelpGuideItem) => { + completeItem(item); + if (item.ctaUrl) { + window.location.href = item.ctaUrl; + } + }, + [completeItem], + ); + + const goNext = useCallback(() => { + setIsExiting(true); + setTimeout(() => { + setActiveIndex((prev) => (prev + 1) % mockHelpGuideItems.length); + setIsExiting(false); + }, 150); + }, []); + + const goPrev = useCallback(() => { + setIsExiting(true); + setTimeout(() => { + setActiveIndex( + (prev) => + (prev - 1 + mockHelpGuideItems.length) % mockHelpGuideItems.length, + ); + setIsExiting(false); + }, 150); + }, []); + + const goToIndex = useCallback( + (index: number) => { + if (index === activeIndex) { + return; + } + + setIsExiting(true); + setTimeout(() => { + setActiveIndex(index); + setIsExiting(false); + }, 150); + }, + [activeIndex], + ); + + const minimize = useCallback(() => { + setIsExiting(true); + setTimeout(() => { + setWidgetState('minimized'); + localStorage.setItem(WIDGET_STATE_KEY, 'minimized'); + setPopoverOpen(false); + setIsExiting(false); + }, 200); + }, []); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setPopoverOpen(false); + } + }; + + if (popoverOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [popoverOpen]); + + if (!user) { + return null; + } + + // Minimized: show as sidebar item at the bottom + if (widgetState === 'minimized') { + return ( +
+ + + {/* Popover — positioned to the right of sidebar */} + {popoverOpen && ( + { + setActiveIndex(index); + setWidgetState('expanded'); + localStorage.setItem(WIDGET_STATE_KEY, 'expanded'); + setPopoverOpen(false); + }} + onCtaClick={handleCtaClick} + className="absolute bottom-0 left-full ml-2" + /> + )} +
+ ); + } + + if (!activeItem) { + return null; + } + + const showNav = mockHelpGuideItems.length > 1; + + // Expanded: show card anchored to sidebar bottom, popping to the right + return ( +
+ {/* Collapsed sidebar: just show the sparkle icon as trigger */} + {!sidebarExpanded && ( + + )} + + {/* Expanded sidebar: show inline preview card */} + {sidebarExpanded && ( + + )} + + {/* Detail card — pops out to the right of the sidebar */} + {popoverOpen && ( +
+ setPopoverOpen(false)} + onMinimize={() => { + minimize(); + setPopoverOpen(false); + }} + onNext={goNext} + onPrev={goPrev} + onGoToIndex={goToIndex} + /> +
+ )} +
+ ); +} + +interface DetailCardProps { + activeItem: HelpGuideItem; + activeIndex: number; + isCompleted: boolean; + showNav: boolean; + completedIds: string[]; + onCtaClick: (item: HelpGuideItem) => void; + onClose: () => void; + onMinimize: () => void; + onNext: () => void; + onPrev: () => void; + onGoToIndex: (index: number) => void; +} + +function DetailCard({ + activeItem, + activeIndex, + isCompleted, + showNav, + completedIds, + onCtaClick, + onClose, + onMinimize, + onNext, + onPrev, + onGoToIndex, +}: DetailCardProps): ReactElement { + return ( +
+ {/* Gradient accent bar */} +
+ + {/* Close button */} + + )} + {showNav && ( +
+
+ )} +
+
+ + {/* Step indicator — clickable */} + {showNav && ( +
+ {mockHelpGuideItems.map((item, index) => ( +
+ )} + + ); +} + +interface PopoverMenuProps { + items: HelpGuideItem[]; + completedIds: string[]; + onItemClick: (item: HelpGuideItem, index: number) => void; + onCtaClick: (item: HelpGuideItem) => void; + className?: string; +} + +const PopoverMenu = React.forwardRef( + function PopoverMenu( + { items, completedIds, onItemClick, onCtaClick, className }, + ref, + ) { + const pending = items.filter((item) => !completedIds.includes(item.id)); + const completed = items.filter((item) => completedIds.includes(item.id)); + + return ( +
+ {/* Pending items */} + {pending.length > 0 && ( +
+
+
+ + Suggested for you + +
    + {pending.map((item) => { + const index = items.indexOf(item); + return ( +
  • + +
  • + ); + })} +
+
+
+ )} + + {/* Completed items */} + {completed.length > 0 && ( +
0 && 'border-t border-border-subtlest-tertiary', + )} + > + + Completed + +
    + {completed.map((item) => ( +
  • + + ✓ + + + {item.title} + +
  • + ))} +
+
+ )} + + {/* All done state */} + {items.length === 0 && ( +
+ + + You're all caught up! + +
+ )} + + {/* Dev-only reset */} + {process.env.NODE_ENV !== 'production' && ( +
+ +
+ )} +
+ ); + }, +); diff --git a/packages/shared/src/components/help/mockHelpGuideData.ts b/packages/shared/src/components/help/mockHelpGuideData.ts new file mode 100644 index 00000000000..c6c18188e35 --- /dev/null +++ b/packages/shared/src/components/help/mockHelpGuideData.ts @@ -0,0 +1,60 @@ +export interface HelpGuideItem { + id: string; + title: string; + description: string; + ctaLabel: string; + ctaUrl?: string; + tag?: string; + isNew?: boolean; +} + +/** + * Mock data for demo purposes. + * In production this would come from an API based on user state. + */ +export const mockHelpGuideItems: HelpGuideItem[] = [ + { + id: 'customize-feed', + title: 'Customize your feed', + description: + 'Pick your favorite tags and sources to get a feed tailored to your interests. The more you customize, the better your feed gets.', + ctaLabel: 'Go to feed settings', + ctaUrl: '/feeds/new', + tag: 'Action', + }, + { + id: 'smart-prompts', + title: 'Smart Prompts are here!', + description: + 'Ask follow-up questions on any post with AI-powered Smart Prompts. Available for Plus members.', + ctaLabel: 'Try it now', + isNew: true, + tag: 'New', + }, + { + id: 'squads', + title: 'Create your first Squad', + description: + 'Squads are private groups where you and your team can share and discuss articles together.', + ctaLabel: 'Create a Squad', + ctaUrl: '/squads/new', + tag: 'Getting started', + }, + { + id: 'bookmarks', + title: 'Organize with Bookmark Folders', + description: + 'Save posts for later and organize them into folders so you never lose track of great content.', + ctaLabel: 'View bookmarks', + ctaUrl: '/bookmarks', + }, + { + id: 'streak', + title: 'Keep your reading streak going!', + description: + "You're on a 3-day streak. Read one more post today to keep it alive and unlock streak milestones.", + ctaLabel: 'Browse posts', + ctaUrl: '/', + tag: 'Streak', + }, +]; diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index ec062985c5b..8a40971a1b3 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -13,6 +13,7 @@ import { CreatePostButton } from '../post/write'; import { ButtonSize } from '../buttons/Button'; import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; +import { HelpWidget } from '../help/HelpWidget'; type SidebarDesktopProps = { activePage?: string; @@ -54,7 +55,7 @@ export const SidebarDesktop = ({ featureTheme && 'bg-transparent', )} > - + + + {/* Help guide — pinned to sidebar bottom */} +
+ +
); };