diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 06f61982..8d88ae97 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(find:*)", "Bash(npm run build:*)", "Bash(npm run type-check:*)", - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(move:*)", + "Bash(mv:*)" ], "deny": [] } diff --git a/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql b/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql new file mode 100644 index 00000000..ad779ae3 --- /dev/null +++ b/worklenz-backend/database/migrations/release-v2.1.4/20250724000000-add-survey-tables.sql @@ -0,0 +1,93 @@ +-- Migration: Add survey tables for account setup questionnaire +-- Date: 2025-07-24 +-- Description: Creates tables to store survey questions and user responses for account setup flow + +BEGIN; + +-- Create surveys table to define different types of surveys +CREATE TABLE IF NOT EXISTS surveys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback' + is_active BOOLEAN DEFAULT TRUE NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_questions table to store individual questions +CREATE TABLE IF NOT EXISTS survey_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + question_key VARCHAR(100) NOT NULL, -- Used for localization keys + question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text' + is_required BOOLEAN DEFAULT FALSE NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + options JSONB, -- For choice questions, store options as JSON array + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_responses table to track user responses to surveys +CREATE TABLE IF NOT EXISTS survey_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, + is_completed BOOLEAN DEFAULT FALSE NOT NULL, + started_at TIMESTAMP DEFAULT now() NOT NULL, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Create survey_answers table to store individual question answers +CREATE TABLE IF NOT EXISTS survey_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL, + question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL, + answer_text TEXT, + answer_json JSONB, -- For multiple choice answers stored as array + created_at TIMESTAMP DEFAULT now() NOT NULL, + updated_at TIMESTAMP DEFAULT now() NOT NULL +); + +-- Add performance indexes +CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active); +CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id); +CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed); +CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id); + +-- Add constraints +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0); +ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text')); + +-- Add unique constraint to prevent duplicate responses per user per survey +ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id); + +-- Add unique constraint to prevent duplicate answers per question per response +ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id); + +-- Insert the default account setup survey +INSERT INTO surveys (name, description, survey_type, is_active) VALUES +('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true) +ON CONFLICT DO NOTHING; + +-- Get the survey ID for inserting questions +DO $$ +DECLARE + survey_uuid UUID; +BEGIN + SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1; + + -- Insert survey questions + INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES + (survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'), + (survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'), + (survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'), + (survey_uuid, 'previous_tools', 'text', false, 4, null), + (survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]') + ON CONFLICT DO NOTHING; +END $$; + +COMMIT; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/survey-controller.ts b/worklenz-backend/src/controllers/survey-controller.ts new file mode 100644 index 00000000..10bcc29e --- /dev/null +++ b/worklenz-backend/src/controllers/survey-controller.ts @@ -0,0 +1,167 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import { ISurveySubmissionRequest } from "../interfaces/survey"; +import db from "../config/db"; + +export default class SurveyController extends WorklenzControllerBase { + @HandleExceptions() + public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const q = ` + SELECT + s.id, + s.name, + s.description, + s.survey_type, + s.is_active, + COALESCE( + json_agg( + json_build_object( + 'id', sq.id, + 'survey_id', sq.survey_id, + 'question_key', sq.question_key, + 'question_type', sq.question_type, + 'is_required', sq.is_required, + 'sort_order', sq.sort_order, + 'options', sq.options + ) ORDER BY sq.sort_order + ) FILTER (WHERE sq.id IS NOT NULL), + '[]' + ) AS questions + FROM surveys s + LEFT JOIN survey_questions sq ON s.id = sq.survey_id + WHERE s.survey_type = 'account_setup' AND s.is_active = true + GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active + LIMIT 1; + `; + + const result = await db.query(q); + const [survey] = result.rows; + + if (!survey) { + return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found")); + } + + return res.status(200).send(new ServerResponse(true, survey)); + } + + @HandleExceptions() + public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + const body = req.body as ISurveySubmissionRequest; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) { + return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data")); + } + + // Check if user has already submitted a response for this survey + const existingResponseQuery = ` + SELECT id FROM survey_responses + WHERE user_id = $1 AND survey_id = $2; + `; + const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]); + + let responseId: string; + + if (existingResult.rows.length > 0) { + // Update existing response + responseId = existingResult.rows[0].id; + + const updateResponseQuery = ` + UPDATE survey_responses + SET is_completed = true, completed_at = NOW(), updated_at = NOW() + WHERE id = $1; + `; + await db.query(updateResponseQuery, [responseId]); + + // Delete existing answers + const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`; + await db.query(deleteAnswersQuery, [responseId]); + } else { + // Create new response + const createResponseQuery = ` + INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at) + VALUES ($1, $2, true, NOW()) + RETURNING id; + `; + const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]); + responseId = responseResult.rows[0].id; + } + + // Insert new answers + if (body.answers.length > 0) { + const answerValues: string[] = []; + const params: any[] = []; + + body.answers.forEach((answer, index) => { + const baseIndex = index * 4; + answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`); + + params.push( + responseId, + answer.question_id, + answer.answer_text || null, + answer.answer_json ? JSON.stringify(answer.answer_json) : null + ); + }); + + const insertAnswersQuery = ` + INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json) + VALUES ${answerValues.join(', ')}; + `; + + await db.query(insertAnswersQuery, params); + } + + return res.status(200).send(new ServerResponse(true, { response_id: responseId })); + } + + @HandleExceptions() + public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const userId = req.user?.id; + const surveyId = req.params.survey_id; + + if (!userId) { + return res.status(200).send(new ServerResponse(false, null, "User not authenticated")); + } + + const q = ` + SELECT + sr.id, + sr.survey_id, + sr.user_id, + sr.is_completed, + sr.started_at, + sr.completed_at, + COALESCE( + json_agg( + json_build_object( + 'question_id', sa.question_id, + 'answer_text', sa.answer_text, + 'answer_json', sa.answer_json + ) + ) FILTER (WHERE sa.id IS NOT NULL), + '[]' + ) AS answers + FROM survey_responses sr + LEFT JOIN survey_answers sa ON sr.id = sa.response_id + WHERE sr.user_id = $1 AND sr.survey_id = $2 + GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at; + `; + + const result = await db.query(q, [userId, surveyId]); + const [response] = result.rows; + + if (!response) { + return res.status(200).send(new ServerResponse(false, null, "Survey response not found")); + } + + return res.status(200).send(new ServerResponse(true, response)); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/interfaces/survey.ts b/worklenz-backend/src/interfaces/survey.ts new file mode 100644 index 00000000..8cb3f5a9 --- /dev/null +++ b/worklenz-backend/src/interfaces/survey.ts @@ -0,0 +1,37 @@ +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[]; +} \ No newline at end of file diff --git a/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts b/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts new file mode 100644 index 00000000..a9f75bdb --- /dev/null +++ b/worklenz-backend/src/middlewares/validators/survey-submission-validator.ts @@ -0,0 +1,56 @@ +import { NextFunction } from "express"; +import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; +import { ServerResponse } from "../../models/server-response"; +import { ISurveySubmissionRequest } from "../../interfaces/survey"; + +export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void { + const body = req.body as ISurveySubmissionRequest; + + if (!body) { + return res.status(200).send(new ServerResponse(false, null, "Request body is required")); + } + + if (!body.survey_id || typeof body.survey_id !== 'string') { + return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string")); + } + + if (!body.answers || !Array.isArray(body.answers)) { + return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array")); + } + + // Validate each answer + for (let i = 0; i < body.answers.length; i++) { + const answer = body.answers[i]; + + if (!answer.question_id || typeof answer.question_id !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`)); + } + + // At least one of answer_text or answer_json should be provided + if (!answer.answer_text && !answer.answer_json) { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Either answer_text or answer_json is required`)); + } + + // Validate answer_text if provided + if (answer.answer_text && typeof answer.answer_text !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`)); + } + + // Validate answer_json if provided + if (answer.answer_json && !Array.isArray(answer.answer_json)) { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`)); + } + + // Validate answer_json items are strings + if (answer.answer_json) { + for (let j = 0; j < answer.answer_json.length; j++) { + if (typeof answer.answer_json[j] !== 'string') { + return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`)); + } + } + } + } + + return next(); +} \ No newline at end of file diff --git a/worklenz-backend/src/routes/apis/index.ts b/worklenz-backend/src/routes/apis/index.ts index 5a2019c8..7bd13eec 100644 --- a/worklenz-backend/src/routes/apis/index.ts +++ b/worklenz-backend/src/routes/apis/index.ts @@ -51,13 +51,14 @@ import roadmapApiRouter from "./gannt-apis/roadmap-api-router"; import scheduleApiRouter from "./gannt-apis/schedule-api-router"; import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router"; import projectManagerApiRouter from "./project-managers-api-router"; +import surveyApiRouter from "./survey-api-router"; import billingApiRouter from "./billing-api-router"; import taskDependenciesApiRouter from "./task-dependencies-api-router"; import taskRecurringApiRouter from "./task-recurring-api-router"; - import customColumnsApiRouter from "./custom-columns-api-router"; - + import customColumnsApiRouter from "./custom-columns-api-router"; + const api = express.Router(); api.use("/projects", projectsApiRouter); @@ -103,6 +104,7 @@ api.use("/roadmap-gannt", roadmapApiRouter); api.use("/schedule-gannt", scheduleApiRouter); api.use("/schedule-gannt-v2", scheduleApiV2Router); api.use("/project-managers", projectManagerApiRouter); +api.use("/surveys", surveyApiRouter); api.get("/overview/:id", safeControllerFunction(OverviewController.getById)); api.get("/task-priorities", safeControllerFunction(TaskPrioritiesController.get)); @@ -115,6 +117,6 @@ api.use("/task-dependencies", taskDependenciesApiRouter); api.use("/task-recurring", taskRecurringApiRouter); -api.use("/custom-columns", customColumnsApiRouter); - +api.use("/custom-columns", customColumnsApiRouter); + export default api; diff --git a/worklenz-backend/src/routes/apis/survey-api-router.ts b/worklenz-backend/src/routes/apis/survey-api-router.ts new file mode 100644 index 00000000..dbcde5a4 --- /dev/null +++ b/worklenz-backend/src/routes/apis/survey-api-router.ts @@ -0,0 +1,17 @@ +import express from "express"; +import SurveyController from "../../controllers/survey-controller"; +import surveySubmissionValidator from "../../middlewares/validators/survey-submission-validator"; +import safeControllerFunction from "../../shared/safe-controller-function"; + +const surveyApiRouter = express.Router(); + +// Get account setup survey with questions +surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey)); + +// Submit survey response +surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse)); + +// Get user's survey response for a specific survey +surveyApiRouter.get("/responses/:survey_id", safeControllerFunction(SurveyController.getUserSurveyResponse)); + +export default surveyApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/public/locales/alb/account-setup.json b/worklenz-frontend/public/locales/alb/account-setup.json index d5f624b3..eac67579 100644 --- a/worklenz-frontend/public/locales/alb/account-setup.json +++ b/worklenz-frontend/public/locales/alb/account-setup.json @@ -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" } diff --git a/worklenz-frontend/public/locales/de/account-setup.json b/worklenz-frontend/public/locales/de/account-setup.json index ddfb7b80..1ef9e8d1 100644 --- a/worklenz-frontend/public/locales/de/account-setup.json +++ b/worklenz-frontend/public/locales/de/account-setup.json @@ -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" } diff --git a/worklenz-frontend/public/locales/en/account-setup.json b/worklenz-frontend/public/locales/en/account-setup.json index 5e71ca40..e7eb96df 100644 --- a/worklenz-frontend/public/locales/en/account-setup.json +++ b/worklenz-frontend/public/locales/en/account-setup.json @@ -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" } diff --git a/worklenz-frontend/public/locales/es/account-setup.json b/worklenz-frontend/public/locales/es/account-setup.json index 3f7b013e..941d7ff6 100644 --- a/worklenz-frontend/public/locales/es/account-setup.json +++ b/worklenz-frontend/public/locales/es/account-setup.json @@ -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" } diff --git a/worklenz-frontend/public/locales/pt/account-setup.json b/worklenz-frontend/public/locales/pt/account-setup.json index 1d8a8cba..2b11772d 100644 --- a/worklenz-frontend/public/locales/pt/account-setup.json +++ b/worklenz-frontend/public/locales/pt/account-setup.json @@ -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" } diff --git a/worklenz-frontend/public/locales/zh/account-setup.json b/worklenz-frontend/public/locales/zh/account-setup.json index 51cac1eb..40e3ac15 100644 --- a/worklenz-frontend/public/locales/zh/account-setup.json +++ b/worklenz-frontend/public/locales/zh/account-setup.json @@ -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": "其他" } \ No newline at end of file diff --git a/worklenz-frontend/src/api/survey/survey.api.service.ts b/worklenz-frontend/src/api/survey/survey.api.service.ts new file mode 100644 index 00000000..381958f0 --- /dev/null +++ b/worklenz-frontend/src/api/survey/survey.api.service.ts @@ -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> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/account-setup`); + return response.data; + }, + + async submitSurveyResponse(data: ISurveySubmissionRequest): Promise> { + const response = await apiClient.post>(`${API_BASE_URL}/surveys/responses`, data); + return response.data; + }, + + async getUserSurveyResponse(surveyId: string): Promise> { + const response = await apiClient.get>(`${API_BASE_URL}/surveys/responses/${surveyId}`); + return response.data; + } +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/organization-step.tsx b/worklenz-frontend/src/components/account-setup/organization-step.tsx index ec69b999..ccddc35e 100644 --- a/worklenz-frontend/src/components/account-setup/organization-step.tsx +++ b/worklenz-frontend/src/components/account-setup/organization-step.tsx @@ -13,19 +13,25 @@ interface Props { onEnter: () => void; styles: any; organizationNamePlaceholder: string; + organizationNameInitialValue?: string; } export const OrganizationStep: React.FC = ({ onEnter, styles, organizationNamePlaceholder, + organizationNameInitialValue, }) => { const { t } = useTranslation('account-setup'); const dispatch = useDispatch(); const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer); const inputRef = useRef(null); + // Autofill organization name if not already set useEffect(() => { + if (!organizationName && organizationNameInitialValue) { + dispatch(setOrganizationName(organizationNameInitialValue)); + } setTimeout(() => inputRef.current?.focus(), 300); }, []); diff --git a/worklenz-frontend/src/components/account-setup/survey-step.tsx b/worklenz-frontend/src/components/account-setup/survey-step.tsx new file mode 100644 index 00000000..e3fa7d25 --- /dev/null +++ b/worklenz-frontend/src/components/account-setup/survey-step.tsx @@ -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 = ({ 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 ( +
+ + + {t('surveyStepTitle')} + +

+ {t('surveyStepLabel')} +

+
+ + {/* Organization Type */} + {t('organizationType')}} + className="mb-6" + > +
+ {organizationTypeOptions.map((option) => ( + + ))} +
+
+ + {/* User Role */} + {t('userRole')}} + className="mb-6" + > +
+ {userRoleOptions.map((option) => ( + + ))} +
+
+ + {/* Main Use Cases */} + {t('mainUseCases')}} + className="mb-6" + > +
+ {useCaseOptions.map((option) => { + const isSelected = (surveyData.main_use_cases || []).includes(option.value); + return ( + + ); + })} +
+
+ + {/* Previous Tools */} + {t('previousTools')}} + className="mb-6" + > +