diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fc3b4e38..d21cf3c3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,6 +8,7 @@ "Bash(move:*)", "Bash(mv:*)", "Bash(grep:*)", + "Bash(rm:*)", "Bash(rm:*)" ], "deny": [] diff --git a/worklenz-backend/src/controllers/survey-controller.ts b/worklenz-backend/src/controllers/survey-controller.ts index 10bcc29e..cd66f97a 100644 --- a/worklenz-backend/src/controllers/survey-controller.ts +++ b/worklenz-backend/src/controllers/survey-controller.ts @@ -164,4 +164,38 @@ export default class SurveyController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, response)); } + + @HandleExceptions() + public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + const q = ` + SELECT EXISTS( + SELECT 1 + FROM survey_responses sr + INNER JOIN surveys s ON sr.survey_id = s.id + WHERE sr.user_id = $1 + AND s.survey_type = 'account_setup' + AND sr.is_completed = true + ) as is_completed, + ( + SELECT sr.completed_at + FROM survey_responses sr + INNER JOIN surveys s ON sr.survey_id = s.id + WHERE sr.user_id = $1 + AND s.survey_type = 'account_setup' + AND sr.is_completed = true + LIMIT 1 + ) as completed_at; + `; + + const result = await db.query(q, [userId]); + const status = result.rows[0] || { is_completed: false, completed_at: null }; + + return res.status(200).send(new ServerResponse(true, status)); + } } \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/survey-api-router.ts b/worklenz-backend/src/routes/apis/survey-api-router.ts index dbcde5a4..b068294b 100644 --- a/worklenz-backend/src/routes/apis/survey-api-router.ts +++ b/worklenz-backend/src/routes/apis/survey-api-router.ts @@ -8,6 +8,9 @@ const surveyApiRouter = express.Router(); // Get account setup survey with questions surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey)); +// Check if user has completed account setup survey +surveyApiRouter.get("/account-setup/status", safeControllerFunction(SurveyController.checkAccountSetupSurveyStatus)); + // Submit survey response surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse)); diff --git a/worklenz-frontend/public/locales/alb/account-setup.json b/worklenz-frontend/public/locales/alb/account-setup.json index 2f811092..bf2b98a9 100644 --- a/worklenz-frontend/public/locales/alb/account-setup.json +++ b/worklenz-frontend/public/locales/alb/account-setup.json @@ -80,6 +80,8 @@ "discoveryQuestion": "Si dëgjove për ne?", "allSetTitle": "Çdo gjë gati!", "allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz", + "surveyCompleteTitle": "Faleminderit!", + "surveyCompleteDescription": "Përgjigjet tuaja na ndihmojnë të përmirësojmë Worklenz për të gjithë", "aboutYouStepName": "Rreth teje", "yourNeedsStepName": "Nevojat e tua", "discoveryStepName": "Zbulimi", diff --git a/worklenz-frontend/public/locales/de/account-setup.json b/worklenz-frontend/public/locales/de/account-setup.json index 9890bcc0..200d5eb8 100644 --- a/worklenz-frontend/public/locales/de/account-setup.json +++ b/worklenz-frontend/public/locales/de/account-setup.json @@ -90,6 +90,8 @@ "discoveryQuestion": "Wie haben Sie von uns erfahren?", "allSetTitle": "Sie sind bereit!", "allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen", + "surveyCompleteTitle": "Vielen Dank!", + "surveyCompleteDescription": "Ihr Feedback hilft uns, Worklenz für alle zu verbessern", "aboutYouStepName": "Über Sie", "yourNeedsStepName": "Ihre Bedürfnisse", "discoveryStepName": "Entdeckung", diff --git a/worklenz-frontend/public/locales/en/account-setup.json b/worklenz-frontend/public/locales/en/account-setup.json index 22681256..0d03f057 100644 --- a/worklenz-frontend/public/locales/en/account-setup.json +++ b/worklenz-frontend/public/locales/en/account-setup.json @@ -88,6 +88,8 @@ "discoveryQuestion": "How did you hear about us?", "allSetTitle": "You're all set!", "allSetDescription": "Let's create your first project and get started with Worklenz", + "surveyCompleteTitle": "Thank you!", + "surveyCompleteDescription": "Your feedback helps us improve Worklenz for everyone", "aboutYouStepName": "About You", "yourNeedsStepName": "Your Needs", "discoveryStepName": "Discovery", diff --git a/worklenz-frontend/public/locales/es/account-setup.json b/worklenz-frontend/public/locales/es/account-setup.json index 0910f64c..f1c40f08 100644 --- a/worklenz-frontend/public/locales/es/account-setup.json +++ b/worklenz-frontend/public/locales/es/account-setup.json @@ -91,6 +91,8 @@ "discoveryQuestion": "¿Cómo te enteraste de nosotros?", "allSetTitle": "¡Ya estás listo!", "allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz", + "surveyCompleteTitle": "¡Gracias!", + "surveyCompleteDescription": "Tu retroalimentación nos ayuda a mejorar Worklenz para todos", "aboutYouStepName": "Sobre ti", "yourNeedsStepName": "Tus necesidades", "discoveryStepName": "Descubrimiento", diff --git a/worklenz-frontend/public/locales/pt/account-setup.json b/worklenz-frontend/public/locales/pt/account-setup.json index 68cdd224..ca406b97 100644 --- a/worklenz-frontend/public/locales/pt/account-setup.json +++ b/worklenz-frontend/public/locales/pt/account-setup.json @@ -91,6 +91,8 @@ "discoveryQuestion": "Como você soube sobre nós?", "allSetTitle": "Você está pronto!", "allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz", + "surveyCompleteTitle": "Obrigado!", + "surveyCompleteDescription": "Seu feedback nos ajuda a melhorar o Worklenz para todos", "aboutYouStepName": "Sobre você", "yourNeedsStepName": "Suas necessidades", "discoveryStepName": "Descoberta", diff --git a/worklenz-frontend/public/locales/zh/account-setup.json b/worklenz-frontend/public/locales/zh/account-setup.json index 57b8aa25..abca6e81 100644 --- a/worklenz-frontend/public/locales/zh/account-setup.json +++ b/worklenz-frontend/public/locales/zh/account-setup.json @@ -89,6 +89,8 @@ "discoveryQuestion": "您是如何听说我们的?", "allSetTitle": "一切就绪!", "allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧", + "surveyCompleteTitle": "谢谢!", + "surveyCompleteDescription": "您的反馈有助于我们为所有人改进 Worklenz", "aboutYouStepName": "关于您", "yourNeedsStepName": "您的需求", "discoveryStepName": "发现", diff --git a/worklenz-frontend/src/api/survey/survey.api.service.ts b/worklenz-frontend/src/api/survey/survey.api.service.ts index f4c29509..a236bf51 100644 --- a/worklenz-frontend/src/api/survey/survey.api.service.ts +++ b/worklenz-frontend/src/api/survey/survey.api.service.ts @@ -18,5 +18,10 @@ export const surveyApiService = { async getUserSurveyResponse(surveyId: string): Promise> { const response = await apiClient.get>(`${API_BASE_URL}/surveys/responses/${surveyId}`); return response.data; + }, + + async checkAccountSetupSurveyStatus(): Promise> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/account-setup/status`); + return response.data; } }; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/survey-step.tsx b/worklenz-frontend/src/components/account-setup/survey-step.tsx index 2394ac06..17efc3d2 100644 --- a/worklenz-frontend/src/components/account-setup/survey-step.tsx +++ b/worklenz-frontend/src/components/account-setup/survey-step.tsx @@ -20,6 +20,7 @@ interface Props { styles: any; isDarkMode: boolean; token?: any; + isModal?: boolean; // New prop to indicate if used in modal context } interface SurveyPageProps { @@ -29,6 +30,7 @@ interface SurveyPageProps { surveyData: IAccountSetupSurveyData; handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void; handleUseCaseToggle?: (value: UseCase) => void; + isModal?: boolean; } // Page 1: About You @@ -235,7 +237,7 @@ const YourNeedsPage: React.FC = ({ styles, token, surveyData, h }; // Page 3: Discovery -const DiscoveryPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange }) => { +const DiscoveryPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange, isModal }) => { const { t } = useTranslation('account-setup'); const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [ @@ -291,14 +293,18 @@ const DiscoveryPage: React.FC = ({ styles, token, surveyData, h
🎉
- {t('allSetTitle')} - {t('allSetDescription')} + + {isModal ? t('surveyCompleteTitle') : t('allSetTitle')} + + + {isModal ? t('surveyCompleteDescription') : t('allSetDescription')} +
); }; -export const SurveyStep: React.FC = ({ onEnter, styles, isDarkMode, token }) => { +export const SurveyStep: React.FC = ({ onEnter, styles, isDarkMode, token, isModal = false }) => { const { t } = useTranslation('account-setup'); const dispatch = useDispatch(); const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer); @@ -339,9 +345,9 @@ export const SurveyStep: React.FC = ({ onEnter, styles, isDarkMode, token }; const surveyPages = [ - , - , - + , + , + ]; React.useEffect(() => { diff --git a/worklenz-frontend/src/components/survey/SurveyPromptModal.tsx b/worklenz-frontend/src/components/survey/SurveyPromptModal.tsx new file mode 100644 index 00000000..63453a85 --- /dev/null +++ b/worklenz-frontend/src/components/survey/SurveyPromptModal.tsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Modal, Button, Result, Spin, Flex } from '@/shared/antd-imports'; +import { SurveyStep } from '@/components/account-setup/survey-step'; +import { useSurveyStatus } from '@/hooks/useSurveyStatus'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { surveyApiService } from '@/api/survey/survey.api.service'; +import { appMessage } from '@/shared/antd-imports'; +import { ISurveySubmissionRequest } from '@/types/account-setup/survey.types'; +import logger from '@/utils/errorLogger'; +import { resetSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice'; + +interface SurveyPromptModalProps { + forceShow?: boolean; + onClose?: () => void; +} + +export const SurveyPromptModal: React.FC = ({ forceShow = false, onClose }) => { + const { t } = useTranslation('survey'); + const dispatch = useAppDispatch(); + const [visible, setVisible] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [surveyCompleted, setSurveyCompleted] = useState(false); + const [surveyInfo, setSurveyInfo] = useState<{ id: string; questions: any[] } | null>(null); + const { hasCompletedSurvey, loading, refetch } = useSurveyStatus(); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const surveyData = useAppSelector(state => state.accountSetupReducer.surveyData); + const surveySubStep = useAppSelector(state => state.accountSetupReducer.surveySubStep); + const isDarkMode = themeMode === 'dark'; + + useEffect(() => { + if (forceShow) { + setVisible(true); + dispatch(resetSurveyData()); + dispatch(setSurveySubStep(0)); + + // Fetch survey info + const fetchSurvey = async () => { + try { + const response = await surveyApiService.getAccountSetupSurvey(); + if (response.done && response.body) { + setSurveyInfo({ + id: response.body.id, + questions: response.body.questions || [] + }); + } + } catch (error) { + logger.error('Failed to fetch survey', error); + } + }; + + fetchSurvey(); + } else if (!loading && hasCompletedSurvey === false) { + // Reset survey data when modal will be shown + dispatch(resetSurveyData()); + dispatch(setSurveySubStep(0)); + + // Fetch survey info + const fetchSurvey = async () => { + try { + const response = await surveyApiService.getAccountSetupSurvey(); + if (response.done && response.body) { + setSurveyInfo({ + id: response.body.id, + questions: response.body.questions || [] + }); + } + } catch (error) { + logger.error('Failed to fetch survey', error); + } + }; + + fetchSurvey(); + + // Show modal after a 5 second delay to not interrupt user immediately + const timer = setTimeout(() => { + setVisible(true); + }, 5000); + + return () => clearTimeout(timer); + } + }, [loading, hasCompletedSurvey, dispatch, forceShow]); + + const handleComplete = async () => { + try { + setSubmitting(true); + + if (!surveyData || !surveyInfo) { + throw new Error('Survey data not found'); + } + + // Create a map of question keys to IDs + const questionMap = surveyInfo.questions.reduce((acc, q) => { + acc[q.question_key] = q.id; + return acc; + }, {} as Record); + + // Prepare submission data with actual question IDs + const submissionData: ISurveySubmissionRequest = { + survey_id: surveyInfo.id, + answers: [ + { + question_id: questionMap['organization_type'], + answer_text: surveyData.organization_type || '' + }, + { + question_id: questionMap['user_role'], + answer_text: surveyData.user_role || '' + }, + { + question_id: questionMap['main_use_cases'], + answer_json: surveyData.main_use_cases || [] + }, + { + question_id: questionMap['previous_tools'], + answer_text: surveyData.previous_tools || '' + }, + { + question_id: questionMap['how_heard_about'], + answer_text: surveyData.how_heard_about || '' + } + ].filter(answer => answer.question_id) // Filter out any missing question IDs + }; + + const response = await surveyApiService.submitSurveyResponse(submissionData); + + if (response.done) { + setSurveyCompleted(true); + appMessage.success('Thank you for completing the survey!'); + + // Wait a moment before closing + setTimeout(() => { + setVisible(false); + refetch(); // Update the survey status + }, 2000); + } else { + throw new Error(response.message || 'Failed to submit survey'); + } + } catch (error) { + logger.error('Failed to submit survey', error); + appMessage.error('Failed to submit survey. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleSkip = () => { + setVisible(false); + // Optionally, you can set a flag in localStorage to not show again for some time + localStorage.setItem('survey_skipped_at', new Date().toISOString()); + onClose?.(); + }; + + const isCurrentStepValid = () => { + switch (surveySubStep) { + case 0: + return surveyData.organization_type && surveyData.user_role; + case 1: + return surveyData.main_use_cases && surveyData.main_use_cases.length > 0; + case 2: + return surveyData.how_heard_about; + default: + return false; + } + }; + + const handleNext = () => { + if (surveySubStep < 2) { + dispatch(setSurveySubStep(surveySubStep + 1)); + } else { + handleComplete(); + } + }; + + const handlePrevious = () => { + if (surveySubStep > 0) { + dispatch(setSurveySubStep(surveySubStep - 1)); + } + }; + + if (loading) { + return null; + } + + return ( + +
+ +
+ + {surveySubStep > 0 && ( + + )} + + + + ) + } + width={800} + maskClosable={false} + centered + > + {submitting ? ( +
+ +

Submitting your responses...

+
+ ) : surveyCompleted ? ( + + ) : ( +
+ {}} // Empty function since we handle navigation via buttons + styles={{}} + isDarkMode={isDarkMode} + isModal={true} // Pass true to indicate modal context + /> +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/survey/SurveySettingsCard.tsx b/worklenz-frontend/src/components/survey/SurveySettingsCard.tsx new file mode 100644 index 00000000..eec3aa2c --- /dev/null +++ b/worklenz-frontend/src/components/survey/SurveySettingsCard.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { Card, Button, Result, Alert } from '@/shared/antd-imports'; +import { CheckCircleOutlined, FormOutlined } from '@/shared/antd-imports'; +import { useSurveyStatus } from '@/hooks/useSurveyStatus'; +import { SurveyPromptModal } from './SurveyPromptModal'; +import { useTranslation } from 'react-i18next'; + +export const SurveySettingsCard: React.FC = () => { + const { t } = useTranslation('settings'); + const [showModal, setShowModal] = useState(false); + const { hasCompletedSurvey, loading } = useSurveyStatus(); + + if (loading) { + return ( + + ); + } + + return ( + <> + + + Personalization Survey + + } + extra={ + hasCompletedSurvey && ( + + ) + } + > + {hasCompletedSurvey ? ( + } + title="Survey Completed" + subTitle="Thank you for completing the personalization survey. Your responses help us improve Worklenz." + extra={ + + } + /> + ) : ( + <> + +
+ +
+ + )} +
+ + {showModal && ( + setShowModal(false)} + /> + )} + + ); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/survey/index.ts b/worklenz-frontend/src/components/survey/index.ts new file mode 100644 index 00000000..b0e2d39f --- /dev/null +++ b/worklenz-frontend/src/components/survey/index.ts @@ -0,0 +1,2 @@ +export { SurveyPromptModal } from './SurveyPromptModal'; +export { SurveySettingsCard } from './SurveySettingsCard'; \ No newline at end of file diff --git a/worklenz-frontend/src/features/account-setup/account-setup.slice.ts b/worklenz-frontend/src/features/account-setup/account-setup.slice.ts index 52595f7f..bf3d17c3 100644 --- a/worklenz-frontend/src/features/account-setup/account-setup.slice.ts +++ b/worklenz-frontend/src/features/account-setup/account-setup.slice.ts @@ -61,6 +61,10 @@ const accountSetupSlice = createSlice({ setSurveySubStep: (state, action: PayloadAction) => { state.surveySubStep = action.payload; }, + resetSurveyData: (state) => { + state.surveyData = {}; + state.surveySubStep = 0; + }, resetAccountSetup: () => initialState, }, }); @@ -74,6 +78,7 @@ export const { setCurrentStep, setSurveyData, setSurveySubStep, + resetSurveyData, resetAccountSetup, } = accountSetupSlice.actions; diff --git a/worklenz-frontend/src/hooks/useSurveyStatus.ts b/worklenz-frontend/src/hooks/useSurveyStatus.ts new file mode 100644 index 00000000..06663e0c --- /dev/null +++ b/worklenz-frontend/src/hooks/useSurveyStatus.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; +import { surveyApiService } from '@/api/survey/survey.api.service'; +import logger from '@/utils/errorLogger'; + +export interface UseSurveyStatusResult { + hasCompletedSurvey: boolean | null; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +export const useSurveyStatus = (): UseSurveyStatusResult => { + const [hasCompletedSurvey, setHasCompletedSurvey] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const checkSurveyStatus = async () => { + try { + setLoading(true); + setError(null); + + const response = await surveyApiService.checkAccountSetupSurveyStatus(); + + if (response.done) { + setHasCompletedSurvey(response.body.is_completed); + } else { + setHasCompletedSurvey(false); + } + } catch (err) { + logger.error('Failed to check survey status', err); + setError(err as Error); + // Assume not completed if there's an error + setHasCompletedSurvey(false); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + checkSurveyStatus(); + }, []); + + return { + hasCompletedSurvey, + loading, + error, + refetch: checkSurveyStatus + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index 72d06bf6..fc3874dd 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -27,6 +27,7 @@ const SIDEBAR_MAX_WIDTH = 400; // Lazy load heavy components const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer')); +const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))); const HomePage = memo(() => { const dispatch = useAppDispatch(); @@ -142,6 +143,11 @@ const HomePage = memo(() => { document.body, 'project-drawer' )} + + {/* Survey Modal - only shown to users who haven't completed it */} + + + ); });