feat(surveys): implement account setup survey functionality

- Added new database migration to create survey-related tables for storing questions and responses.
- Developed SurveyController to handle fetching and submitting survey data.
- Created survey API routes for account setup, including endpoints for retrieving the survey and submitting responses.
- Implemented frontend components for displaying the survey and capturing user responses, integrating with Redux for state management.
- Enhanced localization files to include survey-related text for multiple languages.
- Added validation middleware for survey submissions to ensure data integrity.
This commit is contained in:
chamikaJ
2025-07-24 17:12:47 +05:30
parent 15ff69a031
commit fe7c15ced1
22 changed files with 1344 additions and 204 deletions

View File

@@ -27,5 +27,43 @@
"formTitle": "Krijoni detyrën tuaj të parë.",
"step3Title": "Fto ekipin tënd të punojë me",
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
"maxTasks": " (Mund të krijoni deri në 5 detyra)",
"surveyStepTitle": "Na tregoni për ju",
"surveyStepLabel": "Na ndihmoni të personalizojmë eksperiencën tuaj në Worklenz duke përgjigjur disa pyetjeve.",
"organizationType": "Cila përshkruan më mirë organizatën tuaj?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Biznes i Vogël ose i Mesmu",
"organizationTypeAgency": "Agjensi",
"organizationTypeEnterprise": "Ndërmarrje",
"organizationTypeOther": "Tjetër",
"userRole": "Cili është roli juaj?",
"userRoleFounderCeo": "Themeluesi / CEO",
"userRoleProjectManager": "Menaxheri i Projektit",
"userRoleSoftwareDeveloper": "Zhvilluesi i Software-it",
"userRoleDesigner": "Dizajneri",
"userRoleOperations": "Operacionet",
"userRoleOther": "Tjetër",
"mainUseCases": "Për çfarë do ta përdorni kryësisht Worklenz?",
"mainUseCasesTaskManagement": "Menaxhimi i detyrave",
"mainUseCasesTeamCollaboration": "Bashkëpunimi i ekipit",
"mainUseCasesResourcePlanning": "Planifikimi i burimeve",
"mainUseCasesClientCommunication": "Komunikimi me klientët & raportet",
"mainUseCasesTimeTracking": "Ndjekja e kohës",
"mainUseCasesOther": "Tjetër",
"previousTools": "Cilat vegla përdornit para Worklenz?",
"previousToolsPlaceholder": "p.sh. Trello, Asana, Monday.com",
"howHeardAbout": "Si dëgjuat për Worklenz?",
"howHeardAboutGoogleSearch": "Kërkimi Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Një miku ose kolegu",
"howHeardAboutBlogArticle": "Një blog ose artikulli",
"howHeardAboutOther": "Tjetër"
}

View File

@@ -27,5 +27,43 @@
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)"
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)",
"surveyStepTitle": "Erzählen Sie uns von sich",
"surveyStepLabel": "Helfen Sie uns, Ihre Worklenz-Erfahrung zu personalisieren, indem Sie ein paar Fragen beantworten.",
"organizationType": "Was beschreibt Ihre Organisation am besten?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Kleines oder mittleres Unternehmen",
"organizationTypeAgency": "Agentur",
"organizationTypeEnterprise": "Unternehmen",
"organizationTypeOther": "Andere",
"userRole": "Was ist Ihre Rolle?",
"userRoleFounderCeo": "Gründer / CEO",
"userRoleProjectManager": "Projektmanager",
"userRoleSoftwareDeveloper": "Software-Entwickler",
"userRoleDesigner": "Designer",
"userRoleOperations": "Betrieb",
"userRoleOther": "Andere",
"mainUseCases": "Wofür werden Sie Worklenz hauptsächlich verwenden?",
"mainUseCasesTaskManagement": "Aufgabenverwaltung",
"mainUseCasesTeamCollaboration": "Teamzusammenarbeit",
"mainUseCasesResourcePlanning": "Ressourcenplanung",
"mainUseCasesClientCommunication": "Kundenkommunikation & Berichterstattung",
"mainUseCasesTimeTracking": "Zeiterfassung",
"mainUseCasesOther": "Andere",
"previousTools": "Welche Tools haben Sie vor Worklenz verwendet?",
"previousToolsPlaceholder": "z.B. Trello, Asana, Monday.com",
"howHeardAbout": "Wie haben Sie von Worklenz erfahren?",
"howHeardAboutGoogleSearch": "Google-Suche",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Ein Freund oder Kollege",
"howHeardAboutBlogArticle": "Ein Blog oder Artikel",
"howHeardAboutOther": "Andere"
}

View File

@@ -27,5 +27,43 @@
"formTitle": "Create your first task.",
"step3Title": "Invite your team to work with",
"maxMembers": " (You can invite up to 5 members)",
"maxTasks": " (You can create up to 5 tasks)"
"maxTasks": " (You can create up to 5 tasks)",
"surveyStepTitle": "Tell us about yourself",
"surveyStepLabel": "Help us personalize your Worklenz experience by answering a few questions.",
"organizationType": "What best describes your organization?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Small or Medium Business",
"organizationTypeAgency": "Agency",
"organizationTypeEnterprise": "Enterprise",
"organizationTypeOther": "Other",
"userRole": "What is your role?",
"userRoleFounderCeo": "Founder / CEO",
"userRoleProjectManager": "Project Manager",
"userRoleSoftwareDeveloper": "Software Developer",
"userRoleDesigner": "Designer",
"userRoleOperations": "Operations",
"userRoleOther": "Other",
"mainUseCases": "What will you mainly use Worklenz for?",
"mainUseCasesTaskManagement": "Task management",
"mainUseCasesTeamCollaboration": "Team collaboration",
"mainUseCasesResourcePlanning": "Resource planning",
"mainUseCasesClientCommunication": "Client communication & reporting",
"mainUseCasesTimeTracking": "Time tracking",
"mainUseCasesOther": "Other",
"previousTools": "What tool(s) were you using before Worklenz?",
"previousToolsPlaceholder": "e.g. Trello, Asana, Monday.com",
"howHeardAbout": "How did you hear about Worklenz?",
"howHeardAboutGoogleSearch": "Google Search",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "A friend or colleague",
"howHeardAboutBlogArticle": "A blog or article",
"howHeardAboutOther": "Other"
}

View File

@@ -28,5 +28,43 @@
"step3Title": "Invita a tu equipo a trabajar",
"maxMembers": " (Puedes invitar hasta 5 miembros)",
"maxTasks": " (Puedes crear hasta 5 tareas)"
"maxTasks": " (Puedes crear hasta 5 tareas)",
"surveyStepTitle": "Cuéntanos sobre ti",
"surveyStepLabel": "Ayúdanos a personalizar tu experiencia de Worklenz respondiendo algunas preguntas.",
"organizationType": "¿Qué describe mejor tu organización?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Pequeña o Mediana Empresa",
"organizationTypeAgency": "Agencia",
"organizationTypeEnterprise": "Empresa",
"organizationTypeOther": "Otro",
"userRole": "¿Cuál es tu rol?",
"userRoleFounderCeo": "Fundador / CEO",
"userRoleProjectManager": "Gerente de Proyecto",
"userRoleSoftwareDeveloper": "Desarrollador de Software",
"userRoleDesigner": "Diseñador",
"userRoleOperations": "Operaciones",
"userRoleOther": "Otro",
"mainUseCases": "¿Para qué usarás principalmente Worklenz?",
"mainUseCasesTaskManagement": "Gestión de tareas",
"mainUseCasesTeamCollaboration": "Colaboración de equipo",
"mainUseCasesResourcePlanning": "Planificación de recursos",
"mainUseCasesClientCommunication": "Comunicación con clientes e informes",
"mainUseCasesTimeTracking": "Seguimiento de tiempo",
"mainUseCasesOther": "Otro",
"previousTools": "¿Qué herramienta(s) usabas antes de Worklenz?",
"previousToolsPlaceholder": "ej. Trello, Asana, Monday.com",
"howHeardAbout": "¿Cómo conociste Worklenz?",
"howHeardAboutGoogleSearch": "Búsqueda de Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Un amigo o colega",
"howHeardAboutBlogArticle": "Un blog o artículo",
"howHeardAboutOther": "Otro"
}

View File

@@ -28,5 +28,43 @@
"step3Title": "Convide sua equipe para trabalhar",
"maxMembers": " (Você pode convidar até 5 membros)",
"maxTasks": " (Você pode criar até 5 tarefas)"
"maxTasks": " (Você pode criar até 5 tarefas)",
"surveyStepTitle": "Conte-nos sobre você",
"surveyStepLabel": "Ajude-nos a personalizar sua experiência no Worklenz respondendo algumas perguntas.",
"organizationType": "O que melhor descreve sua organização?",
"organizationTypeFreelancer": "Freelancer",
"organizationTypeStartup": "Startup",
"organizationTypeSmallMediumBusiness": "Pequena ou Média Empresa",
"organizationTypeAgency": "Agência",
"organizationTypeEnterprise": "Empresa",
"organizationTypeOther": "Outro",
"userRole": "Qual é o seu papel?",
"userRoleFounderCeo": "Fundador / CEO",
"userRoleProjectManager": "Gerente de Projeto",
"userRoleSoftwareDeveloper": "Desenvolvedor de Software",
"userRoleDesigner": "Designer",
"userRoleOperations": "Operações",
"userRoleOther": "Outro",
"mainUseCases": "Para que você usará principalmente o Worklenz?",
"mainUseCasesTaskManagement": "Gerenciamento de tarefas",
"mainUseCasesTeamCollaboration": "Colaboração em equipe",
"mainUseCasesResourcePlanning": "Planejamento de recursos",
"mainUseCasesClientCommunication": "Comunicação com clientes e relatórios",
"mainUseCasesTimeTracking": "Controle de tempo",
"mainUseCasesOther": "Outro",
"previousTools": "Que ferramenta(s) você usava antes do Worklenz?",
"previousToolsPlaceholder": "ex. Trello, Asana, Monday.com",
"howHeardAbout": "Como você soube do Worklenz?",
"howHeardAboutGoogleSearch": "Busca no Google",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "Um amigo ou colega",
"howHeardAboutBlogArticle": "Um blog ou artigo",
"howHeardAboutOther": "Outro"
}

View File

@@ -23,5 +23,43 @@
"formTitle": "创建您的第一个任务。",
"step3Title": "邀请您的团队一起工作",
"maxMembers": "您最多可以邀请5名成员",
"maxTasks": "您最多可以创建5个任务"
"maxTasks": "您最多可以创建5个任务",
"surveyStepTitle": "告诉我们关于您的信息",
"surveyStepLabel": "通过回答几个问题帮助我们个性化您的Worklenz体验。",
"organizationType": "什么最能描述您的组织?",
"organizationTypeFreelancer": "自由职业者",
"organizationTypeStartup": "初创公司",
"organizationTypeSmallMediumBusiness": "中小企业",
"organizationTypeAgency": "代理机构",
"organizationTypeEnterprise": "企业",
"organizationTypeOther": "其他",
"userRole": "您的角色是什么?",
"userRoleFounderCeo": "创始人/CEO",
"userRoleProjectManager": "项目经理",
"userRoleSoftwareDeveloper": "软件开发者",
"userRoleDesigner": "设计师",
"userRoleOperations": "运营",
"userRoleOther": "其他",
"mainUseCases": "您主要将Worklenz用于什么",
"mainUseCasesTaskManagement": "任务管理",
"mainUseCasesTeamCollaboration": "团队协作",
"mainUseCasesResourcePlanning": "资源规划",
"mainUseCasesClientCommunication": "客户沟通和报告",
"mainUseCasesTimeTracking": "时间跟踪",
"mainUseCasesOther": "其他",
"previousTools": "您在使用Worklenz之前使用什么工具",
"previousToolsPlaceholder": "例如Trello、Asana、Monday.com",
"howHeardAbout": "您是如何了解Worklenz的",
"howHeardAboutGoogleSearch": "Google搜索",
"howHeardAboutTwitter": "Twitter",
"howHeardAboutLinkedin": "LinkedIn",
"howHeardAboutFriendColleague": "朋友或同事",
"howHeardAboutBlogArticle": "博客或文章",
"howHeardAboutOther": "其他"
}

View File

@@ -0,0 +1,22 @@
import { IServerResponse } from '@/types/common.types';
import { ISurvey, ISurveySubmissionRequest, ISurveyResponse } from '@/types/account-setup/survey.types';
import apiClient from '../api-client';
const API_BASE_URL = '/api';
export const surveyApiService = {
async getAccountSetupSurvey(): Promise<IServerResponse<ISurvey>> {
const response = await apiClient.get<IServerResponse<ISurvey>>(`${API_BASE_URL}/surveys/account-setup`);
return response.data;
},
async submitSurveyResponse(data: ISurveySubmissionRequest): Promise<IServerResponse<{ response_id: string }>> {
const response = await apiClient.post<IServerResponse<{ response_id: string }>>(`${API_BASE_URL}/surveys/responses`, data);
return response.data;
},
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
return response.data;
}
};

View File

@@ -13,19 +13,25 @@ interface Props {
onEnter: () => void;
styles: any;
organizationNamePlaceholder: string;
organizationNameInitialValue?: string;
}
export const OrganizationStep: React.FC<Props> = ({
onEnter,
styles,
organizationNamePlaceholder,
organizationNameInitialValue,
}) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRef = useRef<InputRef>(null);
// Autofill organization name if not already set
useEffect(() => {
if (!organizationName && organizationNameInitialValue) {
dispatch(setOrganizationName(organizationNameInitialValue));
}
setTimeout(() => inputRef.current?.focus(), 300);
}, []);

View File

@@ -0,0 +1,210 @@
import React from 'react';
import { Form, Input, Typography, Button } from '@/shared/antd-imports';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setSurveyData } from '@/features/account-setup/account-setup.slice';
import { RootState } from '@/app/store';
import {
OrganizationType,
UserRole,
UseCase,
HowHeardAbout,
IAccountSetupSurveyData
} from '@/types/account-setup/survey.types';
const { Title } = Typography;
const { TextArea } = Input;
interface Props {
onEnter: () => void;
styles: any;
isDarkMode: boolean;
token?: any;
}
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { surveyData } = useSelector((state: RootState) => state.accountSetupReducer);
const handleSurveyDataChange = (field: keyof IAccountSetupSurveyData, value: any) => {
dispatch(setSurveyData({ [field]: value }));
};
// Get Ant Design button type based on selection state
const getButtonType = (isSelected: boolean) => {
return isSelected ? 'primary' : 'default';
};
// Handle multi-select for use cases (button-based)
const handleUseCaseToggle = (value: UseCase) => {
const currentUseCases = surveyData.main_use_cases || [];
const isSelected = currentUseCases.includes(value);
let newUseCases;
if (isSelected) {
// Remove if already selected
newUseCases = currentUseCases.filter(useCase => useCase !== value);
} else {
// Add if not selected
newUseCases = [...currentUseCases, value];
}
handleSurveyDataChange('main_use_cases', newUseCases);
};
const onPressEnter = () => {
onEnter();
};
const organizationTypeOptions: { value: OrganizationType; label: string }[] = [
{ value: 'freelancer', label: t('organizationTypeFreelancer') },
{ value: 'startup', label: t('organizationTypeStartup') },
{ value: 'small_medium_business', label: t('organizationTypeSmallMediumBusiness') },
{ value: 'agency', label: t('organizationTypeAgency') },
{ value: 'enterprise', label: t('organizationTypeEnterprise') },
{ value: 'other', label: t('organizationTypeOther') },
];
const userRoleOptions: { value: UserRole; label: string }[] = [
{ value: 'founder_ceo', label: t('userRoleFounderCeo') },
{ value: 'project_manager', label: t('userRoleProjectManager') },
{ value: 'software_developer', label: t('userRoleSoftwareDeveloper') },
{ value: 'designer', label: t('userRoleDesigner') },
{ value: 'operations', label: t('userRoleOperations') },
{ value: 'other', label: t('userRoleOther') },
];
const useCaseOptions: { value: UseCase; label: string }[] = [
{ value: 'task_management', label: t('mainUseCasesTaskManagement') },
{ value: 'team_collaboration', label: t('mainUseCasesTeamCollaboration') },
{ value: 'resource_planning', label: t('mainUseCasesResourcePlanning') },
{ value: 'client_communication', label: t('mainUseCasesClientCommunication') },
{ value: 'time_tracking', label: t('mainUseCasesTimeTracking') },
{ value: 'other', label: t('mainUseCasesOther') },
];
const howHeardAboutOptions: { value: HowHeardAbout; label: string }[] = [
{ value: 'google_search', label: t('howHeardAboutGoogleSearch') },
{ value: 'twitter', label: t('howHeardAboutTwitter') },
{ value: 'linkedin', label: t('howHeardAboutLinkedin') },
{ value: 'friend_colleague', label: t('howHeardAboutFriendColleague') },
{ value: 'blog_article', label: t('howHeardAboutBlogArticle') },
{ value: 'other', label: t('howHeardAboutOther') },
];
return (
<Form className="step-form" style={styles.form}>
<Form.Item className="mb-6">
<Title level={2} className="mb-2 text-2xl" style={{ color: token?.colorText }}>
{t('surveyStepTitle')}
</Title>
<p className="mb-4 text-sm" style={{ color: token?.colorTextSecondary }}>
{t('surveyStepLabel')}
</p>
</Form.Item>
{/* Organization Type */}
<Form.Item
label={<span className="font-medium text-sm" style={{ color: token?.colorText }}>{t('organizationType')}</span>}
className="mb-6"
>
<div className="mt-3 flex flex-wrap gap-2">
{organizationTypeOptions.map((option) => (
<Button
key={option.value}
onClick={() => handleSurveyDataChange('organization_type', option.value)}
type={getButtonType(surveyData.organization_type === option.value)}
size="small"
className="h-8"
>
{option.label}
</Button>
))}
</div>
</Form.Item>
{/* User Role */}
<Form.Item
label={<span className="font-medium text-sm" style={{ color: token?.colorText }}>{t('userRole')}</span>}
className="mb-6"
>
<div className="mt-3 flex flex-wrap gap-2">
{userRoleOptions.map((option) => (
<Button
key={option.value}
onClick={() => handleSurveyDataChange('user_role', option.value)}
type={getButtonType(surveyData.user_role === option.value)}
size="small"
className="h-8"
>
{option.label}
</Button>
))}
</div>
</Form.Item>
{/* Main Use Cases */}
<Form.Item
label={<span className="font-medium text-sm" style={{ color: token?.colorText }}>{t('mainUseCases')}</span>}
className="mb-6"
>
<div className="mt-3 flex flex-wrap gap-2">
{useCaseOptions.map((option) => {
const isSelected = (surveyData.main_use_cases || []).includes(option.value);
return (
<Button
key={option.value}
onClick={() => handleUseCaseToggle(option.value)}
type={getButtonType(isSelected)}
size="small"
className="h-8"
>
{option.label}
</Button>
);
})}
</div>
</Form.Item>
{/* Previous Tools */}
<Form.Item
label={<span className="font-medium text-sm" style={{ color: token?.colorText }}>{t('previousTools')}</span>}
className="mb-6"
>
<TextArea
placeholder={t('previousToolsPlaceholder')}
value={surveyData.previous_tools || ''}
onChange={(e) => handleSurveyDataChange('previous_tools', e.target.value)}
autoSize={{ minRows: 2, maxRows: 3 }}
className="mt-2 text-sm"
style={{
backgroundColor: token?.colorBgContainer,
borderColor: token?.colorBorder,
color: token?.colorText
}}
/>
</Form.Item>
{/* How Heard About */}
<Form.Item
label={<span className="font-medium text-sm" style={{ color: token?.colorText }}>{t('howHeardAbout')}</span>}
className="mb-2"
>
<div className="mt-3 flex flex-wrap gap-2">
{howHeardAboutOptions.map((option) => (
<Button
key={option.value}
onClick={() => handleSurveyDataChange('how_heard_about', option.value)}
type={getButtonType(surveyData.how_heard_about === option.value)}
size="small"
className="h-8"
>
{option.label}
</Button>
))}
</div>
</Form.Item>
</Form>
);
};

View File

@@ -1,4 +1,5 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IAccountSetupSurveyData } from '@/types/account-setup/survey.types';
interface Task {
id: number;
@@ -17,6 +18,7 @@ interface AccountSetupState {
tasks: Task[];
teamMembers: Email[];
currentStep: number;
surveyData: IAccountSetupSurveyData;
}
const initialState: AccountSetupState = {
@@ -26,6 +28,7 @@ const initialState: AccountSetupState = {
tasks: [{ id: 0, value: '' }],
teamMembers: [{ id: 0, value: '' }],
currentStep: 0,
surveyData: {},
};
const accountSetupSlice = createSlice({
@@ -50,6 +53,9 @@ const accountSetupSlice = createSlice({
setCurrentStep: (state, action: PayloadAction<number>) => {
state.currentStep = action.payload;
},
setSurveyData: (state, action: PayloadAction<Partial<IAccountSetupSurveyData>>) => {
state.surveyData = { ...state.surveyData, ...action.payload };
},
resetAccountSetup: () => initialState,
},
});
@@ -61,6 +67,7 @@ export const {
setTasks,
setTeamMembers,
setCurrentStep,
setSurveyData,
resetAccountSetup,
} = accountSetupSlice.actions;

View File

@@ -1,5 +1,7 @@
/* Steps styling - using Ant Design theme tokens */
.ant-steps-item-finish .ant-steps-item-icon {
border-color: #1890ff !important;
border-color: var(--ant-color-primary) !important;
background-color: var(--ant-color-primary) !important;
}
.ant-steps-item-icon {
@@ -9,37 +11,48 @@
font-size: 16px !important;
line-height: 32px !important;
text-align: center !important;
border: 1px solid rgba(0, 0, 0, 0.25) !important;
border: 1px solid var(--ant-color-border) !important;
border-radius: 32px !important;
transition:
background-color 0.3s,
border-color 0.3s !important;
transition: all 0.3s !important;
background-color: var(--ant-color-bg-container) !important;
color: var(--ant-color-text) !important;
}
.ant-steps-item-wait .ant-steps-item-icon {
cursor: not-allowed;
}
.dark-mode .ant-steps-item-wait .ant-steps-item-icon {
cursor: not-allowed;
opacity: 0.6;
}
.progress-steps .ant-steps-item.ant-steps-item-process .ant-steps-item-title::after {
background-color: #1677ff !important;
background-color: var(--ant-color-primary) !important;
width: 60px !important;
}
/* Steps title styling */
.ant-steps-item-title {
color: var(--ant-color-text) !important;
}
.ant-steps-item-description {
color: var(--ant-color-text-secondary) !important;
}
/* Responsive design improvements */
@media (max-width: 1000px) {
.progress-steps {
width: 400px !important;
}
.step {
width: 400px !important;
}
.progress-steps,
.step,
.step-content {
width: 400px !important;
width: 90% !important;
max-width: 500px !important;
}
}
@media (max-width: 768px) {
.progress-steps,
.step,
.step-content {
width: 95% !important;
max-width: 400px !important;
}
}
@@ -53,44 +66,51 @@
}
.step-content {
width: 200px !important;
width: 100% !important;
max-width: 300px !important;
padding: 0 1rem !important;
}
}
@media (max-width: 1000px) {
.organization-name-form {
width: 400px !important;
.organization-name-form,
.first-project-form,
.create-first-task-form {
width: 90% !important;
max-width: 500px !important;
}
}
@media (max-width: 768px) {
.organization-name-form,
.first-project-form,
.create-first-task-form {
width: 95% !important;
max-width: 400px !important;
}
}
@media (max-width: 500px) {
.organization-name-form {
width: 200px !important;
.organization-name-form,
.first-project-form,
.create-first-task-form {
width: 100% !important;
max-width: 300px !important;
}
}
.vert-text {
max-width: 40px;
background-color: #fff;
background-color: var(--ant-color-bg-container);
color: var(--ant-color-text);
position: relative;
z-index: 99;
margin-left: auto;
margin-right: auto;
margin: 2rem auto;
text-align: center;
display: flex;
justify-content: center;
margin-top: 2rem;
}
.vert-text-dark {
max-width: 40px;
background-color: #141414;
position: relative;
z-index: 99;
margin-left: auto;
margin-right: auto;
text-align: center;
justify-content: center;
margin-top: 2rem;
align-items: center;
padding: 0.5rem;
}
.vert-line {
@@ -98,53 +118,105 @@
left: 0;
right: 0;
width: 100%;
content: "";
height: 2px;
background-color: #00000047;
bottom: 0;
top: 0;
margin-bottom: auto;
margin-top: auto;
height: 1px;
background-color: var(--ant-color-border);
top: 50%;
transform: translateY(-50%);
}
/* Legacy dark mode classes for backward compatibility */
.dark-mode .vert-text,
.vert-text-dark {
background-color: var(--ant-color-bg-container);
color: var(--ant-color-text);
}
.dark-mode .vert-line,
.vert-line-dark {
position: absolute;
left: 0;
right: 0;
width: 100%;
content: "";
height: 2px;
background-color: white;
bottom: 0;
top: 0;
margin-bottom: auto;
margin-top: auto;
}
@media (max-width: 1000px) {
.first-project-form {
width: 400px !important;
}
}
@media (max-width: 500px) {
.first-project-form {
width: 200px !important;
}
background-color: var(--ant-color-border);
}
/* Button and component improvements */
.custom-close-button:hover {
background-color: transparent !important;
}
@media (max-width: 1000px) {
.create-first-task-form {
width: 400px !important;
}
.setup-action-buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 500px) {
.create-first-task-form {
width: 200px !important;
}
.setup-action-buttons .ant-btn {
min-height: 40px;
font-weight: 500;
}
/* Form styling with Ant Design theme tokens */
.step-form .ant-form-item-label > label {
color: var(--ant-color-text) !important;
}
.step-form .ant-input,
.step-form .ant-input-affix-wrapper {
background-color: var(--ant-color-bg-container) !important;
border-color: var(--ant-color-border) !important;
color: var(--ant-color-text) !important;
}
.step-form .ant-input:focus,
.step-form .ant-input-affix-wrapper:focus,
.step-form .ant-input-affix-wrapper-focused {
background-color: var(--ant-color-bg-container) !important;
border-color: var(--ant-color-primary) !important;
color: var(--ant-color-text) !important;
}
.step-form .ant-input::placeholder {
color: var(--ant-color-text-placeholder) !important;
}
/* Select styling */
.step-form .ant-select-selector {
background-color: var(--ant-color-bg-container) !important;
border-color: var(--ant-color-border) !important;
color: var(--ant-color-text) !important;
}
.step-form .ant-select-selection-placeholder {
color: var(--ant-color-text-placeholder) !important;
}
/* Typography */
.step-form .ant-typography {
color: var(--ant-color-text);
}
.step-form .ant-typography.ant-typography-secondary {
color: var(--ant-color-text-secondary);
}
/* Card styling */
.step-form .ant-card {
background-color: var(--ant-color-bg-container);
border-color: var(--ant-color-border);
}
.step-form .ant-card-body {
color: var(--ant-color-text);
}
/* Checkbox and Radio styling */
.step-form .ant-checkbox-wrapper {
color: var(--ant-color-text);
}
.step-form .ant-radio-wrapper {
color: var(--ant-color-text);
}
/* Smooth transitions for theme switching */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

View File

@@ -2,13 +2,14 @@ import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Space, Steps, Button, Typography } from 'antd/es';
import { Space, Steps, Button, Typography, theme } from '@/shared/antd-imports';
import logger from '@/utils/errorLogger';
import { setCurrentStep } from '@/features/account-setup/account-setup.slice';
import { OrganizationStep } from '@/components/account-setup/organization-step';
import { ProjectStep } from '@/components/account-setup/project-step';
import { TasksStep } from '@/components/account-setup/tasks-step';
import { SurveyStep } from '@/components/account-setup/survey-step';
import MembersStep from '@/components/account-setup/members-step';
import {
evt_account_setup_complete,
@@ -31,23 +32,104 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import './account-setup.css';
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { surveyApiService } from '@/api/survey/survey.api.service';
import { ISurveySubmissionRequest, ISurveyAnswer } from '@/types/account-setup/survey.types';
const { Title } = Typography;
// Simplified styles for form components using Ant Design theme tokens
const getAccountSetupStyles = (token: any) => ({
form: {
width: '100%',
maxWidth: '600px',
},
label: {
color: token.colorText,
fontWeight: 500,
},
buttonContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '1rem',
},
drawerFooter: {
display: 'flex',
justifyContent: 'right',
padding: '10px 16px',
},
});
const AccountSetup: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('account-setup');
useDocumentTitle(t('setupYourAccount', 'Account Setup'));
const navigate = useNavigate();
const { trackMixpanelEvent } = useMixpanelTracking();
const { token } = theme.useToken();
const { currentStep, organizationName, projectName, templateId, tasks, teamMembers } =
const { currentStep, organizationName, projectName, templateId, tasks, teamMembers, surveyData } =
useSelector((state: RootState) => state.accountSetupReducer);
const userDetails = getUserSession();
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
const [surveyId, setSurveyId] = React.useState<string | null>(null);
const isDarkMode = themeMode === 'dark';
const organizationNamePlaceholder = userDetails?.name ? `e.g. ${userDetails?.name}'s Team` : '';
// Helper to extract organization name from email or fallback to user name
function getOrganizationNamePlaceholder(userDetails: { email?: string; name?: string } | null): string {
if (!userDetails) return '';
const email = userDetails.email || '';
const name = userDetails.name || '';
if (email) {
const match = email.match(/^([^@]+)@([^@]+)$/);
if (match) {
const domain = match[2].toLowerCase();
// List of common public email providers
const publicProviders = [
'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com', 'aol.com', 'protonmail.com', 'zoho.com', 'gmx.com', 'mail.com', 'yandex.com', 'msn.com', 'live.com', 'me.com', 'comcast.net', 'rediffmail.com', 'ymail.com', 'rocketmail.com', 'inbox.com', 'mail.ru', 'qq.com', 'naver.com', '163.com', '126.com', 'sina.com', 'yeah.net', 'googlemail.com', 'fastmail.com', 'hushmail.com', 'tutanota.com', 'pm.me', 'mailbox.org', 'proton.me'
];
if (!publicProviders.includes(domain)) {
// Use the first part of the domain (before the first dot)
const org = domain.split('.')[0];
if (org && org.length > 1) {
return `e.g. ${org.charAt(0).toUpperCase() + org.slice(1)} Team`;
}
}
}
}
// Fallback to user name
return name ? `e.g. ${name}'s Team` : '';
}
const organizationNamePlaceholder = getOrganizationNamePlaceholder(userDetails);
// Helper to extract organization name from email or fallback to user name
function getOrganizationNameInitialValue(userDetails: { email?: string; name?: string } | null): string {
if (!userDetails) return '';
const email = userDetails.email || '';
const name = userDetails.name || '';
if (email) {
const match = email.match(/^([^@]+)@([^@]+)$/);
if (match) {
const domain = match[2].toLowerCase();
const publicProviders = [
'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com', 'aol.com', 'protonmail.com', 'zoho.com', 'gmx.com', 'mail.com', 'yandex.com', 'msn.com', 'live.com', 'me.com', 'comcast.net', 'rediffmail.com', 'ymail.com', 'rocketmail.com', 'inbox.com', 'mail.ru', 'qq.com', 'naver.com', '163.com', '126.com', 'sina.com', 'yeah.net', 'googlemail.com', 'fastmail.com', 'hushmail.com', 'tutanota.com', 'pm.me', 'mailbox.org', 'proton.me'
];
if (!publicProviders.includes(domain)) {
const org = domain.split('.')[0];
if (org && org.length > 1) {
return org.charAt(0).toUpperCase() + org.slice(1);
}
}
}
}
return name || '';
}
const organizationNameInitialValue = getOrganizationNameInitialValue(userDetails);
const styles = getAccountSetupStyles(token);
useEffect(() => {
trackMixpanelEvent(evt_account_setup_visit);
@@ -65,88 +147,25 @@ const AccountSetup: React.FC = () => {
logger.error('Failed to verify authentication status', error);
}
};
const loadSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyId(response.body.id);
} else {
logger.error('Survey not found or inactive (warn replaced with error)');
}
} catch (error) {
logger.error('Failed to load survey', error);
// Continue without survey - don't block account setup
}
};
void verifyAuthStatus();
void loadSurvey();
}, [dispatch, navigate, trackMixpanelEvent]);
const calculateHeight = () => {
if (currentStep === 2) {
return tasks.length * 105;
}
if (currentStep === 3) {
return teamMembers.length * 105;
}
return 'min-content';
};
const styles = {
form: {
width: '600px',
paddingBottom: '1rem',
marginTop: '3rem',
height: '100%',
overflow: 'hidden',
},
label: {
color: isDarkMode ? '' : '#00000073',
fontWeight: 500,
},
buttonContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '1rem',
},
drawerFooter: {
display: 'flex',
justifyContent: 'right',
padding: '10px 16px',
},
container: {
height: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
padding: '3rem 0',
backgroundColor: isDarkMode ? 'black' : '#FAFAFA',
},
contentContainer: {
backgroundColor: isDarkMode ? '#141414' : 'white',
marginTop: '1.5rem',
paddingTop: '3rem',
margin: '1.5rem auto 0',
width: '100%',
maxWidth: '66.66667%',
minHeight: 'fit-content',
display: 'flex',
flexDirection: 'column' as const,
},
space: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
gap: '0',
flexGrow: 1,
width: '100%',
minHeight: 'fit-content',
},
steps: {
margin: '1rem 0',
width: '600px',
},
stepContent: {
flexGrow: 1,
width: '600px',
minHeight: calculateHeight(),
overflow: 'visible',
},
actionButtons: {
flexGrow: 1,
width: '600px',
marginBottom: '1rem',
},
};
const completeAccountSetup = async (skip = false) => {
try {
@@ -159,6 +178,13 @@ const AccountSetup: React.FC = () => {
: teamMembers
.map(teamMember => sanitizeInput(teamMember.value.trim()))
.filter(email => validateEmail(email)),
survey_data: {
organization_type: surveyData.organization_type,
user_role: surveyData.user_role,
main_use_cases: surveyData.main_use_cases,
previous_tools: surveyData.previous_tools,
how_heard_about: surveyData.how_heard_about,
},
};
const res = await profileSettingsApiService.setupAccount(model);
if (res.done && res.body.id) {
@@ -190,6 +216,20 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
organizationNamePlaceholder={organizationNamePlaceholder}
organizationNameInitialValue={organizationNameInitialValue}
isDarkMode={isDarkMode}
token={token}
/>
),
},
{
title: '',
content: (
<SurveyStep
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
@@ -200,6 +240,7 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
@@ -210,12 +251,13 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
{
title: '',
content: <MembersStep isDarkMode={isDarkMode} styles={styles} />,
content: <MembersStep isDarkMode={isDarkMode} styles={styles} token={token} />,
},
];
@@ -224,10 +266,13 @@ const AccountSetup: React.FC = () => {
case 0:
return !organizationName?.trim();
case 1:
return !projectName?.trim() && !templateId;
// Survey step - no required fields, can always continue
return false;
case 2:
return tasks.length === 0 || tasks.every(task => !task.value?.trim());
return !projectName?.trim() && !templateId;
case 3:
return tasks.length === 0 || tasks.every(task => !task.value?.trim());
case 4:
return (
teamMembers.length > 0 && !teamMembers.some(member => validateEmail(member.value?.trim()))
);
@@ -236,8 +281,99 @@ const AccountSetup: React.FC = () => {
}
};
const nextStep = () => {
if (currentStep === 3) {
const saveSurveyData = async () => {
if (!surveyId || !surveyData) {
logger.error('Skipping survey save - no survey ID or data (info replaced with error)');
return;
}
try {
const answers: ISurveyAnswer[] = [];
// Get the survey questions to map data properly
const surveyResponse = await surveyApiService.getAccountSetupSurvey();
if (!surveyResponse.done || !surveyResponse.body?.questions) {
logger.error('Could not retrieve survey questions for data mapping (warn replaced with error)');
return;
}
const questions = surveyResponse.body.questions;
// Map survey data to answers based on question keys
questions.forEach(question => {
switch (question.question_key) {
case 'organization_type':
if (surveyData.organization_type) {
answers.push({
question_id: question.id,
answer_text: surveyData.organization_type
});
}
break;
case 'user_role':
if (surveyData.user_role) {
answers.push({
question_id: question.id,
answer_text: surveyData.user_role
});
}
break;
case 'main_use_cases':
if (surveyData.main_use_cases && surveyData.main_use_cases.length > 0) {
answers.push({
question_id: question.id,
answer_json: surveyData.main_use_cases
});
}
break;
case 'previous_tools':
if (surveyData.previous_tools) {
answers.push({
question_id: question.id,
answer_text: surveyData.previous_tools
});
}
break;
case 'how_heard_about':
if (surveyData.how_heard_about) {
answers.push({
question_id: question.id,
answer_text: surveyData.how_heard_about
});
}
break;
}
});
if (answers.length > 0) {
const submissionData: ISurveySubmissionRequest = {
survey_id: surveyId,
answers
};
const result = await surveyApiService.submitSurveyResponse(submissionData);
if (result.done) {
logger.error('Survey data saved successfully (info replaced with error)');
} else {
logger.error('Survey submission returned unsuccessful response (warn replaced with error)');
}
} else {
logger.error('No survey answers to save (info replaced with error)');
}
} catch (error) {
logger.error('Failed to save survey data', error);
// Don't block account setup flow if survey fails
}
};
const nextStep = async () => {
if (currentStep === 1) {
// Save survey data when moving from survey step
await saveSurveyData();
}
if (currentStep === 4) {
// Complete setup after members step
completeAccountSetup();
} else {
dispatch(setCurrentStep(currentStep + 1));
@@ -245,46 +381,71 @@ const AccountSetup: React.FC = () => {
};
return (
<div style={styles.container}>
<div>
<div
className="min-h-screen w-full flex flex-col items-center py-8 px-4"
style={{ backgroundColor: token.colorBgLayout }}
>
{/* Logo */}
<div className="mb-4">
<img src={isDarkMode ? logoDark : logo} alt="Logo" width={235} height={50} />
</div>
<Title level={5} style={{ textAlign: 'center', margin: '4px 0 24px' }}>
{/* Title */}
<Title
level={3}
className="text-center mb-6 font-semibold"
style={{ color: token.colorText }}
>
{t('setupYourAccount')}
</Title>
<div style={styles.contentContainer}>
<Space className={isDarkMode ? 'dark-mode' : ''} style={styles.space} direction="vertical">
<Steps
className={isContinueDisabled() ? 'step' : 'progress-steps'}
current={currentStep}
items={steps}
style={styles.steps}
/>
<div className="step-content" style={styles.stepContent}>
{steps[currentStep].content}
{/* Content Container */}
<div
className="w-full max-w-4xl rounded-lg shadow-lg mt-6 p-8"
style={{
backgroundColor: token.colorBgContainer,
borderColor: token.colorBorder,
border: `1px solid ${token.colorBorder}`,
boxShadow: token.boxShadowTertiary
}}
>
<div className="flex flex-col items-center space-y-6 w-full">
{/* Steps */}
<div className="w-full max-w-2xl">
<Steps
className={`${isContinueDisabled() ? 'step' : 'progress-steps'} ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
current={currentStep}
items={steps}
/>
</div>
<div style={styles.actionButtons} className="setup-action-buttons">
<div
style={{
display: 'flex',
justifyContent: currentStep !== 0 ? 'space-between' : 'flex-end',
}}
>
{/* Step Content */}
<div className="w-full max-w-2xl flex flex-col items-center min-h-fit">
<div className="step-content w-full">
{steps[currentStep].content}
</div>
</div>
{/* Action Buttons */}
<div className="w-full max-w-2xl mt-8">
<div className={`flex ${
currentStep !== 0 ? 'justify-between' : 'justify-end'
} items-center`}>
{currentStep !== 0 && (
<div>
<div className="flex flex-col space-y-2">
<Button
style={{ padding: 0 }}
type="link"
className="my-7"
className="p-0 font-medium"
style={{ color: token.colorTextSecondary }}
onClick={() => dispatch(setCurrentStep(currentStep - 1))}
>
{t('goBack')}
</Button>
{currentStep === 3 && (
{currentStep === 4 && (
<Button
style={{ color: isDarkMode ? '' : '#00000073', fontWeight: 500 }}
type="link"
className="my-7"
className="p-0 font-medium"
style={{ color: token.colorTextTertiary }}
onClick={() => completeAccountSetup(true)}
>
{t('skipForNow')}
@@ -296,14 +457,14 @@ const AccountSetup: React.FC = () => {
type="primary"
htmlType="submit"
disabled={isContinueDisabled()}
className="mt-7 mb-7"
className="min-h-10 font-medium px-8"
onClick={nextStep}
>
{t('continue')}
</Button>
</div>
</div>
</Space>
</div>
</div>
</div>
);

View File

@@ -65,6 +65,7 @@ import {
Timeline,
Mentions,
Radio,
Steps
} from 'antd/es';
// Icons - Import commonly used ones
@@ -240,6 +241,7 @@ export {
Timeline,
Mentions,
Radio,
Steps
};
// TypeScript Types - Import commonly used ones

View File

@@ -0,0 +1,51 @@
export interface ISurveyQuestion {
id: string;
survey_id: string;
question_key: string;
question_type: 'single_choice' | 'multiple_choice' | 'text';
is_required: boolean;
sort_order: number;
options?: string[];
}
export interface ISurvey {
id: string;
name: string;
description?: string;
survey_type: 'account_setup' | 'onboarding' | 'feedback';
is_active: boolean;
questions?: ISurveyQuestion[];
}
export interface ISurveyAnswer {
question_id: string;
answer_text?: string;
answer_json?: string[];
}
export interface ISurveyResponse {
id?: string;
survey_id: string;
user_id?: string;
is_completed: boolean;
answers: ISurveyAnswer[];
}
export interface ISurveySubmissionRequest {
survey_id: string;
answers: ISurveyAnswer[];
}
// Account setup survey specific types
export type OrganizationType = 'freelancer' | 'startup' | 'small_medium_business' | 'agency' | 'enterprise' | 'other';
export type UserRole = 'founder_ceo' | 'project_manager' | 'software_developer' | 'designer' | 'operations' | 'other';
export type UseCase = 'task_management' | 'team_collaboration' | 'resource_planning' | 'client_communication' | 'time_tracking' | 'other';
export type HowHeardAbout = 'google_search' | 'twitter' | 'linkedin' | 'friend_colleague' | 'blog_article' | 'other';
export interface IAccountSetupSurveyData {
organization_type?: OrganizationType;
user_role?: UserRole;
main_use_cases?: UseCase[];
previous_tools?: string;
how_heard_about?: HowHeardAbout;
}

View File

@@ -52,6 +52,13 @@ export interface IAccountSetupRequest {
tasks: string[];
team_members: string[];
template_id?: string | null;
survey_data?: {
organization_type?: string;
user_role?: string;
main_use_cases?: string[];
previous_tools?: string;
how_heard_about?: string;
};
}
export interface IAccountSetupResponse {