diff --git a/frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx b/frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx new file mode 100644 index 00000000..c32b4d48 --- /dev/null +++ b/frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { ShowcaseStageMedia } from '@/components/showcase/ShowcaseStageMedia'; +import type { ShowcaseConfig } from '@/types/showcase'; + +interface FeatureShowcaseDialogProps { + config: ShowcaseConfig; +} + +/** + * FeatureShowcaseDialog - Generic multi-stage modal for showcasing features with media + * + * Displays a modal with stages containing videos or images, title, description, + * and navigation controls. ESC key is disabled; only Next/Finish buttons dismiss. + * + * Features: + * - Multi-stage or single-stage support (hides navigation if 1 stage) + * - Video support with loading states and progress bars + * - Image support with loading skeleton + * - Responsive design (full-width on mobile, 2/3 width on desktop) + * - i18n support via translation keys + * - Smooth transitions between stages + * + * Usage: + * ```ts + * FeatureShowcaseDialog.show({ config: showcases.taskPanel }); + * ``` + */ +const FeatureShowcaseDialogImpl = NiceModal.create( + ({ config }: FeatureShowcaseDialogProps) => { + const modal = useModal(); + const [currentStage, setCurrentStage] = useState(0); + const { t } = useTranslation('tasks'); + + const stage = config.stages[currentStage]; + const totalStages = config.stages.length; + + const handleNext = () => { + setCurrentStage((prev) => { + if (prev >= totalStages - 1) { + modal.resolve(); + return prev; + } + return prev + 1; + }); + }; + + const handlePrevious = () => { + setCurrentStage((prev) => Math.max(prev - 1, 0)); + }; + + const handleClose = () => { + modal.hide(); + modal.resolve(); + modal.remove(); + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + uncloseable + className="max-w-none xl:max-w-[min(66.66vw,calc((100svh-20rem)*1.6))] p-0 overflow-hidden" + > + + + + + +
+
+
+

+ {t(stage.titleKey)} +

+
+
+ {currentStage + 1} / {totalStages} +
+
+ +

+ {t(stage.descriptionKey)} +

+ +
+ {Array.from({ length: totalStages }).map((_, index) => ( +
+ ))} +
+ + {totalStages > 1 && ( +
+ {currentStage > 0 && ( + + )} + +
+ )} +
+ + + +
+ ); + } +); + +export const FeatureShowcaseDialog = defineModal< + FeatureShowcaseDialogProps, + void +>(FeatureShowcaseDialogImpl); diff --git a/frontend/src/components/showcase/FeatureShowcaseModal.tsx b/frontend/src/components/showcase/FeatureShowcaseModal.tsx deleted file mode 100644 index ad40ba98..00000000 --- a/frontend/src/components/showcase/FeatureShowcaseModal.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect, useState, useRef } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { ChevronRight, ChevronLeft } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { useHotkeysContext } from 'react-hotkeys-hook'; -import { useKeyExit, Scope } from '@/keyboard'; -import { ShowcaseStageMedia } from './ShowcaseStageMedia'; -import type { ShowcaseConfig } from '@/types/showcase'; - -interface FeatureShowcaseModalProps { - isOpen: boolean; - onClose: () => void; - config: ShowcaseConfig; -} - -/** - * FeatureShowcaseModal - Generic multi-stage modal for showcasing features with media - * - * Displays a bottom-aligned modal with stages containing videos or images, title, description, - * and navigation controls. Properly manages keyboard shortcuts (ESC captured but disabled) - * and scopes to prevent closing underlying features. - * - * Features: - * - Multi-stage or single-stage support (hides navigation if 1 stage) - * - Video support with loading states and progress bars - * - Image support with loading skeleton - * - Responsive design (full-width on mobile, 2/3 width on desktop) - * - i18n support via translation keys - * - Smooth transitions between stages - * - * @param isOpen - Controls modal visibility - * @param onClose - Called when user finishes the showcase (via Finish button on last stage) - * @param config - ShowcaseConfig object defining stages, media, and translation keys - */ -export function FeatureShowcaseModal({ - isOpen, - onClose, - config, -}: FeatureShowcaseModalProps) { - const [currentStage, setCurrentStage] = useState(0); - const { t } = useTranslation('tasks'); - const { enableScope, disableScope, activeScopes } = useHotkeysContext(); - const previousScopesRef = useRef([]); - - const stage = config.stages[currentStage]; - const totalStages = config.stages.length; - - /** - * Scope management for keyboard shortcuts: - * When showcase opens, we capture all currently active scopes, disable them, - * and enable only DIALOG scope. This ensures ESC key presses are captured by - * our showcase handler (which does nothing) instead of triggering underlying - * close handlers. When closing, we restore the original scopes. - */ - useEffect(() => { - if (isOpen) { - previousScopesRef.current = activeScopes; - activeScopes.forEach((scope) => disableScope(scope)); - enableScope(Scope.DIALOG); - } else { - disableScope(Scope.DIALOG); - previousScopesRef.current.forEach((scope) => enableScope(scope)); - } - - return () => { - disableScope(Scope.DIALOG); - previousScopesRef.current.forEach((scope) => enableScope(scope)); - }; - // activeScopes intentionally omitted - we only capture on open, not on every scope change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, enableScope, disableScope]); - - useKeyExit( - (e) => { - e?.preventDefault(); - }, - { scope: Scope.DIALOG, enabled: isOpen } - ); - - const handleNext = () => { - setCurrentStage((prev) => { - if (prev >= totalStages - 1) { - onClose(); - return prev; - } - return prev + 1; - }); - }; - - const handlePrevious = () => { - setCurrentStage((prev) => Math.max(prev - 1, 0)); - }; - - return ( - - {isOpen && ( - <> - - - - - - -
-
-
-

- {t(stage.titleKey)} -

-
-
- {currentStage + 1} / {totalStages} -
-
- -

- {t(stage.descriptionKey)} -

- -
- {Array.from({ length: totalStages }).map((_, index) => ( -
- ))} -
- - {totalStages > 1 && ( -
- {currentStage > 0 && ( - - )} - -
- )} -
- - - - - )} - - ); -} diff --git a/frontend/src/hooks/useShowcasePersistence.ts b/frontend/src/hooks/useShowcasePersistence.ts deleted file mode 100644 index e32f5b03..00000000 --- a/frontend/src/hooks/useShowcasePersistence.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useUserSystem } from '@/components/config-provider'; - -export interface ShowcasePersistence { - hasSeen: (id: string) => boolean; - markSeen: (id: string) => Promise; - isLoaded: boolean; -} - -export function useShowcasePersistence(): ShowcasePersistence { - const { config, updateAndSaveConfig, loading } = useUserSystem(); - - const seenFeatures = useMemo( - () => config?.showcases?.seen_features ?? [], - [config?.showcases?.seen_features] - ); - - const hasSeen = useCallback( - (id: string): boolean => { - return seenFeatures.includes(id); - }, - [seenFeatures] - ); - - const markSeen = useCallback( - async (id: string): Promise => { - if (seenFeatures.includes(id)) { - return; - } - - await updateAndSaveConfig({ - showcases: { - seen_features: [...seenFeatures, id], - }, - }); - }, - [seenFeatures, updateAndSaveConfig] - ); - - return { - hasSeen, - markSeen, - isLoaded: !loading, - }; -} diff --git a/frontend/src/hooks/useShowcaseTrigger.ts b/frontend/src/hooks/useShowcaseTrigger.ts deleted file mode 100644 index 6f8fed4b..00000000 --- a/frontend/src/hooks/useShowcaseTrigger.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import type { ShowcaseConfig } from '@/types/showcase'; -import { useShowcasePersistence } from './useShowcasePersistence'; - -export interface ShowcaseTriggerOptions { - enabled: boolean; - openDelay?: number; - resetOnDisable?: boolean; - markSeenOnClose?: boolean; -} - -export interface ShowcaseTriggerResult { - isOpen: boolean; - open: () => void; - close: () => void; - hasSeen: boolean; -} - -export function useShowcaseTrigger( - config: ShowcaseConfig, - options: ShowcaseTriggerOptions -): ShowcaseTriggerResult { - const { - enabled, - openDelay = 300, - resetOnDisable = true, - markSeenOnClose = true, - } = options; - - const persistence = useShowcasePersistence(); - const [isOpen, setIsOpen] = useState(false); - const [hasSeenState, setHasSeenState] = useState(false); - const timerRef = useRef(null); - const mountedRef = useRef(true); - - // Keep 'hasSeenState' in sync if id change or config loads - useEffect(() => { - if (!persistence.isLoaded) return; - setHasSeenState(persistence.hasSeen(config.id)); - }, [persistence.isLoaded, config.id, persistence]); - - // Cleanup timers - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, []); - - // Handle enabled state changes - useEffect(() => { - if (!persistence.isLoaded) return; - - if (enabled) { - // Only show if not seen - if (!hasSeenState) { - // Clear any existing timer - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - } - - // Delay opening to ensure UI is mounted - timerRef.current = window.setTimeout(() => { - if (mountedRef.current) { - setIsOpen(true); - timerRef.current = null; - } - }, openDelay); - } - } else { - // Reset when disabled (if configured) - if (resetOnDisable) { - // Clear pending timer - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - setIsOpen(false); - } - } - - return () => { - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]); - - const open = useCallback(() => { - setIsOpen(true); - }, []); - - const close = useCallback(() => { - if (markSeenOnClose) { - persistence.markSeen(config.id); - setHasSeenState(true); - } - if (timerRef.current !== null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - setIsOpen(false); - }, [config.id, markSeenOnClose, persistence]); - - return { isOpen, open, close, hasSeen: hasSeenState }; -} diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index dca51cb1..b77b0856 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -8,9 +8,9 @@ import { Loader } from '@/components/ui/loader'; import { tasksApi } from '@/lib/api'; import type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types'; import { openTaskForm } from '@/lib/openTaskForm'; -import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal'; +import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog'; import { showcases } from '@/config/showcases'; -import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger'; +import { useUserSystem } from '@/components/config-provider'; import { usePostHog } from 'posthog-js/react'; import { useSearch } from '@/contexts/search-context'; @@ -201,10 +201,31 @@ export function ProjectTasks() { const isSharedPanelOpen = Boolean(selectedSharedTask); const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen; - const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } = - useShowcaseTrigger(showcases.taskPanel, { - enabled: isPanelOpen, + const { config, updateAndSaveConfig, loading } = useUserSystem(); + + const isLoaded = !loading; + const showcaseId = showcases.taskPanel.id; + const seenFeatures = config?.showcases?.seen_features ?? []; + const seen = isLoaded && seenFeatures.includes(showcaseId); + + useEffect(() => { + if (!isLoaded || !isPanelOpen || seen) return; + + FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => { + FeatureShowcaseDialog.hide(); + if (seenFeatures.includes(showcaseId)) return; + void updateAndSaveConfig({ + showcases: { seen_features: [...seenFeatures, showcaseId] }, + }); }); + }, [ + isLoaded, + isPanelOpen, + seen, + showcaseId, + updateAndSaveConfig, + seenFeatures, + ]); const isLatest = attemptId === 'latest'; const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts( @@ -1049,11 +1070,6 @@ export function ProjectTasks() { )}
{attemptArea}
-
); }