Compare commits

...

14 Commits

Author SHA1 Message Date
Dhruwang Jariwala
6671d877ad fix: skip button label validation for required nps and rating questions (#5153) 2025-04-02 09:53:25 +00:00
Matti Nannt
2867c95494 chore: update SHARE_RATE_LIMIT to 50 request per 5 minute (#5194)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-04-02 09:48:59 +00:00
Johannes
aa55cec060 fix: bulk member invite and table layout (#5209) 2025-04-02 09:32:01 +00:00
Matti Nannt
dfb6c4cd9e chore: update demo app dependencies (#5207) 2025-04-02 06:34:15 +00:00
Dhruwang Jariwala
a9082f66e8 fix: (Security) implement HSTS (#5206) 2025-04-02 05:38:33 +00:00
Dhruwang Jariwala
bf39b0fbfb fix: added cache no-store when formbricksDebug is enabled (#5197)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-02 05:19:27 +00:00
Dhruwang Jariwala
e347f2179a fix: consent/cta back button issue (#5201) 2025-04-02 02:29:53 +00:00
Matti Nannt
d4f155b6bc chore: update storybook app dependencies (#5195) 2025-04-01 19:39:45 +02:00
Matti Nannt
da001834f5 chore: remove unused tailwind import from mobile SDK webviews (#5198) 2025-04-01 12:59:57 +00:00
Anshuman Pandey
f54352dd82 chore: changes storage cache to 5 minutes (#5196) 2025-04-01 07:25:17 +00:00
Matti Nannt
0fba0fae73 chore: remove posthog provider from top layout (#5169) 2025-04-01 06:24:17 +00:00
Anshuman Pandey
406ec88515 fix: adding back hidden fields for backwards compatibility (#5163) 2025-04-01 05:20:30 +00:00
Matti Nannt
b97957d166 chore(infra): increase ressource limits to 1 cpu & 1Gi mem (#5192) 2025-04-01 04:50:55 +00:00
Matti Nannt
655ad6b9e0 docs: fix response client api endpoint is missing environmentId (#5161) 2025-03-31 12:14:44 +02:00
50 changed files with 1324 additions and 1073 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/demo",
"version": "0.1.0",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -12,8 +12,8 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
"lucide-react": "0.468.0",
"next": "15.2.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},

View File

@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.16",
"react": "19.0.0",
"react-dom": "19.0.0"
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.2",
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"@storybook/addon-a11y": "8.6.11",
"@storybook/addon-essentials": "8.6.11",
"@storybook/addon-interactions": "8.6.11",
"@storybook/addon-links": "8.6.11",
"@storybook/addon-onboarding": "8.6.11",
"@storybook/blocks": "8.6.11",
"@storybook/react": "8.6.11",
"@storybook/react-vite": "8.6.11",
"@storybook/test": "8.6.11",
"@typescript-eslint/eslint-plugin": "8.29.0",
"@typescript-eslint/parser": "8.29.0",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.1",
"eslint-plugin-storybook": "0.11.1",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.12"
"storybook": "8.6.11",
"tsup": "8.4.0",
"vite": "6.2.4"
}
}

View File

@@ -47,12 +47,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ph-provider">{children}</div>
),
PostHogPageview: () => <div data-testid="ph-pageview" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
@@ -74,8 +68,6 @@ describe("(app) AppLayout", () => {
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");

View File

@@ -105,7 +105,6 @@ describe("RootLayout", () => {
console.log("vercel", process.env.VERCEL);
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");

View File

@@ -1,5 +1,4 @@
import { SentryProvider } from "@/app/sentry/SentryProvider";
import { PHProvider } from "@/modules/ui/components/post-hog-client";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
@@ -7,7 +6,7 @@ import { TolgeeStaticData } from "@tolgee/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
import React from "react";
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import "../modules/ui/globals.css";
export const metadata: Metadata = {
@@ -29,11 +28,9 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
<body className="flex h-dvh flex-col transition-all ease-in-out">
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
<SentryProvider sentryDsn={SENTRY_DSN}>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
</PHProvider>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
</SentryProvider>
</body>
</html>

View File

@@ -19,7 +19,7 @@ export const getFile = async (
headers: {
"Content-Type": metaData.contentType,
"Content-Disposition": "attachment",
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
@@ -35,10 +35,7 @@ export const getFile = async (
status: 302,
headers: {
Location: signedUrl,
"Cache-Control":
accessType === "public"
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
} catch (error: unknown) {

View File

@@ -26,12 +26,15 @@ export const EditMemberships = async ({
return (
<div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1">{t("common.full_name")}</div>
<div className="col-span-1 text-center">{t("common.email")}</div>
{canDoRoleManagement && <div className="col-span-1 text-center">{t("common.role")}</div>}
<div className="col-span-1 text-center">{t("common.status")}</div>
<div className="col-span-1"></div>
<div className="flex h-12 w-full max-w-full items-center gap-x-4 rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="w-1/2 overflow-hidden">{t("common.full_name")}</div>
<div className="w-1/2 overflow-hidden">{t("common.email")}</div>
{canDoRoleManagement && <div className="min-w-[100px] whitespace-nowrap">{t("common.role")}</div>}
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
</div>
{role && (

View File

@@ -110,47 +110,46 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
return (
<div className="flex gap-2">
{showDeleteButton && (
<>
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="secondary"
size="icon"
id="deleteMemberButton"
onClick={() => setDeleteMemberModalOpen(true)}>
<TrashIcon />
</Button>
</TooltipRenderer>
</>
)}
<TooltipRenderer tooltipContent={t("common.delete")} shouldRender={!!showDeleteButton}>
<Button
variant="destructive"
size="icon"
id="deleteMemberButton"
disabled={!showDeleteButton}
onClick={() => setDeleteMemberModalOpen(true)}>
<TrashIcon />
</Button>
</TooltipRenderer>
{invite && (
<>
<TooltipRenderer tooltipContent={t("environments.settings.general.share_invite_link")}>
<Button
variant="secondary"
size="icon"
id="shareInviteButton"
onClick={() => {
handleShareInvite();
}}>
<ShareIcon />
</Button>
</TooltipRenderer>
<TooltipRenderer
tooltipContent={t("environments.settings.general.share_invite_link")}
shouldRender={!!invite}>
<Button
variant="secondary"
size="icon"
id="shareInviteButton"
disabled={!invite}
onClick={() => {
handleShareInvite();
}}>
<ShareIcon />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("environments.settings.general.resend_invitation_email")}>
<Button
variant="secondary"
size="icon"
id="resendInviteButton"
onClick={() => {
handleResendInvite();
}}>
<SendHorizonalIcon />
</Button>
</TooltipRenderer>
</>
)}
<TooltipRenderer
tooltipContent={t("environments.settings.general.resend_invitation_email")}
shouldRender={!!invite}>
<Button
variant="secondary"
size="icon"
id="resendInviteButton"
disabled={!invite}
onClick={() => {
handleResendInvite();
}}>
<SendHorizonalIcon />
</Button>
</TooltipRenderer>
<DeleteDialog
open={isDeleteMemberModalOpen}

View File

@@ -86,20 +86,21 @@ export const MembersInfo = ({
};
return (
<div className="grid-cols-5" id="membersInfoWrapper">
<div className="max-w-full space-y-4 px-4 py-3" id="membersInfoWrapper">
{allMembers.map((member) => (
<div
className="singleMemberInfo grid h-auto w-full grid-cols-5 content-center rounded-lg px-4 py-3 text-left text-sm text-slate-900"
id="singleMemberInfo"
className="flex w-full max-w-full items-center gap-x-4 text-left text-sm text-slate-900"
key={member.email}>
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all">
<p>{member.name}</p>
<div className="ph-no-capture w-1/2 overflow-hidden">
<p className="w-full truncate">{member.name}</p>
</div>
<div className="ph-no-capture col-span-1 flex flex-col justify-center break-all text-center">
{member.email}
<div className="ph-no-capture w-1/2 overflow-hidden">
<p className="w-full truncate"> {member.email}</p>
</div>
<div className="ph-no-capture col-span-1 flex flex-col items-center justify-center break-all">
{canDoRoleManagement && allMembers?.length > 0 && (
{canDoRoleManagement && allMembers?.length > 0 && (
<div className="ph-no-capture min-w-[100px]">
<EditMembershipRole
currentUserRole={currentUserRole}
memberRole={member.role}
@@ -111,17 +112,16 @@ export const MembersInfo = ({
doesOrgHaveMoreThanOneOwner={doesOrgHaveMoreThanOneOwner}
isFormbricksCloud={isFormbricksCloud}
/>
)}
</div>
<div className="col-span-1 flex items-center justify-center">{getMembershipBadge(member)}</div>
<div className="col-span-1 flex items-center justify-end gap-x-4 pr-4">
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
/>
</div>
</div>
)}
<div className="min-w-[80px]">{getMembershipBadge(member)}</div>
<MemberActions
organization={organization}
member={!isInvitee(member) ? member : undefined}
invite={isInvitee(member) ? member : undefined}
showDeleteButton={showDeleteButton(member)}
/>
</div>
))}
</div>

View File

@@ -39,19 +39,23 @@ export const BulkInviteTab = ({
}
Papa.parse(csvFile, {
skipEmptyLines: true,
comments: "Full Name,Email Address,Role",
complete: (results: ParseResult<string[]>) => {
header: true,
transformHeader: (header) => {
if (header === "Full Name") return "name";
if (header === "Email Address") return "email";
if (header === "Role") return "role";
return header;
},
complete: (results: ParseResult<{ name: string; email: string; role: string }>) => {
const members = results.data.map((csv) => {
const [name, email, role] = csv;
let orgRole = canDoRoleManagement ? role.trim().toLowerCase() : "owner";
let orgRole = canDoRoleManagement ? csv.role.trim().toLowerCase() : "owner";
if (!isFormbricksCloud) {
orgRole = orgRole === "billing" ? "owner" : orgRole;
}
return {
name: name.trim(),
email: email.trim(),
name: csv.name.trim(),
email: csv.email.trim(),
role: orgRole as TOrganizationRole,
teamIds: [],
};
@@ -60,7 +64,6 @@ export const BulkInviteTab = ({
ZInvitees.parse(members);
onSubmit(members);
} catch (err) {
console.error(err.message);
toast.error(t("environments.settings.general.please_check_csv_file"));
}
setOpen(false);

View File

@@ -146,6 +146,10 @@ export const validationRules = {
fieldsToValidate = fieldsToValidate.filter((field) => field !== "backButtonLabel");
}
if ((question.type === "nps" || question.type === "rating") && question.required) {
fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel");
}
for (const field of fieldsToValidate) {
if (
question[field] &&

View File

@@ -116,6 +116,15 @@ const nextConfig = {
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
{
// matching all API routes
source: "/api/v1/client/:path*",
@@ -321,8 +330,6 @@ const sentryConfig = {
disableLogger: true,
};
const exportConfig = process.env.SENTRY_DSN
? withSentryConfig(nextConfig, sentryOptions)
: nextConfig;
const exportConfig = process.env.SENTRY_DSN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
export default nextConfig;

View File

@@ -48,7 +48,7 @@ test.describe("Invite, accept and remove organization member", async () => {
await test.step("Copy invite Link", async () => {
await expect(page.locator("#membersInfoWrapper")).toBeVisible();
const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
const lastMemberInfo = page.locator("#membersInfoWrapper > #singleMemberInfo:last-child");
await expect(lastMemberInfo).toBeVisible();
const pendingSpan = lastMemberInfo.locator("span").locator("span").filter({ hasText: "Pending" });
@@ -106,7 +106,7 @@ test.describe("Invite, accept and remove organization member", async () => {
// await expect(page.locator("#membersInfoWrapper")).toBeVisible();
// const lastMemberInfo = page.locator("#membersInfoWrapper > .singleMemberInfo:last-child");
// const lastMemberInfo = page.locator("#membersInfoWrapper > #singleMemberInfo:last-child");
// await expect(lastMemberInfo).toBeVisible();
// const deleteMemberButton = lastMemberInfo.locator("#deleteMemberButton");

View File

@@ -1,3 +1,3 @@
---
openapi: put /api/v1/client/responses/{responseId}
---
openapi: put /api/v1/client/{environmentId}/responses/{responseId}
---

View File

@@ -6,208 +6,6 @@
},
"openapi": "3.0.0",
"paths": {
"/api/v1/client/responses/{responseId}": {
"put": {
"description": "Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
"parameters": [
{
"in": "path",
"name": "responseId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"example": {
"data": {
"hs8yd14l9h8u353tjmv6rzawqqq": "clicked",
"tcgls0063n8ri7dtrbnepcmz": "Who? Who? Who?"
},
"finished": true
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {}
},
"schema": {
"type": "object"
}
}
},
"description": "OK",
"headers": {
"Access-Control-Allow-Credentials": {
"schema": {
"example": "true",
"type": "boolean"
}
},
"Access-Control-Allow-Origin": {
"schema": {
"example": "*",
"type": "string"
}
},
"Connection": {
"schema": {
"example": "keep-alive",
"type": "string"
}
},
"Date": {
"schema": {
"example": "Tue, 23 Apr 2024 08:09:19 GMT",
"type": "string"
}
},
"Keep-Alive": {
"schema": {
"example": "timeout=5",
"type": "string"
}
},
"Transfer-Encoding": {
"schema": {
"example": "chunked",
"type": "string"
}
},
"access-control-allow-headers": {
"schema": {
"example": "Content-Type, Authorization",
"type": "string"
}
},
"access-control-allow-methods": {
"schema": {
"example": "GET, POST, PUT, DELETE, OPTIONS",
"type": "string"
}
},
"cache-control": {
"schema": {
"example": "private, no-store",
"type": "string"
}
},
"content-type": {
"schema": {
"example": "application/json",
"type": "string"
}
},
"vary": {
"schema": {
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
"type": "string"
}
}
}
},
"404": {
"content": {
"application/json": {
"example": {
"code": "not_found",
"details": {
"resource_id": "nonexistentid",
"resource_type": "Response"
},
"message": "Response not found"
},
"schema": {
"type": "object"
}
}
},
"description": "Not Found",
"headers": {
"Access-Control-Allow-Credentials": {
"schema": {
"example": "true",
"type": "boolean"
}
},
"Access-Control-Allow-Origin": {
"schema": {
"example": "*",
"type": "string"
}
},
"Connection": {
"schema": {
"example": "keep-alive",
"type": "string"
}
},
"Date": {
"schema": {
"example": "Tue, 23 Apr 2024 08:13:50 GMT",
"type": "string"
}
},
"Keep-Alive": {
"schema": {
"example": "timeout=5",
"type": "string"
}
},
"Transfer-Encoding": {
"schema": {
"example": "chunked",
"type": "string"
}
},
"access-control-allow-headers": {
"schema": {
"example": "Content-Type, Authorization",
"type": "string"
}
},
"access-control-allow-methods": {
"schema": {
"example": "GET, POST, PUT, DELETE, OPTIONS",
"type": "string"
}
},
"cache-control": {
"schema": {
"example": "private, no-store",
"type": "string"
}
},
"content-type": {
"schema": {
"example": "application/json",
"type": "string"
}
},
"vary": {
"schema": {
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
"type": "string"
}
}
}
}
},
"summary": "Update Response",
"tags": ["Client API > Response"]
}
},
"/api/v1/client/{environmentId}/contacts/{userId}/attributes": {
"put": {
"description": "Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.",
@@ -1714,6 +1512,208 @@
"tags": ["Client API > Response"]
}
},
"/api/v1/client/{environmentId}/responses/{responseId}": {
"put": {
"description": "Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
"parameters": [
{
"in": "path",
"name": "responseId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"example": {
"data": {
"hs8yd14l9h8u353tjmv6rzawqqq": "clicked",
"tcgls0063n8ri7dtrbnepcmz": "Who? Who? Who?"
},
"finished": true
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"example": {
"data": {}
},
"schema": {
"type": "object"
}
}
},
"description": "OK",
"headers": {
"Access-Control-Allow-Credentials": {
"schema": {
"example": "true",
"type": "boolean"
}
},
"Access-Control-Allow-Origin": {
"schema": {
"example": "*",
"type": "string"
}
},
"Connection": {
"schema": {
"example": "keep-alive",
"type": "string"
}
},
"Date": {
"schema": {
"example": "Tue, 23 Apr 2024 08:09:19 GMT",
"type": "string"
}
},
"Keep-Alive": {
"schema": {
"example": "timeout=5",
"type": "string"
}
},
"Transfer-Encoding": {
"schema": {
"example": "chunked",
"type": "string"
}
},
"access-control-allow-headers": {
"schema": {
"example": "Content-Type, Authorization",
"type": "string"
}
},
"access-control-allow-methods": {
"schema": {
"example": "GET, POST, PUT, DELETE, OPTIONS",
"type": "string"
}
},
"cache-control": {
"schema": {
"example": "private, no-store",
"type": "string"
}
},
"content-type": {
"schema": {
"example": "application/json",
"type": "string"
}
},
"vary": {
"schema": {
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
"type": "string"
}
}
}
},
"404": {
"content": {
"application/json": {
"example": {
"code": "not_found",
"details": {
"resource_id": "nonexistentid",
"resource_type": "Response"
},
"message": "Response not found"
},
"schema": {
"type": "object"
}
}
},
"description": "Not Found",
"headers": {
"Access-Control-Allow-Credentials": {
"schema": {
"example": "true",
"type": "boolean"
}
},
"Access-Control-Allow-Origin": {
"schema": {
"example": "*",
"type": "string"
}
},
"Connection": {
"schema": {
"example": "keep-alive",
"type": "string"
}
},
"Date": {
"schema": {
"example": "Tue, 23 Apr 2024 08:13:50 GMT",
"type": "string"
}
},
"Keep-Alive": {
"schema": {
"example": "timeout=5",
"type": "string"
}
},
"Transfer-Encoding": {
"schema": {
"example": "chunked",
"type": "string"
}
},
"access-control-allow-headers": {
"schema": {
"example": "Content-Type, Authorization",
"type": "string"
}
},
"access-control-allow-methods": {
"schema": {
"example": "GET, POST, PUT, DELETE, OPTIONS",
"type": "string"
}
},
"cache-control": {
"schema": {
"example": "private, no-store",
"type": "string"
}
},
"content-type": {
"schema": {
"example": "application/json",
"type": "string"
}
},
"vary": {
"schema": {
"example": "RSC, Next-Router-State-Tree, Next-Router-Prefetch",
"type": "string"
}
}
}
}
},
"summary": "Update Response",
"tags": ["Client API > Response"]
}
},
"/api/v1/client/{environmentId}/user": {
"post": {
"description": "Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.\n",

View File

@@ -77,8 +77,8 @@ deployment:
limits:
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
cpu: 1
memory: 1Gi
env:
DOCKER_CRON_ENABLED:
value: "0"

View File

@@ -31,7 +31,6 @@ class FormbricksViewModel : ViewModel() {
<head>
<title>Formbricks WebView Survey</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">

View File

@@ -1,31 +0,0 @@
import { type TAttributeUpdateInput } from "@formbricks/types/attributes";
import { type Result } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class AttributeAPI {
private appUrl: string;
private environmentId: string;
constructor(appUrl: string, environmentId: string) {
this.appUrl = appUrl;
this.environmentId = environmentId;
}
async update(
attributeUpdateInput: Omit<TAttributeUpdateInput, "environmentId">
): Promise<Result<{ changed: boolean; message: string; messages?: string[] }, ApiErrorResponse>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};
for (const key in attributeUpdateInput.attributes) {
attributes[key] = String(attributeUpdateInput.attributes[key]);
}
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/contacts/${attributeUpdateInput.userId}/attributes`,
"PUT",
{ attributes }
);
}
}

View File

@@ -1,20 +0,0 @@
import { type TDisplayCreateInput } from "@formbricks/types/displays";
import { type Result } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/make-request";
export class DisplayAPI {
private appUrl: string;
private environmentId: string;
constructor(appUrl: string, environmentId: string) {
this.appUrl = appUrl;
this.environmentId = environmentId;
}
async create(
displayInput: Omit<TDisplayCreateInput, "environmentId">
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
}
}

View File

@@ -6,13 +6,21 @@ import { makeRequest } from "../../utils/make-request";
export class EnvironmentAPI {
private appUrl: string;
private environmentId: string;
private isDebug: boolean;
constructor(appUrl: string, environmentId: string) {
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
this.appUrl = appUrl;
this.environmentId = environmentId;
this.isDebug = isDebug;
}
async getState(): Promise<Result<TJsEnvironmentState, ApiErrorResponse>> {
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/environment`, "GET");
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/environment`,
"GET",
undefined,
this.isDebug
);
}
}

View File

@@ -1,27 +1,16 @@
import { type ApiConfig } from "../../types";
import { AttributeAPI } from "./attribute";
import { DisplayAPI } from "./display";
import { EnvironmentAPI } from "./environment";
import { ResponseAPI } from "./response";
import { StorageAPI } from "./storage";
import { UserAPI } from "./user";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
storage: StorageAPI;
attribute: AttributeAPI;
user: UserAPI;
environment: EnvironmentAPI;
constructor(options: ApiConfig) {
const { appUrl, environmentId } = options;
const { appUrl, environmentId, isDebug } = options;
const isDebugMode = isDebug ?? false;
this.response = new ResponseAPI(appUrl, environmentId);
this.display = new DisplayAPI(appUrl, environmentId);
this.attribute = new AttributeAPI(appUrl, environmentId);
this.storage = new StorageAPI(appUrl, environmentId);
this.user = new UserAPI(appUrl, environmentId);
this.environment = new EnvironmentAPI(appUrl, environmentId);
this.user = new UserAPI(appUrl, environmentId, isDebugMode);
this.environment = new EnvironmentAPI(appUrl, environmentId, isDebugMode);
}
}

View File

@@ -1,41 +0,0 @@
import { type Result } from "@formbricks/types/error-handlers";
import { type ApiErrorResponse } from "@formbricks/types/errors";
import { type TResponseInput, type TResponseUpdateInput } from "@formbricks/types/responses";
import { makeRequest } from "../../utils/make-request";
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
export class ResponseAPI {
private appUrl: string;
private environmentId: string;
constructor(appUrl: string, environmentId: string) {
this.appUrl = appUrl;
this.environmentId = environmentId;
}
async create(
responseInput: Omit<TResponseInput, "environmentId">
): Promise<Result<{ id: string }, ApiErrorResponse>> {
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}
async update({
responseId,
finished,
endingId,
data,
ttc,
variables,
language,
}: TResponseUpdateInputWithResponseId): Promise<Result<object, ApiErrorResponse>> {
return makeRequest(this.appUrl, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
endingId,
data,
ttc,
variables,
language,
});
}
}

View File

@@ -1,127 +0,0 @@
/* eslint-disable no-console -- used for error logging */
import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
export class StorageAPI {
private appUrl: string;
private environmentId: string;
constructor(appUrl: string, environmentId: string) {
this.appUrl = appUrl;
this.environmentId = environmentId;
}
async uploadFile(
file: {
type: string;
name: string;
base64: string;
},
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
): Promise<string> {
if (!file.name || !file.type || !file.base64) {
throw new Error(`Invalid file object`);
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
surveyId,
};
const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${String(response.status)}`);
}
const json = (await response.json()) as TUploadFileResponse;
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
surveyId: surveyId ?? "",
signature,
timestamp: String(timestamp),
uuid,
};
}
const formData: Record<string, string> = {};
const formDataForS3 = new FormData();
if (presignedFields) {
Object.entries(presignedFields).forEach(([key, value]) => {
formDataForS3.append(key, value);
});
try {
const binaryString = atob(file.base64.split(",")[1]);
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
const blob = new Blob([uint8Array], { type: file.type });
formDataForS3.append("file", blob);
} catch (err) {
console.error(err);
throw new Error("Error uploading file");
}
}
formData.fileBase64String = file.base64;
let uploadResponse: Response = {} as Response;
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
try {
uploadResponse = await fetch(signedUrlCopy, {
method: "POST",
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
} catch (err) {
console.error("Error uploading file", err);
}
if (!uploadResponse.ok) {
// if local storage is used, we'll use the json response:
if (signingData) {
const uploadJson = (await uploadResponse.json()) as { message: string };
const error = new Error(uploadJson.message);
error.name = "FileTooLargeError";
throw error;
}
// if s3 is used, we'll use the text response:
const errorText = await uploadResponse.text();
if (presignedFields && errorText.includes("EntityTooLarge")) {
const error = new Error("File size exceeds the size limit for your plan");
error.name = "FileTooLargeError";
throw error;
}
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
}
return fileUrl;
}
}

View File

@@ -5,10 +5,12 @@ import { makeRequest } from "../../utils/make-request";
export class UserAPI {
private appUrl: string;
private environmentId: string;
private isDebug: boolean;
constructor(appUrl: string, environmentId: string) {
constructor(appUrl: string, environmentId: string, isDebug: boolean) {
this.appUrl = appUrl;
this.environmentId = environmentId;
this.isDebug = isDebug;
}
async createOrUpdate(userUpdateInput: { userId: string; attributes?: Record<string, string> }): Promise<
@@ -37,9 +39,15 @@ export class UserAPI {
attributes[key] = String(userUpdateInput.attributes[key]);
}
return makeRequest(this.appUrl, `/api/v2/client/${this.environmentId}/user`, "POST", {
userId: userUpdateInput.userId,
attributes,
});
return makeRequest(
this.appUrl,
`/api/v2/client/${this.environmentId}/user`,
"POST",
{
userId: userUpdateInput.userId,
attributes,
},
this.isDebug
);
}
}

View File

@@ -3,6 +3,7 @@ import { type ApiErrorResponse } from "@formbricks/types/errors";
export interface ApiConfig {
environmentId: string;
appUrl: string;
isDebug?: boolean;
}
export type ApiResponse = ApiSuccessResponse | ApiErrorResponse;

View File

@@ -6,15 +6,16 @@ export const makeRequest = async <T>(
appUrl: string,
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE",
data?: unknown
data?: unknown,
isDebug?: boolean
): Promise<Result<T, ApiErrorResponse>> => {
const url = new URL(appUrl + endpoint);
const body = data ? JSON.stringify(data) : undefined;
const res = await wrapThrowsAsync(fetch)(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
...(isDebug && { "Cache-Control": "no-cache" }),
},
body,
});

View File

@@ -7,9 +7,9 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "5.2.0",
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "0.6.9",
"prettier-plugin-sort-json": "4.0.0"
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "0.6.11",
"prettier-plugin-sort-json": "4.1.1"
}
}

View File

@@ -24,7 +24,6 @@ private extension FormbricksViewModel {
<head>
<title>Formbricks WebView Survey</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">

View File

@@ -7,6 +7,7 @@ import { checkPageUrl } from "@/lib/survey/no-code-action";
import * as Attribute from "@/lib/user/attribute";
import * as User from "@/lib/user/user";
import { type TConfigInput, type TLegacyConfigInput } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
const queue = new CommandQueue();
@@ -67,8 +68,12 @@ const logout = async (): Promise<void> => {
await queue.wait();
};
const track = async (code: string): Promise<void> => {
queue.add(Action.trackCodeAction, true, code);
/**
* @param code - The code of the action to track
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
*/
const track = async (code: string, properties?: TTrackProperties): Promise<void> => {
queue.add<string | TTrackProperties | undefined>(Action.trackCodeAction, true, code, properties);
await queue.wait();
};
@@ -91,5 +96,6 @@ const formbricks = {
registerRouteChange,
};
export type TFormbricks = typeof formbricks;
type TFormbricks = typeof formbricks;
export type { TFormbricks };
export default formbricks;

View File

@@ -4,9 +4,13 @@ import { checkSetup } from "@/lib/common/setup";
import { wrapThrowsAsync } from "@/lib/common/utils";
import type { Result } from "@/types/error";
export type TCommand = (
...args: any[]
) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
export class CommandQueue {
private queue: {
command: (...args: any[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
command: TCommand;
checkSetup: boolean;
commandArgs: any[];
}[] = [];
@@ -14,11 +18,7 @@ export class CommandQueue {
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
public add<A>(
command: (...args: A[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>,
shouldCheckSetup = true,
...args: A[]
): void {
public add<A>(command: TCommand, shouldCheckSetup = true, ...args: A[]): void {
this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args });
if (!this.running) {

View File

@@ -179,7 +179,11 @@ export const setup = async (
let environmentState: TEnvironmentState = existingConfig.environment;
let userState: TUserState = existingConfig.user;
if (isEnvironmentStateExpired) {
if (isEnvironmentStateExpired || isDebug) {
if (isDebug) {
logger.debug("Debug mode is active, refetching environment state");
}
const environmentStateResponse = await fetchEnvironmentState({
appUrl: configInput.appUrl,
environmentId: configInput.environmentId,
@@ -201,10 +205,14 @@ export const setup = async (
}
}
if (isUserStateExpired) {
if (isUserStateExpired || isDebug) {
// If the existing person state (expired) has a userId, we need to fetch the person state
// If the existing person state (expired) has no userId, we need to set the person state to the default
if (isDebug) {
logger.debug("Debug mode is active, refetching user state");
}
if (userState.data.userId) {
const updatesResponse = await sendUpdatesToBackend({
appUrl: configInput.appUrl,
@@ -230,7 +238,7 @@ export const setup = async (
responseMessage: "Unknown error",
});
}
} else {
} else if (!isDebug) {
userState = DEFAULT_USER_STATE_NO_USER_ID;
}
}
@@ -271,7 +279,6 @@ export const setup = async (
throw environmentStateResponse.error;
}
// const personState = DEFAULT_USER_STATE_NO_USER_ID;
let userState: TUserState = DEFAULT_USER_STATE_NO_USER_ID;
if ("userId" in configInput && configInput.userId) {

View File

@@ -1,3 +1,4 @@
import { Logger } from "@/lib/common/logger";
import type {
TEnvironmentState,
TEnvironmentStateActionClass,
@@ -8,7 +9,11 @@ import type {
TUserState,
} from "@/types/config";
import type { Result } from "@/types/error";
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
import {
type TActionClassNoCodeConfig,
type TActionClassPageUrlRule,
type TTrackProperties,
} from "@/types/survey";
// Helper function to calculate difference in days between two dates
export const diffInDays = (date1: Date, date2: Date): number => {
@@ -225,6 +230,38 @@ export const handleUrlFilters = (urlFilters: TActionClassNoCodeConfig["urlFilter
return isMatch;
};
export const handleHiddenFields = (
hiddenFieldsConfig: TEnvironmentStateSurvey["hiddenFields"],
hiddenFields?: TTrackProperties["hiddenFields"]
): TTrackProperties["hiddenFields"] => {
const logger = Logger.getInstance();
const { enabled: enabledHiddenFields, fieldIds: surveyHiddenFieldIds } = hiddenFieldsConfig;
let hiddenFieldsObject: TTrackProperties["hiddenFields"] = {};
if (!enabledHiddenFields) {
logger.error("Hidden fields are not enabled for this survey");
} else if (surveyHiddenFieldIds && hiddenFields) {
const unknownHiddenFields: string[] = [];
hiddenFieldsObject = Object.keys(hiddenFields).reduce<TTrackProperties["hiddenFields"]>((acc, key) => {
if (surveyHiddenFieldIds.includes(key)) {
acc[key] = hiddenFields[key];
} else {
unknownHiddenFields.push(key);
}
return acc;
}, {});
if (unknownHiddenFields.length > 0) {
logger.error(
`Unknown hidden fields: ${unknownHiddenFields.join(", ")}. Please add them to the survey hidden fields.`
);
}
}
return hiddenFieldsObject;
};
export const evaluateNoCodeConfigClick = (
targetElement: HTMLElement,
action: TEnvironmentStateActionClass

View File

@@ -2,7 +2,7 @@
import { FormbricksAPI } from "@formbricks/api";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys } from "@/lib/common/utils";
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
import type { TConfigInput, TEnvironmentState } from "@/types/config";
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
@@ -20,7 +20,7 @@ export const fetchEnvironmentState = async ({
environmentId,
}: TConfigInput): Promise<Result<TEnvironmentState, ApiErrorResponse>> => {
const url = `${appUrl}/api/v1/client/${environmentId}/environment`;
const api = new FormbricksAPI({ appUrl, environmentId });
const api = new FormbricksAPI({ appUrl, environmentId, isDebug: getIsDebug() });
try {
const response = await api.client.environment.getState();

View File

@@ -37,6 +37,7 @@ vi.mock("@/lib/common/logger", () => ({
// Mock filterSurveys
vi.mock("@/lib/common/utils", () => ({
filterSurveys: vi.fn(),
getIsDebug: vi.fn(),
}));
// Mock Config

View File

@@ -2,14 +2,20 @@ import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { triggerSurvey } from "@/lib/survey/widget";
import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error";
import { type TTrackProperties } from "@/types/survey";
/**
* Tracks an action name and triggers associated surveys
* @param name - The name of the action to track
* @param alias - Optional alias for the action name
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
* @returns Result indicating success or network error
*/
export const trackAction = async (name: string, alias?: string): Promise<Result<void, NetworkError>> => {
export const trackAction = async (
name: string,
alias?: string,
properties?: TTrackProperties
): Promise<Result<void, NetworkError>> => {
const logger = Logger.getInstance();
const appConfig = Config.getInstance();
@@ -24,7 +30,7 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
await triggerSurvey(survey, name);
await triggerSurvey(survey, name, properties);
}
}
}
@@ -38,10 +44,12 @@ export const trackAction = async (name: string, alias?: string): Promise<Result<
/**
* Tracks an action by its code and triggers associated surveys (used for code actions only)
* @param code - The action code to track
* @param properties - Optional properties to set, like the hidden fields (deprecated, hidden fields will be removed in a future version)
* @returns Result indicating success, network error, or invalid code error
*/
export const trackCodeAction = async (
code: string
code: string,
properties?: TTrackProperties
): Promise<Result<void, NetworkError> | Result<void, InvalidCodeError>> => {
const appConfig = Config.getInstance();
@@ -61,7 +69,7 @@ export const trackCodeAction = async (
});
}
return trackAction(actionClass.name, code);
return trackAction(actionClass.name, code, properties);
};
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {

View File

@@ -33,6 +33,7 @@ vi.mock("@/lib/common/logger", () => ({
vi.mock("@/lib/common/utils", () => ({
shouldDisplayBasedOnPercentage: vi.fn(),
handleHiddenFields: vi.fn(),
}));
vi.mock("@/lib/survey/widget", () => ({
@@ -100,10 +101,10 @@ describe("survey/action.ts", () => {
filteredSurveys: [mockSurvey],
});
const result = await trackAction("testAction");
const result = await trackAction("testAction", undefined);
expect(result.ok).toBe(true);
expect(triggerSurvey).toHaveBeenCalledWith(mockSurvey, "testAction");
expect(triggerSurvey).toHaveBeenCalledWith(mockSurvey, "testAction", undefined);
});
test("handles multiple matching surveys", async () => {

View File

@@ -40,6 +40,7 @@ vi.mock("@/lib/common/utils", () => ({
getStyling: vi.fn(),
shouldDisplayBasedOnPercentage: vi.fn(),
wrapThrowsAsync: vi.fn(),
handleHiddenFields: vi.fn(),
}));
describe("widget-file", () => {

View File

@@ -7,9 +7,11 @@ import {
filterSurveys,
getLanguageCode,
getStyling,
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
let isSurveyRunning = false;
@@ -17,7 +19,11 @@ export const setIsSurveyRunning = (value: boolean): void => {
isSurveyRunning = value;
};
export const triggerSurvey = async (survey: TEnvironmentStateSurvey, action?: string): Promise<void> => {
export const triggerSurvey = async (
survey: TEnvironmentStateSurvey,
action?: string,
properties?: TTrackProperties
): Promise<void> => {
const logger = Logger.getInstance();
// Check if the survey should be displayed based on displayPercentage
@@ -29,10 +35,19 @@ export const triggerSurvey = async (survey: TEnvironmentStateSurvey, action?: st
}
}
await renderWidget(survey, action);
const hiddenFieldsObject: TTrackProperties["hiddenFields"] = handleHiddenFields(
survey.hiddenFields,
properties?.hiddenFields
);
await renderWidget(survey, action, hiddenFieldsObject);
};
export const renderWidget = async (survey: TEnvironmentStateSurvey, action?: string): Promise<void> => {
export const renderWidget = async (
survey: TEnvironmentStateSurvey,
action?: string,
hiddenFieldsObject?: TTrackProperties["hiddenFields"]
): Promise<void> => {
const logger = Logger.getInstance();
const config = Config.getInstance();
const timeoutStack = TimeoutStack.getInstance();
@@ -87,6 +102,7 @@ export const renderWidget = async (survey: TEnvironmentStateSurvey, action?: str
languageCode,
placement,
styling: getStyling(project, survey),
hiddenFieldsRecord: hiddenFieldsObject,
onDisplayCreated: () => {
const existingDisplays = config.get().user.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };

View File

@@ -30,6 +30,7 @@ vi.mock("@/lib/common/logger", () => ({
vi.mock("@/lib/common/utils", () => ({
filterSurveys: vi.fn(),
getIsDebug: vi.fn(),
}));
vi.mock("@formbricks/api", () => ({

View File

@@ -2,7 +2,7 @@
import { FormbricksAPI } from "@formbricks/api";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys } from "@/lib/common/utils";
import { filterSurveys, getIsDebug } from "@/lib/common/utils";
import { type TUpdates, type TUserState } from "@/types/config";
import { type ApiErrorResponse, type Result, type ResultError, err, ok, okVoid } from "@/types/error";
@@ -26,7 +26,7 @@ export const sendUpdatesToBackend = async ({
const url = `${appUrl}/api/v1/client/${environmentId}/user`;
try {
const api = new FormbricksAPI({ appUrl, environmentId });
const api = new FormbricksAPI({ appUrl, environmentId, isDebug: getIsDebug() });
const response = await api.client.user.createOrUpdate({
userId: updates.userId,

View File

@@ -75,3 +75,7 @@ export type TActionClassNoCodeConfig =
rule: TActionClassPageUrlRule;
}[];
};
export interface TTrackProperties {
hiddenFields: Record<string, string | number | string[]>;
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "4.0.0",
"version": "4.1.0",
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -1,7 +1,10 @@
import type Formbricks from "@formbricks/js-core";
import { type TFormbricks as TFormbricksCore } from "@formbricks/js-core";
import { loadFormbricksToProxy } from "./lib/load-formbricks";
type TFormbricks = typeof Formbricks;
type TFormbricks = Omit<TFormbricksCore, "track"> & {
track: (code: string) => Promise<void>;
};
declare global {
interface Window {
formbricks: TFormbricks | undefined;
@@ -14,7 +17,7 @@ const formbricksProxyHandler: ProxyHandler<TFormbricks> = {
},
};
const formbricks: TFormbricks = new Proxy({} as TFormbricks, formbricksProxyHandler);
const formbricks: TFormbricksCore = new Proxy({} as TFormbricks, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricks;

View File

@@ -174,8 +174,8 @@ export const MANAGEMENT_API_RATE_LIMIT = {
};
export const SHARE_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 100,
interval: 60 * 1, // 1 minutes
allowedPerInterval: 30,
};
export const FORGET_PASSWORD_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes

View File

@@ -104,9 +104,6 @@ type TGetSignedUrlResponse =
};
const getS3SignedUrl = async (fileKey: string): Promise<string> => {
const [_, accessType] = fileKey.split("/");
const expiresIn = accessType === "public" ? 60 * 60 : 10 * 60;
const getObjectCommand = new GetObjectCommand({
Bucket: S3_BUCKET_NAME,
Key: fileKey,
@@ -114,7 +111,7 @@ const getS3SignedUrl = async (fileKey: string): Promise<string> => {
try {
const s3Client = getS3Client();
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn });
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 });
} catch (err) {
throw err;
}

View File

@@ -219,7 +219,6 @@ const renderHtml = (options: Partial<SurveyContainerProps> & { appUrl?: string }
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
<head>
<title>Formbricks WebView Survey</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body style="overflow: hidden; height: 100vh; margin: 0;">
</body>

View File

@@ -135,7 +135,6 @@ export function ConsentQuestion({
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
onBack();
}}
/>

View File

@@ -107,7 +107,6 @@ export function CTAQuestion({
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: "" }, updatedTtcObj);
onBack();
}}
/>

View File

@@ -943,11 +943,19 @@ export const ZSurvey = z
"placeholder",
];
const fieldsToValidate =
let fieldsToValidate =
questionIndex === 0 || isBackButtonHidden
? initialFieldsToValidate
: [...initialFieldsToValidate, "backButtonLabel"];
// Skip buttonLabel validation for required NPS and Rating questions
if (
(question.type === TSurveyQuestionTypeEnum.NPS || question.type === TSurveyQuestionTypeEnum.Rating) &&
question.required
) {
fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel");
}
for (const field of fieldsToValidate) {
// Skip label validation for consent questions as its called checkbox label
if (field === "label" && question.type === TSurveyQuestionTypeEnum.Consent) {

1289
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff