diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index a4f7db80..a94ec25e 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -60,6 +60,7 @@ fn generate_types_content() -> String { services::services::config::GitHubConfig::decl(), services::services::config::SoundFile::decl(), services::services::config::UiLanguage::decl(), + services::services::config::ShowcaseState::decl(), services::services::auth::DeviceFlowStartResponse::decl(), server::routes::auth::DevicePollStatus::decl(), server::routes::auth::CheckTokenResponse::decl(), diff --git a/crates/services/src/services/config/mod.rs b/crates/services/src/services/config/mod.rs index e3de32c2..358266e8 100644 --- a/crates/services/src/services/config/mod.rs +++ b/crates/services/src/services/config/mod.rs @@ -22,6 +22,7 @@ pub type SoundFile = versions::v7::SoundFile; pub type EditorType = versions::v7::EditorType; pub type GitHubConfig = versions::v7::GitHubConfig; pub type UiLanguage = versions::v7::UiLanguage; +pub type ShowcaseState = versions::v7::ShowcaseState; /// Will always return config, trying old schemas or eventually returning default pub async fn load_config_from_file(config_path: &PathBuf) -> Config { diff --git a/crates/services/src/services/config/versions/v7.rs b/crates/services/src/services/config/versions/v7.rs index c12dfb0f..524cf804 100644 --- a/crates/services/src/services/config/versions/v7.rs +++ b/crates/services/src/services/config/versions/v7.rs @@ -11,6 +11,12 @@ fn default_git_branch_prefix() -> String { "vk".to_string() } +#[derive(Clone, Debug, Serialize, Deserialize, TS, Default)] +pub struct ShowcaseState { + #[serde(default)] + pub seen_features: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -41,6 +47,8 @@ pub struct Config { pub language: UiLanguage, #[serde(default = "default_git_branch_prefix")] pub git_branch_prefix: String, + #[serde(default)] + pub showcases: ShowcaseState, } impl Config { @@ -90,6 +98,7 @@ impl Config { show_release_notes: old_config.show_release_notes, language: old_config.language, git_branch_prefix: default_git_branch_prefix(), + showcases: ShowcaseState::default(), }) } } @@ -134,6 +143,7 @@ impl Default for Config { show_release_notes: false, language: UiLanguage::default(), git_branch_prefix: default_git_branch_prefix(), + showcases: ShowcaseState::default(), } } } diff --git a/frontend/src/components/showcase/FeatureShowcaseModal.tsx b/frontend/src/components/showcase/FeatureShowcaseModal.tsx index 9d227203..ad40ba98 100644 --- a/frontend/src/components/showcase/FeatureShowcaseModal.tsx +++ b/frontend/src/components/showcase/FeatureShowcaseModal.tsx @@ -78,17 +78,17 @@ export function FeatureShowcaseModal({ ); const handleNext = () => { - if (currentStage < totalStages - 1) { - setCurrentStage((prev) => prev + 1); - } else { - onClose(); - } + setCurrentStage((prev) => { + if (prev >= totalStages - 1) { + onClose(); + return prev; + } + return prev + 1; + }); }; const handlePrevious = () => { - if (currentStage > 0) { - setCurrentStage((prev) => prev - 1); - } + setCurrentStage((prev) => Math.max(prev - 1, 0)); }; return ( diff --git a/frontend/src/config/showcases.ts b/frontend/src/config/showcases.ts index 40300d3c..c15defa1 100644 --- a/frontend/src/config/showcases.ts +++ b/frontend/src/config/showcases.ts @@ -1,44 +1,41 @@ import { ShowcaseConfig } from '@/types/showcase'; -export const taskPanelShowcase: ShowcaseConfig = { - id: 'task-panel-onboarding', - version: 1, - stages: [ - { - titleKey: 'showcases.taskPanel.companion.title', - descriptionKey: 'showcases.taskPanel.companion.description', - media: { - type: 'video', - src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4', - }, - }, - { - titleKey: 'showcases.taskPanel.installation.title', - descriptionKey: 'showcases.taskPanel.installation.description', - media: { - type: 'video', - src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-install-companion-3.mp4', - }, - }, - { - titleKey: 'showcases.taskPanel.codeReview.title', - descriptionKey: 'showcases.taskPanel.codeReview.description', - media: { - type: 'video', - src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4', - }, - }, - { - titleKey: 'showcases.taskPanel.pullRequest.title', - descriptionKey: 'showcases.taskPanel.pullRequest.description', - media: { - type: 'video', - src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-git-pr-3.mp4', - }, - }, - ], -}; - export const showcases = { - taskPanel: taskPanelShowcase, -}; + taskPanel: { + id: 'task-panel-onboarding', + stages: [ + { + titleKey: 'showcases.taskPanel.companion.title', + descriptionKey: 'showcases.taskPanel.companion.description', + media: { + type: 'video', + src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4', + }, + }, + { + titleKey: 'showcases.taskPanel.installation.title', + descriptionKey: 'showcases.taskPanel.installation.description', + media: { + type: 'video', + src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-install-companion-3.mp4', + }, + }, + { + titleKey: 'showcases.taskPanel.codeReview.title', + descriptionKey: 'showcases.taskPanel.codeReview.description', + media: { + type: 'video', + src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4', + }, + }, + { + titleKey: 'showcases.taskPanel.pullRequest.title', + descriptionKey: 'showcases.taskPanel.pullRequest.description', + media: { + type: 'video', + src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-git-pr-3.mp4', + }, + }, + ], + } satisfies ShowcaseConfig, +} as const; diff --git a/frontend/src/hooks/useShowcasePersistence.ts b/frontend/src/hooks/useShowcasePersistence.ts new file mode 100644 index 00000000..881beede --- /dev/null +++ b/frontend/src/hooks/useShowcasePersistence.ts @@ -0,0 +1,42 @@ +import { useCallback } 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 = 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 index 7fc53c47..6f8fed4b 100644 --- a/frontend/src/hooks/useShowcaseTrigger.ts +++ b/frontend/src/hooks/useShowcaseTrigger.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import type { ShowcaseConfig } from '@/types/showcase'; -import { hasSeen as hasSeenUtil, markSeen } from '@/utils/showcasePersistence'; +import { useShowcasePersistence } from './useShowcasePersistence'; export interface ShowcaseTriggerOptions { enabled: boolean; @@ -27,17 +27,17 @@ export function useShowcaseTrigger( markSeenOnClose = true, } = options; + const persistence = useShowcasePersistence(); const [isOpen, setIsOpen] = useState(false); - const [hasSeen, setHasSeen] = useState(() => - hasSeenUtil(config.id, config.version) - ); + const [hasSeenState, setHasSeenState] = useState(false); const timerRef = useRef(null); const mountedRef = useRef(true); - // Keep 'hasSeen' in sync if id/version change + // Keep 'hasSeenState' in sync if id change or config loads useEffect(() => { - setHasSeen(hasSeenUtil(config.id, config.version)); - }, [config.id, config.version]); + if (!persistence.isLoaded) return; + setHasSeenState(persistence.hasSeen(config.id)); + }, [persistence.isLoaded, config.id, persistence]); // Cleanup timers useEffect(() => { @@ -53,9 +53,11 @@ export function useShowcaseTrigger( // Handle enabled state changes useEffect(() => { + if (!persistence.isLoaded) return; + if (enabled) { // Only show if not seen - if (!hasSeen) { + if (!hasSeenState) { // Clear any existing timer if (timerRef.current !== null) { clearTimeout(timerRef.current); @@ -87,7 +89,7 @@ export function useShowcaseTrigger( timerRef.current = null; } }; - }, [enabled, hasSeen, openDelay, resetOnDisable]); + }, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]); const open = useCallback(() => { setIsOpen(true); @@ -95,15 +97,15 @@ export function useShowcaseTrigger( const close = useCallback(() => { if (markSeenOnClose) { - markSeen(config.id, config.version); - setHasSeen(true); + persistence.markSeen(config.id); + setHasSeenState(true); } if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } setIsOpen(false); - }, [config.id, config.version, markSeenOnClose]); + }, [config.id, markSeenOnClose, persistence]); - return { isOpen, open, close, hasSeen }; + return { isOpen, open, close, hasSeen: hasSeenState }; } diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index 8b0267e8..a4d82c08 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -9,7 +9,7 @@ import { tasksApi } from '@/lib/api'; import type { GitBranch } from 'shared/types'; import { openTaskForm } from '@/lib/openTaskForm'; import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal'; -import { taskPanelShowcase } from '@/config/showcases'; +import { showcases } from '@/config/showcases'; import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger'; import { useSearch } from '@/contexts/search-context'; @@ -157,7 +157,7 @@ export function ProjectTasks() { const isPanelOpen = Boolean(taskId && selectedTask); const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } = - useShowcaseTrigger(taskPanelShowcase, { + useShowcaseTrigger(showcases.taskPanel, { enabled: isPanelOpen, }); @@ -766,7 +766,7 @@ export function ProjectTasks() { ); diff --git a/frontend/src/types/showcase.ts b/frontend/src/types/showcase.ts index 0212ef6d..3b7f70af 100644 --- a/frontend/src/types/showcase.ts +++ b/frontend/src/types/showcase.ts @@ -13,6 +13,5 @@ export interface ShowcaseStage { export interface ShowcaseConfig { id: string; - version: number; stages: ShowcaseStage[]; } diff --git a/frontend/src/utils/showcasePersistence.ts b/frontend/src/utils/showcasePersistence.ts deleted file mode 100644 index b0b77a85..00000000 --- a/frontend/src/utils/showcasePersistence.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Check if a user has seen a specific showcase version - * - * @param id - Unique identifier for the showcase - * @param version - Version number for the showcase - * @returns true if the user has seen this showcase version - * - * Storage key format: `showcase:{id}:v{version}:seen` - */ -export function hasSeen(id: string, version: number): boolean { - const key = `showcase:${id}:v${version}:seen`; - return localStorage.getItem(key) === 'true'; -} - -/** - * Mark a showcase as seen - * - * @param id - Unique identifier for the showcase - * @param version - Version number for the showcase - * - * Storage key format: `showcase:{id}:v{version}:seen` - */ -export function markSeen(id: string, version: number): void { - const key = `showcase:${id}:v${version}:seen`; - localStorage.setItem(key, 'true'); -} diff --git a/shared/types.ts b/shared/types.ts index a14f4563..5edc4b88 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -92,7 +92,7 @@ export type ImageResponse = { id: string, file_path: string, original_name: stri export enum GitHubServiceError { TOKEN_INVALID = "TOKEN_INVALID", INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", REPO_NOT_FOUND_OR_NO_ACCESS = "REPO_NOT_FOUND_OR_NO_ACCESS" } -export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, github_login_acknowledged: boolean, telemetry_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, }; +export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, github_login_acknowledged: boolean, telemetry_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, }; @@ -108,6 +108,8 @@ export enum SoundFile { ABSTRACT_SOUND1 = "ABSTRACT_SOUND1", ABSTRACT_SOUND2 = " export type UiLanguage = "BROWSER" | "EN" | "JA" | "ES" | "KO"; +export type ShowcaseState = { seen_features: Array, }; + export type DeviceFlowStartResponse = { user_code: string, verification_uri: string, expires_in: number, interval: number, }; export enum DevicePollStatus { SLOW_DOWN = "SLOW_DOWN", AUTHORIZATION_PENDING = "AUTHORIZATION_PENDING", SUCCESS = "SUCCESS" }