Compare commits

..

1 Commits

Author SHA1 Message Date
Johannes 403d837430 feat: enhance contact attributes and refactor date picker
- Contact Attributes: Fixed TypeScript errors in attribute actions by strictly validating organizationId and projectId existence.
- Date Picker: Refactored DatePicker component to use native HTML input, removing the react-calendar dependency for reduced complexity and better performance.
- General Fixes: Resolved various TypeScript errors and missing imports across contact management and segment filtering modules.
- New Features: Added initial structure for contact attribute management.
2025-12-14 08:26:15 +01:00
259 changed files with 7484 additions and 19008 deletions
-352
View File
@@ -1,352 +0,0 @@
# Create New Question Element
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
## Usage
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
1. **Create the component file** `{question-type}.tsx` with this structure:
```typescript
import * as React from "react";
import { ElementHeader } from "../components/element-header";
import { useTextDirection } from "../hooks/use-text-direction";
import { cn } from "../lib/utils";
interface {QuestionType}Props {
/** Unique identifier for the element container */
elementId: string;
/** The main question or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the input/control group */
inputId: string;
/** Current value */
value?: {ValueType};
/** Callback function called when the value changes */
onChange: (value: {ValueType}) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
// Add question-specific props here
}
function {QuestionType}({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
// ... question-specific props
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", /* add other text content from question */],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
export { {QuestionType} };
export type { {QuestionType}Props };
```
2. **Create the Storybook file** `{question-type}.stories.tsx`:
```typescript
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
// Styling options for the StylingPlayground story
interface StylingOptions {
// Question styling
questionHeadlineFontFamily: string;
questionHeadlineFontSize: string;
questionHeadlineFontWeight: string;
questionHeadlineColor: string;
questionDescriptionFontFamily: string;
questionDescriptionFontWeight: string;
questionDescriptionFontSize: string;
questionDescriptionColor: string;
// Add component-specific styling options here
}
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Elements/{QuestionType}",
component: {QuestionType},
parameters: {
layout: "centered",
docs: {
description: {
component: "A complete {question type} question element...",
},
},
},
tags: ["autodocs"],
argTypes: {
headline: {
control: "text",
description: "The main question text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
value: {
control: "object",
description: "Current value",
table: { category: "State" },
},
required: {
control: "boolean",
description: "Whether the field is required",
table: { category: "Validation" },
},
errorMessage: {
control: "text",
description: "Error message to display",
table: { category: "Validation" },
},
dir: {
control: { type: "select" },
options: ["ltr", "rtl", "auto"],
description: "Text direction for RTL support",
table: { category: "Layout" },
},
disabled: {
control: "boolean",
description: "Whether the controls are disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
// Add question-specific argTypes here
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
const args = context.args as StoryProps;
const {
questionHeadlineFontFamily,
questionHeadlineFontSize,
questionHeadlineFontWeight,
questionHeadlineColor,
questionDescriptionFontFamily,
questionDescriptionFontSize,
questionDescriptionFontWeight,
questionDescriptionColor,
// Extract component-specific styling options
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": questionHeadlineFontFamily,
"--fb-question-headline-font-size": questionHeadlineFontSize,
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
"--fb-question-headline-color": questionHeadlineColor,
"--fb-question-description-font-family": questionDescriptionFontFamily,
"--fb-question-description-font-size": questionDescriptionFontSize,
"--fb-question-description-font-weight": questionDescriptionFontWeight,
"--fb-question-description-color": questionDescriptionColor,
// Add component-specific CSS variables
};
return (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
export const StylingPlayground: Story = {
args: {
headline: "Example question?",
description: "Example description",
// Default styling values
questionHeadlineFontFamily: "system-ui, sans-serif",
questionHeadlineFontSize: "1.125rem",
questionHeadlineFontWeight: "600",
questionHeadlineColor: "#1e293b",
questionDescriptionFontFamily: "system-ui, sans-serif",
questionDescriptionFontSize: "0.875rem",
questionDescriptionFontWeight: "400",
questionDescriptionColor: "#64748b",
// Add component-specific default values
},
argTypes: {
// Question styling argTypes
questionHeadlineFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineColor: {
control: "color",
table: { category: "Question Styling" },
},
questionDescriptionFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionColor: {
control: "color",
table: { category: "Question Styling" },
},
// Add component-specific argTypes
},
decorators: [withCSSVariables],
};
export const Default: Story = {
args: {
headline: "Example question?",
// Add default props
},
};
export const WithDescription: Story = {
args: {
headline: "Example question?",
description: "Example description text",
},
};
export const Required: Story = {
args: {
headline: "Example question?",
required: true,
},
};
export const WithError: Story = {
args: {
headline: "Example question?",
errorMessage: "This field is required",
required: true,
},
};
export const Disabled: Story = {
args: {
headline: "Example question?",
disabled: true,
},
};
export const RTL: Story = {
args: {
headline: "مثال على السؤال؟",
description: "مثال على الوصف",
// Add RTL-specific props
},
};
```
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
```css
/* Component-specific CSS variables */
--fb-{component}-{property}: {default-value};
```
4. **Export from** `packages/survey-ui/src/index.ts`:
```typescript
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
```
## Key Requirements
- ✅ Always use `ElementHeader` component for headline/description
- ✅ Always use `useTextDirection` hook for RTL support
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
- ✅ Always include error message display if applicable
- ✅ Always support disabled state if applicable
- ✅ Always add JSDoc comments to props interface
- ✅ Always create Storybook stories with styling playground
- ✅ Always export types from component file
- ✅ Always add to index.ts exports
## Examples
- `open-text.tsx` - Text input/textarea question (string value)
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
## Checklist
When creating a new question element, verify:
- [ ] Component file created with proper structure
- [ ] Props interface with JSDoc comments for all props
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
- [ ] Uses `useTextDirection` hook for RTL support
- [ ] Handles undefined/null values safely
- [ ] Storybook file created with styling playground
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
- [ ] CSS variables added to `globals.css` if component needs custom styling
- [ ] Exported from `index.ts` with types
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable
+2 -7
View File
@@ -9,12 +9,8 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys
# Please set both for now, we will change this in the future
@@ -193,9 +189,8 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=
+2 -25
View File
@@ -1,11 +1,8 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -28,25 +25,5 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
};
export default config;
+15 -16
View File
@@ -1,6 +1,19 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
import "../../../packages/survey-ui/src/styles/globals.css";
import { I18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -9,23 +22,9 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
expanded: true,
},
backgrounds: {
default: "light",
},
},
decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
decorators: [withLingodotDev],
};
export default preview;
+14 -16
View File
@@ -11,24 +11,22 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
"storybook": "9.0.15",
"vite": "6.4.1",
"@storybook/addon-docs": "9.0.15"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3 -11
View File
@@ -1,15 +1,7 @@
/** @type {import('tailwindcss').Config} */
import surveyUi from "../../packages/survey-ui/tailwind.config";
import base from "../web/tailwind.config";
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
...base,
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
};
+2 -3
View File
@@ -1,17 +1,16 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react()],
define: {
"process.env": {},
},
resolve: {
alias: {
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
"@": path.resolve(__dirname, "../web"),
},
},
});
+7 -12
View File
@@ -37,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory
WORKDIR /app
@@ -77,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -138,13 +134,12 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]
@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -233,7 +231,6 @@ export const ProjectSettings = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}
@@ -5,7 +5,6 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -48,8 +47,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -65,7 +62,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button
@@ -0,0 +1,29 @@
import { getTranslate } from "@/lingodotdev/server";
import { AttributeKeysManager } from "@/modules/ee/contacts/attributes/attribute-keys-manager";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export default async function AttributeKeysPage({
params: paramsProps,
}: {
params: Promise<{ environmentId: string }>;
}) {
const params = await paramsProps;
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const attributeKeys = await getContactAttributeKeys(params.environmentId);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.contacts")}>
<ContactsSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
</PageHeader>
<AttributeKeysManager environmentId={environment.id} attributeKeys={attributeKeys} />
</PageContentWrapper>
);
}
@@ -1,7 +1,6 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -16,7 +15,6 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -74,7 +72,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
@@ -46,7 +46,6 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -57,7 +56,6 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -288,16 +286,15 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
+2 -11
View File
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -19,15 +18,7 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
+2
View File
@@ -1,9 +1,11 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);
-97
View File
@@ -1,97 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
chatwootWebsiteToken?: string;
userEmail?: string | null;
userName?: string | null;
userId?: string | null;
}
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
export const ChatwootWidget = ({
userEmail,
userName,
userId,
chatwootWebsiteToken,
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
name: userName,
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
if (existingScript) return;
const script = document.createElement("script");
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
script.id = CHATWOOT_SCRIPT_ID;
script.async = true;
script.onload = () => {
(
globalThis as unknown as {
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
}
).chatwootSDK?.run({
websiteToken: chatwootWebsiteToken,
baseUrl: chatwootBaseUrl,
});
};
document.head.appendChild(script);
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
scriptElement?.remove();
userSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { useCallback, useEffect } from "react";
import { TUser } from "@formbricks/types/user";
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
initParams = {
user_id: id,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
};
}
Intercom({
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
if (typeof window !== "undefined" && window.Intercom) {
window.Intercom("shutdown");
}
};
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};
@@ -0,0 +1,26 @@
import { createHmac } from "crypto";
import type { TUser } from "@formbricks/types/user";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};
+3 -8
View File
@@ -311,7 +311,7 @@ checksums:
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
common/quotas: e6afead11b5b8ae627885ce2b84a548f
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/read_docs: 426ba960bfedf186a878b7467867f9d2
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
@@ -324,6 +324,7 @@ checksums:
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
common/restart: bab6232e89f24e3129f8e48268739d5b
common/role: 53743bbb6ca938f5b893552e839d067f
common/role_organization: e7dbf80450ceac1c6c22ba5602ea7e66
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -444,7 +445,6 @@ checksums:
emails/forgot_password_email_link_valid_for_24_hours: 1616714e6bf36e4379b9868e98e82957
emails/forgot_password_email_subject: bd7a2b22e7b480c29f512532fd2b7e2b
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
@@ -456,14 +456,12 @@ checksums:
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
emails/password_changed_email_heading: 601f68fc8bef9c5ecf79f4ec4de5ad06
emails/password_changed_email_text: f9ed4db250ec1b2adf4cb4527ec72d78
emails/password_reset_notify_email_subject: 0a6805fc27c5bb7999f0d311ef5981e1
emails/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
emails/reject: 417c19f66db70a0548bdeb398cdc46e0
emails/render_email_response_value_file_upload_response_link_not_included: 56f400d68c00b06a2bd976389778df9f
emails/response_data: 26363c0d3a839c3b33c9e8c6dd3deca9
emails/response_finished_email_subject: 7e8b92b483242ddb31ba83e8fcf890f9
emails/response_finished_email_subject_with_email: 14798acfdaec4b2b2f33dc4a9f4f8ee5
emails/schedule_your_meeting: 01683323bd7373560cd2cb2737dbaf06
@@ -475,7 +473,6 @@ checksums:
emails/survey_response_finished_email_turn_off_notifications_for_this_form: 7b6a7074490ceaf3d1903a37169364d6
emails/survey_response_finished_email_view_more_responses: fe053505f470cbbb5823ca15ceefcedd
emails/survey_response_finished_email_view_survey_summary: c4e8b5207c0dc856a01011c8b91e0d94
emails/text_variable: 5fdfcc48b8010a4f44e16b8051272a75
emails/verification_email_click_on_this_link: 3c9ad15bd2e3822d3ecd85a421311ebc
emails/verification_email_heading: 0f86a46d434bb4595b8753d3cf2524e0
emails/verification_email_hey: 20c5157a424f7d49ceeb27e6fb13d194
@@ -1309,13 +1306,11 @@ checksums:
environments/surveys/edit/follow_ups_ending_card_delete_modal_text: 71ac1865afe2b2f76836dcbebd1a813e
environments/surveys/edit/follow_ups_ending_card_delete_modal_title: 11d0b31535034e0a86c906557fb6f22e
environments/surveys/edit/follow_ups_hidden_field_error: 28aa017b194fb6d7d6c06a8a0bf843ff
environments/surveys/edit/follow_ups_include_hidden_fields: 8f0c2f8ddd3b95a3e7456a42be9362bb
environments/surveys/edit/follow_ups_include_variables: 2604dd580ceafec167ff9136d800f31e
environments/surveys/edit/follow_ups_item_ending_tag: 159c4e3bc953aae9a9dba27f7917228b
environments/surveys/edit/follow_ups_item_issue_detected_tag: bfb6b1f7b9f0a0a76bac853f01f72ba8
environments/surveys/edit/follow_ups_item_response_tag: 4b63073494e2224e1333624c6cee4240
environments/surveys/edit/follow_ups_item_send_email_tag: 0ef83c0bb40de25921a9ee7fa05babec
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: 901a493d60331420da61d0e76bf07eae
environments/surveys/edit/follow_ups_modal_action_attach_response_data_description: d23abb5a7e610b1ec3273c60d36a81e7
environments/surveys/edit/follow_ups_modal_action_attach_response_data_label: 32eff1a88e1a044fc22b0bff54f3c683
environments/surveys/edit/follow_ups_modal_action_body_label: e88eb1ea71f5ef886aa43ea6ba292d87
environments/surveys/edit/follow_ups_modal_action_body_placeholder: 4a658fa2f0af640a07f956551043eb88
+3 -3
View File
@@ -215,9 +215,9 @@ export const BILLING_LIMITS = {
},
} as const;
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
+4 -6
View File
@@ -39,12 +39,11 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
INTERCOM_APP_ID: z.string().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -163,16 +162,15 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
+2 -7
View File
@@ -351,6 +351,7 @@
"responses": "Antworten",
"restart": "Neustart",
"role": "Rolle",
"role_organization": "Rolle (Organisation)",
"saas": "SaaS",
"sales": "Vertrieb",
"save": "Speichern",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"number_variable": "Zahlenvariable",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
"response_data": "Antwortdaten",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Benachrichtigungen für dieses Formular ausschalten",
"survey_response_finished_email_view_more_responses": "Zeige {responseCount} weitere Antworten",
"survey_response_finished_email_view_survey_summary": "Umfragezusammenfassung anzeigen",
"text_variable": "Textvariable",
"verification_email_click_on_this_link": "Du kannst auch auf diesen Link klicken:",
"verification_email_heading": "Fast geschafft!",
"verification_email_hey": "Hey 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
"follow_ups_include_hidden_fields": "Werte versteckter Felder einbeziehen",
"follow_ups_include_variables": "Variablenwerte einbeziehen",
"follow_ups_item_ending_tag": "Abschluss",
"follow_ups_item_issue_detected_tag": "Problem erkannt",
"follow_ups_item_response_tag": "Jede Antwort",
"follow_ups_item_send_email_tag": "E-Mail senden",
"follow_ups_modal_action_attach_response_data_description": "Fügt nur die Fragen bei, die in der Umfrageantwort beantwortet wurden",
"follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
"follow_ups_modal_action_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
+1 -7
View File
@@ -473,7 +473,6 @@
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
@@ -485,14 +484,12 @@
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"number_variable": "Number variable",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
"response_data": "Response data",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
@@ -504,7 +501,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Turn off notifications for this form",
"survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"survey_response_finished_email_view_survey_summary": "View survey summary",
"text_variable": "Text variable",
"verification_email_click_on_this_link": "You can also click on this link:",
"verification_email_heading": "Almost there!",
"verification_email_hey": "Hey \uD83D\uDC4B",
@@ -1394,13 +1390,11 @@
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
"follow_ups_include_hidden_fields": "Include hidden field values",
"follow_ups_include_variables": "Include variable values",
"follow_ups_item_ending_tag": "Ending(s)",
"follow_ups_item_issue_detected_tag": "Issue detected",
"follow_ups_item_response_tag": "Any response",
"follow_ups_item_send_email_tag": "Send email",
"follow_ups_modal_action_attach_response_data_description": "Attaches only the questions that were answered in the survey response",
"follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
"follow_ups_modal_action_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
+2 -7
View File
@@ -351,6 +351,7 @@
"responses": "Respuestas",
"restart": "Reiniciar",
"role": "Rol",
"role_organization": "Rol (organización)",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
"number_variable": "Variable numérica",
"password_changed_email_heading": "Contraseña cambiada",
"password_changed_email_text": "Tu contraseña se ha cambiado correctamente.",
"password_reset_notify_email_subject": "Tu contraseña de Formbricks ha sido cambiada",
"privacy_policy": "Política de privacidad",
"reject": "Rechazar",
"render_email_response_value_file_upload_response_link_not_included": "El enlace al archivo subido no está incluido por razones de privacidad de datos",
"response_data": "Datos de respuesta",
"response_finished_email_subject": "Se completó una respuesta para {surveyName} ✅",
"response_finished_email_subject_with_email": "{personEmail} acaba de completar tu encuesta {surveyName} ✅",
"schedule_your_meeting": "Programa tu reunión",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desactivar notificaciones para este formulario",
"survey_response_finished_email_view_more_responses": "Ver {responseCount} respuestas más",
"survey_response_finished_email_view_survey_summary": "Ver resumen de la encuesta",
"text_variable": "Variable de texto",
"verification_email_click_on_this_link": "También puedes hacer clic en este enlace:",
"verification_email_heading": "¡Ya casi está!",
"verification_email_hey": "Hola 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Esta tarjeta de finalización se utiliza en seguimientos. Al eliminarla se quitará de todos los seguimientos. ¿Estás seguro de que quieres eliminarla?",
"follow_ups_ending_card_delete_modal_title": "¿Eliminar tarjeta de finalización?",
"follow_ups_hidden_field_error": "El campo oculto se utiliza en un seguimiento. Por favor, elimínalo primero del seguimiento.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variables",
"follow_ups_item_ending_tag": "Finalización(es)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Cualquier respuesta",
"follow_ups_item_send_email_tag": "Enviar correo electrónico",
"follow_ups_modal_action_attach_response_data_description": "Adjunta solo las preguntas que fueron respondidas en la respuesta de la encuesta",
"follow_ups_modal_action_attach_response_data_description": "Añadir los datos de la respuesta de la encuesta al seguimiento",
"follow_ups_modal_action_attach_response_data_label": "Adjuntar datos de respuesta",
"follow_ups_modal_action_body_label": "Cuerpo",
"follow_ups_modal_action_body_placeholder": "Cuerpo del correo electrónico",
+3 -8
View File
@@ -338,7 +338,7 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire la documentation",
"read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
@@ -351,6 +351,7 @@
"responses": "Réponses",
"restart": "Recommencer",
"role": "Rôle",
"role_organization": "Rôle (Organisation)",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"number_variable": "Variable numérique",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
"response_data": "Données de réponse",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Désactiver les notifications pour ce formulaire",
"survey_response_finished_email_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"survey_response_finished_email_view_survey_summary": "Voir le résumé de l'enquête",
"text_variable": "Variable texte",
"verification_email_click_on_this_link": "Vous pouvez également cliquer sur ce lien :",
"verification_email_heading": "Presque là !",
"verification_email_hey": "Salut 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Cette carte de fin est utilisée dans les suivis. La supprimer la retirera de tous les suivis. Êtes-vous sûr de vouloir la supprimer ?",
"follow_ups_ending_card_delete_modal_title": "Supprimer la carte de fin ?",
"follow_ups_hidden_field_error": "Le champ caché est utilisé dans un suivi. Veuillez d'abord le supprimer du suivi.",
"follow_ups_include_hidden_fields": "Inclure les valeurs des champs cachés",
"follow_ups_include_variables": "Inclure les valeurs des variables",
"follow_ups_item_ending_tag": "Fin(s)",
"follow_ups_item_issue_detected_tag": "Problème détecté",
"follow_ups_item_response_tag": "Une réponse quelconque",
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
"follow_ups_modal_action_attach_response_data_description": "Joint uniquement les questions auxquelles on a répondu dans la réponse au sondage",
"follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
"follow_ups_modal_action_body_label": "Corps",
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
+2 -7
View File
@@ -351,6 +351,7 @@
"responses": "回答",
"restart": "再開",
"role": "役割",
"role_organization": "役割(組織)",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました!",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
"number_variable": "数値変数",
"password_changed_email_heading": "パスワードが変更されました",
"password_changed_email_text": "パスワードが正常に変更されました。",
"password_reset_notify_email_subject": "Formbricksのパスワードが変更されました",
"privacy_policy": "プライバシーポリシー",
"reject": "拒否",
"render_email_response_value_file_upload_response_link_not_included": "データプライバシーのため、アップロードされたファイルへのリンクは含まれていません",
"response_data": "回答データ",
"response_finished_email_subject": "{surveyName} の回答が完了しました ✅",
"response_finished_email_subject_with_email": "{personEmail} が {surveyName} フォームを完了しました ✅",
"schedule_your_meeting": "ミーティングを予約",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "このフォームの通知をオフにする",
"survey_response_finished_email_view_more_responses": "さらに {responseCount} 件の回答を見る",
"survey_response_finished_email_view_survey_summary": "フォームの概要を見る",
"text_variable": "テキスト変数",
"verification_email_click_on_this_link": "このリンクをクリックすることもできます:",
"verification_email_heading": "あと少しです!",
"verification_email_hey": "こんにちは 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "この終了カードはフォローアップで使用されています。これを削除すると、すべてのフォローアップから削除されます。本当に削除しますか?",
"follow_ups_ending_card_delete_modal_title": "終了カードを削除しますか?",
"follow_ups_hidden_field_error": "非表示フィールドはフォローアップで使用されています。まず、フォローアップから削除してください。",
"follow_ups_include_hidden_fields": "非表示フィールドの値を含める",
"follow_ups_include_variables": "変数の値を含める",
"follow_ups_item_ending_tag": "終了",
"follow_ups_item_issue_detected_tag": "問題が検出されました",
"follow_ups_item_response_tag": "任意の回答",
"follow_ups_item_send_email_tag": "メールを送信",
"follow_ups_modal_action_attach_response_data_description": "アンケート回答で答えられた質問のみを添付します",
"follow_ups_modal_action_attach_response_data_description": "フォームの回答データをフォローアップに追加する",
"follow_ups_modal_action_attach_response_data_label": "回答データを添付",
"follow_ups_modal_action_body_label": "本文",
"follow_ups_modal_action_body_placeholder": "メールの本文",
+3 -8
View File
@@ -338,7 +338,7 @@
"quota": "Quotum",
"quotas": "Quota",
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
"read_docs": "Documentatie lezen",
"read_docs": "Lees Documenten",
"recipients": "Ontvangers",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
@@ -351,6 +351,7 @@
"responses": "Reacties",
"restart": "Opnieuw opstarten",
"role": "Rol",
"role_organization": "Rol (organisatie)",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
"number_variable": "Numerieke variabele",
"password_changed_email_heading": "Wachtwoord gewijzigd",
"password_changed_email_text": "Uw wachtwoord is succesvol gewijzigd.",
"password_reset_notify_email_subject": "Uw Formbricks-wachtwoord is gewijzigd",
"privacy_policy": "Privacybeleid",
"reject": "Afwijzen",
"render_email_response_value_file_upload_response_link_not_included": "De link naar het geüploade bestand is om redenen van gegevensprivacy niet opgenomen",
"response_data": "Responsgegevens",
"response_finished_email_subject": "Er is een reactie voor {surveyName} voltooid ✅",
"response_finished_email_subject_with_email": "{personEmail} heeft zojuist uw {surveyName} enquête voltooid ✅",
"schedule_your_meeting": "Plan uw vergadering",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Schakel meldingen voor dit formulier uit",
"survey_response_finished_email_view_more_responses": "Bekijk nog {responseCount} reacties",
"survey_response_finished_email_view_survey_summary": "Bekijk de samenvatting van het onderzoek",
"text_variable": "Tekstvariabele",
"verification_email_click_on_this_link": "U kunt ook op deze link klikken:",
"verification_email_heading": "Bijna daar!",
"verification_email_hey": "Hé 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Deze eindkaart wordt gebruikt bij vervolgacties. Als u het verwijdert, wordt het uit alle vervolgacties verwijderd. Weet je zeker dat je het wilt verwijderen?",
"follow_ups_ending_card_delete_modal_title": "Eindkaart verwijderen?",
"follow_ups_hidden_field_error": "Verborgen veld wordt gebruikt in een follow-up. Verwijder het eerst uit de follow-up.",
"follow_ups_include_hidden_fields": "Inclusief waarden van verborgen velden",
"follow_ups_include_variables": "Inclusief variabele waarden",
"follow_ups_item_ending_tag": "Einde(n)",
"follow_ups_item_issue_detected_tag": "Probleem gedetecteerd",
"follow_ups_item_response_tag": "Enige reactie",
"follow_ups_item_send_email_tag": "E-mail verzenden",
"follow_ups_modal_action_attach_response_data_description": "Voegt alleen de vragen toe die zijn beantwoord in de enquêterespons",
"follow_ups_modal_action_attach_response_data_description": "Voeg de gegevens van de enquêtereactie toe aan de follow-up",
"follow_ups_modal_action_attach_response_data_label": "Reactiegegevens bijvoegen",
"follow_ups_modal_action_body_label": "Lichaam",
"follow_ups_modal_action_body_placeholder": "Hoofdgedeelte van de e-mail",
+3 -8
View File
@@ -338,7 +338,7 @@
"quota": "Cota",
"quotas": "Cotas",
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler documentação",
"read_docs": "Ler Documentação",
"recipients": "Destinatários",
"remove": "remover",
"remove_from_team": "Remover da equipe",
@@ -351,6 +351,7 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Rolê",
"role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo da pesquisa",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Você também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Oi 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Final(is)",
"follow_ups_item_issue_detected_tag": "Problema detectado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar e-mail",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta da pesquisa",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
+3 -8
View File
@@ -338,7 +338,7 @@
"quota": "Quota",
"quotas": "Quotas",
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler documentação",
"read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
@@ -351,6 +351,7 @@
"responses": "Respostas",
"restart": "Reiniciar",
"role": "Função",
"role_organization": "Função (Organização)",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"number_variable": "Variável numérica",
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
"response_data": "Dados de resposta",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
"schedule_your_meeting": "Agende a sua reunião",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Desativar notificações para este formulário",
"survey_response_finished_email_view_more_responses": "Ver mais {responseCount} respostas",
"survey_response_finished_email_view_survey_summary": "Ver resumo do inquérito",
"text_variable": "Variável de texto",
"verification_email_click_on_this_link": "Também pode clicar neste link:",
"verification_email_heading": "Quase lá!",
"verification_email_hey": "Olá 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Este cartão de encerramento é utilizado em seguimentos. Eliminá-lo irá removê-lo de todos os seguimentos. Tem a certeza de que deseja eliminá-lo?",
"follow_ups_ending_card_delete_modal_title": "Eliminar cartão de encerramento?",
"follow_ups_hidden_field_error": "O campo oculto é usado num seguimento. Por favor, remova-o do seguimento primeiro.",
"follow_ups_include_hidden_fields": "Incluir valores de campos ocultos",
"follow_ups_include_variables": "Incluir valores de variáveis",
"follow_ups_item_ending_tag": "Encerramento(s)",
"follow_ups_item_issue_detected_tag": "Problema detetado",
"follow_ups_item_response_tag": "Qualquer resposta",
"follow_ups_item_send_email_tag": "Enviar email",
"follow_ups_modal_action_attach_response_data_description": "Anexa apenas as perguntas que foram respondidas na resposta ao inquérito",
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
"follow_ups_modal_action_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do email",
+2 -7
View File
@@ -351,6 +351,7 @@
"responses": "Răspunsuri",
"restart": "Repornește",
"role": "Rolul",
"role_organization": "Rol (Organizație)",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
"number_variable": "Variabilă numerică",
"password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de confidențialitate",
"reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
"response_data": "Datele răspunsului",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
"response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅",
"schedule_your_meeting": "Programați întâlnirea",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular",
"survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri",
"survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului",
"text_variable": "Variabilă text",
"verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:",
"verification_email_heading": "Aproape gata!",
"verification_email_hey": "Salut 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
"follow_ups_include_hidden_fields": "Include valorile câmpurilor ascunse",
"follow_ups_include_variables": "Include valorile variabilelor",
"follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email",
"follow_ups_modal_action_attach_response_data_description": "Atașează doar întrebările la care s-a răspuns în răspunsul sondajului",
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului",
+1 -6
View File
@@ -351,6 +351,7 @@
"responses": "Svar",
"restart": "Starta om",
"role": "Roll",
"role_organization": "Roll (Organisation)",
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
"hidden_field": "Dolt fält",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hej",
"invite_accepted_email_subject": "Du har fått en ny organisationsmedlem!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "bjöd in dig att gå med dem på Formbricks. För att acceptera inbjudan, vänligen klicka på länken nedan:",
"invite_member_email_subject": "Du är inbjuden att samarbeta på Formbricks!",
"new_email_verification_text": "För att verifiera din nya e-postadress, vänligen klicka på knappen nedan:",
"number_variable": "Nummervariabel",
"password_changed_email_heading": "Lösenord ändrat",
"password_changed_email_text": "Ditt lösenord har ändrats.",
"password_reset_notify_email_subject": "Ditt Formbricks-lösenord har ändrats",
"privacy_policy": "Integritetspolicy",
"reject": "Avvisa",
"render_email_response_value_file_upload_response_link_not_included": "Länk till uppladdad fil ingår inte av dataskyddsskäl",
"response_data": "Svarsdata",
"response_finished_email_subject": "Ett svar för {surveyName} har slutförts ✅",
"response_finished_email_subject_with_email": "{personEmail} har precis slutfört din {surveyName}-enkät ✅",
"schedule_your_meeting": "Boka ditt möte",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "Stäng av aviseringar för detta formulär",
"survey_response_finished_email_view_more_responses": "Visa {responseCount} fler svar",
"survey_response_finished_email_view_survey_summary": "Visa enkätsammanfattning",
"text_variable": "Textvariabel",
"verification_email_click_on_this_link": "Du kan också klicka på denna länk:",
"verification_email_heading": "Nästan där!",
"verification_email_hey": "Hej 👋",
@@ -1394,8 +1391,6 @@
"follow_ups_ending_card_delete_modal_text": "Detta avslutningskort används i uppföljningar. Att ta bort det kommer att ta bort det från alla uppföljningar. Är du säker på att du vill ta bort det?",
"follow_ups_ending_card_delete_modal_title": "Ta bort avslutningskort?",
"follow_ups_hidden_field_error": "Dolt fält används i en uppföljning. Vänligen ta bort det från uppföljningen först.",
"follow_ups_include_hidden_fields": "Inkludera värden för dolda fält",
"follow_ups_include_variables": "Inkludera värden för variabler",
"follow_ups_item_ending_tag": "Avslutning(ar)",
"follow_ups_item_issue_detected_tag": "Problem upptäckt",
"follow_ups_item_response_tag": "Alla svar",
+3 -8
View File
@@ -338,7 +338,7 @@
"quota": "配额",
"quotas": "配额",
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读文档",
"read_docs": "阅读 文档",
"recipients": "收件人",
"remove": "移除",
"remove_from_team": "从团队中移除",
@@ -351,6 +351,7 @@
"responses": "反馈",
"restart": "重新启动",
"role": "角色",
"role_organization": "角色 (组织)",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 :",
"number_variable": "数字变量",
"password_changed_email_heading": "密码 已更改",
"password_changed_email_text": "您的 密码已成功更改",
"password_reset_notify_email_subject": "您的 Formbricks 密码已更改",
"privacy_policy": "隐私政策",
"reject": "拒绝",
"render_email_response_value_file_upload_response_link_not_included": "未包括上传文件的链接 数据隐私原因",
"response_data": "响应数据",
"response_finished_email_subject": "对 {surveyName} 的回答已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 刚刚完成了你的 {surveyName} 调查 ✅",
"schedule_your_meeting": "安排你的会议",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "关闭 此表单 的通知",
"survey_response_finished_email_view_more_responses": "查看 {responseCount} 更多 响应",
"survey_response_finished_email_view_survey_summary": "查看 问卷 摘要",
"text_variable": "文本变量",
"verification_email_click_on_this_link": "您 也 可以 点击 此 链接:",
"verification_email_heading": "马上就好!",
"verification_email_hey": "嗨 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "此结束卡片 用于 后续跟踪. 删除 它 将会 从 所有 后续跟踪 中 移除. 确定 要 删除 它 吗?",
"follow_ups_ending_card_delete_modal_title": "删除 结尾卡片?",
"follow_ups_hidden_field_error": "隐藏 字段 用于 后续 。请 先 从 后续 中 移除 它 。",
"follow_ups_include_hidden_fields": "包括隐藏字段值",
"follow_ups_include_variables": "包括变量值",
"follow_ups_item_ending_tag": "结尾",
"follow_ups_item_issue_detected_tag": "问题 检测",
"follow_ups_item_response_tag": "任何 响应",
"follow_ups_item_send_email_tag": "发送 邮件",
"follow_ups_modal_action_attach_response_data_description": "仅附加调查响应中已回答的问题",
"follow_ups_modal_action_attach_response_data_description": "添加 调查 响应 数据 到 跟进",
"follow_ups_modal_action_attach_response_data_label": "附加响应数据",
"follow_ups_modal_action_body_label": "正文",
"follow_ups_modal_action_body_placeholder": "电子邮件正文",
+2 -7
View File
@@ -351,6 +351,7 @@
"responses": "回應",
"restart": "重新開始",
"role": "角色",
"role_organization": "角色(組織)",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
@@ -473,7 +474,6 @@
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_subject": "您有一位新的組織成員!",
@@ -485,14 +485,12 @@
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"number_variable": "數字變數",
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
"response_data": "回應資料",
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
"schedule_your_meeting": "安排你的會議",
@@ -504,7 +502,6 @@
"survey_response_finished_email_turn_off_notifications_for_this_form": "關閉此表單的通知",
"survey_response_finished_email_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"survey_response_finished_email_view_survey_summary": "檢視問卷摘要",
"text_variable": "文字變數",
"verification_email_click_on_this_link": "您也可以點擊此連結:",
"verification_email_heading": "快完成了!",
"verification_email_hey": "嗨 👋",
@@ -1394,13 +1391,11 @@
"follow_ups_ending_card_delete_modal_text": "此結尾卡片用於後續追蹤中。刪除它將會從所有後續追蹤中移除。您確定要刪除它嗎?",
"follow_ups_ending_card_delete_modal_title": "刪除結尾卡片?",
"follow_ups_hidden_field_error": "隱藏欄位在後續追蹤中使用。請先從後續追蹤中移除。",
"follow_ups_include_hidden_fields": "包含隱藏欄位的值",
"follow_ups_include_variables": "包含變數的值",
"follow_ups_item_ending_tag": "結尾",
"follow_ups_item_issue_detected_tag": "偵測到問題",
"follow_ups_item_response_tag": "任何回應",
"follow_ups_item_send_email_tag": "發送電子郵件",
"follow_ups_modal_action_attach_response_data_description": "僅附加在調查回應中回答過的問題",
"follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
"follow_ups_modal_action_body_label": "內文",
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export const SignIn = ({ token, webAppUrl }) => {
export const SignIn = ({ token }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
callbackUrl: webAppUrl,
callbackUrl: `/`,
});
}
}, [token, webAppUrl]);
}, [token]);
return <></>;
};
+1 -2
View File
@@ -1,4 +1,3 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -10,7 +9,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} webAppUrl={WEBAPP_URL} />
<SignIn token={token} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>
@@ -1,13 +1,121 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
const ZUpdateContactAttributeAction = z.object({
contactId: ZId,
attributes: ZContactAttributes,
});
export const updateContactAttributeAction = authenticatedActionClient
.schema(ZUpdateContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
const contact = await prisma.contact.findUnique({
where: { id: parsedInput.contactId },
select: { environmentId: true },
});
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const result = await updateAttributes(
parsedInput.contactId,
ctx.user.id,
contact.environmentId,
parsedInput.attributes
);
return result;
});
const ZDeleteContactAttributeAction = z.object({
contactId: ZId,
attributeKey: z.string(),
});
export const deleteContactAttributeAction = authenticatedActionClient
.schema(ZDeleteContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
const contact = await prisma.contact.findUnique({
where: { id: parsedInput.contactId },
select: { environmentId: true },
});
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Find the attribute key
const attributeKey = await prisma.contactAttributeKey.findFirst({
where: {
key: parsedInput.attributeKey,
environmentId: contact.environmentId,
},
});
if (!attributeKey) {
// If key doesn't exist, nothing to delete.
return { success: true };
}
await prisma.contactAttribute.deleteMany({
where: {
contactId: parsedInput.contactId,
attributeKeyId: attributeKey.id,
},
});
return { success: true };
});
const ZGeneratePersonalSurveyLinkAction = z.object({
contactId: ZId,
surveyId: ZId,
@@ -1,10 +1,17 @@
import { format } from "date-fns";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getResponsesByContactId } from "@/lib/response/service";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
interface AttributesSectionProps {
contactId: string;
attributeKeys: TContactAttributeKey[];
}
export const AttributesSection = async ({ contactId, attributeKeys }: AttributesSectionProps) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
@@ -22,7 +29,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
<span>{attributes.email as string}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -32,7 +39,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
<span>{attributes.language as string}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -42,7 +49,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
<IdBadge id={attributes.userId as string} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
@@ -56,10 +63,26 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
const attributeKey = attributeKeys.find((ak) => ak.key === key);
let displayValue = attributeData;
if (attributeKey?.dataType === "date" && displayValue) {
try {
// assume attributeData is string ISO date or Date object
displayValue = format(new Date(displayValue as string | number | Date), "do 'of' MMMM, yyyy");
} catch (e) {
// fallback
}
}
if (displayValue instanceof Date) {
displayValue = displayValue.toLocaleDateString();
}
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
<dt className="text-sm font-medium text-slate-500">{attributeKey?.name ?? key}</dt>
<dd className="mt-1 text-sm text-slate-900">{displayValue}</dd>
</div>
);
})}
@@ -1,15 +1,18 @@
"use client";
import { LinkIcon, TrashIcon } from "lucide-react";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditAttributesModal } from "./edit-attributes-modal";
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
interface ContactControlBarProps {
@@ -18,6 +21,8 @@ interface ContactControlBarProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
publishedLinkSurveys: PublishedLinkSurvey[];
attributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const ContactControlBar = ({
@@ -26,12 +31,15 @@ export const ContactControlBar = ({
isReadOnly,
isQuotasAllowed,
publishedLinkSurveys,
attributes,
attributeKeys,
}: ContactControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
const handleDeletePerson = async () => {
setIsDeletingPerson(true);
@@ -61,6 +69,14 @@ export const ContactControlBar = ({
},
isVisible: true,
},
{
icon: PencilIcon,
tooltip: t("common.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
@@ -94,6 +110,13 @@ export const ContactControlBar = ({
contactId={contactId}
publishedLinkSurveys={publishedLinkSurveys}
/>
<EditAttributesModal
open={isEditAttributesModalOpen}
setOpen={setIsEditAttributesModalOpen}
contactId={contactId}
attributes={attributes}
attributeKeys={attributeKeys}
/>
</>
);
};
@@ -0,0 +1,254 @@
"use client";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { deleteContactAttributeAction, updateContactAttributeAction } from "../actions";
interface EditAttributesModalProps {
open: boolean;
setOpen: (open: boolean) => void;
contactId: string;
attributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const EditAttributesModal = ({
open,
setOpen,
contactId,
attributes,
attributeKeys,
}: EditAttributesModalProps) => {
const router = useRouter();
const { t } = useTranslation();
// Local state for editing. explicit key-value pairs array for easier rendering
const [localAttributes, setLocalAttributes] = useState<
{ key: string; value: string | number | Date; isNew?: boolean }[]
>(
Object.entries(attributes)
.filter(([key]) => key !== "email" && key !== "userId" && key !== "language") // exclude standard/read-only attributes from generic editor?
.map(([key, value]) => ({ key, value }))
);
const [isSaving, setIsSaving] = useState(false);
// Deletion state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [attributeToDelete, setAttributeToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const availableKeys = useMemo(() => {
// Filter out keys that are already used (except for the one being edited if we supported key change, but we lock key for existing)
// Actually for new rows we want to show unused keys.
const usedKeys = new Set(localAttributes.map((a) => a.key));
return attributeKeys.filter((ak) => !usedKeys.has(ak.key));
}, [attributeKeys, localAttributes]);
const handleUpdateAttribute = (index: number, field: "key" | "value", newValue: any) => {
const updated = [...localAttributes];
updated[index] = { ...updated[index], [field]: newValue };
setLocalAttributes(updated);
};
const handleAddAttribute = () => {
setLocalAttributes([...localAttributes, { key: "", value: "", isNew: true }]);
};
const handleRemoveAttribute = (index: number) => {
const attribute = localAttributes[index];
if (attribute.isNew) {
// Just remove from state
const updated = [...localAttributes];
updated.splice(index, 1);
setLocalAttributes(updated);
} else {
// Trigger delete confirmation for existing attributes
setAttributeToDelete(attribute.key);
setDeleteDialogOpen(true);
}
};
const confirmDelete = async () => {
if (!attributeToDelete) return;
setIsDeleting(true);
const result = await deleteContactAttributeAction({ contactId, attributeKey: attributeToDelete });
setIsDeleting(false);
setDeleteDialogOpen(false);
if (result?.data?.success) {
toast.success("Attribute deleted successfully");
setLocalAttributes(localAttributes.filter((a) => a.key !== attributeToDelete));
setAttributeToDelete(null);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
const handleSave = async () => {
setIsSaving(true);
// Convert array back to record
const attributesRecord: TContactAttributes = {};
for (const attr of localAttributes) {
if (attr.key && attr.value !== "") {
attributesRecord[attr.key] = attr.value;
}
}
const result = await updateContactAttributeAction({
contactId,
attributes: attributesRecord,
});
setIsSaving(false);
if (result?.data?.success) {
toast.success("Attributes updated successfully");
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
// Helper to determine input type based on key
const getInputType = (key: string) => {
const attributeKey = attributeKeys.find((ak) => ak.key === key);
return attributeKey?.dataType ?? "text";
};
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl bg-white p-6">
<DialogHeader>
<DialogTitle>{t("common.edit_attributes")}</DialogTitle>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto pr-2">
<div className="space-y-4">
{localAttributes.length === 0 && (
<p className="text-sm italic text-slate-500">No custom attributes found.</p>
)}
{localAttributes.map((attr, index) => {
const dataType = getInputType(attr.key);
return (
<div key={index} className="flex items-start gap-3">
<div className="flex-1">
<Label className="mb-1 block text-xs">Key</Label>
{attr.isNew ? (
<div className="relative">
<Input
value={attr.key}
onChange={(e) => handleUpdateAttribute(index, "key", e.target.value)}
placeholder="Attribute Key"
list={`keys-${index}`}
/>
{/* Simple datalist for suggestion */}
<datalist id={`keys-${index}`}>
{availableKeys.map((ak) => (
<option key={ak.id} value={ak.key} />
))}
</datalist>
</div>
) : (
<Input value={attr.key} disabled className="bg-slate-50" />
)}
</div>
<div className="flex-1">
<Label className="mb-1 flex items-center gap-2 text-xs">
Value
<AttributeIcon dataType={dataType} className="h-3 w-3 text-slate-400" />
</Label>
{dataType === "date" ? (
<DatePicker
date={
attr.value instanceof Date
? attr.value
: attr.value
? new Date(attr.value as string)
: null
}
updateSurveyDate={(date) => handleUpdateAttribute(index, "value", date ?? "")}
/>
) : (
<Input
type={dataType === "number" ? "number" : "text"}
value={attr.value instanceof Date ? "" : attr.value}
onChange={(e) => {
const val = e.target.value;
if (dataType === "number") {
handleUpdateAttribute(index, "value", val === "" ? "" : Number(val));
} else {
handleUpdateAttribute(index, "value", val);
}
}}
/>
)}
</div>
<div className="mt-6">
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveAttribute(index)}
type="button">
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-500" />
</Button>
</div>
</div>
);
})}
<Button variant="outline" size="sm" onClick={handleAddAttribute} className="mt-2">
<PlusIcon className="mr-2 h-4 w-4" />
Add Attribute
</Button>
</div>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t("common.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="attribute"
onDelete={confirmDelete}
isDeleting={isDeleting}
text={`Are you sure you want to delete the attribute "${attributeToDelete}"? This action cannot be undone.`}
/>
</>
);
};
@@ -2,6 +2,7 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
@@ -21,12 +22,14 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
]);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, attributeKeys] =
await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -42,6 +45,8 @@ export const SingleContactPage = async (props: {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
publishedLinkSurveys={publishedLinkSurveys}
attributes={contactAttributes}
attributeKeys={attributeKeys}
/>
);
};
@@ -52,7 +57,7 @@ export const SingleContactPage = async (props: {
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} />
<AttributesSection contactId={params.contactId} attributeKeys={attributeKeys} />
<ResponseSection
environment={environment}
contactId={params.contactId}
+70
View File
@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -12,6 +13,7 @@ import {
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createContactAttributeKey, updateContactAttributeKey } from "./lib/contact-attribute-keys";
import { createContactsFromCSV, deleteContact, getContacts } from "./lib/contacts";
import {
ZContactCSVAttributeMap,
@@ -129,3 +131,71 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
}
)
);
const ZUpdateAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
name: z.string(),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
export const updateAttributeKeyAction = authenticatedActionClient
.schema(ZUpdateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await updateContactAttributeKey(parsedInput.environmentId, parsedInput.id, {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
});
});
const ZCreateAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string(),
name: z.string(),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
export const createAttributeKeyAction = authenticatedActionClient
.schema(ZCreateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await createContactAttributeKey(parsedInput.environmentId, parsedInput.key, "custom", {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
});
});
@@ -0,0 +1,185 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType, ZContactAttributeKeyType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
// I need proper helpers for auth. usually checkAuthorizationUpdated wrapper handles project/environment checks via getters?
// Or I manually fetch.
const ZCreateAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
type: ZContactAttributeKeyType.optional(), // custom usually
dataType: ZContactAttributeDataType.optional(),
});
export const createAttributeKeyAction = authenticatedActionClient
.schema(ZCreateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Environment", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
// Auth check
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const existingKey = await prisma.contactAttributeKey.findFirst({
where: {
environmentId: parsedInput.environmentId,
key: parsedInput.key,
},
});
if (existingKey) {
throw new Error("Attribute key already exists");
}
const attributeKey = await prisma.contactAttributeKey.create({
data: {
environmentId: parsedInput.environmentId,
key: parsedInput.key,
name: parsedInput.name,
description: parsedInput.description,
type: parsedInput.type ?? "custom",
dataType: parsedInput.dataType ?? "text",
},
});
return attributeKey;
});
const ZUpdateAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(), // allowing update?
});
export const updateAttributeKeyAction = authenticatedActionClient
.schema(ZUpdateAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
// Auth check
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// check if data type update is safe?
// For now trusting the user or UI to warn.
const attributeKey = await prisma.contactAttributeKey.update({
where: {
id: parsedInput.id,
},
data: {
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
},
});
return attributeKey;
});
const ZDeleteAttributeKeyAction = z.object({
id: ZId,
environmentId: ZId,
});
export const deleteAttributeKeyAction = authenticatedActionClient
.schema(ZDeleteAttributeKeyAction)
.action(async ({ ctx, parsedInput }) => {
// Auth check
const organizationId = await getOrganizationIdByEnvironmentId(parsedInput.environmentId);
if (!organizationId) throw new ResourceNotFoundError("Organization", parsedInput.environmentId);
const projectId = await getProjectIdByEnvironmentId(parsedInput.environmentId);
if (!projectId) throw new ResourceNotFoundError("Project", parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
await prisma.contactAttributeKey.delete({
where: {
id: parsedInput.id,
},
});
return { success: true };
});
// Helpers (mocked or should be imported from somewhere)
// I need `getOrganizationIdByEnvironmentId` and `getProjectIdByEnvironmentId`.
// Usually in `@/lib/project/service` or similar. I'll need to confirm imports.
// `getProjectIdByEnvironmentId` is usually available.
// `getOrganizationIdByEnvironmentId` - I might need to fetch environment then project then org.
// Or helper exists.
async function getOrganizationIdByEnvironmentId(environmentId: string) {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: { project: { select: { organizationId: true } } },
});
return environment?.project.organizationId;
}
async function getProjectIdByEnvironmentId(environmentId: string) {
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: { projectId: true },
});
return environment?.projectId;
}
@@ -0,0 +1,99 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { deleteAttributeKeyAction } from "./actions";
// updated imports
import { AttributeKeysTable } from "./attribute-keys-table";
import { CreateAttributeKeyModal } from "./create-attribute-key-modal";
import { EditAttributeKeyModal } from "./edit-attribute-key-modal";
interface AttributeKeysManagerProps {
environmentId: string;
attributeKeys: TContactAttributeKey[];
}
export const AttributeKeysManager = ({ environmentId, attributeKeys }: AttributeKeysManagerProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingKey, setEditingKey] = useState<TContactAttributeKey | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [deletingKey, setDeletingKey] = useState<TContactAttributeKey | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleEdit = (key: TContactAttributeKey) => {
setEditingKey(key);
setIsEditOpen(true);
};
const handleDelete = (key: TContactAttributeKey) => {
setDeletingKey(key);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deletingKey) return;
setIsDeleting(true);
const result = await deleteAttributeKeyAction({ id: deletingKey.id, environmentId });
setIsDeleting(false);
if (result?.data?.success) {
toast.success("Attribute key deleted successfully");
setDeletingKey(null);
setIsDeleteOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
setIsDeleteOpen(false); // Close anyway?
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
{t("environments.contacts.attributes.title")}
</h2>
<p className="text-sm text-slate-500">{t("environments.contacts.attributes.description")}</p>
</div>
<Button onClick={() => setIsCreateOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.contacts.attributes.create_attribute_key")}
</Button>
</div>
<AttributeKeysTable attributeKeys={attributeKeys} onEdit={handleEdit} onDelete={handleDelete} />
<CreateAttributeKeyModal open={isCreateOpen} setOpen={setIsCreateOpen} environmentId={environmentId} />
<EditAttributeKeyModal
open={isEditOpen}
setOpen={setIsEditOpen}
environmentId={environmentId}
attributeKey={editingKey}
/>
<DeleteDialog
open={isDeleteOpen}
setOpen={setIsDeleteOpen}
deleteWhat="attribute key"
onDelete={confirmDelete}
isDeleting={isDeleting}
text={`Are you sure you want to delete the attribute key "${deletingKey?.key}"? This will delete all data associated with this key.`}
/>
</div>
);
};
@@ -0,0 +1,89 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Edit2Icon, Trash2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { AttributeIcon } from "@/modules/ee/contacts/segments/components/attribute-icon";
import { Button } from "@/modules/ui/components/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
interface AttributeKeysTableProps {
attributeKeys: TContactAttributeKey[];
onEdit: (key: TContactAttributeKey) => void;
onDelete: (key: TContactAttributeKey) => void;
isDeleting?: boolean;
}
export const AttributeKeysTable = ({
attributeKeys,
onEdit,
onDelete,
isDeleting,
}: AttributeKeysTableProps) => {
const { t } = useTranslation();
if (attributeKeys.length === 0) {
return (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-lg border border-slate-200 bg-slate-50">
<p className="text-slate-500">{t("environments.contacts.attributes.no_keys_found")}</p>
</div>
);
}
return (
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("common.name")} / {t("common.key")}
</TableHead>
<TableHead>{t("common.description")}</TableHead>
<TableHead>{t("common.type")}</TableHead>
<TableHead>{t("common.last_updated")}</TableHead>
<TableHead className="text-right">{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attributeKeys.map((attributeKey) => (
<TableRow key={attributeKey.key}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-slate-900">{attributeKey.name}</span>
<span className="font-mono text-xs text-slate-500">{attributeKey.key}</span>
</div>
</TableCell>
<TableCell className="max-w-[200px] truncate text-slate-500">
{attributeKey.description || "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4 text-slate-500" />
<span className="capitalize text-slate-700">{attributeKey.dataType}</span>
</div>
</TableCell>
<TableCell className="text-slate-500">
{formatDistanceToNow(new Date(attributeKey.updatedAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button size="icon" variant="ghost" onClick={() => onEdit(attributeKey)}>
<Edit2Icon className="h-4 w-4 text-slate-500" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => onDelete(attributeKey)}
disabled={isDeleting}>
<Trash2Icon className="h-4 w-4 text-slate-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
@@ -0,0 +1,148 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import {
TContactAttributeDataType,
ZContactAttributeDataType,
} from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { createAttributeKeyAction } from "../actions";
const ZCreateAttributeKeyForm = z.object({
key: z.string().min(1, "Key is required"),
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
type TCreateAttributeKeyForm = z.infer<typeof ZCreateAttributeKeyForm>;
interface CreateAttributeKeyModalProps {
open: boolean;
setOpen: (open: boolean) => void;
environmentId: string;
}
export const CreateAttributeKeyModal = ({ open, setOpen, environmentId }: CreateAttributeKeyModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting, errors },
} = useForm<TCreateAttributeKeyForm>({
resolver: zodResolver(ZCreateAttributeKeyForm),
defaultValues: {
key: "",
name: "",
description: "",
dataType: "text",
},
});
const dataType = watch("dataType");
const onSubmit = async (data: TCreateAttributeKeyForm) => {
const result = await createAttributeKeyAction({
environmentId,
key: data.key,
name: data.name,
description: data.description,
dataType: data.dataType,
});
if (result?.data) {
toast.success("Attribute key created successfully");
reset();
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Attribute Key</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key">Key</Label>
<Input id="key" {...register("key")} placeholder="e.g. date_of_birth" />
{errors.key && <p className="text-sm text-red-500">{errors.key.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} placeholder="e.g. Date of Birth" />
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label>
<textarea
id="description"
{...register("description")}
placeholder="Short description"
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dataType">Data Type</Label>
<Select
value={dataType}
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
Create Key
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,166 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import {
TContactAttributeDataType,
TContactAttributeKey,
ZContactAttributeDataType,
} from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { updateAttributeKeyAction } from "../actions";
const ZEditAttributeKeyForm = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
dataType: ZContactAttributeDataType,
});
type TEditAttributeKeyForm = z.infer<typeof ZEditAttributeKeyForm>;
interface EditAttributeKeyModalProps {
open: boolean;
setOpen: (open: boolean) => void;
environmentId: string;
attributeKey: TContactAttributeKey | null;
}
export const EditAttributeKeyModal = ({
open,
setOpen,
environmentId,
attributeKey,
}: EditAttributeKeyModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting, errors },
} = useForm<TEditAttributeKeyForm>({
resolver: zodResolver(ZEditAttributeKeyForm),
defaultValues: {
name: "",
description: "",
dataType: "text",
},
});
useEffect(() => {
if (attributeKey) {
reset({
name: attributeKey.name ?? "",
description: attributeKey.description ?? "",
dataType: attributeKey.dataType ?? "text",
});
}
}, [attributeKey, reset]);
const dataType = watch("dataType");
const onSubmit = async (data: TEditAttributeKeyForm) => {
if (!attributeKey) return;
const result = await updateAttributeKeyAction({
id: attributeKey.id,
environmentId,
name: data.name,
description: data.description,
dataType: data.dataType,
});
if (result?.data) {
toast.success("Attribute key updated successfully");
setOpen(false);
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Attribute Key</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-edit">Key</Label>
<Input id="key-edit" value={attributeKey?.key ?? ""} disabled className="bg-slate-50" />
<p className="text-xs text-slate-500">The key cannot be changed once created.</p>
</div>
<div className="space-y-2">
<Label htmlFor="name-edit">Name</Label>
<Input id="name-edit" {...register("name")} placeholder="e.g. Date of Birth" />
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="description-edit">Description (Optional)</Label>
<textarea
id="description-edit"
{...register("description")}
placeholder="Short description"
className="flex min-h-[80px] w-full rounded-md border border-slate-200 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dataType-edit">Data Type</Label>
<Select
value={dataType}
onValueChange={(val) => setValue("dataType", val as TContactAttributeDataType)}>
<SelectTrigger id="dataType-edit">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Changing data type may affect existing data.</p>
{errors.dataType && <p className="text-sm text-red-500">{errors.dataType.message}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
Save Changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -35,6 +35,11 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
+38 -14
View File
@@ -5,6 +5,7 @@ import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { detectAttributeType } from "@/modules/ee/contacts/lib/detect-attribute-type";
export const updateAttributes = async (
contactId: string,
@@ -24,7 +25,7 @@ export const updateAttributes = async (
// Fetch contact attribute keys and email check in parallel
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributeKeys(environmentId),
contactAttributesParam.email
contactAttributesParam.email && typeof contactAttributesParam.email === "string"
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
: Promise.resolve(null),
]);
@@ -42,6 +43,14 @@ export const updateAttributes = async (
(acc, [key, value]) => {
const attributeKey = contactAttributeKeyMap.get(key);
if (attributeKey) {
// Validate type
if (attributeKey.dataType === "number") {
const num = Number(value);
if (isNaN(num)) return acc; // Skip invalid number
} else if (attributeKey.dataType === "date") {
const date = new Date(value as string | number | Date);
if (isNaN(date.getTime())) return acc; // Skip invalid date
}
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
} else {
acc.newAttributes.push({ key, value });
@@ -49,8 +58,8 @@ export const updateAttributes = async (
return acc;
},
{ existingAttributes: [], newAttributes: [] } as {
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
newAttributes: { key: string; value: string }[];
existingAttributes: { key: string; value: string | number | Date; attributeKeyId: string }[];
newAttributes: { key: string; value: string | number | Date }[];
}
);
@@ -65,22 +74,29 @@ export const updateAttributes = async (
// First, update all existing attributes
if (existingAttributes.length > 0) {
await prisma.$transaction(
existingAttributes.map(({ attributeKeyId, value }) =>
prisma.contactAttribute.upsert({
existingAttributes.map(({ attributeKeyId, value }) => {
let stringValue = value;
if (value instanceof Date) {
stringValue = value.toISOString();
} else {
stringValue = String(value);
}
return prisma.contactAttribute.upsert({
where: {
contactId_attributeKeyId: {
contactId,
attributeKeyId,
},
},
update: { value },
update: { value: stringValue },
create: {
contactId,
attributeKeyId,
value,
value: stringValue,
},
})
)
});
})
);
}
@@ -96,18 +112,26 @@ export const updateAttributes = async (
} else {
// Create new attributes since we're under the limit
await prisma.$transaction(
newAttributes.map(({ key, value }) =>
prisma.contactAttributeKey.create({
newAttributes.map(({ key, value }) => {
let stringValue = value;
if (value instanceof Date) {
stringValue = value.toISOString();
} else {
stringValue = String(value);
}
return prisma.contactAttributeKey.create({
data: {
key,
type: "custom",
dataType: detectAttributeType(value),
environment: { connect: { id: environmentId } },
attributes: {
create: { contactId, value },
create: { contactId, value: stringValue },
},
},
})
)
});
})
);
}
}
@@ -1,6 +1,6 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
export const getContactAttributeKeys = reactCache(
async (environmentId: string): Promise<TContactAttributeKey[]> => {
@@ -9,3 +9,41 @@ export const getContactAttributeKeys = reactCache(
});
}
);
export const updateContactAttributeKey = async (
environmentId: string,
keyId: string,
data: {
name: string;
description?: string;
dataType: TContactAttributeDataType;
}
): Promise<TContactAttributeKey> => {
return await prisma.contactAttributeKey.update({
where: {
id: keyId,
environmentId,
},
data,
});
};
export const createContactAttributeKey = async (
environmentId: string,
key: string,
type: "default" | "custom",
data: {
name: string;
description?: string;
dataType: TContactAttributeDataType;
}
): Promise<TContactAttributeKey> => {
return await prisma.contactAttributeKey.create({
data: {
key,
type,
environmentId,
...data,
},
});
};
@@ -0,0 +1,36 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
export const detectAttributeType = (value: string | number | Date): TContactAttributeDataType => {
// if the value is a number, return "number"
if (typeof value === "number") {
return "number";
}
// if the value is a string and looks like a number, return "number"
if (typeof value === "string") {
const trimmedValue = value.trim();
if (trimmedValue !== "" && !isNaN(Number(trimmedValue))) {
return "number";
}
}
// if the value is a date, return "date"
if (value instanceof Date) {
return "date";
}
// if the value is a string and looks like a date, return "date"
if (typeof value === "string") {
// Check if it starts with YYYY-MM-DD (ISO 8601 partial match is enough for our needs)
// we want to avoid treating arbitrary strings as dates even if Date.parse accepts them
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return "date";
}
}
}
// otherwise, return "text"
return "text";
};
@@ -5,6 +5,7 @@ import { FingerprintIcon, MonitorSmartphoneIcon, TagIcon, Users2Icon } from "luc
import React, { type JSX, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import type {
TBaseFilter,
TSegment,
@@ -15,6 +16,7 @@ import { cn } from "@/lib/cn";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { AttributeIcon } from "./attribute-icon";
import AttributeTabContent from "./attribute-tab-content";
import FilterButton from "./filter-button";
@@ -35,6 +37,7 @@ export const handleAddFilter = ({
contactAttributeKey,
deviceType,
segmentId,
dataType,
}: {
type: TFilterType;
onAddFilter: (filter: TBaseFilter) => void;
@@ -42,10 +45,22 @@ export const handleAddFilter = ({
contactAttributeKey?: string;
segmentId?: string;
deviceType?: string;
dataType?: TContactAttributeDataType;
}): void => {
if (type === "attribute") {
if (!contactAttributeKey) return;
let operator: string = "equals";
let value: any = "";
if (dataType === "date") {
operator = "isOlderThan";
value = { amount: 7, unit: "days" };
} else if (dataType === "number") {
operator = "equals";
value = 0;
}
const newFilterResource: TSegmentAttributeFilter = {
id: createId(),
root: {
@@ -53,9 +68,9 @@ export const handleAddFilter = ({
contactAttributeKey,
},
qualifier: {
operator: "equals",
operator: operator as any,
},
value: "",
value,
};
const newFilter: TBaseFilter = {
id: createId(),
@@ -239,7 +254,7 @@ export function AddFilterModal({
<FilterButton
key={attributeKey.id}
data-testid={`filter-btn-attribute-${attributeKey.key}`}
icon={<TagIcon className="h-4 w-4" />}
icon={<AttributeIcon dataType={attributeKey.dataType} className="h-4 w-4" />}
label={attributeKey.name ?? attributeKey.key}
onClick={() => {
handleAddFilter({
@@ -247,6 +262,7 @@ export function AddFilterModal({
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
dataType: attributeKey.dataType,
});
}}
onKeyDown={(e) => {
@@ -257,6 +273,7 @@ export function AddFilterModal({
onAddFilter,
setOpen,
contactAttributeKey: attributeKey.key,
dataType: attributeKey.dataType,
});
}
}}
@@ -0,0 +1,19 @@
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
interface AttributeIconProps {
dataType?: TContactAttributeDataType;
className?: string;
}
export const AttributeIcon = ({ dataType, className }: AttributeIconProps) => {
switch (dataType) {
case "date":
return <CalendarIcon className={className} />;
case "number":
return <HashIcon className={className} />;
case "text":
default:
return <TagIcon className={className} />;
}
};
@@ -0,0 +1,112 @@
import { CalendarIcon } from "lucide-react";
import { TTimeUnit } from "@formbricks/types/segment";
import { DatePicker } from "@/modules/ui/components/date-picker";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface DateFilterValueProps {
filterId: string;
operator: string;
value: string | number | { amount: number; unit: TTimeUnit } | [string, string];
onChange: (value: string | number | { amount: number; unit: TTimeUnit } | [string, string]) => void;
}
export const DateFilterValue = ({ operator, value, onChange }: DateFilterValueProps) => {
// Handle relative operators (isOlderThan, isNewerThan)
if (operator === "isOlderThan" || operator === "isNewerThan") {
const relativeValue =
typeof value === "object" && !Array.isArray(value)
? (value as { amount: number; unit: TTimeUnit })
: { amount: 1, unit: "days" as TTimeUnit };
return (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={relativeValue.amount}
onChange={(e) =>
onChange({
...relativeValue,
amount: parseInt(e.target.value) || 0,
})
}
className="w-20 bg-white"
/>
<Select
value={relativeValue.unit}
onValueChange={(unit) =>
onChange({
...relativeValue,
unit: unit as TTimeUnit,
})
}>
<SelectTrigger className="w-32 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">days</SelectItem>
<SelectItem value="weeks">weeks</SelectItem>
<SelectItem value="months">months</SelectItem>
<SelectItem value="years">years</SelectItem>
</SelectContent>
</Select>
</div>
);
}
// Handle isBetween (Range)
if (operator === "isBetween") {
const rangeValue = Array.isArray(value) ? (value as [string, string]) : ["", ""];
const [startDate, endDate] = rangeValue;
return (
<div className="flex items-center gap-2">
<div className="relative w-full">
<DatePicker
date={startDate ? new Date(startDate) : null}
updateSurveyDate={(date) => {
if (date) {
onChange([date.toISOString(), endDate]);
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
<span className="text-sm text-slate-500">and</span>
<div className="relative w-full">
<DatePicker
date={endDate ? new Date(endDate) : null}
updateSurveyDate={(date) => {
if (date) {
onChange([startDate, date.toISOString()]);
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
</div>
);
}
// Handle absolute operators (isBefore, isAfter, isSameDay)
return (
<div className="relative w-full">
<DatePicker
date={typeof value === "string" && value ? new Date(value) : null}
updateSurveyDate={(date) => {
if (date) {
onChange(date.toISOString());
}
}}
/>
<CalendarIcon className="pointer-events-none absolute right-2 top-2.5 h-4 w-4 text-slate-400" />
</div>
);
};
@@ -6,7 +6,6 @@ import {
FingerprintIcon,
MonitorSmartphoneIcon,
MoreVertical,
TagIcon,
Trash2,
Users2Icon,
} from "lucide-react";
@@ -14,11 +13,16 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type {
import {
ARITHMETIC_OPERATORS,
DATE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
TArithmeticOperator,
TAttributeOperator,
TBaseFilter,
TDeviceOperator,
TEXT_ATTRIBUTE_OPERATORS,
TSegment,
TSegmentAttributeFilter,
TSegmentConnector,
@@ -29,14 +33,9 @@ import type {
TSegmentPersonFilter,
TSegmentSegmentFilter,
} from "@formbricks/types/segment";
import {
ARITHMETIC_OPERATORS,
ATTRIBUTE_OPERATORS,
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { DateFilterValue } from "@/modules/ee/contacts/segments/components/date-filter-value";
import {
convertOperatorToText,
convertOperatorToTitle,
@@ -64,6 +63,7 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
import { AttributeIcon } from "./attribute-icon";
interface TSegmentFilterProps {
connector: TSegmentConnector;
@@ -224,6 +224,10 @@ function AttributeSegmentFilter({
const [valueError, setValueError] = useState("");
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
const dataType = attributeKey?.dataType ?? "text";
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
// when the operator changes, we need to check if the value is valid
useEffect(() => {
const { operator } = resource.qualifier;
@@ -239,17 +243,27 @@ function AttributeSegmentFilter({
}
}, [resource.qualifier, resource.value, t]);
const operatorArr = ATTRIBUTE_OPERATORS.map((operator) => {
const getAvailableOperators = () => {
switch (dataType) {
case "number":
return [...ARITHMETIC_OPERATORS, "isSet", "isNotSet"] as const;
case "date":
return [...DATE_OPERATORS, "isSet", "isNotSet"] as const;
default:
// Text is default
return TEXT_ATTRIBUTE_OPERATORS;
}
};
const availableOperators = getAvailableOperators();
const operatorArr = availableOperators.map((operator) => {
return {
id: operator,
name: convertOperatorToText(operator),
};
});
const attributeKey = contactAttributeKeys.find((attrKey) => attrKey.key === contactAttributeKey);
const attrKeyValue = attributeKey?.name ?? attributeKey?.key ?? "";
const updateOperatorInLocalSurvey = (filterId: string, newOperator: TAttributeOperator) => {
const updatedSegment = structuredClone(segment);
if (updatedSegment.filters) {
@@ -279,7 +293,7 @@ function AttributeSegmentFilter({
const { operator } = resource.qualifier;
if (ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
if (dataType === "number" && ARITHMETIC_OPERATORS.includes(operator as TArithmeticOperator)) {
const isNumber = z.coerce.number().safeParse(value);
if (isNumber.success) {
@@ -319,7 +333,7 @@ function AttributeSegmentFilter({
hideArrow>
<SelectValue>
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-sm" />
<AttributeIcon dataType={dataType} className="h-4 w-4 text-sm" />
<p>{attrKeyValue}</p>
</div>
</SelectValue>
@@ -355,25 +369,37 @@ function AttributeSegmentFilter({
</SelectContent>
</Select>
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<div className="relative flex flex-col gap-1">
<Input
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
checkValueAndUpdate(e);
}}
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) &&
(dataType === "date" ? (
<DateFilterValue
filterId={resource.id}
operator={resource.qualifier.operator}
value={resource.value}
onChange={(newValue) => updateValueInLocalSurvey(resource.id, newValue)}
/>
) : (
<div className="relative flex flex-col gap-1">
<Input
className={cn(
"h-9 w-auto bg-white",
valueError && "border border-red-500 focus:border-red-500"
)}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value as any}
type={dataType === "number" ? "number" : "text"}
/>
{valueError ? (
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
{valueError}
</p>
) : null}
</div>
)}
{valueError ? (
<p className="absolute right-2 -mt-1 rounded-md bg-white px-2 text-xs text-red-500">
{valueError}
</p>
) : null}
</div>
))}
<SegmentFilterItemContextMenu
filterId={resource.id}
@@ -544,7 +570,7 @@ function PersonSegmentFilter({
if (viewOnly) return;
checkValueAndUpdate(e);
}}
value={resource.value}
value={resource.value as any}
/>
{valueError ? (
@@ -0,0 +1,63 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
export const segmentTableColumns: ColumnDef<TSegmentWithSurveyNames>[] = [
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => {
const segment = row.original;
return (
<div className="flex items-center gap-4">
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<UsersIcon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{segment.title}</div>
{segment.description && (
<div className="ph-no-capture max-w-[300px] truncate text-xs font-medium text-slate-500">
{segment.description}
</div>
)}
</div>
</div>
);
},
},
{
accessorKey: "surveys",
header: "Surveys",
cell: ({ row }) => {
// segments table data row had this hidden on small screens
return <div className="text-center text-slate-900">{row.original.surveys?.length ?? 0}</div>;
},
},
{
accessorKey: "updatedAt",
header: "Updated",
cell: ({ row }) => {
return (
<div className="text-center text-slate-900">
{formatDistanceToNow(row.original.updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
);
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<div className="text-center text-slate-900">
{format(row.original.createdAt, "do 'of' MMMM, yyyy")}
</div>
);
},
},
];
@@ -0,0 +1,95 @@
"use client";
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { segmentTableColumns } from "./segment-table-columns";
interface SegmentsDataTableProps {
segments: TSegmentWithSurveyNames[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
export const SegmentsDataTable = ({
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentsDataTableProps) => {
const { t } = useTranslation();
const [selectedSegment, setSelectedSegment] = useState<TSegmentWithSurveyNames | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const table = useReactTable({
data: segments,
columns: segmentTableColumns,
getCoreRowModel: getCoreRowModel(),
});
const handleRowClick = (segment: TSegmentWithSurveyNames) => {
setSelectedSegment(segment);
setIsEditModalOpen(true);
};
return (
<div className="rounded-xl border border-slate-200 bg-white">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell key={header.id} className="font-semibold text-slate-900">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => handleRowClick(row.original)}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{segments.length === 0 && (
<TableRow>
<TableCell
colSpan={segmentTableColumns.length}
className="py-6 text-center text-sm text-slate-400">
{t("environments.segments.create_your_first_segment")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{selectedSegment && (
<EditSegmentModal
environmentId={selectedSegment.environmentId}
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
currentSegment={selectedSegment}
contactAttributeKeys={contactAttributeKeys}
segments={segments as TSegment[]} // types might slightly differ if WithSurveyNames extends TSegment
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
)}
</div>
);
};
@@ -0,0 +1,59 @@
import { TTimeUnit } from "@formbricks/types/segment";
export const subtractTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
const result = new Date(date);
switch (unit) {
case "days":
result.setDate(result.getDate() - amount);
break;
case "weeks":
result.setDate(result.getDate() - amount * 7);
break;
case "months":
result.setMonth(result.getMonth() - amount);
break;
case "years":
result.setFullYear(result.getFullYear() - amount);
break;
}
return result;
};
export const addTimeUnit = (date: Date, amount: number, unit: TTimeUnit): Date => {
const result = new Date(date);
switch (unit) {
case "days":
result.setDate(result.getDate() + amount);
break;
case "weeks":
result.setDate(result.getDate() + amount * 7);
break;
case "months":
result.setMonth(result.getMonth() + amount);
break;
case "years":
result.setFullYear(result.getFullYear() + amount);
break;
}
return result;
};
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};
@@ -3,19 +3,76 @@ import { cache as reactCache } from "react";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import {
DATE_OPERATORS,
TBaseFilters,
TDateOperator,
TSegmentAttributeFilter,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentPersonFilter,
TSegmentSegmentFilter,
TTimeUnit,
} from "@formbricks/types/segment";
import { endOfDay, startOfDay, subtractTimeUnit } from "@/modules/ee/contacts/segments/lib/date-utils";
import { isResourceFilter } from "@/modules/ee/contacts/segments/lib/utils";
import { getSegment } from "../segments";
// Type for the result of the segment filter to prisma query generation
export type SegmentFilterQueryResult = {
whereClause: Prisma.ContactWhereInput;
const isDateOperator = (operator: string): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prisma.StringFilter => {
const { qualifier, value } = filter;
const { operator } = qualifier;
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (typeof value !== "object" || Array.isArray(value) || !("amount" in value) || !("unit" in value)) {
return {};
}
const { amount, unit } = value as { amount: number; unit: TTimeUnit };
const now = new Date();
const thresholdDate = subtractTimeUnit(now, amount, unit);
if (operator === "isOlderThan") {
return { lt: thresholdDate.toISOString() };
} else {
return { gt: thresholdDate.toISOString() };
}
}
if (operator === "isBetween") {
if (!Array.isArray(value) || value.length !== 2) {
return {};
}
const [startStr, endStr] = value as [string, string];
const startDate = startOfDay(new Date(startStr));
const endDate = endOfDay(new Date(endStr));
return {
gte: startDate.toISOString(),
lte: endDate.toISOString(),
};
}
if (typeof value !== "string") {
return {};
}
const compareDate = new Date(value);
switch (operator) {
case "isBefore":
return { lt: startOfDay(compareDate).toISOString() };
case "isAfter":
return { gt: endOfDay(compareDate).toISOString() };
case "isSameDay":
return {
gte: startOfDay(compareDate).toISOString(),
lte: endOfDay(compareDate).toISOString(),
};
default:
return {};
}
};
/**
@@ -60,39 +117,56 @@ const buildAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): Prism
},
} satisfies Prisma.ContactWhereInput;
if (isDateOperator(operator)) {
// @ts-ignore
valueQuery.attributes.some.value = buildDateAttributeFilterWhereClause(filter);
return valueQuery;
}
// Apply the appropriate operator to the attribute value
switch (operator) {
case "equals":
// @ts-ignore
valueQuery.attributes.some.value = { equals: String(value), mode: "insensitive" };
break;
case "notEquals":
// @ts-ignore
valueQuery.attributes.some.value = { not: String(value), mode: "insensitive" };
break;
case "contains":
// @ts-ignore
valueQuery.attributes.some.value = { contains: String(value), mode: "insensitive" };
break;
case "doesNotContain":
// @ts-ignore
valueQuery.attributes.some.value = { not: { contains: String(value) }, mode: "insensitive" };
break;
case "startsWith":
// @ts-ignore
valueQuery.attributes.some.value = { startsWith: String(value), mode: "insensitive" };
break;
case "endsWith":
// @ts-ignore
valueQuery.attributes.some.value = { endsWith: String(value), mode: "insensitive" };
break;
case "greaterThan":
// @ts-ignore
valueQuery.attributes.some.value = { gt: String(value) };
break;
case "greaterEqual":
// @ts-ignore
valueQuery.attributes.some.value = { gte: String(value) };
break;
case "lessThan":
// @ts-ignore
valueQuery.attributes.some.value = { lt: String(value) };
break;
case "lessEqual":
// @ts-ignore
valueQuery.attributes.some.value = { lte: String(value) };
break;
default:
// @ts-ignore
valueQuery.attributes.some.value = String(value);
}
@@ -22,12 +22,20 @@ import {
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
ZSegmentCreateInput,
ZSegmentFilters,
ZSegmentUpdateInput,
} from "@formbricks/types/segment";
import { DATE_OPERATORS, TDateOperator, TTimeUnit } from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
import {
endOfDay,
isSameDay,
startOfDay,
subtractTimeUnit,
} from "@/modules/ee/contacts/segments/lib/date-utils";
import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/contacts/segments/lib/utils";
export type PrismaSegment = Prisma.SegmentGetPayload<{
@@ -35,6 +43,8 @@ export type PrismaSegment = Prisma.SegmentGetPayload<{
surveys: {
select: {
id: true;
name: true;
status: true;
};
};
};
@@ -65,6 +75,21 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegment => {
};
};
export const transformPrismaSegmentWithSurveyNames = (segment: PrismaSegment): TSegmentWithSurveyNames => {
const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name);
const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name);
return {
...transformPrismaSegment(segment),
activeSurveys,
inactiveSurveys,
};
};
export const getSegment = reactCache(async (segmentId: string): Promise<TSegment> => {
validateInputs([segmentId, ZId]);
try {
@@ -89,7 +114,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegment[]> => {
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
@@ -103,7 +128,7 @@ export const getSegments = reactCache(async (environmentId: string): Promise<TSe
return [];
}
return segments.map((segment) => transformPrismaSegment(segment));
return segments.map((segment) => transformPrismaSegmentWithSurveyNames(segment));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -375,6 +400,77 @@ export const getSegmentsByAttributeKey = reactCache(async (environmentId: string
}
});
const isDateOperator = (operator: string): operator is TDateOperator => {
return DATE_OPERATORS.includes(operator as TDateOperator);
};
const evaluateDateFilter = (
attributeValue: string | number,
filterValue: TSegmentAttributeFilter["value"],
operator: TDateOperator
): boolean => {
const date = new Date(attributeValue);
if (isNaN(date.getTime())) {
return false;
}
// Handle relative date operators
if (operator === "isOlderThan" || operator === "isNewerThan") {
if (
typeof filterValue !== "object" ||
Array.isArray(filterValue) ||
!("amount" in filterValue) ||
!("unit" in filterValue)
) {
return false;
}
const { amount, unit } = filterValue as { amount: number; unit: TTimeUnit };
const now = new Date();
const thresholdDate = subtractTimeUnit(now, amount, unit);
if (operator === "isOlderThan") {
// Something is older than 5 days if valid < (now - 5 days)
return date < thresholdDate;
} else {
// Something is newer than 5 days if valid > (now - 5 days)
return date > thresholdDate;
}
}
// Handle between operator
if (operator === "isBetween") {
if (!Array.isArray(filterValue) || filterValue.length !== 2) {
return false;
}
const [startStr, endStr] = filterValue as [string, string];
const startDate = startOfDay(new Date(startStr));
const endDate = endOfDay(new Date(endStr));
return date >= startDate && date <= endDate;
}
// Handle absolute operators
if (typeof filterValue !== "string") {
return false;
}
const compareDate = new Date(filterValue);
if (isNaN(compareDate.getTime())) {
return false;
}
switch (operator) {
case "isBefore":
return date < startOfDay(compareDate);
case "isAfter":
return date > endOfDay(compareDate);
case "isSameDay":
return isSameDay(date, compareDate);
default:
return false;
}
};
const evaluateAttributeFilter = (
attributes: TEvaluateSegmentUserAttributeData,
filter: TSegmentAttributeFilter
@@ -384,10 +480,21 @@ const evaluateAttributeFilter = (
const attributeValue = attributes[contactAttributeKey];
if (!attributeValue) {
// Special handling for isSet/isNotSet if needed, but compareValues handles it generically
// However, if checks below depend on value existence, we might need to delegate earlier
// For date operators, if no value, usually false (unless we add isNotSet for dates)
// But compareValues handles isSet/isNotSet.
// We should check operator type first.
if (qualifier.operator === "isNotSet") return true;
if (qualifier.operator === "isSet") return false;
return false;
}
const attResult = compareValues(attributeValue, value, qualifier.operator);
if (isDateOperator(qualifier.operator)) {
return evaluateDateFilter(attributeValue, value, qualifier.operator);
}
const attResult = compareValues(attributeValue, value as string | number, qualifier.operator);
return attResult;
};
@@ -396,7 +503,7 @@ const evaluatePersonFilter = (userId: string, filter: TSegmentPersonFilter): boo
const { personIdentifier } = root;
if (personIdentifier === "userId") {
const attResult = compareValues(userId, value, qualifier.operator);
const attResult = compareValues(userId, value as string | number, qualifier.operator);
return attResult;
}
@@ -437,7 +544,7 @@ const evaluateSegmentFilter = async (
const evaluateDeviceFilter = (device: "phone" | "desktop", filter: TSegmentDeviceFilter): boolean => {
const { value, qualifier } = filter;
return compareValues(device, value, qualifier.operator);
return compareValues(device, value as string | number, qualifier.operator);
};
export const compareValues = (
@@ -10,6 +10,7 @@ import {
TSegmentConnector,
TSegmentDeviceFilter,
TSegmentFilter,
TSegmentFilterValue,
TSegmentOperator,
TSegmentPersonFilter,
TSegmentSegmentFilter,
@@ -50,6 +51,18 @@ export const convertOperatorToText = (operator: TAllOperators) => {
return "User is in";
case "userIsNotIn":
return "User is not in";
case "isOlderThan":
return "is older than";
case "isNewerThan":
return "is newer than";
case "isBefore":
return "is before";
case "isAfter":
return "is after";
case "isBetween":
return "is between";
case "isSameDay":
return "is on";
default:
return operator;
}
@@ -85,6 +98,18 @@ export const convertOperatorToTitle = (operator: TAllOperators) => {
return "User is in";
case "userIsNotIn":
return "User is not in";
case "isOlderThan":
return "Is older than";
case "isNewerThan":
return "Is newer than";
case "isBefore":
return "Is before";
case "isAfter":
return "Is after";
case "isBetween":
return "Is between";
case "isSameDay":
return "Is on";
default:
return operator;
}
@@ -398,7 +423,7 @@ export const updateSegmentIdInFilter = (group: TBaseFilters, filterId: string, n
}
};
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: string | number) => {
export const updateFilterValue = (group: TBaseFilters, filterId: string, newValue: TSegmentFilterValue) => {
for (let i = 0; i < group.length; i++) {
const { resource } = group[i];
+8 -19
View File
@@ -2,14 +2,14 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
import { CreateSegmentModal } from "@/modules/ee/contacts/segments/components/create-segment-modal";
import { SegmentsDataTable } from "@/modules/ee/contacts/segments/components/segments-data-table";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { CreateSegmentModal } from "./components/create-segment-modal";
export const SegmentsPage = async ({
params: paramsProps,
@@ -17,22 +17,11 @@ export const SegmentsPage = async ({
params: Promise<{ environmentId: string }>;
}) => {
const params = await paramsProps;
const t = await getTranslate();
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [segments, contactAttributeKeys] = await Promise.all([
getSegments(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
const t = await getTranslate();
const isContactsEnabled = await getIsContactsEnabled();
if (!segments) {
throw new Error("Failed to fetch segments");
}
const filteredSegments = segments.filter((segment) => !segment.isPrivate);
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
const segments = await getSegments(params.environmentId);
return (
<PageContentWrapper>
@@ -43,7 +32,7 @@ export const SegmentsPage = async ({
<CreateSegmentModal
environmentId={params.environmentId}
contactAttributeKeys={contactAttributeKeys}
segments={filteredSegments}
segments={segments}
/>
) : undefined
}>
@@ -51,8 +40,8 @@ export const SegmentsPage = async ({
</PageHeader>
{isContactsEnabled ? (
<SegmentTable
segments={filteredSegments}
<SegmentsDataTable
segments={segments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
+11 -8
View File
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
@@ -54,17 +54,20 @@ export const renderEmailResponseValue = async (
<Container>
<Row className="mb-2 text-sm text-slate-700" dir="auto">
{Array.isArray(response) &&
response.filter(Boolean).map((item, index) => (
<Row key={item} className="mb-1 flex items-center">
<Column className="w-6 text-slate-400">#{index + 1}</Column>
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
</Row>
))}
response.map(
(item, index) =>
item && (
<Row key={item} className="mb-1 flex items-center">
<Column className="w-6 text-slate-400">#{index + 1}</Column>
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
</Row>
)
)}
</Row>
</Container>
);
default:
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
}
};
@@ -52,17 +52,9 @@ export async function ResponseFinishedEmail({
</Row>
);
})}
{survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = response.variables[variable.id];
{survey.variables.map((variable) => {
const variableResponse = response.variables[variable.id];
if (variableResponse && ["number", "string"].includes(typeof variable)) {
return (
<Row key={variable.id}>
<Column className="w-full text-sm font-medium">
@@ -74,33 +66,33 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
{survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId] as string;
}
return null;
})}
{survey.hiddenFields.fieldIds?.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
if (hiddenFieldResponse && typeof hiddenFieldResponse === "string") {
return (
<Row key={hiddenFieldId}>
<Column className="w-full font-medium">
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
}
return null;
})}
<EmailButton
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA`}
label={
@@ -38,7 +38,6 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
publicDomain: string;
}
export const ThemeStyling = ({
@@ -48,7 +47,6 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -201,7 +199,6 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
publicDomain={publicDomain}
/>
</div>
</div>
@@ -1,7 +1,6 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -28,7 +27,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -51,7 +49,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
publicDomain={publicDomain}
/>
</SettingsCard>
<SettingsCard
@@ -284,7 +284,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
className="opacity-0 hover:cursor-move group-hover:opacity-100"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>
@@ -22,7 +22,6 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -35,7 +34,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -67,7 +65,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -137,7 +135,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
@@ -153,7 +150,6 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
@@ -174,7 +170,6 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -808,7 +808,6 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
)}
@@ -50,7 +50,6 @@ interface SurveyEditorProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
}
export const SurveyEditor = ({
@@ -80,7 +79,6 @@ export const SurveyEditor = ({
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
publicDomain,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
@@ -274,7 +272,6 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>
</aside>
</div>
@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
{!isStorageConfigured && (
<div>
<Alert variant="warning" size="small">
-3
View File
@@ -6,7 +6,6 @@ import {
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -106,7 +105,6 @@ export const SurveyEditorPage = async (props) => {
}
const isCxMode = searchParams.mode === "cx";
const publicDomain = getPublicDomain();
return (
<SurveyEditor
@@ -136,7 +134,6 @@ export const SurveyEditorPage = async (props) => {
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
/>
);
};
@@ -9,8 +9,6 @@ export const ZCreateSurveyFollowUpFormSchema = z.object({
subject: z.string().trim().min(1, "Subject is required"),
body: z.string().trim().min(1, "Body is required"),
attachResponseData: z.boolean(),
includeVariables: z.boolean(),
includeHiddenFields: z.boolean(),
});
export type TCreateSurveyFollowUpForm = z.infer<typeof ZCreateSurveyFollowUpFormSchema>;
@@ -1,21 +1,34 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import {
Body,
Column,
Container,
Hr,
Html,
Img,
Link,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import sanitizeHtml from "sanitize-html";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
const fbLogoUrl = FB_LOGO_URL;
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly includeVariables: boolean;
readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
@@ -29,97 +42,91 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
const elements = props.attachResponseData ? getElementResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
// If the logo is not set, we are not using white labeling
const isDefaultLogo = !props.logoUrl || props.logoUrl === fbLogoUrl;
return (
<EmailTemplate logoUrl={props.logoUrl} t={t}>
<>
<div
dangerouslySetInnerHTML={{
__html: sanitizeHtml(body, {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
}),
}}
/>
<Html>
<Tailwind>
<Body
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-slate-800"
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Section>
{isDefaultLogo ? (
<Link href={logoLink} target="_blank">
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={props.logoUrl} />
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
}),
}}
/>
{elements.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{elements.length > 0 ? <Hr /> : null}
{elements.map((e) => {
if (!e.response) return;
return (
<Row key={e.element}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeVariables &&
props.survey.variables
.filter((variable) => {
const variableResponse = props.response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = props.response.variables[variable.id];
{elements.map((e) => {
if (!e.response) return;
return (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{variable.type === "number"
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{variableResponse}
</Text>
<Row key={e.element}>
<Column className="w-full font-medium">
<Text className="mb-2 text-sm">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
</Container>
{props.attachResponseData &&
props.includeHiddenFields &&
props.survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
</>
</EmailTemplate>
{/* If the logo is not set, we are not using white labeling */}
{isDefaultLogo ? (
<Section className="mt-4 text-center text-sm">
<Link
className="m-0 text-sm text-slate-500"
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
target="_blank"
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{IMPRINT_ADDRESS && (
<Text className="m-0 text-sm text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 text-sm text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link
href={IMPRINT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
<Link
href={PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.privacy_policy")}
</Link>
)}
</Text>
</Section>
) : null}
</Body>
</Tailwind>
</Html>
);
}
@@ -46,11 +46,6 @@ export const FollowUpItem = ({
if (!to) return true;
// Verified email is always valid as an option (handled at execution time)
if (to === "verifiedEmail") {
return false;
}
// Derive questions from blocks
const questions = getElementsFromBlocks(localSurvey.blocks);
@@ -155,7 +150,7 @@ export const FollowUpItem = ({
</div>
</button>
<div className="absolute top-4 right-4 flex items-center">
<div className="absolute right-4 top-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
@@ -201,8 +196,6 @@ export const FollowUpItem = ({
emailTo: followUp.action.properties.to,
replyTo: followUp.action.properties.replyTo,
attachResponseData: followUp.action.properties.attachResponseData,
includeVariables: followUp.action.properties.includeVariables ?? false,
includeHiddenFields: followUp.action.properties.includeHiddenFields ?? false,
}}
mode="edit"
teamMemberDetails={teamMemberDetails}
@@ -31,7 +31,6 @@ import {
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementIconMap } from "@/modules/survey/lib/elements";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -79,7 +78,7 @@ interface AddFollowUpModalProps {
}
type EmailSendToOption = {
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user" | "verifiedEmail";
type: "openTextElement" | "contactInfoElement" | "hiddenField" | "user";
label: string;
id: string;
};
@@ -141,18 +140,7 @@ export const FollowUpModal = ({
? updatedTeamMemberDetails
: [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }];
const verifiedEmailOption = localSurvey.isVerifyEmailEnabled
? [
{
label: t("common.verified_email"),
id: "verifiedEmail",
type: "verifiedEmail" as EmailSendToOption["type"],
},
]
: [];
return [
...verifiedEmailOption,
...openTextAndContactElements.map((element) => ({
label: getTextContent(
recallToHeadline(element.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]
@@ -176,7 +164,7 @@ export const FollowUpModal = ({
type: "user" as EmailSendToOption["type"],
})),
] satisfies EmailSendToOption[];
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail, t]);
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail]);
const form = useForm<TCreateSurveyFollowUpForm>({
defaultValues: {
@@ -271,8 +259,6 @@ export const FollowUpModal = ({
subject: data.subject,
body: sanitizedBody,
attachResponseData: data.attachResponseData,
includeVariables: data.includeVariables,
includeHiddenFields: data.includeHiddenFields,
},
},
};
@@ -320,8 +306,6 @@ export const FollowUpModal = ({
subject: data.subject,
body: sanitizedBody,
attachResponseData: data.attachResponseData,
includeVariables: data.includeVariables,
includeHiddenFields: data.includeHiddenFields,
},
},
};
@@ -377,8 +361,6 @@ export const FollowUpModal = ({
subject: defaultValues?.subject ?? "Thanks for your answers!",
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
attachResponseData: defaultValues?.attachResponseData ?? false,
includeVariables: defaultValues?.includeVariables ?? false,
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
});
}
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale, t]);
@@ -390,50 +372,33 @@ export const FollowUpModal = ({
setOpen(open);
};
const emailSendToVerifiedEmailOptions = emailSendToOptions.filter(
(option) => option.type === "verifiedEmail"
);
const emailSendToElementOptions = emailSendToOptions.filter(
(option) => option.type === "openTextElement" || option.type === "contactInfoElement"
);
const emailSendToHiddenFieldOptions = emailSendToOptions.filter((option) => option.type === "hiddenField");
const userSendToEmailOptions = emailSendToOptions.filter((option) => option.type === "user");
const getSelectItemIcon = (
type: EmailSendToOption["type"]
): { icon: React.ReactNode; textClass?: string } => {
switch (type) {
case "verifiedEmail":
return { icon: <MailIcon className="h-4 w-4" /> };
case "hiddenField":
return { icon: <EyeOffIcon className="h-4 w-4" /> };
case "user":
return {
icon: <UserIcon className="h-4 w-4" />,
textClass: "overflow-hidden text-ellipsis whitespace-nowrap",
};
case "openTextElement":
case "contactInfoElement":
return {
icon: (
<div className="h-4 w-4">
{ELEMENTS_ICON_MAP[type === "openTextElement" ? "openText" : "contactInfo"]}
</div>
),
textClass: "overflow-hidden text-ellipsis whitespace-nowrap",
};
}
};
const renderSelectItem = (option: EmailSendToOption) => {
const { icon, textClass } = getSelectItemIcon(option.type);
return (
<SelectItem key={option.id} value={option.id}>
<div className="flex items-center space-x-2">
{icon}
<span className={textClass}>{option.label}</span>
</div>
{option.type === "hiddenField" ? (
<div className="flex items-center space-x-2">
<EyeOffIcon className="h-4 w-4" />
<span>{option.label}</span>
</div>
) : option.type === "user" ? (
<div className="flex items-center space-x-2">
<UserIcon className="h-4 w-4" />
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<div className="h-4 w-4">
{ELEMENTS_ICON_MAP[option.type === "openTextElement" ? "openText" : "contactInfo"]}
</div>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
</div>
)}
</SelectItem>
);
};
@@ -683,8 +648,7 @@ export const FollowUpModal = ({
</SelectTrigger>
<SelectContent>
{emailSendToVerifiedEmailOptions.length > 0 ||
emailSendToElementOptions.length > 0 ? (
{emailSendToElementOptions.length > 0 ? (
<div className="flex flex-col">
<div className="flex items-center space-x-2 p-2">
<p className="text-sm text-slate-500">
@@ -692,10 +656,6 @@ export const FollowUpModal = ({
</p>
</div>
{emailSendToVerifiedEmailOptions.map((option) =>
renderSelectItem(option)
)}
{emailSendToElementOptions.map((option) =>
renderSelectItem(option)
)}
@@ -872,60 +832,27 @@ export const FollowUpModal = ({
render={({ field }) => {
return (
<FormItem>
<AdvancedOptionToggle
htmlId="attachResponseData"
isChecked={field.value}
onToggle={(checked) => field.onChange(checked)}
title={t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
)}
description={t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
)}
customContainerClass="p-0"
childBorder>
<div className="flex w-full flex-col gap-4 p-4">
<FormField
control={form.control}
name="includeVariables"
render={({ field: variablesField }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="includeVariables"
checked={variablesField.value}
onCheckedChange={(checked) => variablesField.onChange(checked)}
disabled={!field.value}
/>
<FormLabel htmlFor="includeVariables" className="font-medium">
{t("environments.surveys.edit.follow_ups_include_variables")}
</FormLabel>
</div>
</FormItem>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="attachResponseData"
checked={field.value}
defaultChecked={defaultValues?.attachResponseData ?? false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<FormField
control={form.control}
name="includeHiddenFields"
render={({ field: hiddenFieldsField }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="includeHiddenFields"
checked={hiddenFieldsField.value}
onCheckedChange={(checked) => hiddenFieldsField.onChange(checked)}
disabled={!field.value}
/>
<FormLabel htmlFor="includeHiddenFields" className="font-medium">
{t("environments.surveys.edit.follow_ups_include_hidden_fields")}
</FormLabel>
</div>
</FormItem>
<FormLabel htmlFor="attachResponseData" className="font-medium">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
)}
/>
</FormLabel>
</div>
</AdvancedOptionToggle>
<FormDescription className="text-sm text-slate-500">
{t(
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
)}
</FormDescription>
</div>
</FormItem>
);
}}
@@ -12,16 +12,12 @@ export const sendFollowUpEmail = async ({
survey,
response,
attachResponseData = false,
includeVariables = false,
includeHiddenFields = false,
logoUrl,
}: {
followUp: TSurveyFollowUp;
to: string;
replyTo: string[];
attachResponseData: boolean;
includeVariables?: boolean;
includeHiddenFields?: boolean;
survey: TSurvey;
response: TResponse;
logoUrl?: string;
@@ -37,8 +33,6 @@ export const sendFollowUpEmail = async ({
followUp,
logoUrl,
attachResponseData,
includeVariables,
includeHiddenFields,
survey,
response,
})
@@ -40,8 +40,6 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
logoUrl,
});
@@ -73,8 +71,6 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
});
return {
@@ -108,8 +104,6 @@ const evaluateFollowUp = async (
survey,
response,
attachResponseData: properties.attachResponseData,
includeVariables: properties.includeVariables,
includeHiddenFields: properties.includeHiddenFields,
});
return {
@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -1,73 +0,0 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { transformElement } from "./transformers";
import { validateElement } from "./validators";
/**
* Extract prefilled values from URL search parameters
*
* Supports prefilling for all survey element types with the following features:
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
* - Comma-separated values for multi-select and ranking
* - Backward compatibility with label-based prefilling
*
* @param survey - The survey object containing blocks and elements
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
* @param languageId - Current language code for label matching
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
*
* @example
* // Single select with option ID
* ?questionId=option-abc123
*
* // Multi-select with labels (backward compatible)
* ?questionId=Option1,Option2,Option3
*
* // Ranking with option IDs
* ?rankingId=choice-3,choice-1,choice-2
*
* // NPS question
* ?npsId=9
*
* // Multiple questions
* ?q1=answer1&q2=10&q3=option-xyz
*/
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillData: TResponseData = {};
const elements = getElementsFromBlocks(survey.blocks);
searchParams.forEach((value, key) => {
try {
// Skip reserved parameter names
if (FORBIDDEN_IDS.includes(key)) {
return;
}
// Find matching element
const element = elements.find((el) => el.id === key);
if (!element) {
return;
}
// Validate the value for this element type (returns match data)
const validationResult = validateElement(element, value, languageId);
if (!validationResult.isValid) {
return;
}
// Transform the value using pre-matched data from validation
const transformedValue = transformElement(validationResult, value, languageId);
prefillData[element.id] = transformedValue;
} catch (error) {
// Catch any errors to prevent one bad prefill from breaking all prefills
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
}
});
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
};
@@ -1,94 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
describe("matchOptionByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First", de: "Erste" } },
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
{ id: "other", label: { en: "Other", de: "Andere" } },
];
test("matches by ID", () => {
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in English", () => {
const result = matchOptionByIdOrLabel(choices, "First", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in German", () => {
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
expect(result).toEqual(choices[1]);
});
test("prefers ID match over label match", () => {
const choicesWithConflict = [
{ id: "First", label: { en: "Not First" } },
{ id: "choice-2", label: { en: "First" } },
];
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
});
test("returns null for no match", () => {
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = matchOptionByIdOrLabel(choices, "", "en");
expect(result).toBeNull();
});
test("handles special characters in labels", () => {
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
expect(result).toEqual(specialChoices[0]);
});
});
describe("matchMultipleOptionsByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First" } },
{ id: "choice-2", label: { en: "Second" } },
{ id: "choice-3", label: { en: "Third" } },
];
test("matches multiple values by ID", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches multiple values by label", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches mixed IDs and labels", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[1], choices[2]]);
});
test("preserves order of values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
expect(result).toEqual([choices[2], choices[0], choices[1]]);
});
test("skips non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("returns empty array for all non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
expect(result).toEqual([]);
});
test("handles empty values array", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
expect(result).toEqual([]);
});
});
@@ -1,42 +0,0 @@
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
/**
* Match a value against element choices by ID first, then by label
* This enables both option ID-based and label-based prefilling
*
* @param choices - Array of choice objects with id and label
* @param value - Value from URL parameter (either choice ID or label text)
* @param languageCode - Current language code for label matching
* @returns Matched choice or null if no match found
*/
export const matchOptionByIdOrLabel = (
choices: TSurveyElementChoice[],
value: string,
languageCode: string
): TSurveyElementChoice | null => {
const matchById = choices.find((choice) => choice.id === value);
if (matchById) return matchById;
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
if (matchByLabel) return matchByLabel;
return null;
};
/**
* Match multiple values against choices
* Used for multi-select and ranking elements
*
* @param choices - Array of choice objects
* @param values - Array of values from URL parameter
* @param languageCode - Current language code
* @returns Array of matched choices (preserves order)
*/
export const matchMultipleOptionsByIdOrLabel = (
choices: TSurveyElementChoice[],
values: string[],
languageCode: string
): TSurveyElementChoice[] =>
values
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
.filter((match) => match !== null);
@@ -1,64 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { parseCommaSeparated, parseNumber } from "./parsers";
describe("parseCommaSeparated", () => {
test("parses simple comma-separated values", () => {
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
});
test("trims whitespace from values", () => {
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
});
test("filters out empty values", () => {
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
});
test("handles empty string", () => {
expect(parseCommaSeparated("")).toEqual([]);
});
test("handles single value", () => {
expect(parseCommaSeparated("single")).toEqual(["single"]);
});
test("handles values with spaces", () => {
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
});
});
describe("parseNumber", () => {
test("parses valid integers", () => {
expect(parseNumber("5")).toBe(5);
expect(parseNumber("0")).toBe(0);
expect(parseNumber("10")).toBe(10);
});
test("parses valid floats", () => {
expect(parseNumber("5.5")).toBe(5.5);
expect(parseNumber("0.1")).toBe(0.1);
});
test("parses negative numbers", () => {
expect(parseNumber("-5")).toBe(-5);
expect(parseNumber("-5.5")).toBe(-5.5);
});
test("handles ampersand replacement", () => {
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
});
test("returns null for invalid strings", () => {
expect(parseNumber("abc")).toBeNull();
expect(parseNumber("")).toBeNull();
expect(parseNumber("5a")).toBeNull();
});
test("returns null for NaN result", () => {
expect(parseNumber("NaN")).toBeNull();
});
});
@@ -1,31 +0,0 @@
/**
* Simple parsing helpers for URL parameter values
*/
/**
* Parse comma-separated values from URL parameter
* Used for multi-select and ranking elements
* Handles whitespace trimming and empty values
*/
export const parseCommaSeparated = (value: string): string[] => {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
};
/**
* Parse number from URL parameter
* Used for NPS and Rating elements
* Returns null if parsing fails
*/
export const parseNumber = (value: string): number | null => {
try {
// Handle `&` being used instead of `;` in some cases
const cleanedValue = value.replaceAll("&", ";");
const num = Number(JSON.parse(cleanedValue));
return Number.isNaN(num) ? null : num;
} catch {
return null;
}
};
@@ -1,100 +0,0 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parseNumber } from "./parsers";
import {
TValidationResult,
isMultiChoiceResult,
isPictureSelectionResult,
isSingleChoiceResult,
} from "./types";
export const transformOpenText = (answer: string): string => {
return answer;
};
export const transformMultipleChoiceSingle = (
validationResult: TValidationResult,
answer: string,
language: string
): string => {
if (!isSingleChoiceResult(validationResult)) return answer;
const { matchedChoice } = validationResult;
// If we have a matched choice, return its label
if (matchedChoice) {
return matchedChoice.label[language] || answer;
}
// If no matched choice (null), it's an "other" value - return original
return answer;
};
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
if (!isMultiChoiceResult(validationResult)) return [];
const { matched, others } = validationResult;
// Return matched choices + joined "other" values as single string
if (others.length > 0) {
return [...matched, others.join(",")];
}
return matched;
};
export const transformNPS = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformRating = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformConsent = (answer: string): string => {
if (answer === "dismissed") return "";
return answer;
};
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
if (!isPictureSelectionResult(validationResult)) return [];
return validationResult.selectedIds;
};
/**
* Main transformation dispatcher
* Routes to appropriate transformer based on element type
* Uses pre-matched data from validation result to avoid duplicate matching
*/
export const transformElement = (
validationResult: TValidationResult,
answer: string,
language: string
): string | number | string[] => {
if (!validationResult.isValid) return "";
try {
switch (validationResult.type) {
case TSurveyElementTypeEnum.OpenText:
return transformOpenText(answer);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return transformMultipleChoiceSingle(validationResult, answer, language);
case TSurveyElementTypeEnum.Consent:
return transformConsent(answer);
case TSurveyElementTypeEnum.Rating:
return transformRating(answer);
case TSurveyElementTypeEnum.NPS:
return transformNPS(answer);
case TSurveyElementTypeEnum.PictureSelection:
return transformPictureSelection(validationResult);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return transformMultipleChoiceMulti(validationResult);
default:
return "";
}
} catch {
return "";
}
};
@@ -1,62 +0,0 @@
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
type TInvalidResult = {
isValid: false;
};
// Base valid result for simple types (no match data needed)
type TSimpleValidResult = {
isValid: true;
};
// Single choice match result (MultipleChoiceSingle)
type TSingleChoiceValidResult = {
isValid: true;
matchedChoice: TSurveyElementChoice | null; // null means "other" value
};
// Multi choice match result (MultipleChoiceMulti)
type TMultiChoiceValidResult = {
isValid: true;
matched: string[]; // matched labels
others: string[]; // other text values
};
// Picture selection result (indices are already validated)
type TPictureSelectionValidResult = {
isValid: true;
selectedIds: string[];
};
// Discriminated union for all validation results
export type TValidationResult =
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
| (TSimpleValidResult & {
type:
| TSurveyElementTypeEnum.OpenText
| TSurveyElementTypeEnum.NPS
| TSurveyElementTypeEnum.Rating
| TSurveyElementTypeEnum.Consent;
})
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
// Type guards for narrowing validation results
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
result.isValid;
export const isSingleChoiceResult = (
result: TValidationResult
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
export const isMultiChoiceResult = (
result: TValidationResult
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
export const isPictureSelectionResult = (
result: TValidationResult
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;
@@ -1,228 +0,0 @@
import {
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyPictureSelectionElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { matchOptionByIdOrLabel } from "./matchers";
import { parseCommaSeparated, parseNumber } from "./parsers";
import { TValidationResult } from "./types";
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
export const validateOpenText = (): TValidationResult => {
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
};
export const validateMultipleChoiceSingle = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
const hasOther = element.choices.at(-1)?.id === "other";
// Try matching by ID or label (new: supports both)
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
if (matchedChoice) {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice,
};
}
// If no match and has "other" option, accept any non-empty text as "other" value
if (hasOther) {
const trimmedAnswer = answer.trim();
if (trimmedAnswer !== "") {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice: null, // null indicates "other" value
};
}
}
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
};
export const validateMultipleChoiceMulti = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
const hasOther = element.choices.at(-1)?.id === "other";
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
const answerChoices = parseCommaSeparated(answer);
if (answerChoices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
// Process all answers and collect results
const matched: string[] = [];
const others: string[] = [];
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
if (matchedChoice) {
const label = matchedChoice.label[language];
if (label) {
matched.push(label);
}
continue;
}
// Check if it's the "Other" label itself
if (ans === lastChoiceLabel) {
continue;
}
// It's a free-text "other" value
if (hasOther) {
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
}
others.push(ans);
} else {
// No "other" option and doesn't match any choice
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
}
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
matched,
others,
};
};
export const validateNPS = (answer: string): TValidationResult => {
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
return invalid(TSurveyElementTypeEnum.NPS);
}
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
};
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Consent) {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (element.required && answer === "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (answer !== "accepted" && answer !== "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
};
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Rating) {
return invalid(TSurveyElementTypeEnum.Rating);
}
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
return invalid(TSurveyElementTypeEnum.Rating);
}
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
};
export const validatePictureSelection = (
element: TSurveyPictureSelectionElement,
answer: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const answerChoices = parseCommaSeparated(answer);
const selectedIds: string[] = [];
// Validate all indices and collect selected IDs
for (const ans of answerChoices) {
const num = parseNumber(ans);
if (num === null || num < 1 || num > element.choices.length) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const index = num - 1;
const choice = element.choices[index];
if (choice?.id) {
selectedIds.push(choice.id);
}
}
// Apply allowMulti constraint
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
return {
isValid: true,
type: TSurveyElementTypeEnum.PictureSelection,
selectedIds: finalIds,
};
};
/**
* Main validation dispatcher
* Routes to appropriate validator based on element type
* Returns validation result with match data for transformers
*/
export const validateElement = (
element: TSurveyElement,
answer: string,
language: string
): TValidationResult => {
// Empty required fields are invalid
if (element.required && (!answer || answer === "")) {
return invalid(element.type);
}
try {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return validateOpenText();
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return validateMultipleChoiceSingle(element, answer, language);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return validateMultipleChoiceMulti(element, answer, language);
case TSurveyElementTypeEnum.NPS:
return validateNPS(answer);
case TSurveyElementTypeEnum.Consent:
return validateConsent(element, answer);
case TSurveyElementTypeEnum.Rating:
return validateRating(element, answer);
case TSurveyElementTypeEnum.PictureSelection:
return validatePictureSelection(element, answer);
default:
return invalid();
}
} catch {
return invalid(element.type);
}
};
@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getPrefillValue } from "./index";
import { getPrefillValue } from "./utils";
describe("prefill integration tests", () => {
describe("survey link utils", () => {
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -76,7 +76,15 @@ describe("prefill integration tests", () => {
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "q7",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA Question" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
buttonUrl: "",
},
{
id: "q8",
type: TSurveyElementTypeEnum.Consent,
@@ -154,21 +162,13 @@ describe("prefill integration tests", () => {
expect(result).toEqual({ q1: "Open text answer" });
});
test("validates MultipleChoiceSingle questions with label", () => {
test("validates MultipleChoiceSingle questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Option 1");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q2: "Option 1" });
});
test("validates MultipleChoiceSingle questions with option ID", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "c2");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option ID is converted to label
expect(result).toEqual({ q2: "Option 2" });
});
test("invalidates MultipleChoiceSingle with non-existent option", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Non-existent option");
@@ -183,29 +183,13 @@ describe("prefill integration tests", () => {
expect(result).toEqual({ q3: "Custom answer" });
});
test("handles MultipleChoiceMulti questions with labels", () => {
test("handles MultipleChoiceMulti questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti questions with option IDs", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,c5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option IDs are converted to labels
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Mixed: ID converted to label + label stays as-is
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with Other", () => {
const searchParams = new URLSearchParams();
searchParams.set("q5", "Option 6,Custom answer");
@@ -227,6 +211,20 @@ describe("prefill integration tests", () => {
expect(result).toBeUndefined();
});
test("handles CTA questions with clicked value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "clicked");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "clicked" });
});
test("handles CTA questions with dismissed value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "dismissed");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "" });
});
test("validates Consent questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q8", "accepted");
@@ -295,18 +293,4 @@ describe("prefill integration tests", () => {
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toBeUndefined();
});
test("handles whitespace in comma-separated values", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4 , Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("ignores trailing commas in multi-select", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5,");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
});
+230 -2
View File
@@ -1,2 +1,230 @@
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
// This file is kept for any future utility functions
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillAnswer: TResponseData = {};
const questions = getElementsFromBlocks(survey.blocks);
const questionIdxMap = questions.reduce(
(acc, question, idx) => {
acc[question.id] = idx;
return acc;
},
{} as Record<string, number>
);
searchParams.forEach((value, key) => {
if (FORBIDDEN_IDS.includes(key)) return;
const questionId = key;
const questionIdx = questionIdxMap[questionId];
const question = questions[questionIdx];
const answer = value;
if (question) {
if (checkValidity(question, answer, languageId)) {
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
}
}
});
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
};
const validateOpenText = (): boolean => {
return true;
};
const validateMultipleChoiceSingle = (
question: TSurveyMultipleChoiceElement,
answer: string,
language: string
): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
const choices = question.choices;
const hasOther = choices[choices.length - 1].id === "other";
if (!hasOther) {
return choices.some((choice) => choice.label[language] === answer);
}
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
if (matchesAnyChoice) {
return true;
}
const trimmedAnswer = answer.trim();
return trimmedAnswer !== "";
};
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
const choices = (
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
).choices;
const hasOther = choices[choices.length - 1].id === "other";
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
const answerChoices = answer
.split(",")
.map((ans) => ans.trim())
.filter((ans) => ans !== "");
if (answerChoices.length === 0) {
return false;
}
if (!hasOther) {
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
}
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
if (matchesChoice) {
continue;
}
if (ans === lastChoiceLabel) {
continue;
}
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return false;
}
}
return true;
};
const validateNPS = (answer: string): boolean => {
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
} catch {
return false;
}
};
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "clicked" || answer === "dismissed";
};
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "accepted" || answer === "dismissed";
};
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
const ratingQuestion = question;
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
} catch {
return false;
}
};
const validatePictureSelection = (answer: string): boolean => {
const answerChoices = answer.split(",");
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
};
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.required && (!answer || answer === "")) return false;
const validators: Partial<
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
> = {
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
};
const validator = validators[question.type];
if (!validator) return false;
try {
return validator(question, answer, language);
} catch {
return false;
}
};
const transformAnswer = (
question: TSurveyElement,
answer: string,
language: string
): string | number | string[] => {
switch (question.type) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
return answer;
}
case TSurveyElementTypeEnum.Consent:
case TSurveyElementTypeEnum.CTA: {
if (answer === "dismissed") return "";
return answer;
}
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.NPS: {
const cleanedAnswer = answer.replace(/&/g, ";");
return Number(JSON.parse(cleanedAnswer));
}
case TSurveyElementTypeEnum.PictureSelection: {
const answerChoicesIdx = answer.split(",");
const answerArr: string[] = [];
answerChoicesIdx.forEach((ansIdx) => {
const choice = question.choices[Number(ansIdx) - 1];
if (choice) answerArr.push(choice.id);
});
if (question.allowMulti) return answerArr;
return answerArr.slice(0, 1);
}
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
let ansArr = answer.split(",");
const hasOthers = question.choices[question.choices.length - 1].id === "other";
if (!hasOthers) return ansArr;
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
const options = question.choices.map((o) => o.label[language]);
const others = ansArr.filter((a: string) => !options.includes(a));
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
if (others.length > 0) ansArr.push(others.join(","));
return ansArr;
}
default:
return "";
}
};
-1
View File
@@ -70,7 +70,6 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
environment={environment}
project={projectWithRequiredProps}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);
@@ -16,7 +16,6 @@ type TemplateContainerWithPreviewProps = {
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
isTemplatePage?: boolean;
publicDomain: string;
};
export const TemplateContainerWithPreview = ({
@@ -24,7 +23,6 @@ export const TemplateContainerWithPreview = ({
environment,
userId,
isTemplatePage = true,
publicDomain,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslation();
const initialTemplate = customSurveyTemplate(t);
@@ -74,7 +72,6 @@ export const TemplateContainerWithPreview = ({
environment={environment}
languageCode={"default"}
isSpamProtectionAllowed={false} // setting it to false as this is a template
publicDomain={publicDomain}
/>
)}
</aside>
+1 -9
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
@@ -28,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
const publicDomain = getPublicDomain();
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
publicDomain={publicDomain}
/>
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
);
};
@@ -1,28 +1,10 @@
"use client";
import { format } from "date-fns";
import { CalendarCheckIcon, CalendarIcon, XIcon } from "lucide-react";
import { useRef, useState } from "react";
import Calendar from "react-calendar";
import { XIcon } from "lucide-react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import "./styles.css";
const getOrdinalSuffix = (day: number) => {
if (day > 3 && day < 21) return "th"; // 11th, 12th, 13th, etc.
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
};
interface DatePickerProps {
date: Date | null;
@@ -33,103 +15,52 @@ interface DatePickerProps {
export const DatePicker = ({ date, updateSurveyDate, minDate, onClearDate }: DatePickerProps) => {
const { t } = useTranslation();
const [value, onChange] = useState<Date | undefined>(date ? new Date(date) : undefined);
const [formattedDate, setFormattedDate] = useState<string | undefined>(
date ? format(new Date(date), "do MMM, yyyy") : undefined
);
const [isOpen, setIsOpen] = useState(false);
const dateInputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const onDateChange = (date: Date) => {
if (date) {
updateSurveyDate(date);
const day = date.getDate();
const ordinalSuffix = getOrdinalSuffix(day);
const formatted = format(date, `d'${ordinalSuffix}' MMM, yyyy`);
setFormattedDate(formatted);
onChange(date);
setIsOpen(false);
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
updateSurveyDate(new Date(e.target.value));
}
};
const handleClearDate = () => {
if (onClearDate) {
onClearDate();
setFormattedDate(undefined);
onChange(undefined);
if (dateInputRef.current) {
dateInputRef.current.value = "";
}
}
};
return (
<div className="flex items-center gap-2">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
{formattedDate ? (
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground bg-slate-800"
)}
ref={btnRef}>
<CalendarCheckIcon className="mr-2 h-4 w-4" />
{formattedDate}
</Button>
) : (
<Button
variant={"ghost"}
className={cn(
"w-[280px] justify-start border border-slate-300 bg-white text-left font-normal",
!formattedDate && "text-muted-foreground"
)}
onClick={() => setIsOpen(true)}
ref={btnRef}>
<CalendarIcon className="mr-2 h-4 w-4" />
<span>{t("common.pick_a_date")}</span>
</Button>
<div className="relative flex w-full items-center">
<div className="relative w-full">
<input
ref={dateInputRef}
type="date"
min={minDate ? format(minDate, "yyyy-MM-dd") : undefined}
value={date ? format(date, "yyyy-MM-dd") : ""}
onChange={handleDateChange}
placeholder={t("common.pick_a_date")}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
!date && "text-slate-400"
)}
</PopoverTrigger>
<PopoverContent align="start" className="min-w-96 rounded-lg px-4 py-3">
<Calendar
value={value}
onChange={(date) => onDateChange(date as Date)}
minDate={minDate || new Date()}
className="!border-0"
tileClassName={({ date }: { date: Date }) => {
const baseClass =
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal fb-text-heading aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
// today's date class
if (
date.getDate() === new Date().getDate() &&
date.getMonth() === new Date().getMonth() &&
date.getFullYear() === new Date().getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading focus:fb-ring-2 focus:fb-bg-slate-200`;
}
// active date class
if (
date.getDate() === value?.getDate() &&
date.getMonth() === value?.getMonth() &&
date.getFullYear() === value?.getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-heading`;
}
/>
{!date && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center px-3 text-slate-400">
{t("common.pick_a_date")}
</div>
)}
</div>
return baseClass;
}}
showNeighboringMonth={false}
/>
</PopoverContent>
</Popover>
{formattedDate && onClearDate && (
<Button
variant="outline"
size="sm"
{date && onClearDate && (
<button
type="button"
onClick={handleClearDate}
className="h-8 w-8 p-0 hover:bg-slate-200">
className="absolute right-3 rounded-sm opacity-50 hover:opacity-100 focus:outline-none">
<XIcon className="h-4 w-4" />
</Button>
</button>
)}
</div>
);
@@ -25,7 +25,6 @@ interface PreviewSurveyProps {
environment: Pick<Environment, "id" | "appSetupCompleted">;
languageCode: string;
isSpamProtectionAllowed: boolean;
publicDomain: string;
}
let surveyNameTemp: string;
@@ -39,7 +38,6 @@ export const PreviewSurvey = ({
environment,
languageCode,
isSpamProtectionAllowed,
publicDomain,
}: PreviewSurveyProps) => {
const [isModalOpen, setIsModalOpen] = useState(true);
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
@@ -246,7 +244,6 @@ export const PreviewSurvey = ({
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -276,7 +273,6 @@ export const PreviewSurvey = ({
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
@@ -349,7 +345,6 @@ export const PreviewSurvey = ({
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -383,7 +378,6 @@ export const PreviewSurvey = ({
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
{...props}
/>
));
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
@@ -39,8 +37,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
// Set loading flag immediately to prevent concurrent loads
isLoadingScript = true;
try {
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(scriptUrl);
const response = await fetch("/js/surveys.umd.cjs");
if (!response.ok) {
throw new Error("Failed to load the surveys package");
@@ -16,7 +16,6 @@ interface ThemeStylingPreviewSurveyProps {
project: Project;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
publicDomain: string;
}
const previewParentContainerVariant: Variants = {
@@ -51,7 +50,6 @@ export const ThemeStylingPreviewSurvey = ({
project,
previewType,
setPreviewType,
publicDomain,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
@@ -168,7 +166,6 @@ export const ThemeStylingPreviewSurvey = ({
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyKey}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -195,7 +192,6 @@ export const ThemeStylingPreviewSurvey = ({
key={surveyKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
+6 -6
View File
@@ -16,7 +16,6 @@ const getHostname = (url) => {
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
basePath: process.env.BASE_PATH || undefined,
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: true,
@@ -62,6 +61,10 @@ const nextConfig = {
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "api-iam.eu.intercom.io",
},
],
},
async redirects() {
@@ -165,7 +168,7 @@ const nextConfig = {
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",
@@ -404,7 +407,7 @@ const nextConfig = {
];
},
env: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL, // TODO: Remove this once we have a proper solution for the base path
NEXTAUTH_URL: process.env.WEBAPP_URL,
},
};
@@ -442,7 +445,4 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
console.log("BASE PATH", nextConfig.basePath);
export default exportConfig;
+1 -3
View File
@@ -36,6 +36,7 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
"@lexical/code": "0.36.2",
"@lexical/link": "0.36.2",
@@ -111,7 +112,6 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
@@ -121,7 +121,6 @@
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
"redis": "4.7.0",
"sanitize-html": "2.17.0",
"server-only": "0.0.1",
"sharp": "0.34.1",
"stripe": "16.12.0",
@@ -149,7 +148,6 @@
"@types/nodemailer": "7.0.2",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@types/sanitize-html": "2.16.0",
"@types/testing-library__react": "10.2.0",
"@types/ungap__structured-clone": "1.2.0",
"@vitest/coverage-v8": "3.1.3",
+3 -3
View File
@@ -115,12 +115,12 @@ test.describe("JS Package Test", async () => {
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
await page
.locator("#questionCard-3")
.getByRole("textbox")
.getByLabel("textarea")
.fill("People who believe that PMF is necessary");
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-4").getByRole("textbox").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-5").getByRole("textbox").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByRole("button", { name: "Finish" }).click();
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });

Some files were not shown because too many files have changed in this diff Show More