Compare commits

..

5 Commits

Author SHA1 Message Date
Matthias Nannt
f7165272f1 docs: fix js import in developer docs 2024-10-24 22:01:41 +02:00
Matthias Nannt
cd523daa73 fix build errors 2024-10-24 15:52:05 +02:00
Matthias Nannt
d646f82a4a move attributes to root level in local storage 2024-10-24 15:40:02 +02:00
Matthias Nannt
bacabca29f fix build issues 2024-10-24 15:18:08 +02:00
Matthias Nannt
646247024a chore: remove attributes from identify call 2024-10-24 15:11:38 +02:00
45 changed files with 113 additions and 426 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,45 +0,0 @@
import { MdxImage } from "@/components/MdxImage";
import SurveyEmbed from "@/components/SurveyEmbed";
import AddImageOrVideoToQuestionImage from "./images/add-image-or-video-to-question-image.webp";
import AddImageOrVideoToQuestionVideo from "./images/add-image-or-video-to-question-video.webp";
import AddImageOrVideoToQuestion from "./images/add-image-or-video-to-question.webp";
#### Add Image or Video to a Question
Enhance your questions by adding images or videos. This makes instructions clearer and the survey more engaging.
## How to Add Images
Click the icon on the right side of the question to add an image or video:
<MdxImage
src={AddImageOrVideoToQuestion}
alt="Overview of adding image or video to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Upload an image by clicking the upload icon or dragging the file:
<MdxImage
src={AddImageOrVideoToQuestionImage}
alt="Overview of adding image to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## How to Add Videos
Toggle to add a video via link:
<MdxImage
src={AddImageOrVideoToQuestionVideo}
alt="Overview of adding video to question"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
### Supported Video Platforms
We support YouTube, Vimeo, and Loom URLs.
<Note>**YouTube Privacy Mode**: This option reduces tracking by converting YouTube URLs to no-cookie URLs. It only works with YouTube.</Note>

View File

@@ -160,33 +160,6 @@ const NavigationGroup = ({
const pathname = usePathname();
const [isActiveGroup, setIsActiveGroup] = useState<boolean>(false);
// We need to expand the group with the current link so we loop over all links
// Until we find the one and then expand the groups
useEffect(() => {
const findMatchingGroup = () => {
for (const group of navigation) {
for (const link of group.links) {
if (!link.children) continue;
const matchingChild = link.children.find((child) => pathname && child.href.startsWith(pathname));
if (matchingChild) {
setOpenGroups([`${group.title}-${link.title}`]);
setActiveGroup(group);
return;
}
}
}
};
findMatchingGroup();
return () => {
setOpenGroups([]);
setActiveGroup(null);
};
}, [pathname, setActiveGroup, setOpenGroups]);
useEffect(() => {
setIsActiveGroup(activeGroup?.title === group.title);
}, [activeGroup?.title, group.title]);

View File

@@ -49,10 +49,6 @@ export const navigation: Array<NavGroup> = [
{ title: "Verify Email before Survey", href: "/link-surveys/verify-email-before-survey" },
{ title: "PIN Protected Surveys", href: "/link-surveys/pin-protected-surveys" },
{ title: "Partial Submissions", href: "/global/partial-submissions" },
{
title: "Add Image/Video to Question",
href: "/global/add-image-or-video-question",
},
],
},
],
@@ -80,10 +76,6 @@ export const navigation: Array<NavGroup> = [
{ title: "Recall Functionality", href: "/global/recall" }, // global
{ title: "Partial Submissions", href: "/global/partial-submissions" }, // global
{ title: "Shareable Dashboards", href: "/global/shareable-dashboards" },
{
title: "Add Image/Video to Question",
href: "/global/add-image-or-video-question",
},
],
},
],

View File

@@ -48,9 +48,6 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
"/animated-bgs/Thumbnails/36_Thumb.mp4": "/animated-bgs/4K/36_4k.mp4",
"/animated-bgs/Thumbnails/37_Thumb.mp4": "/animated-bgs/4K/37_4k.mp4",
"/animated-bgs/Thumbnails/38_Thumb.mp4": "/animated-bgs/4K/38_4k.mp4",
"/animated-bgs/Thumbnails/39_Thumb.mp4": "/animated-bgs/4K/39_4k.mp4",
"/animated-bgs/Thumbnails/40_Thumb.mp4": "/animated-bgs/4K/40_4k.mp4",
"/animated-bgs/Thumbnails/41_Thumb.mp4": "/animated-bgs/4K/41_4k.mp4",
};
const togglePlayback = (index: number, type: "play" | "pause") => {

View File

@@ -107,9 +107,9 @@ export const FormStylingSettings = ({
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<hr className="py-1 text-slate-600" key={"hello"} />
<div className="flex flex-col gap-6 p-6 pt-2">
<div className="flex flex-col gap-6 p-6 pt-2" key={"hjiii"}>
<div className="flex flex-col gap-2">
<FormField
control={form.control}

View File

@@ -1,4 +1,3 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useState } from "react";
import { TabBar } from "@formbricks/ui/components/TabBar";
import { AnimatedSurveyBg } from "./AnimatedSurveyBg";
@@ -31,7 +30,7 @@ export const SurveyBgSelectorTab = ({
isUnsplashConfigured,
}: SurveyBgSelectorTabProps) => {
const [activeTab, setActiveTab] = useState(bgType || "color");
const [parent] = useAutoAnimate();
const [colorBackground, setColorBackground] = useState(bg);
const [animationBackground, setAnimationBackground] = useState(bg);
const [uploadBackground, setUploadBackground] = useState(bg);
@@ -94,7 +93,7 @@ export const SurveyBgSelectorTab = ({
tabStyle="button"
className="bg-slate-100"
/>
<div className="w-full rounded-b-lg border-x border-b border-slate-200 px-4 pb-4 pt-2" ref={parent}>
<div className="w-full rounded-b-lg border-x border-b border-slate-200 px-4 pb-4 pt-2">
{renderContent()}
</div>
</div>

View File

@@ -61,7 +61,7 @@ export const SurveyMenuBar = ({
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
const [isSurveySaving, setIsSurveySaving] = useState(false);
const cautionText = "Changes will lead to inconsistencies.";
const cautionText = "This survey received responses.";
useEffect(() => {
if (audiencePrompt && activeId === "settings") {

View File

@@ -1,7 +1,6 @@
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/HardcodedTriggers";
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
import clsx from "clsx";
import { Webhook } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -43,11 +42,6 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? "Something went wrong please try again!");
return;
}
setHittingEndpoint(true);
await testEndpointAction({ url: testEndpointInput });
setHittingEndpoint(false);

View File

@@ -3,7 +3,6 @@
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/HardcodedTriggers";
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
import clsx from "clsx";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -47,11 +46,6 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen }: ActionSettings
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
if (!valid) {
toast.error(error ?? "Something went wrong please try again!");
return;
}
setHittingEndpoint(true);
await testEndpointAction({ url: testEndpointInput });
setHittingEndpoint(false);

View File

@@ -1,37 +0,0 @@
export const validWebHookURL = (urlInput: string) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
}
try {
const url = new URL(trimmedInput);
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}
const domainError: string =
"Please enter a complete URL with a valid domain (e.g., https://formbricks.com)";
const multipleSlashesPattern = /(?<!:)\/\/+/;
if (multipleSlashesPattern.test(trimmedInput)) {
return {
valid: false,
error: domainError,
};
}
const validDomainPattern = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!validDomainPattern.test(url.hostname)) {
return {
valid: false,
error: domainError,
};
}
return { valid: true };
} catch (error) {
return { valid: false, error: "Invalid URL format. Please enter a complete URL including https://" };
}
};

View File

@@ -44,7 +44,7 @@ export const CTASummary = ({ questionSummary, survey, attributeClasses }: CTASum
<p className="font-semibold text-slate-700">CTR</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
{convertFloatToNDecimal(questionSummary.ctr.percentage, 1)}%
</p>
</div>
</div>

View File

@@ -26,7 +26,7 @@ export const CalSummary = ({ questionSummary, survey, attributeClasses }: CalSum
<p className="font-semibold text-slate-700">Booked</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
{convertFloatToNDecimal(questionSummary.booked.percentage, 1)}%
</p>
</div>
</div>
@@ -42,7 +42,7 @@ export const CalSummary = ({ questionSummary, survey, attributeClasses }: CalSum
<p className="font-semibold text-slate-700">Dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
{convertFloatToNDecimal(questionSummary.skipped.percentage, 1)}%
</p>
</div>
</div>

View File

@@ -70,7 +70,7 @@ export const ConsentSummary = ({
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(summaryItem.percentage, 2)}%
{convertFloatToNDecimal(summaryItem.percentage, 1)}%
</p>
</div>
</div>

View File

@@ -1,4 +1,3 @@
import { InboxIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
@@ -69,14 +68,6 @@ export const MultipleChoiceSummary = ({
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
additionalInfo={
questionSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${questionSummary.selectionCount} Selections`}
</div>
) : undefined
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
@@ -101,12 +92,12 @@ export const MultipleChoiceSummary = ({
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
{convertFloatToNDecimal(result.percentage, 1)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? "Selection" : "Selections"}
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<div className="group-hover:opacity-80">

View File

@@ -76,7 +76,7 @@ export const NPSSummary = ({ questionSummary, survey, attributeClasses, setFilte
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
{convertFloatToNDecimal(questionSummary[group]?.percentage, 1)}%
</p>
</div>
</div>

View File

@@ -102,7 +102,7 @@ export const OpenTextSummary = ({
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell width={180}>
<TableCell>
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
@@ -110,7 +110,7 @@ export const OpenTextSummary = ({
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-normal text-slate-600 group-hover:underline md:ml-2">
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
@@ -119,12 +119,12 @@ export const OpenTextSummary = ({
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">Anonymous</p>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">{response.value}</TableCell>
<TableCell width={120}>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
<TableCell>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,4 +1,3 @@
import { InboxIcon } from "lucide-react";
import Image from "next/image";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import {
@@ -39,14 +38,6 @@ export const PictureChoiceSummary = ({
questionSummary={questionSummary}
survey={survey}
attributeClasses={attributeClasses}
additionalInfo={
questionSummary.question.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${questionSummary.selectionCount} Selections`}
</div>
) : undefined
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
@@ -75,12 +66,12 @@ export const PictureChoiceSummary = ({
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
{convertFloatToNDecimal(result.percentage, 1)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? "Selection" : "Selections"}
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />

View File

@@ -38,7 +38,7 @@ export const RankingSummary = ({
<div className="rounded bg-gray-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)}
#{convertFloatToNDecimal(result.avgRanking, 1)}
</span>
<span>average</span>
</span>

View File

@@ -78,7 +78,7 @@ export const RatingSummary = ({
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
{convertFloatToNDecimal(result.percentage, 1)}%
</p>
</div>
</div>

View File

@@ -1,6 +1,5 @@
import { TimerIcon } from "lucide-react";
import { getQuestionIcon } from "@formbricks/lib/utils/questions";
import { TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurveySummary } from "@formbricks/types/surveys/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/components/Tooltip";
interface SummaryDropOffsProps {
@@ -8,11 +7,6 @@ interface SummaryDropOffsProps {
}
export const SummaryDropOffs = ({ dropOff }: SummaryDropOffsProps) => {
const getIcon = (questionType: TSurveyQuestionType) => {
const Icon = getQuestionIcon(questionType);
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="">
@@ -37,10 +31,7 @@ export const SummaryDropOffs = ({ dropOff }: SummaryDropOffsProps) => {
<div
key={quesDropOff.questionId}
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
{getIcon(quesDropOff.questionType)}
<p>{quesDropOff.headline}</p>
</div>
<div className="col-span-3 pl-4 md:pl-6">{quesDropOff.headline}</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>

View File

@@ -138,10 +138,7 @@ export const SurveyAnalysisCTA = ({
)}
{!isViewer && (
<Button
href={`/environments/${environment.id}/surveys/${survey.id}/edit`}
EndIcon={SquarePenIcon}
size="base">
<Button href={`/environments/${environment.id}/surveys/${survey.id}/edit`} EndIcon={SquarePenIcon}>
Edit
</Button>
)}

View File

@@ -235,7 +235,6 @@ export const getSurveySummaryDropOff = (
const dropOff = survey.questions.map((question, index) => {
return {
questionId: question.id,
questionType: question.type,
headline: getLocalizedValue(question.headline, "default"),
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
impressions: impressionsArr[index] || 0,
@@ -319,7 +318,7 @@ export const getQuestionSummary = async (
insightsEnabled: question.insightsEnabled,
});
values = [];
values;
break;
}
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
@@ -340,8 +339,6 @@ export const getQuestionSummary = async (
}, {});
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
let totalSelectionCount = 0;
let totalResponseCount = 0;
responses.forEach((response) => {
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
@@ -350,51 +347,36 @@ export const getQuestionSummary = async (
? response.data[question.id]
: checkForI18n(response, question.id, survey, responseLanguageCode);
let hasValidAnswer = false;
if (Array.isArray(answer)) {
answer.forEach((value) => {
if (value) {
totalSelectionCount++;
if (questionChoices.includes(value)) {
choiceCountMap[value]++;
} else if (isOthersEnabled) {
otherValues.push({
value,
person: response.person,
personAttributes: response.personAttributes,
});
}
hasValidAnswer = true;
}
});
} else if (typeof answer === "string") {
if (answer) {
totalSelectionCount++;
if (questionChoices.includes(answer)) {
choiceCountMap[answer]++;
} else if (isOthersEnabled) {
if (questionChoices.includes(value)) {
choiceCountMap[value]++;
} else {
otherValues.push({
value: answer,
value,
person: response.person,
personAttributes: response.personAttributes,
});
}
hasValidAnswer = true;
});
} else if (typeof answer === "string") {
if (questionChoices.includes(answer)) {
choiceCountMap[answer]++;
} else {
otherValues.push({
value: answer,
person: response.person,
personAttributes: response.personAttributes,
});
}
}
if (hasValidAnswer) {
totalResponseCount++;
}
});
Object.entries(choiceCountMap).map(([label, count]) => {
values.push({
value: label,
count,
percentage:
totalSelectionCount > 0 ? convertFloatTo2Decimal((count / totalSelectionCount) * 100) : 0,
percentage: responses.length > 0 ? convertFloatTo2Decimal((count / responses.length) * 100) : 0,
});
});
@@ -402,18 +384,14 @@ export const getQuestionSummary = async (
values.push({
value: getLocalizedValue(lastChoice.label, "default") || "Other",
count: otherValues.length,
percentage:
totalSelectionCount > 0
? convertFloatTo2Decimal((otherValues.length / totalSelectionCount) * 100)
: 0,
percentage: convertFloatTo2Decimal((otherValues.length / responses.length) * 100),
others: otherValues.slice(0, VALUES_LIMIT),
});
}
summary.push({
type: question.type,
question,
responseCount: totalResponseCount,
selectionCount: totalSelectionCount,
responseCount: responses.length,
choices: values,
});
@@ -428,14 +406,12 @@ export const getQuestionSummary = async (
choiceCountMap[choice.id] = 0;
});
let totalResponseCount = 0;
let totalSelectionCount = 0;
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer)) {
totalResponseCount++;
answer.forEach((value) => {
totalSelectionCount++;
totalResponseCount++;
choiceCountMap[value]++;
});
}
@@ -447,8 +423,8 @@ export const getQuestionSummary = async (
imageUrl: choice.imageUrl,
count: choiceCountMap[choice.id],
percentage:
totalSelectionCount > 0
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalSelectionCount) * 100)
totalResponseCount > 0
? convertFloatTo2Decimal((choiceCountMap[choice.id] / totalResponseCount) * 100)
: 0,
});
});
@@ -457,7 +433,6 @@ export const getQuestionSummary = async (
type: question.type,
question,
responseCount: totalResponseCount,
selectionCount: totalSelectionCount,
choices: values,
});

View File

@@ -211,7 +211,6 @@ export const SigninForm = ({
)}
{emailAuthEnabled && (
<Button
size="base"
onClick={() => {
if (!showLogin) {
setShowLogin(true);
@@ -225,7 +224,7 @@ export const SigninForm = ({
loading={loggingIn}>
{totpLogin ? "Submit" : "Login with Email"}
{lastLoggedInWith && lastLoggedInWith === "Email" ? (
<span className="absolute right-3 text-xs opacity-50">Last Used</span>
<span className="absolute right-3 text-xs">Last Used</span>
) : null}
</Button>
)}

View File

@@ -1,13 +1,10 @@
import { prisma } from "@formbricks/database";
import { sendForgotPasswordEmail } from "@formbricks/email";
import { loginLimiter } from "@/app/middleware/bucket";
export const POST = async (request: Request) => {
const { email } = await request.json();
try {
await loginLimiter(request.headers.get("x-forwarded-for") || request.connection.remoteAddress);
const foundUser = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),

View File

@@ -29,8 +29,3 @@ export const syncUserIdentificationLimiter = rateLimit({
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
export const forgotPasswordLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
});

View File

@@ -28,5 +28,3 @@ export const isSyncWithUserIdentificationEndpoint = (
const match = url.match(regex);
return match ? { environmentId: match[1], userId: match[2] } : false;
};
export const forgotPasswordRoute = (url: string) => url === "/api/v1/users/forgot-password";

View File

@@ -4,7 +4,6 @@ import {
shareUrlLimiter,
signUpLimiter,
syncUserIdentificationLimiter,
forgotPasswordLimiter,
} from "@/app/middleware/bucket";
import {
clientSideApiRoute,
@@ -13,7 +12,6 @@ import {
loginRoute,
shareUrlRoute,
signupRoute,
forgotPasswordRoute,
} from "@/app/middleware/endpointValidator";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
@@ -62,8 +60,6 @@ export const middleware = async (request: NextRequest) => {
}
} else if (shareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter(`share-${ip}`);
} else if (forgotPasswordRoute(request.nextUrl.pathname)) {
await forgotPasswordLimiter(`forgot-password-${ip}`);
}
return NextResponse.next();
} catch (e) {
@@ -87,6 +83,5 @@ export const config = {
"/api/auth/signout",
"/auth/login",
"/api/packages/:path*",
"/api/v1/users/forgot-password",
],
};

View File

@@ -162,11 +162,6 @@ const nextConfig = {
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Content-Security-Policy",
value:
"default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; frame-src 'self'; media-src 'self' https:; object-src 'none'; base-uri 'self'; form-action 'self';",
},
],
},
{

View File

@@ -91,6 +91,19 @@ export const updateAttributes = async (
// clean attributes and remove existing attributes if config already exists
const updatedAttributes = { ...attributes };
try {
const existingAttributes = config.get().attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
delete updatedAttributes[key];
}
}
}
} catch (e) {
logger.debug("config not set; sending all attributes to backend");
}
// send to backend if updatedAttributes is not empty
if (Object.keys(updatedAttributes).length === 0) {
logger.debug("No attributes to update. Skipping update.");
@@ -125,6 +138,14 @@ export const updateAttributes = async (
}
};
export const isExistingAttribute = (key: string, value: string): boolean => {
if (config.get().attributes[key] === value) {
return true;
}
return false;
};
export const setAttributeInApp = async (
key: string,
value: any
@@ -137,6 +158,11 @@ export const setAttributeInApp = async (
const userId = config.get().personState.data.userId;
logger.debug("Setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
if (isExistingAttribute(key, value.toString())) {
logger.debug("Attribute already set to this value. Skipping update.");
return okVoid();
}
if (!userId) {
logger.error(
@@ -164,10 +190,6 @@ export const setAttributeInApp = async (
...config.get(),
personState,
filteredSurveys,
attributes: {
...config.get().attributes,
[key]: value.toString(),
},
});
}

View File

@@ -162,6 +162,24 @@ export const initialize = async (
logger.debug("Adding widget container to DOM");
addWidgetContainer();
let updatedAttributes: TAttributes | null = null;
if (configInput.attributes) {
if (configInput.userId) {
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
}
updatedAttributes = res.value;
} else {
updatedAttributes = { ...configInput.attributes };
}
}
if (
existingConfig &&
existingConfig.environmentState &&
@@ -177,12 +195,13 @@ export const initialize = async (
isEnvironmentStateExpired = true;
}
// if the config has a userId and the person state has expired, we need to sync the person state
if (
configInput.userId &&
(existingConfig.personState === null ||
(existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()))
existingConfig.personState.expiresAt &&
new Date(existingConfig.personState.expiresAt) < new Date()
) {
logger.debug("Person state needs syncing - either null or expired");
logger.debug("Person state expired. Syncing.");
isPersonStateExpired = true;
}
@@ -220,7 +239,7 @@ export const initialize = async (
environmentState,
personState,
filteredSurveys,
attributes: configInput.attributes ?? {},
attributes: configInput.attributes || {},
});
const surveyNames = filteredSurveys.map((s) => s.name);
@@ -229,30 +248,12 @@ export const initialize = async (
putFormbricksInErrorState(config);
}
} else {
logger.debug("No valid configuration found. Resetting config and creating new one.");
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
config.resetConfig();
logger.debug("Syncing.");
let updatedAttributes: TAttributes | null = null;
if (configInput.attributes) {
if (configInput.userId) {
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
}
updatedAttributes = res.value;
} else {
updatedAttributes = { ...configInput.attributes };
}
}
try {
const environmentState = await fetchEnvironmentState(
{
@@ -280,7 +281,7 @@ export const initialize = async (
personState,
environmentState,
filteredSurveys,
attributes: updatedAttributes ?? {},
attributes: configInput.attributes || {},
});
} catch (e) {
handleErrorOnFirstInit();
@@ -290,6 +291,17 @@ export const initialize = async (
await trackNoCodeAction("New Session");
}
// update attributes in config
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
config.update({
...config.get(),
attributes: {
...config.get().attributes,
...updatedAttributes,
},
});
}
logger.debug("Adding event listeners");
addEventListeners();
addCleanupEventListeners();

View File

@@ -367,7 +367,6 @@ export const mockSurveySummaryOutput = {
dropOffCount: 0,
dropOffPercentage: 0,
headline: "Question Text",
questionType: "openText",
questionId: "ars2tjk8hsi8oqk1uac00mo8",
ttc: 0,
impressions: 0,

View File

@@ -5323,129 +5323,6 @@ const employeeWellBeing = (): TTemplate => {
};
};
const longTermRetentionCheckIn = (): TTemplate => {
return {
name: "Long-Term Retention Check-In",
role: "productManager",
industries: ["saas", "other"],
channels: ["app", "link"],
description:
"Gauge long-term user satisfaction, loyalty, and areas for improvement to retain loyal users.",
preset: {
...surveyDefault,
name: "Long-Term Retention Check-In",
questions: [
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "star",
headline: { default: "How satisfied are you with {{productName}} overall?" },
required: true,
lowerLabel: { default: "Not satisfied" },
upperLabel: { default: "Very satisfied" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you find most valuable about {{productName}}?" },
required: false,
placeholder: { default: "Describe the feature or benefit you value most..." },
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
shuffleOption: "none",
choices: [
{ id: createId(), label: { default: "Features" } },
{ id: createId(), label: { default: "Customer support" } },
{ id: createId(), label: { default: "User experience" } },
{ id: createId(), label: { default: "Pricing" } },
{ id: createId(), label: { default: "Reliability and uptime" } },
],
headline: {
default: "Which aspect of {{productName}} do you find most essential to your experience?",
},
required: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: { default: "How well does {{productName}} meet your expectations?" },
required: true,
lowerLabel: { default: "Falls short" },
upperLabel: { default: "Exceeds expectations" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What challenges or frustrations have you faced while using {{productName}}?",
},
required: false,
placeholder: { default: "Describe any challenges or improvements youd like to see..." },
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" },
required: false,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
shuffleOption: "none",
choices: [
{ id: createId(), label: { default: "New features and improvements" } },
{ id: createId(), label: { default: "Enhanced customer support" } },
{ id: createId(), label: { default: "Better pricing options" } },
{ id: createId(), label: { default: "More integrations" } },
{ id: createId(), label: { default: "User experience refinements" } },
],
headline: { default: "What would make you more likely to remain a long-term user?" },
required: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "If you could change one thing about {{productName}}, what would it be?" },
required: false,
placeholder: { default: "Share any changes or features you wish wed consider..." },
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "smiley",
headline: { default: "How happy are you with our product updates and frequency?" },
required: true,
lowerLabel: { default: "Not happy" },
upperLabel: { default: "Very happy" },
isColorCodingEnabled: true,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Any additional feedback or comments?" },
required: false,
placeholder: { default: "Share any thoughts or feedback that might help us improve..." },
inputType: "text",
},
],
},
};
};
export const templates: TTemplate[] = [
cartAbandonmentSurvey(),
siteAbandonmentSurvey(),
@@ -5488,7 +5365,6 @@ export const templates: TTemplate[] = [
understandLowEngagement(),
employeeSatisfaction(),
employeeWellBeing(),
longTermRetentionCheckIn(),
];
export const customSurvey = {

View File

@@ -267,10 +267,6 @@ export const QUESTIONS_ICON_MAP: Record<TSurveyQuestionTypeEnum, JSX.Element> =
{} as Record<TSurveyQuestionTypeEnum, JSX.Element>
);
export const getQuestionIcon = (type: TSurveyQuestionTypeEnum) => {
return questionTypes.find((questionType) => questionType.id === type)?.icon;
};
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
(prev, curr) => ({
...prev,

View File

@@ -2106,7 +2106,6 @@ export const ZSurveyQuestionSummaryMultipleChoice = z.object({
type: z.union([z.literal("multipleChoiceMulti"), z.literal("multipleChoiceSingle")]),
question: ZSurveyMultipleChoiceQuestion,
responseCount: z.number(),
selectionCount: z.number(),
choices: z.array(
z.object({
value: z.string(),
@@ -2136,7 +2135,6 @@ export const ZSurveyQuestionSummaryPictureSelection = z.object({
type: z.literal("pictureSelection"),
question: ZSurveyPictureSelectionQuestion,
responseCount: z.number(),
selectionCount: z.number(),
choices: z.array(
z.object({
id: z.string(),
@@ -2426,7 +2424,6 @@ export const ZSurveySummary = z.object({
dropOff: z.array(
z.object({
questionId: z.string().cuid2(),
questionType: ZSurveyQuestionType,
headline: z.string(),
ttc: z.number(),
impressions: z.number(),

View File

@@ -34,7 +34,6 @@ export const AzureButton = ({
return (
<Button
size="base"
type="button"
EndIcon={MicrosoftIcon}
startIconClassName="ml-2"
@@ -42,7 +41,7 @@ export const AzureButton = ({
variant="secondary"
className="relative w-full justify-center">
{text}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">Last Used</span>}
{lastUsed && <span className="absolute right-3 text-xs">Last Used</span>}
</Button>
);
};

View File

@@ -26,7 +26,6 @@ export const GithubButton = ({
return (
<Button
size="base"
type="button"
EndIcon={GithubIcon}
startIconClassName="ml-2"
@@ -34,7 +33,7 @@ export const GithubButton = ({
variant="secondary"
className="relative w-full justify-center">
{text}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">Last Used</span>}
{lastUsed && <span className="absolute right-3 text-xs">Last Used</span>}
</Button>
);
};

View File

@@ -26,7 +26,6 @@ export const GoogleButton = ({
return (
<Button
size="base"
type="button"
EndIcon={GoogleIcon}
startIconClassName="ml-3"
@@ -34,7 +33,7 @@ export const GoogleButton = ({
variant="secondary"
className="relative w-full justify-center">
{text}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">Last Used</span>}
{lastUsed && <span className="absolute right-3 text-xs">Last Used</span>}
</Button>
);
};

View File

@@ -32,14 +32,13 @@ export const OpenIdButton = ({
return (
<Button
size="base"
type="button"
startIconClassName="ml-2"
onClick={handleLogin}
variant="secondary"
className="relative w-full justify-center">
{text}
{lastUsed && <span className="absolute right-3 text-xs opacity-50">Last Used</span>}
{lastUsed && <span className="absolute right-3 text-xs">Last Used</span>}
</Button>
);
};

View File

@@ -157,7 +157,6 @@ export const SignupOptions = ({
)}
{showLogin && (
<Button
size="base"
type="submit"
className="w-full justify-center"
loading={signingUp}
@@ -168,7 +167,6 @@ export const SignupOptions = ({
{!showLogin && (
<Button
size="base"
type="button"
onClick={() => {
setShowLogin(true);