diff --git a/.github/workflows/cron-weeklySummary.yml b/.github/workflows/cron-weeklySummary.yml index 7ccc078ff4..0014ff40b2 100644 --- a/.github/workflows/cron-weeklySummary.yml +++ b/.github/workflows/cron-weeklySummary.yml @@ -10,14 +10,14 @@ jobs: cron-weeklySummary: env: APP_URL: ${{ secrets.APP_URL }} - CRON_API_KEY: ${{ secrets.CRON_SECRET }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} runs-on: ubuntu-latest steps: - name: cURL request - if: ${{ secrets.APP_URL && secrets.CRON_SECRET }} + if: ${{ env.APP_URL && env.CRON_SECRET }} run: | - curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \ + curl ${{ env.APP_URL }}/api/cron/weekly_summary \ -X POST \ -H 'content-type: application/json' \ - -H 'x-api-key: ${{ secrets.CRON_SECRET }}' \ + -H 'x-api-key: ${{ env.CRON_SECRET }}' \ --fail diff --git a/apps/demo/package.json b/apps/demo/package.json index 767d0ccb96..5ffce336d2 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -13,7 +13,7 @@ "dependencies": { "@formbricks/js": "workspace:*", "@heroicons/react": "^2.0.18", - "next": "13.4.10", + "next": "13.4.12", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/apps/formbricks-com/components/home/Features.tsx b/apps/formbricks-com/components/home/Features.tsx index 4ffb2ffa6a..6ef45f9657 100644 --- a/apps/formbricks-com/components/home/Features.tsx +++ b/apps/formbricks-com/components/home/Features.tsx @@ -2,18 +2,18 @@ import { CodeFileIcon, EyeIcon, HandPuzzleIcon } from "@formbricks/ui"; import HeadingCentered from "../shared/HeadingCentered"; const features = [ + { + id: "compliance", + name: "Smoothly Compliant", + description: "Use our GDPR-compliant Cloud or self-host the entire solution.", + icon: EyeIcon, + }, { id: "customizable", name: "Fully Customizable", description: "Full customizability and extendability. Integrate with your stack easily.", icon: HandPuzzleIcon, }, - { - id: "compliance", - name: "Smoothly Compliant", - description: "Self-host the entire product and fly through privacy compliance reviews.", - icon: EyeIcon, - }, { id: "independent", name: "Stay independent", @@ -27,9 +27,9 @@ export const Features: React.FC = () => {
@@ -184,6 +199,7 @@ export default function MultipleChoiceMultiForm({ className={cn(choice.id === "other" && "border-dashed")} placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`} onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })} + isInvalid={isInValid && choice.label.trim() === ""} /> {question.choices && question.choices.length > 2 && ( void; lastQuestion: boolean; + isInValid: boolean; } export default function MultipleChoiceSingleForm({ question, questionIdx, updateQuestion, + isInValid, }: OpenQuestionFormProps): JSX.Element { const lastChoiceRef = useRef(null); const [isNew, setIsNew] = useState(true); @@ -51,16 +53,28 @@ export default function MultipleChoiceSingleForm({ }, }; - const updateChoice = (choiceIdx: number, updatedAttributes: any) => { - const newChoices = !question.choices - ? [] - : question.choices.map((choice, idx) => { - if (idx === choiceIdx) { - return { ...choice, ...updatedAttributes }; - } - return choice; - }); - updateQuestion(questionIdx, { choices: newChoices }); + const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => { + const newLabel = updatedAttributes.label; + const oldLabel = question.choices[choiceIdx].label; + let newChoices: any[] = []; + if (question.choices) { + newChoices = question.choices.map((choice, idx) => { + if (idx !== choiceIdx) return choice; + return { ...choice, ...updatedAttributes }; + }); + } + + let newLogic: any[] = []; + question.logic?.forEach((logic) => { + let newL: string | string[] | undefined = logic.value; + if (Array.isArray(logic.value)) { + newL = logic.value.map((value) => (value === oldLabel ? newLabel : value)); + } else { + newL = logic.value === oldLabel ? newLabel : logic.value; + } + newLogic.push({ ...logic, value: newL }); + }); + updateQuestion(questionIdx, { choices: newChoices, logic: newLogic }); }; const addChoice = (choiceIdx?: number) => { @@ -137,6 +151,7 @@ export default function MultipleChoiceSingleForm({ name="headline" value={question.headline} onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} /> @@ -184,6 +199,7 @@ export default function MultipleChoiceSingleForm({ className={cn(choice.id === "other" && "border-dashed")} placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`} onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })} + isInvalid={isInValid && choice.label.trim() === ""} /> {question.choices && question.choices.length > 2 && ( void; lastQuestion: boolean; + isInValid: boolean; } export default function NPSQuestionForm({ @@ -17,6 +18,7 @@ export default function NPSQuestionForm({ questionIdx, updateQuestion, lastQuestion, + isInValid, }: NPSQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); @@ -31,6 +33,7 @@ export default function NPSQuestionForm({ name="headline" value={question.headline} onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/OpenQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/OpenQuestionForm.tsx index 55c9ec0a3a..9206c23fad 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/OpenQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/OpenQuestionForm.tsx @@ -10,12 +10,14 @@ interface OpenQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + isInValid: boolean; } export default function OpenQuestionForm({ question, questionIdx, updateQuestion, + isInValid, }: OpenQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); @@ -30,6 +32,7 @@ export default function OpenQuestionForm({ name="headline" value={question.headline} onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx index d422231eef..e8c91eb372 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx @@ -39,6 +39,7 @@ interface QuestionCardProps { activeQuestionId: string | null; setActiveQuestionId: (questionId: string | null) => void; lastQuestion: boolean; + isInValid: boolean; } export default function QuestionCard({ @@ -51,6 +52,7 @@ export default function QuestionCard({ activeQuestionId, setActiveQuestionId, lastQuestion, + isInValid, }: QuestionCardProps) { const question = localSurvey.questions[questionIdx]; const open = activeQuestionId === question.id; @@ -69,7 +71,8 @@ export default function QuestionCard({
{questionIdx + 1}
@@ -136,6 +139,7 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + isInValid={isInValid} /> ) : question.type === QuestionType.MultipleChoiceSingle ? ( ) : question.type === QuestionType.MultipleChoiceMulti ? ( ) : question.type === QuestionType.NPS ? ( ) : question.type === QuestionType.CTA ? ( ) : question.type === QuestionType.Rating ? ( ) : question.type === "consent" ? ( ) : null}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionsView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionsView.tsx index f4927fc83c..30f38e9cd3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionsView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionsView.tsx @@ -9,6 +9,8 @@ import AddQuestionButton from "./AddQuestionButton"; import EditThankYouCard from "./EditThankYouCard"; import QuestionCard from "./QuestionCard"; import { StrictModeDroppable } from "./StrictModeDroppable"; +import { Question } from "@formbricks/types/questions"; +import { validateQuestion } from "./Validation"; interface QuestionsViewProps { localSurvey: Survey; @@ -16,6 +18,8 @@ interface QuestionsViewProps { activeQuestionId: string | null; setActiveQuestionId: (questionId: string | null) => void; environmentId: string; + invalidQuestions: String[] | null; + setInvalidQuestions: (invalidQuestions: String[] | null) => void; } export default function QuestionsView({ @@ -24,6 +28,8 @@ export default function QuestionsView({ localSurvey, setLocalSurvey, environmentId, + invalidQuestions, + setInvalidQuestions, }: QuestionsViewProps) { const internalQuestionIdMap = useMemo(() => { return localSurvey.questions.reduce((acc, question) => { @@ -44,12 +50,33 @@ export default function QuestionsView({ return survey; }; + // function to validate individual questions + const validateSurvey = (question: Question) => { + // prevent this function to execute further if user hasnt still tried to save the survey + if (invalidQuestions === null) { + return; + } + let temp = JSON.parse(JSON.stringify(invalidQuestions)); + if (validateQuestion(question)) { + temp = invalidQuestions.filter((id) => id !== question.id); + setInvalidQuestions(temp); + } else if (!invalidQuestions.includes(question.id)) { + temp.push(question.id); + setInvalidQuestions(temp); + } + }; + const updateQuestion = (questionIdx: number, updatedAttributes: any) => { let updatedSurvey = JSON.parse(JSON.stringify(localSurvey)); if ("id" in updatedAttributes) { // if the survey whose id is to be changed is linked to logic of any other survey then changing it const initialQuestionId = updatedSurvey.questions[questionIdx].id; updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id); + if (invalidQuestions?.includes(initialQuestionId)) { + setInvalidQuestions( + invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id)) + ); + } // relink the question to internal Id internalQuestionIdMap[updatedAttributes.id] = @@ -63,6 +90,7 @@ export default function QuestionsView({ ...updatedAttributes, }; setLocalSurvey(updatedSurvey); + validateSurvey(updatedSurvey.questions[questionIdx]); }; const deleteQuestion = (questionIdx: number) => { @@ -120,7 +148,6 @@ export default function QuestionsView({ if (!result.destination) { return; } - const newQuestions = Array.from(localSurvey.questions); const [reorderedQuestion] = newQuestions.splice(result.source.index, 1); newQuestions.splice(result.destination.index, 0, reorderedQuestion); @@ -134,7 +161,6 @@ export default function QuestionsView({ const [reorderedQuestion] = newQuestions.splice(questionIndex, 1); const destinationIndex = up ? questionIndex - 1 : questionIndex + 1; newQuestions.splice(destinationIndex, 0, reorderedQuestion); - const updatedSurvey = { ...localSurvey, questions: newQuestions }; setLocalSurvey(updatedSurvey); }; @@ -159,6 +185,7 @@ export default function QuestionsView({ activeQuestionId={activeQuestionId} setActiveQuestionId={setActiveQuestionId} lastQuestion={questionIdx === localSurvey.questions.length - 1} + isInValid={invalidQuestions ? invalidQuestions.includes(question.id) : false} /> ))} {provided.placeholder} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx index 95e05466b5..94a708de14 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx @@ -12,6 +12,7 @@ interface RatingQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + isInValid: boolean; } export default function RatingQuestionForm({ @@ -19,6 +20,7 @@ export default function RatingQuestionForm({ questionIdx, updateQuestion, lastQuestion, + isInValid, }: RatingQuestionFormProps) { const [showSubheader, setShowSubheader] = useState(!!question.subheader); @@ -33,6 +35,7 @@ export default function RatingQuestionForm({ name="headline" value={question.headline} onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })} + isInvalid={isInValid && question.headline.trim() === ""} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx index d64bb83bf4..cb23359749 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyEditor.tsx @@ -22,7 +22,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr const [activeView, setActiveView] = useState<"questions" | "settings">("questions"); const [activeQuestionId, setActiveQuestionId] = useState(null); const [localSurvey, setLocalSurvey] = useState(); - + const [invalidQuestions, setInvalidQuestions] = useState(null); const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId); const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId); const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId); @@ -56,6 +56,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr environmentId={environmentId} activeId={activeView} setActiveId={setActiveView} + setInvalidQuestions={setInvalidQuestions} />
@@ -67,6 +68,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr activeQuestionId={activeQuestionId} setActiveQuestionId={setActiveQuestionId} environmentId={environmentId} + invalidQuestions={invalidQuestions} + setInvalidQuestions={setInvalidQuestions} /> ) : ( void; + setInvalidQuestions: (invalidQuestions: String[]) => void; } export default function SurveyMenuBar({ @@ -30,6 +32,7 @@ export default function SurveyMenuBar({ setLocalSurvey, activeId, setActiveId, + setInvalidQuestions, }: SurveyMenuBarProps) { const router = useRouter(); const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id); @@ -37,6 +40,7 @@ export default function SurveyMenuBar({ const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false); const { product } = useProduct(environmentId); + let faultyQuestions: String[] = []; useEffect(() => { if (audiencePrompt && activeId === "settings") { @@ -85,6 +89,26 @@ export default function SurveyMenuBar({ } }; + const validateSurvey = (survey) => { + faultyQuestions = []; + for (let index = 0; index < survey.questions.length; index++) { + const question = survey.questions[index]; + const isValid = validateQuestion(question); + + if (!isValid) { + faultyQuestions.push(question.id); + } + } + // if there are any faulty questions, the user won't be allowed to save the survey + if (faultyQuestions.length > 0) { + setInvalidQuestions(faultyQuestions); + toast.error("Please fill required fields"); + return false; + } + + return true; + }; + const saveSurveyAction = (shouldNavigateBack = false) => { // variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question const strippedSurvey = { @@ -94,6 +118,11 @@ export default function SurveyMenuBar({ return rest; }), }; + + if (!validateSurvey(localSurvey)) { + return; + } + triggerSurveyMutate({ ...strippedSurvey }) .then(async (response) => { if (!response?.ok) { @@ -180,6 +209,9 @@ export default function SurveyMenuBar({ variant="darkCTA" loading={isMutatingSurvey} onClick={async () => { + if (!validateSurvey(localSurvey)) { + return; + } await triggerSurveyMutate({ ...localSurvey, status: "inProgress" }); router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`); }}> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts new file mode 100644 index 0000000000..b11dc523e0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts @@ -0,0 +1,32 @@ +// extend this object in order to add more validation rules + +import { + MultipleChoiceMultiQuestion, + MultipleChoiceSingleQuestion, + Question, +} from "@formbricks/types/questions"; + +const validationRules = { + multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => { + return !question.choices.some((element) => element.label.trim() === ""); + }, + multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => { + return !question.choices.some((element) => element.label.trim() === ""); + }, + defaultValidation: (question: Question) => { + return question.headline.trim() !== ""; + }, +}; + +const validateQuestion = (question) => { + const specificValidation = validationRules[question.type]; + const defaultValidation = validationRules.defaultValidation; + + const specificValidationResult = specificValidation ? specificValidation(question) : true; + const defaultValidationResult = defaultValidation(question); + + // Return true only if both specific and default validation pass + return specificValidationResult && defaultValidationResult; +}; + +export { validateQuestion }; diff --git a/apps/web/app/(auth)/auth/login/page.tsx b/apps/web/app/(auth)/auth/login/page.tsx index d078b71b67..f3521ccb2b 100644 --- a/apps/web/app/(auth)/auth/login/page.tsx +++ b/apps/web/app/(auth)/auth/login/page.tsx @@ -1,6 +1,12 @@ import { SigninForm } from "@/components/auth/SigninForm"; import Testimonial from "@/components/auth/Testimonial"; import FormWrapper from "@/components/auth/FormWrapper"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Login", + description: "Open-source Experience Management. Free & open source.", +}; export default function SignInPage() { return ( diff --git a/apps/web/app/api/v1/client/responses/route.ts b/apps/web/app/api/v1/client/responses/route.ts index fac01954e7..152c496e76 100644 --- a/apps/web/app/api/v1/client/responses/route.ts +++ b/apps/web/app/api/v1/client/responses/route.ts @@ -45,6 +45,7 @@ export async function POST(request: Request): Promise { let response: TResponse; try { const meta = { + url: responseInput?.meta?.url ?? "", userAgent: { browser: agent?.browser.name, device: agent?.device.type, diff --git a/apps/web/app/api/v1/js/sync/route.ts b/apps/web/app/api/v1/js/sync/route.ts index 4d9ee4ccf9..11010f3ce1 100644 --- a/apps/web/app/api/v1/js/sync/route.ts +++ b/apps/web/app/api/v1/js/sync/route.ts @@ -2,6 +2,7 @@ import { getSurveys } from "@/app/api/v1/js/surveys"; import { responses } from "@/lib/api/response"; import { transformErrorToDetails } from "@/lib/api/validator"; import { getActionClasses } from "@formbricks/lib/services/actionClass"; +import { getEnvironment } from "@formbricks/lib/services/environment"; import { createPerson, getPerson } from "@formbricks/lib/services/person"; import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; import { createSession, extendSession, getSession } from "@formbricks/lib/services/session"; @@ -31,6 +32,16 @@ export async function POST(req: Request): Promise { const { environmentId, personId, sessionId } = inputValidation.data; + // check if environment exists + const environment = await getEnvironment(environmentId); + if (!environment) { + return responses.badRequestResponse( + "Environment does not exist", + { environmentId: "Environment with this ID does not exist" }, + true + ); + } + if (!personId) { // create a new person const person = await createPerson(environmentId); diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts index 16b0a45e78..e500401fcd 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -18,6 +18,9 @@ export async function GET(_: Request, { params }: { params: { webhookId: string if (!webhook) { return responses.notFoundResponse("Webhook", params.webhookId); } + if (webhook.environmentId !== apiKeyData.environmentId) { + return responses.unauthorizedResponse(); + } return responses.successResponse(webhook); } @@ -31,7 +34,16 @@ export async function DELETE(_: Request, { params }: { params: { webhookId: stri return responses.notAuthenticatedResponse(); } - // add webhook to database + // check if webhook exists + const webhook = await getWebhook(params.webhookId); + if (!webhook) { + return responses.notFoundResponse("Webhook", params.webhookId); + } + if (webhook.environmentId !== apiKeyData.environmentId) { + return responses.unauthorizedResponse(); + } + + // delete webhook from database try { const webhook = await deleteWebhook(params.webhookId); return responses.successResponse(webhook); diff --git a/apps/web/app/s/[surveyId]/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/LinkSurvey.tsx index d58a923a3e..4d83989696 100644 --- a/apps/web/app/s/[surveyId]/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/LinkSurvey.tsx @@ -11,7 +11,7 @@ import { cn } from "@formbricks/lib/cn"; import { Confetti } from "@formbricks/ui"; import { ArrowPathIcon } from "@heroicons/react/24/solid"; import type { Survey } from "@formbricks/types/surveys"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; type EnhancedSurvey = Survey & { brandColor: string; @@ -34,10 +34,22 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) { initiateCountdown, restartSurvey, submitResponse, + goToPreviousQuestion, + goToNextQuestion, + storedResponseValue, } = useLinkSurveyUtils(survey); + const showBackButton = progress !== 0 && !finished; // Create a reference to the top element const topRef = useRef(null); + const [autoFocus, setAutofocus] = useState(false); + + // Not in an iframe, enable autofocus on input fields. + useEffect(() => { + if (window.self === window.top) { + setAutofocus(true); + } + }, []); // Scroll to top when the currentQuestion changes useEffect(() => { @@ -90,6 +102,10 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) { brandColor={survey.brandColor} lastQuestion={lastQuestion} onSubmit={submitResponse} + storedResponseValue={storedResponseValue} + goToNextQuestion={goToNextQuestion} + goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined} + autoFocus={autoFocus} /> )} diff --git a/apps/web/components/Smileys.tsx b/apps/web/components/Smileys.tsx index 1dc7b009a6..6e45fc5877 100644 --- a/apps/web/components/Smileys.tsx +++ b/apps/web/components/Smileys.tsx @@ -1,6 +1,6 @@ export const TiredFace: React.FC> = (props) => { return ( - + > = (props) => export const WearyFace: React.FC> = (props) => { return ( - + > = (props) => export const PerseveringFace: React.FC> = (props) => { return ( - + > = (prop export const FrowningFace: React.FC> = (props) => { return ( - + > = (props) export const ConfusedFace: React.FC> = (props) => { return ( - + > = (props) export const NeutralFace: React.FC> = (props) => { return ( - + > = (props) = export const SlightlySmilingFace: React.FC> = (props) => { return ( - + > = ( export const SmilingFaceWithSmilingEyes: React.FC> = (props) => { return ( - + > = (props) => { return ( - + > = (props) => { return ( - + { {env.NEXT_PUBLIC_TERMS_URL && ( Terms of Service @@ -195,7 +195,7 @@ export const SignupForm = () => { {env.NEXT_PUBLIC_PRIVACY_URL && ( Privacy Policy. diff --git a/apps/web/components/preview/BackButton.tsx b/apps/web/components/preview/BackButton.tsx new file mode 100644 index 0000000000..c23861c06c --- /dev/null +++ b/apps/web/components/preview/BackButton.tsx @@ -0,0 +1,17 @@ +import { Button } from "@formbricks/ui"; + +interface BackButtonProps { + onClick: () => void; +} + +export function BackButton({ onClick }: BackButtonProps) { + return ( + + ); +} diff --git a/apps/web/components/preview/CTAQuestion.tsx b/apps/web/components/preview/CTAQuestion.tsx index ff56d9f69a..2a535b9b30 100644 --- a/apps/web/components/preview/CTAQuestion.tsx +++ b/apps/web/components/preview/CTAQuestion.tsx @@ -3,30 +3,48 @@ import Headline from "./Headline"; import HtmlBody from "./HtmlBody"; import { cn } from "@/../../packages/lib/cn"; import { isLight } from "@/lib/utils"; +import { Response } from "@formbricks/types/js"; +import { BackButton } from "@/components/preview/BackButton"; interface CTAQuestionProps { question: CTAQuestion; onSubmit: (data: { [x: string]: any }) => void; lastQuestion: boolean; brandColor: string; + storedResponseValue: string | null; + goToNextQuestion: (answer: Response["data"]) => void; + goToPreviousQuestion?: (answer?: Response["data"]) => void; } -export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) { +export default function CTAQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: CTAQuestionProps) { return (
+ {goToPreviousQuestion && goToPreviousQuestion()} />}
- {!question.required && ( + {(!question.required || storedResponseValue) && ( )}
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion( + selectedChoice === "other" + ? { + [question.id]: otherSpecify.current?.value ?? "", + } + : { + [question.id]: + question.choices.find((choice) => choice.id === selectedChoice)?.label ?? "", + } + ); + }} + /> + )}
diff --git a/apps/web/components/preview/NPSQuestion.tsx b/apps/web/components/preview/NPSQuestion.tsx index e4ae2152d2..242563eaa3 100644 --- a/apps/web/components/preview/NPSQuestion.tsx +++ b/apps/web/components/preview/NPSQuestion.tsx @@ -1,27 +1,54 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { cn } from "@formbricks/lib/cn"; import type { NPSQuestion } from "@formbricks/types/questions"; import Headline from "./Headline"; import Subheader from "./Subheader"; import SubmitButton from "@/components/preview/SubmitButton"; +import { Response } from "@formbricks/types/js"; +import { BackButton } from "@/components/preview/BackButton"; interface NPSQuestionProps { question: NPSQuestion; onSubmit: (data: { [x: string]: any }) => void; lastQuestion: boolean; brandColor: string; + storedResponseValue: number | null; + goToNextQuestion: (answer: Response["data"]) => void; + goToPreviousQuestion?: (answer?: Response["data"]) => void; } -export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) { +export default function NPSQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, +}: NPSQuestionProps) { const [selectedChoice, setSelectedChoice] = useState(null); + useEffect(() => { + setSelectedChoice(storedResponseValue); + }, [storedResponseValue, question]); + + const handleSubmit = (value: number | null) => { + const data = { + [question.id]: value ?? null, + }; + if (storedResponseValue === value) { + setSelectedChoice(null); + goToNextQuestion(data); + return; + } + setSelectedChoice(null); + onSubmit(data); + }; + const handleSelect = (number: number) => { setSelectedChoice(number); if (question.required) { - setSelectedChoice(null); - onSubmit({ - [question.id]: number, - }); + handleSubmit(number); } }; @@ -29,14 +56,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol { e.preventDefault(); - - const data = { - [question.id]: selectedChoice, - }; - - setSelectedChoice(null); - onSubmit(data); - // reset form + handleSubmit(selectedChoice); }}> @@ -55,6 +75,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol type="radio" name="nps" value={number} + checked={selectedChoice === number} className="absolute h-full w-full cursor-pointer opacity-0" onClick={() => handleSelect(number)} required={question.required} @@ -69,12 +90,23 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
- {!question.required && ( -
-
- -
- )} +
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion( + storedResponseValue !== selectedChoice + ? { + [question.id]: selectedChoice, + } + : undefined + ); + }} + /> + )} +
+ {(!question.required || storedResponseValue) && } +
); } diff --git a/apps/web/components/preview/OpenTextQuestion.tsx b/apps/web/components/preview/OpenTextQuestion.tsx index 296f1fb10b..75c7ff9ecc 100644 --- a/apps/web/components/preview/OpenTextQuestion.tsx +++ b/apps/web/components/preview/OpenTextQuestion.tsx @@ -1,14 +1,20 @@ import type { OpenTextQuestion } from "@formbricks/types/questions"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Headline from "./Headline"; import Subheader from "./Subheader"; import SubmitButton from "@/components/preview/SubmitButton"; +import { Response } from "@formbricks/types/js"; +import { BackButton } from "@/components/preview/BackButton"; interface OpenTextQuestionProps { question: OpenTextQuestion; onSubmit: (data: { [x: string]: any }) => void; lastQuestion: boolean; brandColor: string; + storedResponseValue: string | null; + goToNextQuestion: (answer: Response["data"]) => void; + goToPreviousQuestion?: (answer: Response["data"]) => void; + autoFocus?: boolean; } export default function OpenTextQuestion({ @@ -16,51 +22,75 @@ export default function OpenTextQuestion({ onSubmit, lastQuestion, brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, + autoFocus = false, }: OpenTextQuestionProps) { const [value, setValue] = useState(""); + useEffect(() => { + setValue(storedResponseValue ?? ""); + }, [storedResponseValue, question.id, question.longAnswer]); + + const handleSubmit = (value: string) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + goToNextQuestion(data); + return; + } + onSubmit(data); + setValue(""); // reset value + }; + return (
{ e.preventDefault(); - - const data = { - [question.id]: value, - }; - setValue(""); // reset value - onSubmit(data); + handleSubmit(value); }}>
{question.longAnswer === false ? ( setValue(e.target.value)} - placeholder={question.placeholder} + placeholder={!storedResponseValue ? question.placeholder : undefined} required={question.required} className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm" /> ) : ( )}
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion({ + [question.id]: value, + }); + }} + /> + )}
void; lastQuestion: boolean; brandColor: string; + storedResponseValue: any; + goToNextQuestion: (answer: any) => void; + goToPreviousQuestion?: (answer: any) => void; } export default function QuestionConditional({ @@ -21,6 +24,9 @@ export default function QuestionConditional({ onSubmit, lastQuestion, brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, }: QuestionConditionalProps) { return question.type === QuestionType.OpenText ? ( ) : question.type === QuestionType.MultipleChoiceSingle ? ( ) : question.type === QuestionType.MultipleChoiceMulti ? ( ) : question.type === QuestionType.NPS ? ( ) : question.type === QuestionType.CTA ? ( ) : question.type === QuestionType.Rating ? ( ) : question.type === "consent" ? ( ) : null; } diff --git a/packages/js/src/components/RatingQuestion.tsx b/packages/js/src/components/RatingQuestion.tsx index a7845446d8..bf0dee85dd 100644 --- a/packages/js/src/components/RatingQuestion.tsx +++ b/packages/js/src/components/RatingQuestion.tsx @@ -1,5 +1,5 @@ import { h } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { TResponseData } from "../../../types/v1/responses"; import type { TSurveyRatingQuestion } from "../../../types/v1/surveys"; import { cn } from "../lib/utils"; @@ -18,12 +18,16 @@ import { } from "./Smileys"; import Subheader from "./Subheader"; import SubmitButton from "./SubmitButton"; +import { BackButton } from "./BackButton"; interface RatingQuestionProps { question: TSurveyRatingQuestion; onSubmit: (data: TResponseData) => void; lastQuestion: boolean; brandColor: string; + storedResponseValue: number | null; + goToNextQuestion: (answer: TResponseData) => void; + goToPreviousQuestion?: (answer?: TResponseData) => void; } export default function RatingQuestion({ @@ -31,10 +35,30 @@ export default function RatingQuestion({ onSubmit, lastQuestion, brandColor, + storedResponseValue, + goToNextQuestion, + goToPreviousQuestion, }: RatingQuestionProps) { const [selectedChoice, setSelectedChoice] = useState(null); const [hoveredNumber, setHoveredNumber] = useState(0); + useEffect(() => { + setSelectedChoice(storedResponseValue); + }, [storedResponseValue, question]); + + const handleSubmit = (value: number | null) => { + const data = { + [question.id]: value, + }; + if (storedResponseValue === value) { + goToNextQuestion(data); + setSelectedChoice(null); + return; + } + onSubmit(data); + setSelectedChoice(null); + }; + const handleSelect = (number: number) => { setSelectedChoice(number); if (question.required) { @@ -53,6 +77,7 @@ export default function RatingQuestion({ className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0 fb-left-0" onChange={() => handleSelect(number)} required={question.required} + checked={selectedChoice === number} /> ); @@ -60,15 +85,7 @@ export default function RatingQuestion({ { e.preventDefault(); - - const data = {}; - if (selectedChoice !== null) { - data[question.id] = selectedChoice; - } - - setSelectedChoice(null); // reset choice - - onSubmit(data); + handleSubmit(selectedChoice); }}> @@ -149,17 +166,25 @@ export default function RatingQuestion({
- {!question.required && ( -
-
+ +
+ {goToPreviousQuestion && ( + { + goToPreviousQuestion({ [question.id]: selectedChoice }); + }} + /> + )} +
+ {(!question.required || selectedChoice) && ( {}} /> -
- )} + )} +
); } diff --git a/packages/js/src/components/Smileys.tsx b/packages/js/src/components/Smileys.tsx index c22a00f997..287ef462b9 100644 --- a/packages/js/src/components/Smileys.tsx +++ b/packages/js/src/components/Smileys.tsx @@ -3,7 +3,7 @@ import type { JSX } from "preact"; export const TiredFace: FunctionComponent> = (props) => { return ( - + > export const WearyFace: FunctionComponent> = (props) => { return ( - + > export const PerseveringFace: FunctionComponent> = (props) => { return ( - + > = (props) => { return ( - + > = (props) => { return ( - + > = (props) => { return ( - + export const SlightlySmilingFace: FunctionComponent> = (props) => { return ( - + { return ( - + { return ( - + > = (props) => { return ( - + ]; +export let icons = [ + , +]; diff --git a/packages/js/src/components/Subheader.tsx b/packages/js/src/components/Subheader.tsx index edf1c6cad4..09b145308a 100644 --- a/packages/js/src/components/Subheader.tsx +++ b/packages/js/src/components/Subheader.tsx @@ -2,7 +2,7 @@ import { h } from "preact"; export default function Subheader({ subheader, questionId }: { subheader?: string; questionId: string }) { return ( -