mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-16 03:31:27 -05:00
Compare commits
17 Commits
fix-editor
...
dashboards
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a4607e31 | ||
|
|
2a89766500 | ||
|
|
96d87cff36 | ||
|
|
b03eaf03b1 | ||
|
|
d50c8fb401 | ||
|
|
9a40c0628f | ||
|
|
72eb7532aa | ||
|
|
146ac28649 | ||
|
|
9804851ccc | ||
|
|
0875c25d47 | ||
|
|
e1bf65871e | ||
|
|
36955ddbb8 | ||
|
|
ef56e97c95 | ||
|
|
ea3b4b9413 | ||
|
|
131a04b77c | ||
|
|
ca7e2c64de | ||
|
|
e4bd9a839a |
11
.env.example
11
.env.example
@@ -229,5 +229,16 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (required for the analytics/dashboard feature)
|
||||
# URL where the Cube.js instance is running (used by the Next.js app)
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# Must match CUBEJS_API_SECRET set on the Cube.js server (docker-compose.dev.yml)
|
||||
# CUBEJS_API_SECRET=changeme
|
||||
# API token sent with each Cube.js request (empty is accepted when CUBEJS_DEV_MODE=true)
|
||||
# CUBEJS_API_TOKEN=
|
||||
# Postgres connection used by the Cube.js container (see docker-compose.dev.yml)
|
||||
# The Cube.js container connects as user "formbricks" to database "hub" on this port
|
||||
# POSTGRES_PORT=5432
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -6,9 +6,19 @@ permissions:
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
|
||||
jobs:
|
||||
validate-translations:
|
||||
@@ -23,38 +33,30 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Check for relevant changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
translations:
|
||||
- 'apps/web/**/*.ts'
|
||||
- 'apps/web/**/*.tsx'
|
||||
- 'apps/web/locales/**/*.json'
|
||||
- 'packages/surveys/src/**/*.{ts,tsx}'
|
||||
- 'packages/surveys/locales/**/*.json'
|
||||
- 'packages/email/**/*.{ts,tsx}'
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm run scan-translations
|
||||
run: |
|
||||
echo ""
|
||||
echo "🔍 Validating translation keys..."
|
||||
echo ""
|
||||
pnpm run scan-translations
|
||||
|
||||
- name: Skip (no translation-related changes)
|
||||
if: steps.changes.outputs.translations != 'true'
|
||||
run: echo "No translation-related files changed — skipping validation."
|
||||
- name: Summary
|
||||
if: success()
|
||||
run: |
|
||||
echo ""
|
||||
echo "✅ Translation validation completed successfully!"
|
||||
echo ""
|
||||
|
||||
2
.husky/post-checkout
Normal file
2
.husky/post-checkout
Normal file
@@ -0,0 +1,2 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
@@ -1 +1,40 @@
|
||||
pnpm lint-staged
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
|
||||
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
||||
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
||||
echo ""
|
||||
echo "🌍 Running Lingo.dev translation workflow..."
|
||||
echo ""
|
||||
|
||||
# Run translation generation and validation
|
||||
if pnpm run i18n; then
|
||||
echo ""
|
||||
echo "✅ Translation validation passed"
|
||||
echo ""
|
||||
# Add updated locale files to git
|
||||
git add apps/web/locales/*.json
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Translation validation failed!"
|
||||
echo ""
|
||||
echo "Please fix the translation issues above before committing:"
|
||||
echo " • Add missing translation keys to your locale files"
|
||||
echo " • Remove unused translation keys"
|
||||
echo ""
|
||||
echo "Or run 'pnpm i18n' to see the detailed report"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
||||
echo " (This is expected for community contributors)"
|
||||
echo ""
|
||||
fi
|
||||
@@ -10,20 +10,25 @@
|
||||
"build-storybook": "storybook build",
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.14",
|
||||
"@storybook/addon-links": "10.2.14",
|
||||
"@storybook/addon-onboarding": "10.2.14",
|
||||
"@storybook/react-vite": "10.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "10.1.11",
|
||||
"@storybook/addon-links": "10.1.11",
|
||||
"@storybook/addon-onboarding": "10.1.11",
|
||||
"@storybook/react-vite": "10.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.14",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.1.11",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.14"
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
@@ -101,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
|
||||
) : (
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
|
||||
</FormProvider>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
||||
{logoUrl && (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || "my Product", t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTeamModal
|
||||
open={createTeamModalOpen}
|
||||
|
||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
||||
|
||||
export default function ChartsListLoading() {
|
||||
return <ChartsListSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChartsListPage as default } from "@/modules/ee/analysis/charts/pages/charts-list-page";
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
export default DashboardsListPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AnalysisLayoutClient } from "@/modules/ee/analysis/components/analysis-layout-client";
|
||||
|
||||
export default function AnalysisLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) {
|
||||
return <AnalysisLayoutClient params={params}>{children}</AnalysisLayoutClient>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
export default DashboardDetailPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||
const { environmentId } = await params;
|
||||
if (!environmentId || environmentId === "undefined") {
|
||||
redirect("/");
|
||||
}
|
||||
redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PieChart,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
@@ -105,6 +106,13 @@ export const MainNavigation = ({
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: PieChart,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
|
||||
if (!environmentId || environmentId === "undefined") {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
|
||||
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
|
||||
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
||||
|
||||
const handleSwitchChange = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
|
||||
];
|
||||
}
|
||||
} else {
|
||||
updatedNotificationSettings[notificationType] = {
|
||||
...updatedNotificationSettings[notificationType],
|
||||
[surveyOrProjectOrOrganizationId]:
|
||||
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||
};
|
||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||
}
|
||||
|
||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
|
||||
) {
|
||||
switch (notificationType) {
|
||||
case "alert":
|
||||
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
|
||||
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
t(
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => {
|
||||
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
||||
|
||||
return (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{selectedLanguage ? (
|
||||
<>
|
||||
{selectedLanguage.label["en-US"]}
|
||||
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
||||
` (${selectedLanguage.label.native})`}
|
||||
</>
|
||||
) : (
|
||||
t("common.select")
|
||||
)}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{sortedAppLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label["en-US"]}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPasswordResetEnabled && (
|
||||
|
||||
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||
</div>
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.schema(ZGetDisplaysWithContactAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
type Props = {
|
||||
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.booked.count })}
|
||||
{elementSummary.booked.count}{" "}
|
||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.skipped.count })}
|
||||
{elementSummary.skipped.count}{" "}
|
||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: summaryItem.count })}
|
||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
|
||||
{showResponses && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
|
||||
@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
{elementSummary.responseCount}{" "}
|
||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
if (label) {
|
||||
return label;
|
||||
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
||||
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
)}>
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
|
||||
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
|
||||
elementSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -123,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||
{elementSummary[group]?.count}{" "}
|
||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -157,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
elementSummary.element.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: result.count })}
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||
{elementSummary.dismissed.count}{" "}
|
||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SummaryImpressionsProps {
|
||||
displays: TDisplayWithContact[];
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
displaysError: string | null;
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
|
||||
if (!display.contact) return "";
|
||||
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
|
||||
};
|
||||
|
||||
export const SummaryImpressions = ({
|
||||
displays,
|
||||
isLoading,
|
||||
hasMore,
|
||||
displaysError,
|
||||
environmentId,
|
||||
locale,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
}: SummaryImpressionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderContent = () => {
|
||||
if (displaysError) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircleIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{displaysError}</p>
|
||||
<Button onClick={onRetry} variant="secondary" size="sm">
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displays.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_identified_impressions")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
|
||||
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto">
|
||||
{displays.map((display) => (
|
||||
<div
|
||||
key={display.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
|
||||
<div className="col-span-2 pl-4 md:pl-6">
|
||||
{display.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture break-all text-slate-600 hover:underline"
|
||||
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
|
||||
{getDisplayContactIdentifier(display)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 px-4 text-slate-500 md:px-6">
|
||||
{timeSince(display.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center border-t border-slate-100 py-4">
|
||||
<Button onClick={onLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
||||
const { t } = useTranslation();
|
||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
||||
const change = tab === val ? undefined : val;
|
||||
setTab(change);
|
||||
};
|
||||
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<InteractiveCard
|
||||
key="impressions"
|
||||
tab="impressions"
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("impressions")}
|
||||
isActive={tab === "impressions"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.starts")}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
getDisplaysWithContactAction,
|
||||
getSurveySummaryAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
||||
import { SummaryList } from "./SummaryList";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
const DISPLAYS_PER_PAGE = 15;
|
||||
|
||||
const defaultSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
@@ -61,76 +51,17 @@ export const SummaryPage = ({
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
|
||||
const [displaysError, setDisplaysError] = useState<string | null>(null);
|
||||
const displaysFetchedRef = useRef(false);
|
||||
|
||||
const fetchDisplays = useCallback(
|
||||
async (offset: number) => {
|
||||
const response = await getDisplaysWithContactAction({
|
||||
surveyId,
|
||||
limit: DISPLAYS_PER_PAGE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (!response?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response?.data ?? [];
|
||||
},
|
||||
[surveyId]
|
||||
);
|
||||
|
||||
const loadInitialDisplays = useCallback(async () => {
|
||||
setIsDisplaysLoading(true);
|
||||
setDisplaysError(null);
|
||||
try {
|
||||
const data = await fetchDisplays(0);
|
||||
setDisplays(data);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
setDisplays([]);
|
||||
setHasMoreDisplays(false);
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchDisplays(displays.length);
|
||||
setDisplays((prev) => [...prev, ...data]);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}, [fetchDisplays, displays.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "impressions" && !displaysFetchedRef.current) {
|
||||
displaysFetchedRef.current = true;
|
||||
loadInitialDisplays();
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
@@ -190,18 +121,6 @@ export const SummaryPage = ({
|
||||
setTab={setTab}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
{tab === "impressions" && (
|
||||
<SummaryImpressions
|
||||
displays={displays}
|
||||
isLoading={isDisplaysLoading}
|
||||
hasMore={hasMoreDisplays}
|
||||
displaysError={displaysError}
|
||||
environmentId={environment.id}
|
||||
locale={locale}
|
||||
onLoadMore={handleLoadMoreDisplays}
|
||||
onRetry={loadInitialDisplays}
|
||||
/>
|
||||
)}
|
||||
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
|
||||
interface InteractiveCardProps {
|
||||
tab: "dropOffs" | "quotas" | "impressions";
|
||||
tab: "dropOffs" | "quotas";
|
||||
label: string;
|
||||
percentage: number | null;
|
||||
percentage: number;
|
||||
value: React.ReactNode;
|
||||
tooltipText: string;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||
{projectCustomScripts}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
rows={8}
|
||||
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
{...field}
|
||||
disabled={isReadOnly}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.use_personal_links")}
|
||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
|
||||
@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,49 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
ZIntegrationGoogleSheets,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const ZValidateGoogleSheetsConnectionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
.schema(ZValidateGoogleSheetsConnectionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
|
||||
if (!integration) {
|
||||
return { data: false };
|
||||
}
|
||||
|
||||
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
|
||||
return { data: true };
|
||||
});
|
||||
|
||||
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||
environmentId: z.string(),
|
||||
|
||||
@@ -20,10 +20,6 @@ import {
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
|
||||
} from "@/lib/googleSheet/constants";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
|
||||
resetForm();
|
||||
}, [selectedIntegration, surveys]);
|
||||
|
||||
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
|
||||
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
|
||||
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const linkSheet = async () => {
|
||||
try {
|
||||
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
||||
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
|
||||
if (selectedElements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
setIsLinkingSheet(true);
|
||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||
googleSheetIntegration,
|
||||
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
|
||||
});
|
||||
|
||||
if (!spreadsheetNameResponse?.data) {
|
||||
showErrorMessageToast(spreadsheetNameResponse);
|
||||
return;
|
||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const spreadsheetName = spreadsheetNameResponse.data;
|
||||
|
||||
setIsLinkingSheet(true);
|
||||
integrationData.spreadsheetId = spreadsheetId;
|
||||
integrationData.spreadsheetName = spreadsheetName;
|
||||
integrationData.surveyId = selectedSurvey.id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
|
||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
||||
>(null);
|
||||
|
||||
const validateConnection = useCallback(async () => {
|
||||
if (!isConnected || !googleSheetIntegration) return;
|
||||
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
|
||||
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
setShowReconnectButton(true);
|
||||
}
|
||||
}, [environment.id, isConnected, googleSheetIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
validateConnection();
|
||||
}, [validateConnection]);
|
||||
|
||||
const handleGoogleAuthorization = async () => {
|
||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleGoogleAuthorization={handleGoogleAuthorization}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
||||
showReconnectButton: boolean;
|
||||
handleGoogleAuthorization: () => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
setSelectedIntegration,
|
||||
showReconnectButton,
|
||||
handleGoogleAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_description")}
|
||||
</AlertDescription>
|
||||
<AlertButton onClick={handleGoogleAuthorization}>
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="flex w-full justify-end">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleGoogleAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedIntegration(null);
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
{t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
||||
|
||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
let result: string[] = [];
|
||||
@@ -257,16 +256,10 @@ const processElementResponse = (
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
return element.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
||||
return responseValue
|
||||
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return processResponseData(responseValue);
|
||||
};
|
||||
|
||||
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
|
||||
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||
.map((choice) => choice.imageUrl);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
@@ -97,15 +95,12 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
data: resolvedResponseData,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
@@ -136,17 +131,13 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
|
||||
49
apps/web/app/api/analytics/_lib/cube-client.ts
Normal file
49
apps/web/app/api/analytics/_lib/cube-client.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import cubejs, { Query } from "@cubejs-client/core";
|
||||
|
||||
/**
|
||||
* Cube.js client for executing analytics queries.
|
||||
*
|
||||
* Authentication is handled via the CUBEJS_API_SECRET env var which must match
|
||||
* the secret configured on the Cube.js server. In development an empty token is
|
||||
* accepted when the Cube.js instance runs in dev mode.
|
||||
*/
|
||||
|
||||
const getApiUrl = () => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
const API_URL = getApiUrl();
|
||||
|
||||
export function createCubeClient() {
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
console.log(`[CubeClient] Connecting to ${API_URL} (token ${token ? "set" : "empty"})`);
|
||||
return cubejs(token, {
|
||||
apiUrl: API_URL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Cube.js query and return the table pivot data.
|
||||
*/
|
||||
export async function executeQuery(query: Query) {
|
||||
console.log("[CubeClient] executeQuery called with:", JSON.stringify(query, null, 2));
|
||||
const client = createCubeClient();
|
||||
try {
|
||||
const resultSet = await client.load(query);
|
||||
const rows = resultSet.tablePivot();
|
||||
console.log(`[CubeClient] Query succeeded — ${rows.length} row(s) returned`);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error("[CubeClient] Query failed:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
apiUrl: API_URL,
|
||||
query,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
284
apps/web/app/api/analytics/_lib/schema-parser.ts
Normal file
284
apps/web/app/api/analytics/_lib/schema-parser.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Parses the Cube.js schema file to extract measures and dimensions
|
||||
* This keeps the schema as the single source of truth for AI query generation
|
||||
*/
|
||||
|
||||
interface MeasureInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DimensionInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "string" | "number" | "time";
|
||||
}
|
||||
|
||||
// Path to schema file - self-contained within the analytics folder
|
||||
const SCHEMA_FILE_PATH = path.join(process.cwd(), "app", "api", "analytics", "_schema", "FeedbackRecords.js");
|
||||
|
||||
/**
|
||||
* Extract description from a schema property object
|
||||
*/
|
||||
function extractDescription(objStr: string): string {
|
||||
const descMatch = objStr.match(/description:\s*`([^`]+)`/);
|
||||
return descMatch ? descMatch[1] : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract type from a dimension object
|
||||
*/
|
||||
function extractType(objStr: string): "string" | "number" | "time" {
|
||||
const typeMatch = objStr.match(/type:\s*`(string|number|time)`/);
|
||||
return (typeMatch?.[1] as "string" | "number" | "time") || "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content inside the first matching brace block
|
||||
*/
|
||||
function extractInnerBlockContent(content: string, startRegex: RegExp): string | null {
|
||||
const match = content.match(startRegex);
|
||||
if (!match) return null;
|
||||
|
||||
// Backtrack to find the opening brace in the match
|
||||
const braceIndex = match[0].lastIndexOf("{");
|
||||
if (braceIndex === -1) return null; // Should not happen given regex usage
|
||||
|
||||
// Actually we can just start scanning from the end of the match if the regex ends with {
|
||||
// But let's be safer: start counting from the opening brace.
|
||||
const absoluteStartIndex = match.index! + braceIndex;
|
||||
|
||||
let braceCount = 1;
|
||||
let i = absoluteStartIndex + 1;
|
||||
|
||||
while (braceCount > 0 && i < content.length) {
|
||||
if (content[i] === "{") braceCount++;
|
||||
else if (content[i] === "}") braceCount--;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (braceCount === 0) {
|
||||
return content.substring(absoluteStartIndex + 1, i - 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse measures from the schema file
|
||||
*/
|
||||
function parseMeasures(schemaContent: string): MeasureInfo[] {
|
||||
const measures: MeasureInfo[] = [];
|
||||
|
||||
const measuresBlock = extractInnerBlockContent(schemaContent, /measures:\s*\{/);
|
||||
if (!measuresBlock) return measures;
|
||||
|
||||
// Match each measure: measureName: { ... }
|
||||
const measureRegex = /(\w+):\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = measureRegex.exec(measuresBlock)) !== null) {
|
||||
const name = match[1];
|
||||
const startIndex = match.index + match[0].length;
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let endIndex = startIndex;
|
||||
|
||||
while (braceCount > 0 && endIndex < measuresBlock.length) {
|
||||
if (measuresBlock[endIndex] === "{") braceCount++;
|
||||
if (measuresBlock[endIndex] === "}") braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
const body = measuresBlock.substring(startIndex, endIndex - 1);
|
||||
const description = extractDescription(body);
|
||||
|
||||
if (description) {
|
||||
measures.push({ name, description });
|
||||
}
|
||||
}
|
||||
|
||||
return measures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from a specific cube
|
||||
*/
|
||||
function parseDimensionsFromCube(cubeContent: string, cubeName: string): DimensionInfo[] {
|
||||
const dimensions: DimensionInfo[] = [];
|
||||
|
||||
const dimensionsBlock = extractInnerBlockContent(cubeContent, /dimensions:\s*\{/);
|
||||
if (!dimensionsBlock) return dimensions;
|
||||
|
||||
// Match each dimension: dimensionName: { ... }
|
||||
const dimensionRegex = /(\w+):\s*\{/g;
|
||||
let match;
|
||||
|
||||
while ((match = dimensionRegex.exec(dimensionsBlock)) !== null) {
|
||||
const name = match[1];
|
||||
const startIndex = match.index + match[0].length;
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 1;
|
||||
let endIndex = startIndex;
|
||||
|
||||
while (braceCount > 0 && endIndex < dimensionsBlock.length) {
|
||||
if (dimensionsBlock[endIndex] === "{") braceCount++;
|
||||
if (dimensionsBlock[endIndex] === "}") braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
const body = dimensionsBlock.substring(startIndex, endIndex - 1);
|
||||
const description = extractDescription(body);
|
||||
const type = extractType(body);
|
||||
|
||||
// Skip primaryKey dimensions (like 'id') and internal dimensions
|
||||
if (body.includes("primaryKey: true") || name === "feedbackRecordId") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
dimensions.push({
|
||||
name: cubeName === "FeedbackRecords" ? name : `${cubeName}.${name}`,
|
||||
description,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from the schema file
|
||||
*/
|
||||
function parseDimensions(schemaContent: string): DimensionInfo[] {
|
||||
const dimensions: DimensionInfo[] = [];
|
||||
|
||||
// Extract dimensions from FeedbackRecords cube
|
||||
const feedbackRecordsMatch = schemaContent.match(/cube\(`FeedbackRecords`,\s*\{([\s\S]*?)\n\}\);/);
|
||||
if (feedbackRecordsMatch) {
|
||||
const feedbackRecordsDimensions = parseDimensionsFromCube(feedbackRecordsMatch[1], "FeedbackRecords");
|
||||
dimensions.push(...feedbackRecordsDimensions);
|
||||
}
|
||||
|
||||
// Extract dimensions from TopicsUnnested cube
|
||||
const topicsUnnestedMatch = schemaContent.match(/cube\(`TopicsUnnested`,\s*\{([\s\S]*?)\n\}\);/);
|
||||
if (topicsUnnestedMatch) {
|
||||
const topicsDimensions = parseDimensionsFromCube(topicsUnnestedMatch[1], "TopicsUnnested");
|
||||
dimensions.push(...topicsDimensions);
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the schema file
|
||||
*/
|
||||
export function parseSchemaFile(): {
|
||||
measures: MeasureInfo[];
|
||||
dimensions: DimensionInfo[];
|
||||
} {
|
||||
try {
|
||||
const schemaContent = fs.readFileSync(SCHEMA_FILE_PATH, "utf-8");
|
||||
const measures = parseMeasures(schemaContent);
|
||||
const dimensions = parseDimensions(schemaContent);
|
||||
|
||||
return { measures, dimensions };
|
||||
} catch {
|
||||
return { measures: [], dimensions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the schema context string for AI query generation
|
||||
*/
|
||||
export function generateSchemaContext(): string {
|
||||
const { measures, dimensions } = parseSchemaFile();
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const measuresList = measures.map((m) => `- ${CUBE_NAME}.${m.name} - ${m.description}`).join("\n");
|
||||
|
||||
const dimensionsList = dimensions
|
||||
.map((d) => {
|
||||
const typeLabel = d.type === "time" ? " (time dimension)" : ` (${d.type})`;
|
||||
// Dimensions from TopicsUnnested already have the cube prefix
|
||||
const fullName = d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`;
|
||||
return `- ${fullName} - ${d.description}${typeLabel}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const categoricalDimensions = dimensions
|
||||
.filter(
|
||||
(d) =>
|
||||
d.type === "string" &&
|
||||
!d.name.includes("responseId") &&
|
||||
!d.name.includes("userIdentifier") &&
|
||||
!d.name.includes("feedbackRecordId")
|
||||
)
|
||||
.map((d) => (d.name.includes(".") ? d.name : `${CUBE_NAME}.${d.name}`))
|
||||
.join(", ");
|
||||
|
||||
return `
|
||||
You are a CubeJS query generator. Your task is to convert natural language requests into valid CubeJS query JSON objects.
|
||||
|
||||
Available Cubes: ${CUBE_NAME}, TopicsUnnested
|
||||
|
||||
MEASURES (use these in the "measures" array):
|
||||
${measuresList}
|
||||
|
||||
DIMENSIONS (use these in the "dimensions" array):
|
||||
${dimensionsList}
|
||||
|
||||
TIME DIMENSIONS:
|
||||
- ${CUBE_NAME}.collectedAt can be used with granularity: 'day', 'week', 'month', 'year'
|
||||
- Use "timeDimensions" array for time-based queries with dateRange like "last 7 days", "last 30 days", "this month", etc.
|
||||
|
||||
CHART TYPE SUGGESTIONS:
|
||||
- If query has timeDimensions → suggest "bar" or "line"
|
||||
- If query has categorical dimensions (${categoricalDimensions}) → suggest "donut" or "bar"
|
||||
- If query has only measures → suggest "kpi"
|
||||
- If query compares multiple measures → suggest "bar"
|
||||
|
||||
FILTERS:
|
||||
- Use "filters" array to include/exclude records based on dimension values
|
||||
- Filter format: { "member": "CubeName.dimensionName", "operator": "operator" } OR { "member": "CubeName.dimensionName", "operator": "operator", "values": [...] }
|
||||
- Common operators:
|
||||
* "set" - dimension is not null/empty (Set "values" to null)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "notSet" - dimension is null/empty (Set "values" to null)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
||||
* "equals" - exact match (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
||||
* "notEquals" - not equal (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "notEquals", "values": ["sad"] }
|
||||
* "contains" - contains text (REQUIRES "values" field)
|
||||
Example: { "member": "${CUBE_NAME}.emotion", "operator": "contains", "values": ["happy"] }
|
||||
- Examples for common user requests:
|
||||
* "only records with emotion" or "for records that have emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "exclude records without emotion" or "do not include records without emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "set", "values": null }
|
||||
* "exclude records with emotion" or "do not include records with emotion" → { "member": "${CUBE_NAME}.emotion", "operator": "notSet", "values": null }
|
||||
* "only happy emotions" → { "member": "${CUBE_NAME}.emotion", "operator": "equals", "values": ["happy"] }
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Always return valid JSON only, no markdown or code blocks
|
||||
2. Use exact measure/dimension names as listed above
|
||||
3. Include "chartType" field: "bar", "line", "donut", "kpi", or "area"
|
||||
4. For time queries, use timeDimensions array with granularity and dateRange
|
||||
5. Return format: { "measures": [...], "dimensions": [...], "timeDimensions": [...], "filters": [...], "chartType": "..." }
|
||||
6. If user asks about trends over time, use timeDimensions
|
||||
7. If user asks "by X", add X as a dimension
|
||||
8. If user asks for counts or totals, use ${CUBE_NAME}.count
|
||||
9. If user asks for NPS, use ${CUBE_NAME}.npsScore
|
||||
10. If user asks about topics, use TopicsUnnested.topic (NOT ${CUBE_NAME}.topic)
|
||||
11. CRITICAL: If user says "only records with X", "exclude records without X", or "for records that have X", add a filter with operator "set" for that dimension
|
||||
12. CRITICAL: If user says "exclude records with X", "do not include records with X", or "without X", add a filter with operator "notSet" for that dimension
|
||||
13. Always include filters when user explicitly mentions including/excluding records based on dimension values
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export const CUBE_NAME = "FeedbackRecords";
|
||||
39
apps/web/app/api/analytics/_lib/types.ts
Normal file
39
apps/web/app/api/analytics/_lib/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* TypeScript types for the Analytics API
|
||||
*/
|
||||
|
||||
export interface TimeDimension {
|
||||
dimension: string;
|
||||
granularity?: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
dateRange?: string | string[];
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
member: string;
|
||||
operator:
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
| "contains"
|
||||
| "notContains"
|
||||
| "set"
|
||||
| "notSet"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte";
|
||||
values?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CubeQuery {
|
||||
measures: string[];
|
||||
dimensions?: string[];
|
||||
timeDimensions?: TimeDimension[];
|
||||
filters?: Filter[];
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
query: CubeQuery;
|
||||
chartType: "bar" | "line" | "donut" | "kpi" | "area" | "pie";
|
||||
data?: Record<string, any>[];
|
||||
error?: string;
|
||||
}
|
||||
156
apps/web/app/api/analytics/_schema/FeedbackRecords.js
Normal file
156
apps/web/app/api/analytics/_schema/FeedbackRecords.js
Normal file
@@ -0,0 +1,156 @@
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||
},
|
||||
|
||||
sourceName: {
|
||||
sql: `source_name`,
|
||||
type: `string`,
|
||||
description: `Human-readable name of the source`,
|
||||
},
|
||||
|
||||
fieldType: {
|
||||
sql: `field_type`,
|
||||
type: `string`,
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userIdentifier: {
|
||||
sql: `user_identifier`,
|
||||
type: `string`,
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
},
|
||||
|
||||
joins: {
|
||||
TopicsUnnested: {
|
||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||
relationship: `hasMany`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cube(`TopicsUnnested`, {
|
||||
sql: `
|
||||
SELECT
|
||||
fr.id as feedback_record_id,
|
||||
topic_elem.topic
|
||||
FROM feedback_records fr
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `feedback_record_id || '-' || topic`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
feedbackRecordId: {
|
||||
sql: `feedback_record_id`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
description: `Individual topic from the topics array`,
|
||||
},
|
||||
},
|
||||
});
|
||||
254
apps/web/app/api/analytics/query/route.ts
Normal file
254
apps/web/app/api/analytics/query/route.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { executeQuery } from "../_lib/cube-client";
|
||||
import { CUBE_NAME, generateSchemaContext } from "../_lib/schema-parser";
|
||||
|
||||
const schema = z.object({
|
||||
measures: z.array(z.string()).describe("List of measures to query"),
|
||||
dimensions: z.array(z.string()).nullable().describe("List of dimensions to query"),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
granularity: z.enum(["day", "week", "month", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.nullable()
|
||||
.describe("Time dimensions with granularity and date range"),
|
||||
chartType: z
|
||||
.enum(["bar", "line", "donut", "kpi", "area", "pie"])
|
||||
.describe("Suggested chart type for visualization"),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
]),
|
||||
values: z.array(z.string()).nullable(),
|
||||
})
|
||||
)
|
||||
.nullable()
|
||||
.describe("Filters to apply to the query"),
|
||||
});
|
||||
|
||||
// Generate schema context dynamically from the schema file
|
||||
const SCHEMA_CONTEXT = generateSchemaContext();
|
||||
|
||||
// JSON Schema for OpenAI structured outputs (manually created to avoid zod-to-json-schema dependency)
|
||||
const jsonSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
measures: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "List of measures to query",
|
||||
},
|
||||
dimensions: {
|
||||
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
|
||||
description: "List of dimensions to query",
|
||||
},
|
||||
timeDimensions: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimension: { type: "string" },
|
||||
granularity: {
|
||||
anyOf: [{ type: "string", enum: ["day", "week", "month", "year"] }, { type: "null" }],
|
||||
},
|
||||
dateRange: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["dimension", "granularity", "dateRange"],
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
description: "Time dimensions with granularity and date range",
|
||||
},
|
||||
chartType: {
|
||||
type: "string",
|
||||
enum: ["bar", "line", "donut", "kpi", "area", "pie"],
|
||||
description: "Suggested chart type for visualization",
|
||||
},
|
||||
filters: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
member: { type: "string" },
|
||||
operator: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"contains",
|
||||
"notContains",
|
||||
"set",
|
||||
"notSet",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
],
|
||||
},
|
||||
values: {
|
||||
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["member", "operator", "values"],
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
description: "Filters to apply to the query",
|
||||
},
|
||||
},
|
||||
required: ["measures", "dimensions", "timeDimensions", "chartType", "filters"],
|
||||
} as const;
|
||||
|
||||
// Initialize OpenAI client
|
||||
const getOpenAIClient = () => {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not configured");
|
||||
}
|
||||
return new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { prompt, executeQuery: shouldExecuteQuery = true } = await request.json();
|
||||
|
||||
if (!prompt || typeof prompt !== "string") {
|
||||
return NextResponse.json({ error: "Prompt is required and must be a string" }, { status: 400 });
|
||||
}
|
||||
|
||||
const openai = getOpenAIClient();
|
||||
|
||||
// Generate Cube.js query using OpenAI structured outputs
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: SCHEMA_CONTEXT },
|
||||
{ role: "user", content: `User request: "${prompt}"` },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "generate_cube_query",
|
||||
description: "Generate a Cube.js query based on the user request",
|
||||
parameters: jsonSchema,
|
||||
strict: true, // Enable structured outputs
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function", function: { name: "generate_cube_query" } },
|
||||
});
|
||||
|
||||
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
||||
if (toolCall?.function.name !== "generate_cube_query") {
|
||||
throw new Error("Failed to generate structured output from OpenAI");
|
||||
}
|
||||
|
||||
const query = JSON.parse(toolCall.function.arguments);
|
||||
|
||||
// Validate with zod schema (for type safety)
|
||||
const validatedQuery = schema.parse(query);
|
||||
|
||||
// Validate required fields (measures should minimally be present if not specified, default to count)
|
||||
if (!validatedQuery.measures || validatedQuery.measures.length === 0) {
|
||||
validatedQuery.measures = [`${CUBE_NAME}.count`];
|
||||
}
|
||||
|
||||
// Extract chartType (for UI purposes only, not part of CubeJS query)
|
||||
const { chartType, ...cubeQuery } = validatedQuery;
|
||||
|
||||
// Build a clean query object, stripping null / empty arrays so Cube.js is happy
|
||||
const cleanQuery: Record<string, unknown> = {
|
||||
measures: cubeQuery.measures,
|
||||
};
|
||||
|
||||
if (Array.isArray(cubeQuery.dimensions) && cubeQuery.dimensions.length > 0) {
|
||||
cleanQuery.dimensions = cubeQuery.dimensions;
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.filters) && cubeQuery.filters.length > 0) {
|
||||
cleanQuery.filters = cubeQuery.filters.map(
|
||||
(f: { member: string; operator: string; values?: string[] | null }) => {
|
||||
const cleaned: Record<string, unknown> = { member: f.member, operator: f.operator };
|
||||
if (f.values !== null && f.values !== undefined) cleaned.values = f.values;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(cubeQuery.timeDimensions) && cubeQuery.timeDimensions.length > 0) {
|
||||
cleanQuery.timeDimensions = cubeQuery.timeDimensions.map(
|
||||
(td: { dimension: string; granularity?: string | null; dateRange?: string | null }) => {
|
||||
const cleaned: Record<string, unknown> = { dimension: td.dimension };
|
||||
if (td.granularity !== null && td.granularity !== undefined) cleaned.granularity = td.granularity;
|
||||
if (td.dateRange !== null && td.dateRange !== undefined) cleaned.dateRange = td.dateRange;
|
||||
return cleaned;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Execute query if requested (default: true)
|
||||
let data: Record<string, unknown>[] | undefined;
|
||||
if (shouldExecuteQuery) {
|
||||
try {
|
||||
console.log("[analytics/query] Executing Cube.js query:", JSON.stringify(cleanQuery, null, 2));
|
||||
data = await executeQuery(cleanQuery);
|
||||
console.log(`[analytics/query] Query returned ${data?.length ?? 0} row(s)`);
|
||||
} catch (queryError: unknown) {
|
||||
const message = queryError instanceof Error ? queryError.message : "Unknown error";
|
||||
console.error("[analytics/query] Query execution failed:", {
|
||||
error: message,
|
||||
stack: queryError instanceof Error ? queryError.stack : undefined,
|
||||
query: cleanQuery,
|
||||
cubeUrl: process.env.CUBEJS_API_URL || "http://localhost:4000 (default)",
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
error: `Failed to execute query: ${message}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
query: cleanQuery,
|
||||
chartType,
|
||||
data,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to generate query";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -43,39 +42,33 @@ export const GET = async (req: Request) => {
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
|
||||
if (!code) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
let key;
|
||||
let userEmail;
|
||||
|
||||
if (code) {
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
key = token.res?.data;
|
||||
|
||||
// Set credentials using the provided token
|
||||
oAuth2Client.setCredentials({
|
||||
access_token: key.access_token,
|
||||
});
|
||||
|
||||
// Fetch user's email
|
||||
const oauth2 = google.oauth2({
|
||||
auth: oAuth2Client,
|
||||
version: "v2",
|
||||
});
|
||||
const userInfo = await oauth2.userinfo.get();
|
||||
userEmail = userInfo.data.email;
|
||||
}
|
||||
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
const key = token.res?.data;
|
||||
if (!key) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
}
|
||||
|
||||
oAuth2Client.setCredentials({ access_token: key.access_token });
|
||||
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
|
||||
const userInfo = await oauth2.userinfo.get();
|
||||
const userEmail = userInfo.data.email;
|
||||
|
||||
if (!userEmail) {
|
||||
return responses.internalServerErrorResponse("Failed to get user email");
|
||||
}
|
||||
|
||||
const integrationType = "googleSheets" as const;
|
||||
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
|
||||
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
|
||||
|
||||
const googleSheetIntegration = {
|
||||
type: integrationType,
|
||||
type: "googleSheets" as "googleSheets",
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: existingConfig?.data ?? [],
|
||||
data: [],
|
||||
email: userEmail,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
TJsEnvironmentStateSurvey,
|
||||
} from "@formbricks/types/js";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
overlay: environmentData.project.overlay,
|
||||
placement: environmentData.project.placement,
|
||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||
styling: environmentData.project.styling,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
id: environmentData.project.organization.id,
|
||||
billing: environmentData.project.organization.billing,
|
||||
},
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
surveys: transformedSurveys,
|
||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,10 +44,13 @@ const validateResponse = (
|
||||
...responseUpdateInput.data,
|
||||
};
|
||||
|
||||
const isFinished = responseUpdateInput.finished ?? false;
|
||||
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
mergedData,
|
||||
responseUpdateInput.language ?? response.language ?? "en",
|
||||
isFinished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -6,138 +6,140 @@ export const GET = async (req: NextRequest) => {
|
||||
let brandColor = req.nextUrl.searchParams.get("brandColor");
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<h2
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
),
|
||||
{
|
||||
width: 800,
|
||||
height: 400,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -56,7 +56,6 @@ export const GET = withV1ApiWrapper({
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
redirect_uri: SLACK_REDIRECT_URI,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
...result.response,
|
||||
data: resolveStorageUrlsInObject(result.response.data),
|
||||
}),
|
||||
response: responses.successResponse(result.response),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
responseUpdate.finished,
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
@@ -192,7 +190,7 @@ export const PUT = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||
response: responses.successResponse(updated),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
createResponseWithQuotaEvaluation,
|
||||
getResponses,
|
||||
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||
),
|
||||
response: responses.successResponse(allResponses),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
responseInput.finished,
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
resolveStorageUrlsInObject({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
})
|
||||
),
|
||||
response: responses.successResponse({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||
response: responses.successResponse(result.survey),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Prisma, WebhookSource } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -24,10 +23,6 @@ vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -80,41 +75,6 @@ describe("createWebhook", () => {
|
||||
expect(result).toEqual(createdWebhook);
|
||||
});
|
||||
|
||||
test("should call validateWebhookUrl with the provided URL", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
await createWebhook(webhookInput);
|
||||
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "http://169.254.169.254/latest/meta-data/",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
|
||||
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
|
||||
);
|
||||
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
|
||||
expect(prisma.webhook.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
|
||||
const invalidWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
|
||||
@@ -6,11 +6,9 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
await validateWebhookUrl(webhookInput.url);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
@@ -112,6 +112,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -131,11 +131,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -183,11 +185,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -229,11 +233,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on thrown error", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -285,11 +291,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -339,11 +347,13 @@ describe("withV1ApiWrapper", () => {
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -366,8 +376,9 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
@@ -399,8 +410,9 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -423,8 +435,9 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -449,11 +462,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,7 +257,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||
});
|
||||
@@ -314,19 +313,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for malformed pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/p")).toBe(false);
|
||||
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
@@ -389,8 +375,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||
});
|
||||
@@ -406,7 +390,6 @@ describe("endpoint-validator", () => {
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
@@ -443,8 +426,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||
});
|
||||
@@ -459,8 +440,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with query parameters and fragments", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||
});
|
||||
@@ -471,7 +450,6 @@ describe("endpoint-validator", () => {
|
||||
describe("URL parsing edge cases", () => {
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||
@@ -480,14 +458,12 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle trailing slashes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -502,9 +478,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested client API routes", () => {
|
||||
@@ -556,7 +529,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle special characters in survey IDs", () => {
|
||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -564,7 +536,6 @@ describe("endpoint-validator", () => {
|
||||
test("should properly validate malicious or injection-like URLs", () => {
|
||||
// SQL injection-like attempts
|
||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
@@ -572,12 +543,10 @@ describe("endpoint-validator", () => {
|
||||
|
||||
// Path traversal attempts
|
||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||
|
||||
// XSS-like attempts
|
||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
@@ -587,7 +556,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle URL encoding", () => {
|
||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -623,7 +591,6 @@ describe("endpoint-validator", () => {
|
||||
// These should not match due to case sensitivity
|
||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
|
||||
@@ -7,7 +7,6 @@ const PUBLIC_ROUTES = {
|
||||
SURVEY_ROUTES: [
|
||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||
],
|
||||
|
||||
// API routes accessible from public domain
|
||||
|
||||
@@ -106,6 +106,7 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
@@ -148,12 +149,9 @@ checksums:
|
||||
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||
common/count_attributes: 48805e836a9b50f9635ad00fed953058
|
||||
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
|
||||
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
|
||||
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -167,7 +165,6 @@ checksums:
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
|
||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||
common/dev_env: e650911d5e19ba256358e0cda154c005
|
||||
common/development: 85211dbb918bda7a6e87649dcfc1b17a
|
||||
@@ -183,8 +180,6 @@ checksums:
|
||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||
common/duplicate_copy: 68d2201918610ca87c2914b61dc8010f
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
@@ -197,16 +192,13 @@ checksums:
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
@@ -219,7 +211,6 @@ checksums:
|
||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||
common/image: 048ba7a239de0fbd883ade8558415830
|
||||
common/images: 9305827c28694866f49db42b4c51831f
|
||||
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
@@ -237,7 +228,6 @@ checksums:
|
||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
common/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
||||
common/license_expired: 7af13535e320e4197989472c01387d2c
|
||||
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
||||
@@ -252,6 +242,7 @@ checksums:
|
||||
common/look_and_feel: 9125503712626d495cedec7a79f1418c
|
||||
common/manage: a3d40c0267b81ae53c9598eaeb05087d
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/member: 1606dc30b369856b9dba1fe9aec425d2
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
@@ -263,7 +254,6 @@ checksums:
|
||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||
common/my_product: ad022177062f9ef6e9acf33b13e889aa
|
||||
common/name: 9368b5a047572b6051f334af5aa76819
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
@@ -359,6 +349,8 @@ checksums:
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
common/selected_questions: beffe92d5272d99a0022f004e6a6ad73
|
||||
common/selection: 25b570dc6339916a7aada2142aca0cd1
|
||||
common/selections: 82f0681bf0208e25d7efedc23c556b8f
|
||||
common/send_test_email: 2fd3ea40199b9589132ac826a5b0f3f5
|
||||
common/session_not_found: e9622df3170dbfd9636403bb0c22295b
|
||||
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
@@ -410,7 +402,6 @@ checksums:
|
||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
@@ -619,6 +610,7 @@ checksums:
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||
@@ -629,7 +621,6 @@ checksums:
|
||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
|
||||
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||
@@ -641,7 +632,6 @@ checksums:
|
||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
@@ -656,8 +646,6 @@ checksums:
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
|
||||
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
|
||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
@@ -724,12 +712,7 @@ checksums:
|
||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
||||
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
|
||||
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
|
||||
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
|
||||
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||
@@ -1024,7 +1007,7 @@ checksums:
|
||||
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
|
||||
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
|
||||
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
|
||||
environments/settings/general/from_your_organization: 9ebd6dcd79f7bfad3fea46ed2e3133d2
|
||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
@@ -1179,7 +1162,6 @@ checksums:
|
||||
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
|
||||
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
|
||||
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
|
||||
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
|
||||
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
|
||||
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
|
||||
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
|
||||
@@ -1196,13 +1178,11 @@ checksums:
|
||||
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||
@@ -1380,6 +1360,7 @@ checksums:
|
||||
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
|
||||
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
|
||||
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
|
||||
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
|
||||
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
|
||||
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
|
||||
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
|
||||
@@ -1494,7 +1475,6 @@ checksums:
|
||||
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
|
||||
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
|
||||
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
|
||||
environments/surveys/edit/question_number: 742636e9d2d5dcc7ee6ca1b3016bcee7
|
||||
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
|
||||
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
|
||||
environments/surveys/edit/question_used_in_quota: ceb5e88f6916e4863e589c6be030bb3b
|
||||
@@ -1551,7 +1531,7 @@ checksums:
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
@@ -1597,7 +1577,6 @@ checksums:
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
||||
@@ -1684,9 +1663,9 @@ checksums:
|
||||
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/when: a40ad3eed1b75e76226290eeb9bb20cd
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations: 04241177ba989ef4c1d8c01e1a7b8541
|
||||
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
|
||||
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
|
||||
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
|
||||
@@ -1868,7 +1847,6 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
@@ -1909,7 +1887,6 @@ checksums:
|
||||
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
||||
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
||||
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
@@ -1932,7 +1909,6 @@ checksums:
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
||||
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
|
||||
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
@@ -2052,7 +2028,7 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
|
||||
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
|
||||
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: aa95bc81b5336a548e256bce49350683
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
|
||||
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
|
||||
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
|
||||
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
|
||||
@@ -2066,7 +2042,7 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: bb7439d42ec3848a8fa9edb8b001b69a
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||
@@ -2075,8 +2051,6 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
|
||||
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
|
||||
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
|
||||
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
|
||||
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
|
||||
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
|
||||
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
|
||||
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
|
||||
@@ -2248,16 +2222,6 @@ checksums:
|
||||
templates/alignment_and_engagement_survey_question_4_headline: e36be56ce8aad1d0ca04939bea4e39b7
|
||||
templates/alignment_and_engagement_survey_question_4_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
templates/block_1: 5e1b4dce0cb70662441b663507a69454
|
||||
templates/block_2: f50d8aab8b44f168a2ab00526d4f9a2c
|
||||
templates/block_3: 78d84f8e4763a95710543c5368ce8a41
|
||||
templates/block_4: 2c346374f245a6821940c061b855ac69
|
||||
templates/block_5: 975abfc66e8e377478ff691a040dda0b
|
||||
templates/block_6: 2bd10f1edb210243c5ab459c59e02d30
|
||||
templates/block_7: 13f0f680c09c96081e125123ad2f6786
|
||||
templates/block_8: 1be1b18e159e8c8d11d2fb1082ea5d98
|
||||
templates/block_9: 2da3894d05e4415fa043ba18d11d60e2
|
||||
templates/block_10: 09a42e99b34b45700e734730acfe37ed
|
||||
templates/book_interview: 1cc9c72d1c088b28e5dfa5ec7d7b78c4
|
||||
templates/build_product_roadmap_description: 6ca163ed3b0095cedcbc11822a0d502a
|
||||
templates/build_product_roadmap_name: 8c216b183c3539c0340ce87465a391cc
|
||||
@@ -2465,6 +2429,7 @@ checksums:
|
||||
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
||||
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
||||
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
|
||||
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
||||
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
||||
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
|
||||
@@ -2867,9 +2832,6 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
|
||||
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
|
||||
|
||||
@@ -63,8 +63,7 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
|
||||
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
|
||||
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
|
||||
|
||||
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -24,12 +23,13 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...(filters?.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
...(filters &&
|
||||
filters.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
@@ -42,97 +42,6 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysByContactId = reactCache(
|
||||
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: { contactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return displays;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[limit, z.number().int().min(1).optional()],
|
||||
[offset, z.number().int().nonnegative().optional()]
|
||||
);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return displays.map((display) => ({
|
||||
id: display.id,
|
||||
createdAt: display.createdAt,
|
||||
surveyId: display.surveyId,
|
||||
contact: display.contact
|
||||
? {
|
||||
id: display.contact.id,
|
||||
attributes: display.contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
|
||||
import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisplaysWithContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user-123" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockDisplaysForContact);
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: { contactId: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when contact has no displays", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the contactId is invalid", async () => {
|
||||
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays with contact attributes transformed", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: { email: "test@example.com", userId: "user-123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: { userId: "user-456" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("calls prisma with correct where clause and pagination", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no displays found", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles display with null contact", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the surveyId is invalid", async () => {
|
||||
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +1,44 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock constants module
|
||||
const envMock = {
|
||||
WEBAPP_URL: undefined as string | undefined,
|
||||
VERCEL_URL: undefined as string | undefined,
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
env: {
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("./env", () => ({
|
||||
env: envMock,
|
||||
}));
|
||||
|
||||
const loadGetPublicDomain = async () => {
|
||||
vi.resetModules();
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
return getPublicDomain;
|
||||
};
|
||||
vi.mock("@/lib/env", () => envMock);
|
||||
|
||||
describe("getPublicDomain", () => {
|
||||
beforeEach(() => {
|
||||
envMock.WEBAPP_URL = undefined;
|
||||
envMock.VERCEL_URL = undefined;
|
||||
envMock.PUBLIC_URL = undefined;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("returns trimmed WEBAPP_URL when configured", async () => {
|
||||
envMock.WEBAPP_URL = " https://app.formbricks.com ";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => {
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = " ";
|
||||
envMock.VERCEL_URL = "preview.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
|
||||
test("should return PUBLIC_URL when it is set", async () => {
|
||||
envMock.env.PUBLIC_URL = "https://surveys.example.com";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("https://surveys.example.com");
|
||||
});
|
||||
|
||||
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("http://localhost:3000");
|
||||
test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = "";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("returns PUBLIC_URL when set", async () => {
|
||||
envMock.WEBAPP_URL = "https://app.formbricks.com";
|
||||
envMock.PUBLIC_URL = "https://surveys.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://surveys.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to WEBAPP_URL when PUBLIC_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = "https://app.formbricks.com";
|
||||
envMock.PUBLIC_URL = " ";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = undefined;
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import "server-only";
|
||||
import { env } from "./env";
|
||||
|
||||
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
|
||||
const WEBAPP_URL = (() => {
|
||||
if (configuredWebappUrl !== "") {
|
||||
return configuredWebappUrl;
|
||||
}
|
||||
|
||||
if (env.VERCEL_URL) {
|
||||
return `https://${env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
})();
|
||||
const WEBAPP_URL =
|
||||
env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Returns the public domain URL
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Error codes returned by Google Sheets integration.
|
||||
* Use these constants when comparing error responses to avoid typos and enable reuse.
|
||||
*/
|
||||
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
|
||||
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";
|
||||
@@ -2,12 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import {
|
||||
AuthenticationError,
|
||||
DatabaseError,
|
||||
OperationNotAllowedError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
ZIntegrationGoogleSheets,
|
||||
@@ -16,12 +11,8 @@ import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
GOOGLE_SHEET_MESSAGE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
|
||||
} from "@/lib/googleSheet/constants";
|
||||
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { truncateText } from "../utils/strings";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -90,17 +81,6 @@ export const writeData = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const validateGoogleSheetsConnection = async (
|
||||
googleSheetIntegrationData: TIntegrationGoogleSheets
|
||||
): Promise<void> => {
|
||||
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
|
||||
const integrationData = structuredClone(googleSheetIntegrationData);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
await authorize(integrationData);
|
||||
};
|
||||
|
||||
export const getSpreadsheetNameById = async (
|
||||
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
||||
spreadsheetId: string
|
||||
@@ -114,17 +94,7 @@ export const getSpreadsheetNameById = async (
|
||||
return new Promise((resolve, reject) => {
|
||||
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
||||
if (err) {
|
||||
const msg = err.message?.toLowerCase() ?? "";
|
||||
const isPermissionError =
|
||||
msg.includes("permission") ||
|
||||
msg.includes("caller does not have") ||
|
||||
msg.includes("insufficient permission") ||
|
||||
msg.includes("access denied");
|
||||
if (isPermissionError) {
|
||||
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
|
||||
} else {
|
||||
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
|
||||
}
|
||||
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
const spreadsheetTitle = response.data.properties.title;
|
||||
@@ -139,70 +109,26 @@ export const getSpreadsheetNameById = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isInvalidGrantError = (error: unknown): boolean => {
|
||||
const err = error as { message?: string; response?: { data?: { error?: string } } };
|
||||
return (
|
||||
typeof err?.message === "string" &&
|
||||
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
|
||||
);
|
||||
};
|
||||
|
||||
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
|
||||
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
|
||||
|
||||
/**
|
||||
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
|
||||
* Returns true if token is valid, false if invalid/revoked.
|
||||
*/
|
||||
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
|
||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
const key = googleSheetIntegrationData.config.key;
|
||||
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
|
||||
oAuth2Client.setCredentials({
|
||||
refresh_token,
|
||||
});
|
||||
const { credentials } = await oAuth2Client.refreshAccessToken();
|
||||
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: googleSheetIntegrationData.config?.data ?? [],
|
||||
email: googleSheetIntegrationData.config?.email ?? "",
|
||||
key: credentials,
|
||||
},
|
||||
});
|
||||
|
||||
const hasStoredCredentials =
|
||||
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
||||
oAuth2Client.setCredentials(credentials);
|
||||
|
||||
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
|
||||
oAuth2Client.setCredentials(key);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
|
||||
|
||||
try {
|
||||
const { credentials } = await oAuth2Client.refreshAccessToken();
|
||||
const mergedCredentials = {
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? key.refresh_token,
|
||||
};
|
||||
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: googleSheetIntegrationData.config?.data ?? [],
|
||||
email: googleSheetIntegrationData.config?.email ?? "",
|
||||
key: mergedCredentials,
|
||||
},
|
||||
});
|
||||
|
||||
oAuth2Client.setCredentials(mergedCredentials);
|
||||
return oAuth2Client;
|
||||
} catch (error) {
|
||||
if (isInvalidGrantError(error)) {
|
||||
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return oAuth2Client;
|
||||
};
|
||||
|
||||
@@ -130,102 +130,84 @@ export const appLanguages = [
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
native: "Deutsch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "English (US)",
|
||||
native: "English (US)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
native: "Español",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "fr-FR",
|
||||
label: {
|
||||
"en-US": "French",
|
||||
native: "Français",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "hu-HU",
|
||||
label: {
|
||||
"en-US": "Hungarian",
|
||||
native: "Magyar",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
native: "日本語",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
native: "Nederlands",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
native: "Português (Brasil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-PT",
|
||||
label: {
|
||||
"en-US": "Portuguese (Portugal)",
|
||||
native: "Português (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ro-RO",
|
||||
label: {
|
||||
"en-US": "Romanian",
|
||||
native: "Română",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
native: "Русский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
native: "Svenska",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Chinese (Simplified)",
|
||||
native: "简体中文",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
native: "繁體中文",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const sortedAppLanguages = [...appLanguages].sort((a, b) =>
|
||||
a.label["en-US"].localeCompare(b.label["en-US"])
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
@@ -397,6 +396,7 @@ export const getResponseDownloadFile = async (
|
||||
"Survey ID",
|
||||
"Formbricks ID (internal)",
|
||||
"User ID",
|
||||
"Notes",
|
||||
"Tags",
|
||||
...metaDataFields,
|
||||
...elements.flat(),
|
||||
@@ -408,10 +408,9 @@ export const getResponseDownloadFile = async (
|
||||
if (survey.isVerifyEmailEnabled) {
|
||||
headers.push("Verified Email");
|
||||
}
|
||||
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
|
||||
const jsonData = getResponsesJson(
|
||||
survey,
|
||||
resolvedResponses,
|
||||
responses,
|
||||
elements,
|
||||
userAttributes,
|
||||
hiddenFields,
|
||||
|
||||
@@ -60,7 +60,6 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
|
||||
// Options (Radio / Checkbox)
|
||||
"optionBgColor.light": inputBg,
|
||||
"optionLabelColor.light": questionColor,
|
||||
"optionBorderColor.light": inputBorder,
|
||||
|
||||
// Card
|
||||
"cardBackgroundColor.light": cardBg,
|
||||
@@ -139,7 +138,6 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Options
|
||||
optionBgColor: { light: _colors["optionBgColor.light"] },
|
||||
optionLabelColor: { light: _colors["optionLabelColor.light"] },
|
||||
optionBorderColor: { light: _colors["optionBorderColor.light"] },
|
||||
optionBorderRadius: 8,
|
||||
optionPaddingX: 16,
|
||||
optionPaddingY: 16,
|
||||
@@ -151,43 +149,6 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
|
||||
*
|
||||
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
|
||||
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
|
||||
*
|
||||
* When loading v4.6 data the new fields are absent. Without this helper the
|
||||
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
|
||||
* colour), causing a visible mismatch. This function derives the new fields
|
||||
* from the actually-saved legacy fields so the preview and form stay coherent.
|
||||
*
|
||||
* Only sets a field when the legacy source exists AND the new field is absent.
|
||||
*/
|
||||
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
|
||||
const light = (key: string): string | undefined =>
|
||||
(saved[key] as { light?: string } | null | undefined)?.light;
|
||||
|
||||
const q = light("questionColor");
|
||||
const b = light("brandColor");
|
||||
const i = light("inputColor");
|
||||
const inputBorder = light("inputBorderColor");
|
||||
|
||||
return {
|
||||
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
|
||||
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
|
||||
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
|
||||
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
|
||||
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
|
||||
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
|
||||
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
|
||||
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
|
||||
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
|
||||
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
|
||||
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a complete TProjectStyling object from a single brand color.
|
||||
*
|
||||
@@ -214,7 +175,6 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
|
||||
inputTextColor: { light: colors["inputTextColor.light"] },
|
||||
optionBgColor: { light: colors["optionBgColor.light"] },
|
||||
optionLabelColor: { light: colors["optionLabelColor.light"] },
|
||||
optionBorderColor: { light: colors["optionBorderColor.light"] },
|
||||
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
|
||||
cardBorderColor: { light: colors["cardBorderColor.light"] },
|
||||
highlightBorderColor: { light: colors["highlightBorderColor.light"] },
|
||||
|
||||
@@ -508,7 +508,6 @@ export const updateSurveyInternal = async (
|
||||
newFollowUps.length > 0
|
||||
? {
|
||||
data: newFollowUps.map((followUp) => ({
|
||||
id: followUp.id,
|
||||
name: followUp.name,
|
||||
trigger: followUp.trigger,
|
||||
action: followUp.action,
|
||||
|
||||
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import dns from "node:dns";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { validateWebhookUrl } from "./validate-webhook-url";
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
resolve: vi.fn(),
|
||||
resolve6: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockResolve = vi.mocked(dns.resolve);
|
||||
const mockResolve6 = vi.mocked(dns.resolve6);
|
||||
|
||||
type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void;
|
||||
|
||||
const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => {
|
||||
// dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form
|
||||
mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => {
|
||||
if (ipv4) {
|
||||
callback(null, ipv4);
|
||||
} else {
|
||||
callback(new Error("ENOTFOUND"), []);
|
||||
}
|
||||
}) as never);
|
||||
|
||||
mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => {
|
||||
if (ipv6) {
|
||||
callback(null, ipv6);
|
||||
} else {
|
||||
callback(new Error("ENOTFOUND"), []);
|
||||
}
|
||||
}) as never);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("validateWebhookUrl", () => {
|
||||
describe("valid public URLs", () => {
|
||||
test("accepts HTTPS URL resolving to a public IPv4 address", async () => {
|
||||
setupDnsResolution(["93.184.216.34"]);
|
||||
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts HTTP URL resolving to a public IPv4 address", async () => {
|
||||
setupDnsResolution(["93.184.216.34"]);
|
||||
await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts URL with port and path segments", async () => {
|
||||
setupDnsResolution(["93.184.216.34"]);
|
||||
await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts URL resolving to a public IPv6 address", async () => {
|
||||
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
|
||||
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts a public IPv4 address as hostname", async () => {
|
||||
await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL format validation", () => {
|
||||
test("rejects a completely malformed string", async () => {
|
||||
await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format");
|
||||
});
|
||||
|
||||
test("rejects an empty string", async () => {
|
||||
await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format");
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol validation", () => {
|
||||
test("rejects FTP protocol", async () => {
|
||||
await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects file:// protocol", async () => {
|
||||
await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects javascript: protocol", async () => {
|
||||
await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocked hostname validation", () => {
|
||||
test("rejects localhost", async () => {
|
||||
await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow(
|
||||
"Webhook URL must not point to localhost or internal services"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects localhost.localdomain", async () => {
|
||||
await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow(
|
||||
"Webhook URL must not point to localhost or internal services"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects metadata.google.internal", async () => {
|
||||
await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow(
|
||||
"Webhook URL must not point to localhost or internal services"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private IPv4 literal blocking", () => {
|
||||
test("rejects 127.0.0.1 (loopback)", async () => {
|
||||
await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 127.0.0.53 (loopback range)", async () => {
|
||||
await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 10.0.0.1 (Class A private)", async () => {
|
||||
await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 172.16.0.1 (Class B private)", async () => {
|
||||
await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 172.31.255.255 (Class B private upper bound)", async () => {
|
||||
await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 192.168.1.1 (Class C private)", async () => {
|
||||
await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => {
|
||||
await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 0.0.0.0 ('this' network)", async () => {
|
||||
await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => {
|
||||
await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DNS resolution with private IP results", () => {
|
||||
test("rejects hostname resolving to loopback address", async () => {
|
||||
setupDnsResolution(["127.0.0.1"]);
|
||||
await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to cloud metadata endpoint IP", async () => {
|
||||
setupDnsResolution(["169.254.169.254"]);
|
||||
await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to Class A private network", async () => {
|
||||
setupDnsResolution(["10.0.0.5"]);
|
||||
await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to Class C private network", async () => {
|
||||
setupDnsResolution(["192.168.0.1"]);
|
||||
await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to IPv6 loopback", async () => {
|
||||
setupDnsResolution(null, ["::1"]);
|
||||
await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to IPv6 link-local", async () => {
|
||||
setupDnsResolution(null, ["fe80::1"]);
|
||||
await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to IPv6 unique local address", async () => {
|
||||
setupDnsResolution(null, ["fd12:3456:789a::1"]);
|
||||
await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => {
|
||||
setupDnsResolution(null, ["::ffff:192.168.1.1"]);
|
||||
await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => {
|
||||
setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex
|
||||
await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => {
|
||||
setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex
|
||||
await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => {
|
||||
setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex
|
||||
await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts hex-encoded IPv4-mapped public address", async () => {
|
||||
setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex
|
||||
await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects when any resolved IP is private (mixed public + private)", async () => {
|
||||
setupDnsResolution(["93.184.216.34", "192.168.1.1"]);
|
||||
await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow(
|
||||
"Webhook URL must not point to private or internal IP addresses"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unresolvable hostname", async () => {
|
||||
setupDnsResolution(null, null);
|
||||
await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow(
|
||||
"Could not resolve webhook URL hostname"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects with timeout error when DNS resolution hangs", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mockResolve.mockImplementation((() => {
|
||||
// never calls callback — simulates a hanging DNS server
|
||||
}) as never);
|
||||
|
||||
const promise = validateWebhookUrl("https://slow-dns.example.com/webhook");
|
||||
|
||||
const assertion = expect(promise).rejects.toThrow(
|
||||
"DNS resolution timed out for webhook URL hostname: slow-dns.example.com"
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await assertion;
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error type", () => {
|
||||
test("throws InvalidInputError (not generic Error)", async () => {
|
||||
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"ip6-localhost",
|
||||
"ip6-loopback",
|
||||
"metadata.google.internal",
|
||||
]);
|
||||
|
||||
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
|
||||
/^127\./, // 127.0.0.0/8 – Loopback
|
||||
/^10\./, // 10.0.0.0/8 – Class A private
|
||||
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 – Class B private
|
||||
/^192\.168\./, // 192.168.0.0/16 – Class C private
|
||||
/^169\.254\./, // 169.254.0.0/16 – Link-local (AWS/GCP/Azure metadata)
|
||||
/^0\./, // 0.0.0.0/8 – "This" network
|
||||
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 – Shared address space (RFC 6598)
|
||||
/^192\.0\.0\./, // 192.0.0.0/24 – IETF protocol assignments
|
||||
/^192\.0\.2\./, // 192.0.2.0/24 – TEST-NET-1 (documentation)
|
||||
/^198\.51\.100\./, // 198.51.100.0/24 – TEST-NET-2 (documentation)
|
||||
/^203\.0\.113\./, // 203.0.113.0/24 – TEST-NET-3 (documentation)
|
||||
/^198\.1[89]\./, // 198.18.0.0/15 – Benchmarking
|
||||
/^224\./, // 224.0.0.0/4 – Multicast
|
||||
/^240\./, // 240.0.0.0/4 – Reserved for future use
|
||||
/^255\.255\.255\.255$/, // Limited broadcast
|
||||
];
|
||||
|
||||
const PRIVATE_IPV6_PREFIXES = [
|
||||
"::1", // Loopback
|
||||
"fe80:", // Link-local
|
||||
"fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
|
||||
"fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
|
||||
];
|
||||
|
||||
const isPrivateIPv4 = (ip: string): boolean => {
|
||||
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip));
|
||||
};
|
||||
|
||||
const hexMappedToIPv4 = (hexPart: string): string | null => {
|
||||
const groups = hexPart.split(":");
|
||||
if (groups.length !== 2) return null;
|
||||
const high = Number.parseInt(groups[0], 16);
|
||||
const low = Number.parseInt(groups[1], 16);
|
||||
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null;
|
||||
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
||||
};
|
||||
|
||||
const isIPv4Mapped = (normalized: string): boolean => {
|
||||
if (!normalized.startsWith("::ffff:")) return false;
|
||||
const suffix = normalized.slice(7); // strip "::ffff:"
|
||||
|
||||
if (suffix.includes(".")) {
|
||||
return isPrivateIPv4(suffix);
|
||||
}
|
||||
const dotted = hexMappedToIPv4(suffix);
|
||||
return dotted !== null && isPrivateIPv4(dotted);
|
||||
};
|
||||
|
||||
const isPrivateIPv6 = (ip: string): boolean => {
|
||||
const normalized = ip.toLowerCase();
|
||||
if (normalized === "::") return true;
|
||||
if (isIPv4Mapped(normalized)) return true;
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
};
|
||||
|
||||
const isPrivateIP = (ip: string): boolean => {
|
||||
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
|
||||
};
|
||||
|
||||
const DNS_TIMEOUT_MS = 3000;
|
||||
|
||||
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const settle = <T>(fn: (value: T) => void, value: T): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(reject, new Error(`DNS resolution timed out for hostname: ${hostname}`));
|
||||
}, DNS_TIMEOUT_MS);
|
||||
|
||||
dns.resolve(hostname, (errV4, ipv4Addresses) => {
|
||||
const ipv4 = errV4 ? [] : ipv4Addresses;
|
||||
|
||||
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
|
||||
const ipv6 = errV6 ? [] : ipv6Addresses;
|
||||
const allAddresses = [...ipv4, ...ipv6];
|
||||
|
||||
if (allAddresses.length === 0) {
|
||||
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
|
||||
} else {
|
||||
settle(resolve, allAddresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const stripIPv6Brackets = (hostname: string): string => {
|
||||
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||
return hostname.slice(1, -1);
|
||||
}
|
||||
return hostname;
|
||||
};
|
||||
|
||||
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||
|
||||
/**
|
||||
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
|
||||
*
|
||||
* Checks performed:
|
||||
* 1. URL must be well-formed
|
||||
* 2. Protocol must be HTTPS or HTTP
|
||||
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
|
||||
* 4. IP literal hostnames are checked directly against private ranges
|
||||
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new InvalidInputError("Invalid webhook URL format");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
const isIPv4Literal = IPV4_LITERAL.test(hostname);
|
||||
const isIPv6Literal = hostname.startsWith("[");
|
||||
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Domain name — resolve DNS and validate every resolved IP
|
||||
let resolvedIPs: string[];
|
||||
try {
|
||||
resolvedIPs = await resolveHostnameToIPs(hostname);
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof Error && error.message.includes("timed out");
|
||||
throw new InvalidInputError(
|
||||
isTimeout
|
||||
? `DNS resolution timed out for webhook URL hostname: ${hostname}`
|
||||
: `Could not resolve webhook URL hostname: ${hostname}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"and_response_limit_of": "und Antwortlimit von",
|
||||
"anonymous": "Anonym",
|
||||
@@ -175,12 +176,9 @@
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code kopieren",
|
||||
"copy_link": "Link kopieren",
|
||||
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
|
||||
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
|
||||
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
|
||||
"count_questions": "{count, plural, one {{count} Frage} other {{count} Fragen}}",
|
||||
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
|
||||
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
"create_survey": "Umfrage erstellen",
|
||||
@@ -194,7 +192,6 @@
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
"delete": "Löschen",
|
||||
"delete_what": "{deleteWhat} löschen",
|
||||
"description": "Beschreibung",
|
||||
"dev_env": "Entwicklungsumgebung",
|
||||
"development": "Entwicklung",
|
||||
@@ -210,8 +207,6 @@
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
"duplicate_copy": "(Kopie)",
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"email": "E-Mail",
|
||||
@@ -224,16 +219,13 @@
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"error_loading_data": "Fehler beim Laden der Daten",
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||
"filter": "Filter",
|
||||
"finish": "Fertigstellen",
|
||||
"first_name": "Vorname",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Name",
|
||||
@@ -246,7 +238,6 @@
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
"hidden_fields": "Versteckte Felder",
|
||||
"hide_column": "Spalte ausblenden",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
"images": "Bilder",
|
||||
"import": "Importieren",
|
||||
@@ -264,7 +255,6 @@
|
||||
"key": "Schlüssel",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"last_name": "Nachname",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
@@ -279,6 +269,7 @@
|
||||
"look_and_feel": "Darstellung",
|
||||
"manage": "Verwalten",
|
||||
"marketing": "Marketing",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
@@ -290,7 +281,6 @@
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
"my_product": "mein Produkt",
|
||||
"name": "Name",
|
||||
"new": "Neu",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
@@ -386,6 +376,8 @@
|
||||
"select_teams": "Teams auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
"selected_questions": "Ausgewählte Fragen",
|
||||
"selection": "Auswahl",
|
||||
"selections": "Auswahlen",
|
||||
"send_test_email": "Test-E-Mail senden",
|
||||
"session_not_found": "Sitzung nicht gefunden",
|
||||
"settings": "Einstellungen",
|
||||
@@ -437,7 +429,6 @@
|
||||
"top_right": "Oben rechts",
|
||||
"try_again": "Versuch's nochmal",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Unbekannte Umfrage",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
|
||||
"update": "Aktualisierung",
|
||||
"updated": "Aktualisiert",
|
||||
@@ -655,6 +646,7 @@
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_key": "Schlüssel erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
"custom_attributes": "Benutzerdefinierte Attribute",
|
||||
@@ -665,7 +657,6 @@
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
"displays": "Anzeigen",
|
||||
"edit_attribute": "Attribut bearbeiten",
|
||||
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
||||
"edit_attribute_values": "Attribute bearbeiten",
|
||||
@@ -677,7 +668,6 @@
|
||||
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
|
||||
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
|
||||
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
|
||||
"no_activity_yet": "Noch keine Aktivität",
|
||||
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
|
||||
"no_published_surveys": "Keine veröffentlichten Umfragen",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
@@ -692,8 +682,6 @@
|
||||
"select_a_survey": "Wähle eine Umfrage aus",
|
||||
"select_attribute": "Attribut auswählen",
|
||||
"select_attribute_key": "Attributschlüssel auswählen",
|
||||
"survey_viewed": "Umfrage angesehen",
|
||||
"survey_viewed_at": "Angesehen am",
|
||||
"system_attributes": "Systemattribute",
|
||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||
@@ -765,12 +753,7 @@
|
||||
"link_google_sheet": "Tabelle verlinken",
|
||||
"link_new_sheet": "Neues Blatt verknüpfen",
|
||||
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"reconnect_button": "Erneut verbinden",
|
||||
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
|
||||
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
|
||||
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
|
||||
"spreadsheet_url": "Tabellen-URL",
|
||||
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
|
||||
"spreadsheet_url": "Tabellen-URL"
|
||||
},
|
||||
"include_created_at": "Erstellungsdatum einbeziehen",
|
||||
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
||||
@@ -1085,7 +1068,7 @@
|
||||
"email_customization_preview_email_heading": "Hey {userName}",
|
||||
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
|
||||
"error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.",
|
||||
"from_your_organization": "{memberName} aus Ihrer Organisation",
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invite_expires_on": "Einladung läuft ab am {date}",
|
||||
@@ -1250,7 +1233,6 @@
|
||||
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Gilt nur für In-Product-Umfragen.",
|
||||
"add_logic": "Logik hinzufügen",
|
||||
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
|
||||
"add_option": "Option hinzufügen",
|
||||
@@ -1267,14 +1249,12 @@
|
||||
"adjust_survey_closed_message": "'Umfrage geschlossen'-Nachricht anpassen",
|
||||
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
||||
"adjust_the_theme_in_the": "Passe das Thema an in den",
|
||||
"all_are_true": "alle sind wahr",
|
||||
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
|
||||
"allow_multi_select": "Mehrfachauswahl erlauben",
|
||||
"allow_multiple_files": "Mehrere Dateien zulassen",
|
||||
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
||||
"and_launch_surveys_in_your_website_or_app": "und Umfragen auf deiner Website oder App starten.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "mindestens eine ist wahr",
|
||||
"app_survey_description": "Bette eine Umfrage in deine Web-App oder Website ein, um Antworten zu sammeln.",
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
@@ -1451,6 +1431,7 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
|
||||
"follow_ups_new": "Neues Follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
|
||||
"form_styling": "Umfrage Styling",
|
||||
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
|
||||
"four_points": "4 Punkte",
|
||||
"heading": "Überschrift",
|
||||
@@ -1540,7 +1521,7 @@
|
||||
"option_idx": "Option {choiceIndex}",
|
||||
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"optional": "Optional",
|
||||
"options": "Optionen*",
|
||||
"options": "Optionen",
|
||||
"options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
|
||||
"overwrite_global_waiting_time": "Benutzerdefinierte Abkühlphase festlegen",
|
||||
@@ -1565,7 +1546,6 @@
|
||||
"question_deleted": "Frage gelöscht.",
|
||||
"question_duplicated": "Frage dupliziert.",
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_number": "Frage {number}",
|
||||
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
|
||||
"question_used_in_logic_warning_title": "Logikinkonsistenz",
|
||||
"question_used_in_quota": "Diese Frage wird in der “{quotaName}” Quote verwendet",
|
||||
@@ -1624,7 +1604,7 @@
|
||||
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
|
||||
"response_options": "Antwortoptionen",
|
||||
"roundness": "Rundheit",
|
||||
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
|
||||
"roundness_description": "Steuert, wie abgerundet die Kartenecken sind.",
|
||||
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"rows": "Zeilen",
|
||||
"save_and_close": "Speichern & Schließen",
|
||||
@@ -1670,7 +1650,6 @@
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
"survey_placement": "Platzierung der Umfrage",
|
||||
"survey_styling": "Umfrage Styling",
|
||||
"survey_trigger": "Auslöser der Umfrage",
|
||||
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
|
||||
"target_block_not_found": "Zielblock nicht gefunden",
|
||||
@@ -1759,9 +1738,9 @@
|
||||
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
|
||||
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.",
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
"when": "Wenn",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
|
||||
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Sie müssen zwei oder mehr Sprachen in Ihrem Workspace eingerichtet haben, um mit Übersetzungen zu arbeiten.",
|
||||
"your_description_here_recall_information_with": "Deine Beschreibung hier. Informationen abrufen mit @",
|
||||
"your_question_here_recall_information_with": "Deine Frage hier. Informationen abrufen mit @",
|
||||
"your_web_app": "Deine Web-App",
|
||||
@@ -1969,7 +1948,6 @@
|
||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||
"generating_qr_code": "QR-Code wird generiert",
|
||||
"impressions": "Eindrücke",
|
||||
"impressions_identified_only": "Zeigt nur Impressionen von identifizierten Kontakten",
|
||||
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
|
||||
"in_app": {
|
||||
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
|
||||
@@ -2012,7 +1990,6 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"limit": "Limit",
|
||||
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
@@ -2035,7 +2012,6 @@
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
"survey_results": "{surveyName}-Ergebnisse",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
@@ -2183,7 +2159,7 @@
|
||||
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
|
||||
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_input_height_description": "Steuert die Mindesthöhe der Eingabe.",
|
||||
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
|
||||
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
|
||||
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
||||
@@ -2192,8 +2168,6 @@
|
||||
"advanced_styling_field_input_text_description": "Färbt den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_option_bg": "Hintergrund",
|
||||
"advanced_styling_field_option_bg_description": "Füllt die Optionselemente.",
|
||||
"advanced_styling_field_option_border": "Rahmenfarbe",
|
||||
"advanced_styling_field_option_border_description": "Umrandet Radio- und Checkbox-Optionen.",
|
||||
"advanced_styling_field_option_border_radius_description": "Rundet die Ecken der Optionen ab.",
|
||||
"advanced_styling_field_option_font_size_description": "Skaliert den Text der Optionsbeschriftung.",
|
||||
"advanced_styling_field_option_label": "Label-Farbe",
|
||||
@@ -2403,16 +2377,6 @@
|
||||
"alignment_and_engagement_survey_question_4_headline": "Wie kann das Unternehmen seine Vision und strategische Ausrichtung verbessern?",
|
||||
"alignment_and_engagement_survey_question_4_placeholder": "Tippe deine Antwort hier...",
|
||||
"back": "Zurück",
|
||||
"block_1": "Block 1",
|
||||
"block_10": "Block 10",
|
||||
"block_2": "Block 2",
|
||||
"block_3": "Block 3",
|
||||
"block_4": "Block 4",
|
||||
"block_5": "Block 5",
|
||||
"block_6": "Block 6",
|
||||
"block_7": "Block 7",
|
||||
"block_8": "Block 8",
|
||||
"block_9": "Block 9",
|
||||
"book_interview": "Interview buchen",
|
||||
"build_product_roadmap_description": "Finde die EINE Sache heraus, die deine Nutzer am meisten wollen, und baue sie.",
|
||||
"build_product_roadmap_name": "Produkt Roadmap erstellen",
|
||||
@@ -2620,6 +2584,7 @@
|
||||
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
|
||||
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
|
||||
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
|
||||
"custom_survey_block_1_name": "Block 1",
|
||||
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
|
||||
"custom_survey_name": "Eigene Umfrage erstellen",
|
||||
"custom_survey_question_1_headline": "Was möchtest Du wissen?",
|
||||
@@ -3022,9 +2987,6 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nein, danke!",
|
||||
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
|
||||
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
|
||||
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
|
||||
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
|
||||
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
|
||||
"preview_survey_welcome_card_headline": "Willkommen!",
|
||||
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
|
||||
"prioritize_features_name": "Funktionen priorisieren",
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
"add_action": "Add action",
|
||||
"add_chart": "Add chart",
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
@@ -133,6 +134,7 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"and_response_limit_of": "and response limit of",
|
||||
"anonymous": "Anonymous",
|
||||
@@ -175,12 +177,9 @@
|
||||
"copy": "Copy",
|
||||
"copy_code": "Copy code",
|
||||
"copy_link": "Copy Link",
|
||||
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
|
||||
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
|
||||
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -194,7 +193,6 @@
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"delete_what": "Delete {deleteWhat}",
|
||||
"description": "Description",
|
||||
"dev_env": "Dev Environment",
|
||||
"development": "Development",
|
||||
@@ -210,8 +208,6 @@
|
||||
"download": "Download",
|
||||
"draft": "Draft",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicate_copy": "(copy)",
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
@@ -224,16 +220,13 @@
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"error_loading_data": "Error loading data",
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
@@ -246,7 +239,6 @@
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
"import": "Import",
|
||||
@@ -264,7 +256,6 @@
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"last_name": "Last Name",
|
||||
"learn_more": "Learn more",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Light overlay",
|
||||
@@ -279,6 +270,7 @@
|
||||
"look_and_feel": "Look & Feel",
|
||||
"manage": "Manage",
|
||||
"marketing": "Marketing",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership_not_found": "Membership not found",
|
||||
@@ -290,7 +282,6 @@
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
"my_product": "my Product",
|
||||
"name": "Name",
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
@@ -386,6 +377,8 @@
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
"selected_questions": "Selected questions",
|
||||
"selection": "Selection",
|
||||
"selections": "Selections",
|
||||
"send_test_email": "Send test email",
|
||||
"session_not_found": "Session not found",
|
||||
"settings": "Settings",
|
||||
@@ -408,6 +401,7 @@
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
"chart": "Chart",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
"survey_id": "Survey ID",
|
||||
@@ -437,7 +431,6 @@
|
||||
"top_right": "Top Right",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
@@ -628,6 +621,17 @@
|
||||
"subtitle": "It takes less than 4 minutes.",
|
||||
"waiting_for_your_signal": "Waiting for your signal…"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"edit_chart_description": "View and edit your chart configuration.",
|
||||
"edit_chart_title": "Edit Chart",
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart? This action cannot be undone.",
|
||||
"open_options": "Open options"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"add_attribute": "Add Attribute",
|
||||
"attribute_created_successfully": "Attribute created successfully",
|
||||
@@ -655,6 +659,7 @@
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_key": "Create Key",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"custom_attributes": "Custom Attributes",
|
||||
@@ -665,7 +670,6 @@
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts’ data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"displays": "Displays",
|
||||
"edit_attribute": "Edit attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
@@ -677,7 +681,6 @@
|
||||
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
"invalid_date_format": "Invalid date format. Please use a valid date.",
|
||||
"invalid_number_format": "Invalid number format. Please enter a valid number.",
|
||||
"no_activity_yet": "No activity yet",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
@@ -692,8 +695,6 @@
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"select_attribute_key": "Select attribute key",
|
||||
"survey_viewed": "Survey viewed",
|
||||
"survey_viewed_at": "Viewed At",
|
||||
"system_attributes": "System Attributes",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
@@ -765,12 +766,7 @@
|
||||
"link_google_sheet": "Link Google Sheet",
|
||||
"link_new_sheet": "Link new Sheet",
|
||||
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
|
||||
"reconnect_button": "Reconnect",
|
||||
"reconnect_button_description": "Your Google Sheets connection has expired. Please reconnect to continue syncing responses. Your existing spreadsheet links and data will be preserved.",
|
||||
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing spreadsheet links and data will be preserved.",
|
||||
"spreadsheet_permission_error": "You don't have permission to access this spreadsheet. Please ensure the spreadsheet is shared with your Google account and you have write access to the spreadsheet.",
|
||||
"spreadsheet_url": "Spreadsheet URL",
|
||||
"token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration."
|
||||
"spreadsheet_url": "Spreadsheet URL"
|
||||
},
|
||||
"include_created_at": "Include Created At",
|
||||
"include_hidden_fields": "Include Hidden Fields",
|
||||
@@ -1085,7 +1081,7 @@
|
||||
"email_customization_preview_email_heading": "Hey {userName}",
|
||||
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
|
||||
"error_deleting_organization_please_try_again": "Error deleting organization. Please try again.",
|
||||
"from_your_organization": "{memberName} from your organization",
|
||||
"from_your_organization": "from your organization",
|
||||
"invitation_sent_once_more": "Invitation sent once more.",
|
||||
"invite_deleted_successfully": "Invite deleted successfully",
|
||||
"invite_expires_on": "Invite expires on {date}",
|
||||
@@ -1250,7 +1246,6 @@
|
||||
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Only applies to in-product surveys.",
|
||||
"add_logic": "Add logic",
|
||||
"add_none_of_the_above": "Add “None of the Above”",
|
||||
"add_option": "Add option",
|
||||
@@ -1267,14 +1262,12 @@
|
||||
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
|
||||
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
|
||||
"adjust_the_theme_in_the": "Adjust the theme in the",
|
||||
"all_are_true": "all are true",
|
||||
"all_other_answers_will_continue_to": "All other answers will continue to",
|
||||
"allow_multi_select": "Allow multi-select",
|
||||
"allow_multiple_files": "Allow multiple files",
|
||||
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
|
||||
"and_launch_surveys_in_your_website_or_app": "and launch surveys in your website or app.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "any is true",
|
||||
"app_survey_description": "Embed a survey in your web app or website to collect responses.",
|
||||
"assign": "Assign =",
|
||||
"audience": "Audience",
|
||||
@@ -1451,6 +1444,7 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
|
||||
"follow_ups_new": "New follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
||||
"form_styling": "Form styling",
|
||||
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
|
||||
"four_points": "4 points",
|
||||
"heading": "Heading",
|
||||
@@ -1540,7 +1534,7 @@
|
||||
"option_idx": "Option {choiceIndex}",
|
||||
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"optional": "Optional",
|
||||
"options": "Options*",
|
||||
"options": "Options",
|
||||
"options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
|
||||
"overwrite_global_waiting_time": "Set custom Cooldown Period",
|
||||
@@ -1565,7 +1559,6 @@
|
||||
"question_deleted": "Question deleted.",
|
||||
"question_duplicated": "Question duplicated.",
|
||||
"question_id_updated": "Question ID updated",
|
||||
"question_number": "Question {number}",
|
||||
"question_used_in_logic_warning_text": "Elements from this block are used in a logic rule, are you sure you want to delete it?",
|
||||
"question_used_in_logic_warning_title": "Logic Inconsistency",
|
||||
"question_used_in_quota": "This question is being used in “{quotaName}” quota",
|
||||
@@ -1624,7 +1617,7 @@
|
||||
"response_limits_redirections_and_more": "Response limits, redirections and more.",
|
||||
"response_options": "Response Options",
|
||||
"roundness": "Roundness",
|
||||
"roundness_description": "Controls how rounded corners are.",
|
||||
"roundness_description": "Controls how rounded the card corners are.",
|
||||
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"rows": "Rows",
|
||||
"save_and_close": "Save & Close",
|
||||
@@ -1670,7 +1663,6 @@
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
"survey_placement": "Survey Placement",
|
||||
"survey_styling": "Survey styling",
|
||||
"survey_trigger": "Survey Trigger",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
|
||||
"target_block_not_found": "Target block not found",
|
||||
@@ -1759,9 +1751,9 @@
|
||||
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
|
||||
"waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the workspace-wide Cooldown Period.",
|
||||
"welcome_message": "Welcome message",
|
||||
"when": "When",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
|
||||
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "You need to have two or more languages set up in your workspace to work with translations.",
|
||||
"your_description_here_recall_information_with": "Your description here. Recall information with @",
|
||||
"your_question_here_recall_information_with": "Your question here. Recall information with @",
|
||||
"your_web_app": "Your web app",
|
||||
@@ -1969,7 +1961,6 @@
|
||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||
"generating_qr_code": "Generating QR code",
|
||||
"impressions": "Impressions",
|
||||
"impressions_identified_only": "Only showing impressions from identified contacts",
|
||||
"impressions_tooltip": "Number of times the survey has been viewed.",
|
||||
"in_app": {
|
||||
"connection_description": "The survey will be shown to users of your website, that match the criteria listed below",
|
||||
@@ -2012,7 +2003,6 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"limit": "Limit",
|
||||
"no_identified_impressions": "No impressions from identified contacts",
|
||||
"no_responses_found": "No responses found",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
@@ -2035,7 +2025,6 @@
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
|
||||
"survey_results": "{surveyName} Results",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
@@ -2169,7 +2158,7 @@
|
||||
"advanced_styling_field_description_size": "Description Font Size",
|
||||
"advanced_styling_field_description_size_description": "Scales the description text.",
|
||||
"advanced_styling_field_description_weight": "Description Font Weight",
|
||||
"advanced_styling_field_description_weight_description": "Makes descr. text lighter or bolder.",
|
||||
"advanced_styling_field_description_weight_description": "Makes description text lighter or bolder.",
|
||||
"advanced_styling_field_font_size": "Font Size",
|
||||
"advanced_styling_field_font_weight": "Font Weight",
|
||||
"advanced_styling_field_headline_color": "Headline Color",
|
||||
@@ -2183,7 +2172,7 @@
|
||||
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
|
||||
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
||||
"advanced_styling_field_input_height_description": "Controls the min. height of the input.",
|
||||
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
||||
@@ -2192,8 +2181,6 @@
|
||||
"advanced_styling_field_input_text_description": "Colors the typed text in inputs.",
|
||||
"advanced_styling_field_option_bg": "Background",
|
||||
"advanced_styling_field_option_bg_description": "Fills the option items.",
|
||||
"advanced_styling_field_option_border": "Border Color",
|
||||
"advanced_styling_field_option_border_description": "Outlines radio and checkbox options.",
|
||||
"advanced_styling_field_option_border_radius_description": "Rounds the option corners.",
|
||||
"advanced_styling_field_option_font_size_description": "Scales the option label text.",
|
||||
"advanced_styling_field_option_label": "Label Color",
|
||||
@@ -2403,16 +2390,6 @@
|
||||
"alignment_and_engagement_survey_question_4_headline": "How can the company improve its vision and strategy alignment?",
|
||||
"alignment_and_engagement_survey_question_4_placeholder": "Type your answer here…",
|
||||
"back": "Back",
|
||||
"block_1": "Block 1",
|
||||
"block_10": "Block 10",
|
||||
"block_2": "Block 2",
|
||||
"block_3": "Block 3",
|
||||
"block_4": "Block 4",
|
||||
"block_5": "Block 5",
|
||||
"block_6": "Block 6",
|
||||
"block_7": "Block 7",
|
||||
"block_8": "Block 8",
|
||||
"block_9": "Block 9",
|
||||
"book_interview": "Book interview",
|
||||
"build_product_roadmap_description": "Identify the ONE thing your users want the most and build it.",
|
||||
"build_product_roadmap_name": "Build Product Roadmap",
|
||||
@@ -2620,6 +2597,7 @@
|
||||
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
|
||||
"csat_survey_question_3_placeholder": "Type your answer here…",
|
||||
"cta_description": "Display information and prompt users to take a specific action",
|
||||
"custom_survey_block_1_name": "Block 1",
|
||||
"custom_survey_description": "Create a survey without template.",
|
||||
"custom_survey_name": "Start from scratch",
|
||||
"custom_survey_question_1_headline": "What would you like to know?",
|
||||
@@ -3022,9 +3000,6 @@
|
||||
"preview_survey_question_2_choice_2_label": "No, thank you!",
|
||||
"preview_survey_question_2_headline": "Want to stay in the loop?",
|
||||
"preview_survey_question_2_subheader": "This is an example description.",
|
||||
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
|
||||
"preview_survey_question_open_text_placeholder": "Type your answer here…",
|
||||
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
|
||||
"preview_survey_welcome_card_headline": "Welcome!",
|
||||
"prioritize_features_description": "Identify features your users need most and least.",
|
||||
"prioritize_features_name": "Prioritize Features",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"and_response_limit_of": "y límite de respuesta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -175,12 +176,9 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar enlace",
|
||||
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
|
||||
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
|
||||
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
|
||||
"count_questions": "{count, plural, one {{count} pregunta} other {{count} preguntas}}",
|
||||
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
|
||||
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
|
||||
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -194,7 +192,6 @@
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
"delete": "Eliminar",
|
||||
"delete_what": "Eliminar {deleteWhat}",
|
||||
"description": "Descripción",
|
||||
"dev_env": "Entorno de desarrollo",
|
||||
"development": "Desarrollo",
|
||||
@@ -210,8 +207,6 @@
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
"duplicate_copy": "(copia)",
|
||||
"duplicate_copy_number": "(copia {copyNumber})",
|
||||
"e_commerce": "Comercio electrónico",
|
||||
"edit": "Editar",
|
||||
"email": "Email",
|
||||
@@ -224,16 +219,13 @@
|
||||
"error": "Error",
|
||||
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
|
||||
"error_component_title": "Error al cargar recursos",
|
||||
"error_loading_data": "Error al cargar los datos",
|
||||
"error_rate_limit_description": "Número máximo de solicitudes alcanzado. Por favor, inténtalo de nuevo más tarde.",
|
||||
"error_rate_limit_title": "Límite de frecuencia excedido",
|
||||
"expand_rows": "Expandir filas",
|
||||
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
"follow_these": "Sigue estos",
|
||||
"formbricks_version": "Versión de Formbricks",
|
||||
"full_name": "Nombre completo",
|
||||
@@ -246,7 +238,6 @@
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide_column": "Ocultar columna",
|
||||
"id": "ID",
|
||||
"image": "Imagen",
|
||||
"images": "Imágenes",
|
||||
"import": "Importar",
|
||||
@@ -264,7 +255,6 @@
|
||||
"key": "Clave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"last_name": "Apellido",
|
||||
"learn_more": "Saber más",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Superposición clara",
|
||||
@@ -279,6 +269,7 @@
|
||||
"look_and_feel": "Apariencia",
|
||||
"manage": "Gestionar",
|
||||
"marketing": "Marketing",
|
||||
"member": "Miembro",
|
||||
"members": "Miembros",
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
@@ -290,7 +281,6 @@
|
||||
"move_down": "Mover hacia abajo",
|
||||
"move_up": "Mover hacia arriba",
|
||||
"multiple_languages": "Múltiples idiomas",
|
||||
"my_product": "mi producto",
|
||||
"name": "Nombre",
|
||||
"new": "Nuevo",
|
||||
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
|
||||
@@ -386,6 +376,8 @@
|
||||
"select_teams": "Seleccionar equipos",
|
||||
"selected": "Seleccionado",
|
||||
"selected_questions": "Preguntas seleccionadas",
|
||||
"selection": "Selección",
|
||||
"selections": "Selecciones",
|
||||
"send_test_email": "Enviar correo electrónico de prueba",
|
||||
"session_not_found": "Sesión no encontrada",
|
||||
"settings": "Ajustes",
|
||||
@@ -437,7 +429,6 @@
|
||||
"top_right": "Superior derecha",
|
||||
"try_again": "Intentar de nuevo",
|
||||
"type": "Tipo",
|
||||
"unknown_survey": "Encuesta desconocida",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Desbloquea más proyectos con un plan superior.",
|
||||
"update": "Actualizar",
|
||||
"updated": "Actualizado",
|
||||
@@ -655,6 +646,7 @@
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
"create_key": "Crear clave",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||
"custom_attributes": "Atributos personalizados",
|
||||
@@ -665,7 +657,6 @@
|
||||
"delete_attribute_confirmation": "{value, plural, one {Esto eliminará el atributo seleccionado. Se perderán todos los datos de contacto asociados con este atributo.} other {Esto eliminará los atributos seleccionados. Se perderán todos los datos de contacto asociados con estos atributos.}}",
|
||||
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
|
||||
"displays": "Visualizaciones",
|
||||
"edit_attribute": "Editar atributo",
|
||||
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
|
||||
"edit_attribute_values": "Editar atributos",
|
||||
@@ -677,7 +668,6 @@
|
||||
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
|
||||
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
|
||||
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
|
||||
"no_activity_yet": "Aún no hay actividad",
|
||||
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
|
||||
"no_published_surveys": "No hay encuestas publicadas",
|
||||
"no_responses_found": "No se encontraron respuestas",
|
||||
@@ -692,8 +682,6 @@
|
||||
"select_a_survey": "Selecciona una encuesta",
|
||||
"select_attribute": "Seleccionar atributo",
|
||||
"select_attribute_key": "Seleccionar clave de atributo",
|
||||
"survey_viewed": "Encuesta vista",
|
||||
"survey_viewed_at": "Vista el",
|
||||
"system_attributes": "Atributos del sistema",
|
||||
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
||||
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
||||
@@ -765,12 +753,7 @@
|
||||
"link_google_sheet": "Vincular Google Sheet",
|
||||
"link_new_sheet": "Vincular nueva hoja",
|
||||
"no_integrations_yet": "Tus integraciones de Google Sheet aparecerán aquí tan pronto como las añadas. ⏲️",
|
||||
"reconnect_button": "Reconectar",
|
||||
"reconnect_button_description": "Tu conexión con Google Sheets ha caducado. Reconecta para continuar sincronizando respuestas. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
|
||||
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces de hojas de cálculo y datos existentes se conservarán.",
|
||||
"spreadsheet_permission_error": "No tienes permiso para acceder a esta hoja de cálculo. Asegúrate de que la hoja de cálculo esté compartida con tu cuenta de Google y de que tengas acceso de escritura a la hoja de cálculo.",
|
||||
"spreadsheet_url": "URL de la hoja de cálculo",
|
||||
"token_expired_error": "El token de actualización de Google Sheets ha caducado o ha sido revocado. Reconecta la integración."
|
||||
"spreadsheet_url": "URL de la hoja de cálculo"
|
||||
},
|
||||
"include_created_at": "Incluir fecha de creación",
|
||||
"include_hidden_fields": "Incluir campos ocultos",
|
||||
@@ -1085,7 +1068,7 @@
|
||||
"email_customization_preview_email_heading": "Hola {userName}",
|
||||
"email_customization_preview_email_text": "Este es un correo electrónico de vista previa para mostrarte qué logotipo se mostrará en los correos electrónicos.",
|
||||
"error_deleting_organization_please_try_again": "Error al eliminar la organización. Por favor, inténtalo de nuevo.",
|
||||
"from_your_organization": "{memberName} de tu organización",
|
||||
"from_your_organization": "de tu organización",
|
||||
"invitation_sent_once_more": "Invitación enviada una vez más.",
|
||||
"invite_deleted_successfully": "Invitación eliminada correctamente",
|
||||
"invite_expires_on": "La invitación expira el {date}",
|
||||
@@ -1250,7 +1233,6 @@
|
||||
"add_fallback_placeholder": "Añadir un marcador de posición para mostrar si no hay valor que recuperar.",
|
||||
"add_hidden_field_id": "Añadir ID de campo oculto",
|
||||
"add_highlight_border": "Añadir borde destacado",
|
||||
"add_highlight_border_description": "Solo se aplica a encuestas dentro del producto.",
|
||||
"add_logic": "Añadir lógica",
|
||||
"add_none_of_the_above": "Añadir \"Ninguna de las anteriores\"",
|
||||
"add_option": "Añadir opción",
|
||||
@@ -1267,14 +1249,12 @@
|
||||
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
|
||||
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
||||
"adjust_the_theme_in_the": "Ajustar el tema en el",
|
||||
"all_are_true": "todas son verdaderas",
|
||||
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
|
||||
"allow_multi_select": "Permitir selección múltiple",
|
||||
"allow_multiple_files": "Permitir múltiples archivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
|
||||
"and_launch_surveys_in_your_website_or_app": "y lanzar encuestas en tu sitio web o aplicación.",
|
||||
"animation": "Animación",
|
||||
"any_is_true": "alguna es verdadera",
|
||||
"app_survey_description": "Integra una encuesta en tu aplicación web o sitio web para recopilar respuestas.",
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
@@ -1451,6 +1431,7 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.",
|
||||
"follow_ups_new": "Nuevo seguimiento",
|
||||
"follow_ups_upgrade_button_text": "Actualiza para habilitar seguimientos",
|
||||
"form_styling": "Estilo del formulario",
|
||||
"formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado",
|
||||
"four_points": "4 puntos",
|
||||
"heading": "Encabezado",
|
||||
@@ -1540,7 +1521,7 @@
|
||||
"option_idx": "Opción {choiceIndex}",
|
||||
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
|
||||
"optional": "Opcional",
|
||||
"options": "Opciones*",
|
||||
"options": "Opciones",
|
||||
"options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
|
||||
"overwrite_global_waiting_time": "Establecer periodo de espera personalizado",
|
||||
@@ -1565,7 +1546,6 @@
|
||||
"question_deleted": "Pregunta eliminada.",
|
||||
"question_duplicated": "Pregunta duplicada.",
|
||||
"question_id_updated": "ID de pregunta actualizado",
|
||||
"question_number": "Pregunta {number}",
|
||||
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
|
||||
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota “{quotaName}”",
|
||||
@@ -1624,7 +1604,7 @@
|
||||
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
|
||||
"response_options": "Opciones de respuesta",
|
||||
"roundness": "Redondez",
|
||||
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
|
||||
"roundness_description": "Controla qué tan redondeadas están las esquinas de la tarjeta.",
|
||||
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
|
||||
"rows": "Filas",
|
||||
"save_and_close": "Guardar y cerrar",
|
||||
@@ -1670,7 +1650,6 @@
|
||||
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
|
||||
"survey_display_settings": "Ajustes de visualización de la encuesta",
|
||||
"survey_placement": "Ubicación de la encuesta",
|
||||
"survey_styling": "Estilo del formulario",
|
||||
"survey_trigger": "Activador de la encuesta",
|
||||
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
|
||||
"target_block_not_found": "Bloque objetivo no encontrado",
|
||||
@@ -1759,9 +1738,9 @@
|
||||
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
|
||||
"waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.",
|
||||
"welcome_message": "Mensaje de bienvenida",
|
||||
"when": "Cuando",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Sin un filtro, todos tus usuarios pueden ser encuestados.",
|
||||
"you_have_not_created_a_segment_yet": "Aún no has creado un segmento",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Necesitas tener dos o más idiomas configurados en tu proyecto para trabajar con traducciones.",
|
||||
"your_description_here_recall_information_with": "Tu descripción aquí. Recupera información con @",
|
||||
"your_question_here_recall_information_with": "Tu pregunta aquí. Recupera información con @",
|
||||
"your_web_app": "Tu aplicación web",
|
||||
@@ -1969,7 +1948,6 @@
|
||||
"filtered_responses_excel": "Respuestas filtradas (Excel)",
|
||||
"generating_qr_code": "Generando código QR",
|
||||
"impressions": "Impresiones",
|
||||
"impressions_identified_only": "Solo se muestran impresiones de contactos identificados",
|
||||
"impressions_tooltip": "Número de veces que se ha visto la encuesta.",
|
||||
"in_app": {
|
||||
"connection_description": "La encuesta se mostrará a los usuarios de tu sitio web que cumplan con los criterios enumerados a continuación",
|
||||
@@ -2012,7 +1990,6 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último año",
|
||||
"limit": "Límite",
|
||||
"no_identified_impressions": "No hay impresiones de contactos identificados",
|
||||
"no_responses_found": "No se han encontrado respuestas",
|
||||
"other_values_found": "Otros valores encontrados",
|
||||
"overall": "General",
|
||||
@@ -2035,7 +2012,6 @@
|
||||
"starts": "Inicios",
|
||||
"starts_tooltip": "Número de veces que se ha iniciado la encuesta.",
|
||||
"survey_reset_successfully": "¡Encuesta restablecida correctamente! Se eliminaron {responseCount} respuestas y {displayCount} visualizaciones.",
|
||||
"survey_results": "Resultados de {surveyName}",
|
||||
"this_month": "Este mes",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este año",
|
||||
@@ -2169,7 +2145,7 @@
|
||||
"advanced_styling_field_description_size": "Tamaño de fuente de la descripción",
|
||||
"advanced_styling_field_description_size_description": "Escala el texto de la descripción.",
|
||||
"advanced_styling_field_description_weight": "Grosor de fuente de la descripción",
|
||||
"advanced_styling_field_description_weight_description": "Hace el texto de descripción más ligero o más grueso.",
|
||||
"advanced_styling_field_description_weight_description": "Hace el texto de la descripción más ligero o más grueso.",
|
||||
"advanced_styling_field_font_size": "Tamaño de fuente",
|
||||
"advanced_styling_field_font_weight": "Grosor de fuente",
|
||||
"advanced_styling_field_headline_color": "Color del titular",
|
||||
@@ -2183,7 +2159,7 @@
|
||||
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura mínima de la entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
|
||||
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
|
||||
@@ -2192,8 +2168,6 @@
|
||||
"advanced_styling_field_input_text_description": "Colorea el texto escrito en los campos de entrada.",
|
||||
"advanced_styling_field_option_bg": "Fondo",
|
||||
"advanced_styling_field_option_bg_description": "Rellena los elementos de opción.",
|
||||
"advanced_styling_field_option_border": "Color del borde",
|
||||
"advanced_styling_field_option_border_description": "Delimita las opciones de radio y casillas de verificación.",
|
||||
"advanced_styling_field_option_border_radius_description": "Redondea las esquinas de las opciones.",
|
||||
"advanced_styling_field_option_font_size_description": "Escala el texto de la etiqueta de opción.",
|
||||
"advanced_styling_field_option_label": "Color de la etiqueta",
|
||||
@@ -2403,16 +2377,6 @@
|
||||
"alignment_and_engagement_survey_question_4_headline": "¿Cómo puede mejorar la empresa su alineación de visión y estrategia?",
|
||||
"alignment_and_engagement_survey_question_4_placeholder": "Escribe tu respuesta aquí...",
|
||||
"back": "Atrás",
|
||||
"block_1": "Bloque 1",
|
||||
"block_10": "Bloque 10",
|
||||
"block_2": "Bloque 2",
|
||||
"block_3": "Bloque 3",
|
||||
"block_4": "Bloque 4",
|
||||
"block_5": "Bloque 5",
|
||||
"block_6": "Bloque 6",
|
||||
"block_7": "Bloque 7",
|
||||
"block_8": "Bloque 8",
|
||||
"block_9": "Bloque 9",
|
||||
"book_interview": "Reservar entrevista",
|
||||
"build_product_roadmap_description": "Identifica lo ÚNICO que tus usuarios desean más y constrúyelo.",
|
||||
"build_product_roadmap_name": "Crear hoja de ruta del producto",
|
||||
@@ -2620,6 +2584,7 @@
|
||||
"csat_survey_question_3_headline": "Vaya, ¡lo sentimos! ¿Hay algo que podamos hacer para mejorar tu experiencia?",
|
||||
"csat_survey_question_3_placeholder": "Escribe tu respuesta aquí...",
|
||||
"cta_description": "Muestra información y anima a los usuarios a realizar una acción específica",
|
||||
"custom_survey_block_1_name": "Bloque 1",
|
||||
"custom_survey_description": "Crea una encuesta sin plantilla.",
|
||||
"custom_survey_name": "Empezar desde cero",
|
||||
"custom_survey_question_1_headline": "¿Qué te gustaría saber?",
|
||||
@@ -3022,9 +2987,6 @@
|
||||
"preview_survey_question_2_choice_2_label": "¡No, gracias!",
|
||||
"preview_survey_question_2_headline": "¿Quieres estar al tanto?",
|
||||
"preview_survey_question_2_subheader": "Esta es una descripción de ejemplo.",
|
||||
"preview_survey_question_open_text_headline": "¿Hay algo más que te gustaría compartir?",
|
||||
"preview_survey_question_open_text_placeholder": "Escribe tu respuesta aquí...",
|
||||
"preview_survey_question_open_text_subheader": "Tus comentarios nos ayudan a mejorar.",
|
||||
"preview_survey_welcome_card_headline": "¡Bienvenido!",
|
||||
"prioritize_features_description": "Identifica las funciones que tus usuarios necesitan más y menos.",
|
||||
"prioritize_features_name": "Priorizar funciones",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
"anonymous": "Anonyme",
|
||||
@@ -175,12 +176,9 @@
|
||||
"copy": "Copier",
|
||||
"copy_code": "Copier le code",
|
||||
"copy_link": "Copier le lien",
|
||||
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
|
||||
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
|
||||
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
|
||||
"count_responses": "{count, plural, other {# réponses}}",
|
||||
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -194,7 +192,6 @@
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
"delete": "Supprimer",
|
||||
"delete_what": "Supprimer {deleteWhat}",
|
||||
"description": "Description",
|
||||
"dev_env": "Environnement de développement",
|
||||
"development": "Développement",
|
||||
@@ -210,8 +207,6 @@
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
"duplicate_copy": "(copie)",
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"email": "Email",
|
||||
@@ -224,16 +219,13 @@
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
"error_component_title": "Erreur de chargement des ressources",
|
||||
"error_loading_data": "Erreur lors du chargement des données",
|
||||
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
|
||||
"error_rate_limit_title": "Limite de Taux Dépassée",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
"follow_these": "Suivez ceci",
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
"full_name": "Nom complet",
|
||||
@@ -246,7 +238,6 @@
|
||||
"hidden_field": "Champ caché",
|
||||
"hidden_fields": "Champs cachés",
|
||||
"hide_column": "Cacher la colonne",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
"import": "Importer",
|
||||
@@ -264,7 +255,6 @@
|
||||
"key": "Clé",
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"last_name": "Nom de famille",
|
||||
"learn_more": "En savoir plus",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Claire",
|
||||
@@ -279,6 +269,7 @@
|
||||
"look_and_feel": "Apparence",
|
||||
"manage": "Gérer",
|
||||
"marketing": "Marketing",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
@@ -290,7 +281,6 @@
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
"my_product": "mon produit",
|
||||
"name": "Nom",
|
||||
"new": "Nouveau",
|
||||
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
|
||||
@@ -386,6 +376,8 @@
|
||||
"select_teams": "Sélectionner les équipes",
|
||||
"selected": "Sélectionné",
|
||||
"selected_questions": "Questions sélectionnées",
|
||||
"selection": "Sélection",
|
||||
"selections": "Sélections",
|
||||
"send_test_email": "Envoyer un e-mail de test",
|
||||
"session_not_found": "Session non trouvée",
|
||||
"settings": "Paramètres",
|
||||
@@ -437,7 +429,6 @@
|
||||
"top_right": "En haut à droite",
|
||||
"try_again": "Réessayer",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Enquête inconnue",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Débloquez plus de projets avec un forfait supérieur.",
|
||||
"update": "Mise à jour",
|
||||
"updated": "Mise à jour",
|
||||
@@ -655,6 +646,7 @@
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
"create_key": "Créer une clé",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||
"custom_attributes": "Attributs personnalisés",
|
||||
@@ -665,7 +657,6 @@
|
||||
"delete_attribute_confirmation": "{value, plural, one {Cela supprimera l'attribut sélectionné. Toutes les données de contact associées à cet attribut seront perdues.} other {Cela supprimera les attributs sélectionnés. Toutes les données de contact associées à ces attributs seront perdues.}}",
|
||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
|
||||
"displays": "Affichages",
|
||||
"edit_attribute": "Modifier l'attribut",
|
||||
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
|
||||
"edit_attribute_values": "Modifier les attributs",
|
||||
@@ -677,7 +668,6 @@
|
||||
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s) : {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
|
||||
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
|
||||
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
|
||||
"no_activity_yet": "Aucune activité pour le moment",
|
||||
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
|
||||
"no_published_surveys": "Aucune enquête publiée",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
@@ -692,8 +682,6 @@
|
||||
"select_a_survey": "Sélectionner une enquête",
|
||||
"select_attribute": "Sélectionner un attribut",
|
||||
"select_attribute_key": "Sélectionner une clé d'attribut",
|
||||
"survey_viewed": "Enquête consultée",
|
||||
"survey_viewed_at": "Consultée le",
|
||||
"system_attributes": "Attributs système",
|
||||
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
|
||||
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
|
||||
@@ -765,12 +753,7 @@
|
||||
"link_google_sheet": "Lien Google Sheet",
|
||||
"link_new_sheet": "Lier une nouvelle feuille",
|
||||
"no_integrations_yet": "Vos intégrations Google Sheets apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||
"reconnect_button": "Reconnecter",
|
||||
"reconnect_button_description": "Votre connexion Google Sheets a expiré. Veuillez vous reconnecter pour continuer à synchroniser les réponses. Vos liens de feuilles de calcul et données existants seront préservés.",
|
||||
"reconnect_button_tooltip": "Reconnectez l'intégration pour actualiser votre accès. Vos liens de feuilles de calcul et données existants seront préservés.",
|
||||
"spreadsheet_permission_error": "Vous n'avez pas la permission d'accéder à cette feuille de calcul. Veuillez vous assurer que la feuille de calcul est partagée avec votre compte Google et que vous disposez d'un accès en écriture.",
|
||||
"spreadsheet_url": "URL de la feuille de calcul",
|
||||
"token_expired_error": "Le jeton d'actualisation Google Sheets a expiré ou a été révoqué. Veuillez reconnecter l'intégration."
|
||||
"spreadsheet_url": "URL de la feuille de calcul"
|
||||
},
|
||||
"include_created_at": "Inclure la date de création",
|
||||
"include_hidden_fields": "Inclure les champs cachés",
|
||||
@@ -1085,7 +1068,7 @@
|
||||
"email_customization_preview_email_heading": "Salut {userName}",
|
||||
"email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
|
||||
"error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.",
|
||||
"from_your_organization": "{memberName} de votre organisation",
|
||||
"from_your_organization": "de votre organisation",
|
||||
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
|
||||
"invite_deleted_successfully": "Invitation supprimée avec succès",
|
||||
"invite_expires_on": "L'invitation expire le {date}",
|
||||
@@ -1250,7 +1233,6 @@
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
|
||||
"add_hidden_field_id": "Ajouter un champ caché ID",
|
||||
"add_highlight_border": "Ajouter une bordure de surlignage",
|
||||
"add_highlight_border_description": "S'applique uniquement aux sondages intégrés au produit.",
|
||||
"add_logic": "Ajouter de la logique",
|
||||
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
|
||||
"add_option": "Ajouter une option",
|
||||
@@ -1267,14 +1249,12 @@
|
||||
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
|
||||
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
||||
"adjust_the_theme_in_the": "Ajustez le thème dans le",
|
||||
"all_are_true": "toutes sont vraies",
|
||||
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
|
||||
"allow_multi_select": "Autoriser la sélection multiple",
|
||||
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
||||
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
||||
"and_launch_surveys_in_your_website_or_app": "et lancez des enquêtes sur votre site web ou votre application.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "au moins une est vraie",
|
||||
"app_survey_description": "Intégrez une enquête dans votre application web ou votre site web pour collecter des réponses.",
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
@@ -1451,6 +1431,7 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
|
||||
"follow_ups_new": "Nouveau suivi",
|
||||
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
|
||||
"form_styling": "Style de formulaire",
|
||||
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
|
||||
"four_points": "4 points",
|
||||
"heading": "En-tête",
|
||||
@@ -1540,7 +1521,7 @@
|
||||
"option_idx": "Option {choiceIndex}",
|
||||
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
|
||||
"optional": "Optionnel",
|
||||
"options": "Options*",
|
||||
"options": "Options",
|
||||
"options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique : {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
|
||||
"overwrite_global_waiting_time": "Définir une période de refroidissement personnalisée",
|
||||
@@ -1565,7 +1546,6 @@
|
||||
"question_deleted": "Question supprimée.",
|
||||
"question_duplicated": "Question dupliquée.",
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_number": "Question {number}",
|
||||
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer ?",
|
||||
"question_used_in_logic_warning_title": "Incohérence de logique",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota “{quotaName}”",
|
||||
@@ -1624,7 +1604,7 @@
|
||||
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
|
||||
"response_options": "Options de réponse",
|
||||
"roundness": "Rondeur",
|
||||
"roundness_description": "Contrôle l'arrondi des coins.",
|
||||
"roundness_description": "Contrôle l'arrondi des coins de la carte.",
|
||||
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
|
||||
"rows": "Lignes",
|
||||
"save_and_close": "Enregistrer et fermer",
|
||||
@@ -1670,7 +1650,6 @@
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
"survey_placement": "Placement de l'enquête",
|
||||
"survey_styling": "Style de formulaire",
|
||||
"survey_trigger": "Déclencheur d'enquête",
|
||||
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
|
||||
"target_block_not_found": "Bloc cible non trouvé",
|
||||
@@ -1759,9 +1738,9 @@
|
||||
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
|
||||
"waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.",
|
||||
"welcome_message": "Message de bienvenue",
|
||||
"when": "Quand",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.",
|
||||
"you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Vous devez avoir deux langues ou plus configurées dans votre espace de travail pour travailler avec les traductions.",
|
||||
"your_description_here_recall_information_with": "Votre description ici. Rappelez-vous des informations avec @",
|
||||
"your_question_here_recall_information_with": "Votre question ici. Rappelez-vous des informations avec @",
|
||||
"your_web_app": "Votre application web",
|
||||
@@ -1969,7 +1948,6 @@
|
||||
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
||||
"generating_qr_code": "Génération du code QR",
|
||||
"impressions": "Impressions",
|
||||
"impressions_identified_only": "Affichage uniquement des impressions des contacts identifiés",
|
||||
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
|
||||
"in_app": {
|
||||
"connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous",
|
||||
@@ -2012,7 +1990,6 @@
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"limit": "Limite",
|
||||
"no_identified_impressions": "Aucune impression des contacts identifiés",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
@@ -2035,7 +2012,6 @@
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
"survey_results": "Résultats de {surveyName}",
|
||||
"this_month": "Ce mois-ci",
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
@@ -2183,7 +2159,7 @@
|
||||
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur min. du champ de saisie.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
|
||||
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
|
||||
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
|
||||
@@ -2192,8 +2168,6 @@
|
||||
"advanced_styling_field_input_text_description": "Colore le texte saisi dans les champs.",
|
||||
"advanced_styling_field_option_bg": "Arrière-plan",
|
||||
"advanced_styling_field_option_bg_description": "Remplit les éléments d'option.",
|
||||
"advanced_styling_field_option_border": "Couleur de bordure",
|
||||
"advanced_styling_field_option_border_description": "Contours des options de boutons radio et de cases à cocher.",
|
||||
"advanced_styling_field_option_border_radius_description": "Arrondit les coins des options.",
|
||||
"advanced_styling_field_option_font_size_description": "Ajuste la taille du texte des libellés d'option.",
|
||||
"advanced_styling_field_option_label": "Couleur de l'étiquette",
|
||||
@@ -2403,16 +2377,6 @@
|
||||
"alignment_and_engagement_survey_question_4_headline": "Comment l'entreprise peut-elle améliorer l'alignement de sa vision et de sa stratégie ?",
|
||||
"alignment_and_engagement_survey_question_4_placeholder": "Entrez votre réponse ici...",
|
||||
"back": "Retour",
|
||||
"block_1": "Bloc 1",
|
||||
"block_10": "Bloc 10",
|
||||
"block_2": "Bloc 2",
|
||||
"block_3": "Bloc 3",
|
||||
"block_4": "Bloc 4",
|
||||
"block_5": "Bloc 5",
|
||||
"block_6": "Bloc 6",
|
||||
"block_7": "Bloc 7",
|
||||
"block_8": "Bloc 8",
|
||||
"block_9": "Bloc 9",
|
||||
"book_interview": "Réserver un entretien",
|
||||
"build_product_roadmap_description": "Identifiez la chose UNIQUE que vos utilisateurs désirent le plus et construisez-la.",
|
||||
"build_product_roadmap_name": "Élaborer la feuille de route du produit",
|
||||
@@ -2620,6 +2584,7 @@
|
||||
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
|
||||
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
|
||||
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
|
||||
"custom_survey_block_1_name": "Bloc 1",
|
||||
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
|
||||
"custom_survey_name": "Tout créer moi-même",
|
||||
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
|
||||
@@ -3022,9 +2987,6 @@
|
||||
"preview_survey_question_2_choice_2_label": "Non, merci !",
|
||||
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
|
||||
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
|
||||
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager ?",
|
||||
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
|
||||
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
|
||||
"preview_survey_welcome_card_headline": "Bienvenue !",
|
||||
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
|
||||
"prioritize_features_name": "Prioriser les fonctionnalités",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
||||
"anonymous": "Névtelen",
|
||||
@@ -175,12 +176,9 @@
|
||||
"copy": "Másolás",
|
||||
"copy_code": "Kód másolása",
|
||||
"copy_link": "Hivatkozás másolása",
|
||||
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
|
||||
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
|
||||
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
|
||||
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
|
||||
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
|
||||
"count_selections": "{count, plural, one {{count} kijelölés} other {{count} kijelölés}}",
|
||||
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
|
||||
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
|
||||
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -194,7 +192,6 @@
|
||||
"days": "napok",
|
||||
"default": "Alapértelmezett",
|
||||
"delete": "Törlés",
|
||||
"delete_what": "{deleteWhat} törlése",
|
||||
"description": "Leírás",
|
||||
"dev_env": "Fejlesztői környezet",
|
||||
"development": "Fejlesztés",
|
||||
@@ -210,8 +207,6 @@
|
||||
"download": "Letöltés",
|
||||
"draft": "Piszkozat",
|
||||
"duplicate": "Kettőzés",
|
||||
"duplicate_copy": "(másolat)",
|
||||
"duplicate_copy_number": "({copyNumber}. másolat)",
|
||||
"e_commerce": "E-kereskedelem",
|
||||
"edit": "Szerkesztés",
|
||||
"email": "E-mail",
|
||||
@@ -224,16 +219,13 @@
|
||||
"error": "Hiba",
|
||||
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
|
||||
"error_component_title": "Hiba az erőforrások betöltésekor",
|
||||
"error_loading_data": "Hiba az adatok betöltése során",
|
||||
"error_rate_limit_description": "A kérések legnagyobb száma elérve. Próbálja meg később újra.",
|
||||
"error_rate_limit_title": "A sebességkorlát elérve",
|
||||
"expand_rows": "Sorok kinyitása",
|
||||
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
"follow_these": "Ezek követése",
|
||||
"formbricks_version": "Formbricks verziója",
|
||||
"full_name": "Teljes név",
|
||||
@@ -246,7 +238,6 @@
|
||||
"hidden_field": "Rejtett mező",
|
||||
"hidden_fields": "Rejtett mezők",
|
||||
"hide_column": "Oszlop elrejtése",
|
||||
"id": "ID",
|
||||
"image": "Kép",
|
||||
"images": "Képek",
|
||||
"import": "Importálás",
|
||||
@@ -264,7 +255,6 @@
|
||||
"key": "Kulcs",
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
"last_name": "Vezetéknév",
|
||||
"learn_more": "Tudjon meg többet",
|
||||
"license_expired": "A licenc lejárt",
|
||||
"light_overlay": "Világos rávetítés",
|
||||
@@ -279,6 +269,7 @@
|
||||
"look_and_feel": "Megjelenés",
|
||||
"manage": "Kezelés",
|
||||
"marketing": "Marketing",
|
||||
"member": "Tag",
|
||||
"members": "Tagok",
|
||||
"members_and_teams": "Tagok és csapatok",
|
||||
"membership_not_found": "A tagság nem található",
|
||||
@@ -290,7 +281,6 @@
|
||||
"move_down": "Mozgatás le",
|
||||
"move_up": "Mozgatás fel",
|
||||
"multiple_languages": "Több nyelv",
|
||||
"my_product": "saját termék",
|
||||
"name": "Név",
|
||||
"new": "Új",
|
||||
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
|
||||
@@ -386,6 +376,8 @@
|
||||
"select_teams": "Csapatok kiválasztása",
|
||||
"selected": "Kiválasztva",
|
||||
"selected_questions": "Kiválasztott kérdések",
|
||||
"selection": "Kiválasztás",
|
||||
"selections": "Kiválasztások",
|
||||
"send_test_email": "Teszt e-mail küldése",
|
||||
"session_not_found": "A munkamenet nem található",
|
||||
"settings": "Beállítások",
|
||||
@@ -437,7 +429,6 @@
|
||||
"top_right": "Jobbra fent",
|
||||
"try_again": "Próbálja újra",
|
||||
"type": "Típus",
|
||||
"unknown_survey": "Ismeretlen kérdőív",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Több munkaterület feloldása egy magasabb csomaggal.",
|
||||
"update": "Frissítés",
|
||||
"updated": "Frissítve",
|
||||
@@ -655,6 +646,7 @@
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
||||
"create_attribute": "Attribútum létrehozása",
|
||||
"create_key": "Kulcs létrehozása",
|
||||
"create_new_attribute": "Új attribútum létrehozása",
|
||||
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
||||
"custom_attributes": "Egyéni attribútumok",
|
||||
@@ -665,7 +657,6 @@
|
||||
"delete_attribute_confirmation": "{value, plural, one {Ez törölni fogja a kiválasztott attribútumot. Az ehhez az attribútumhoz hozzárendelt összes partneradat el fog veszni.} other {Ez törölni fogja a kiválasztott attribútumokat. Az ezekhez az attribútumokhoz hozzárendelt összes partneradat el fog veszni.}}",
|
||||
"delete_contact_confirmation": "Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ez a partner olyan válaszokkal rendelkezik, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.} other {Ez törölni fogja az ezekhez a partnerekhez tartozó összes kérdőívválaszt és partnerattribútumot. A partnerek adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ezek a partnerek olyan válaszokkal rendelkeznek, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.}}",
|
||||
"displays": "Megjelenítések",
|
||||
"edit_attribute": "Attribútum szerkesztése",
|
||||
"edit_attribute_description": "Az attribútum címkéjének és leírásának frissítése.",
|
||||
"edit_attribute_values": "Attribútumok szerkesztése",
|
||||
@@ -677,7 +668,6 @@
|
||||
"invalid_csv_column_names": "Érvénytelen CSV oszlopnév(nevek): {columns}. Az új attribútumokká váló oszlopnevek csak kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, és betűvel kell kezdődniük.",
|
||||
"invalid_date_format": "Érvénytelen dátumformátum. Kérlek, adj meg egy érvényes dátumot.",
|
||||
"invalid_number_format": "Érvénytelen számformátum. Kérlek, adj meg egy érvényes számot.",
|
||||
"no_activity_yet": "Még nincs aktivitás",
|
||||
"no_published_link_surveys_available": "Nem érhetők el közzétett hivatkozás-kérdőívek. Először tegyen közzé egy hivatkozás-kérdőívet.",
|
||||
"no_published_surveys": "Nincsenek közzétett kérdőívek",
|
||||
"no_responses_found": "Nem találhatók válaszok",
|
||||
@@ -692,8 +682,6 @@
|
||||
"select_a_survey": "Kérdőív kiválasztása",
|
||||
"select_attribute": "Attribútum kiválasztása",
|
||||
"select_attribute_key": "Attribútum kulcs kiválasztása",
|
||||
"survey_viewed": "Kérdőív megtekintve",
|
||||
"survey_viewed_at": "Megtekintve",
|
||||
"system_attributes": "Rendszer attribútumok",
|
||||
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
|
||||
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
|
||||
@@ -765,12 +753,7 @@
|
||||
"link_google_sheet": "Google Táblázatok összekapcsolása",
|
||||
"link_new_sheet": "Új táblázat összekapcsolása",
|
||||
"no_integrations_yet": "A Google Táblázatok integrációi itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
|
||||
"reconnect_button": "Újrakapcsolódás",
|
||||
"reconnect_button_description": "A Google Táblázatok kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő táblázathivatkozások és adatok megmaradnak.",
|
||||
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő táblázathivatkozások és adatok megmaradnak.",
|
||||
"spreadsheet_permission_error": "Nincs jogosultsága a táblázat eléréséhez. Kérjük, győződjön meg arról, hogy a táblázat meg van osztva a Google-fiókjával, és írási jogosultsággal rendelkezik a táblázathoz.",
|
||||
"spreadsheet_url": "Táblázat URL-e",
|
||||
"token_expired_error": "A Google Táblázatok frissítési tokenje lejárt vagy visszavonásra került. Kérjük, csatlakoztassa újra az integrációt."
|
||||
"spreadsheet_url": "Táblázat URL-e"
|
||||
},
|
||||
"include_created_at": "Létrehozva felvétele",
|
||||
"include_hidden_fields": "Rejtett mezők felvétele",
|
||||
@@ -1085,7 +1068,7 @@
|
||||
"email_customization_preview_email_heading": "Helló {userName}",
|
||||
"email_customization_preview_email_text": "Ez egy e-mail előnézet, amely azt mutatja meg, hogy melyik logó fog megjelenni az e-mailekben.",
|
||||
"error_deleting_organization_please_try_again": "Hiba a szervezet törlésekor. Próbálja meg újra.",
|
||||
"from_your_organization": "{memberName} a szervezetből",
|
||||
"from_your_organization": "a szervezetétől",
|
||||
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
|
||||
"invite_deleted_successfully": "A meghívó sikeresen törölve",
|
||||
"invite_expires_on": "A meghívó lejár ekkor: {date}",
|
||||
@@ -1250,7 +1233,6 @@
|
||||
"add_fallback_placeholder": "Helykitöltő hozzáadása annak megjelenítéshez, hogy nincs visszahívandó érték.",
|
||||
"add_hidden_field_id": "Rejtett mezőazonosító hozzáadása",
|
||||
"add_highlight_border": "Kiemelési szegély hozzáadása",
|
||||
"add_highlight_border_description": "Csak a terméken belüli felmérésekre vonatkozik.",
|
||||
"add_logic": "Logika hozzáadása",
|
||||
"add_none_of_the_above": "„A fentiek közül egyik sem” hozzáadása",
|
||||
"add_option": "Lehetőség hozzáadása",
|
||||
@@ -1267,14 +1249,12 @@
|
||||
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
|
||||
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
|
||||
"adjust_the_theme_in_the": "A téma beállítása ebben:",
|
||||
"all_are_true": "az összes igaz",
|
||||
"all_other_answers_will_continue_to": "Az összes többi válasz továbbra is",
|
||||
"allow_multi_select": "Több választás engedélyezése",
|
||||
"allow_multiple_files": "Több fájl engedélyezése",
|
||||
"allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki",
|
||||
"and_launch_surveys_in_your_website_or_app": "és kérdőívek indítása a webhelyén vagy az alkalmazásában.",
|
||||
"animation": "Animáció",
|
||||
"any_is_true": "bármelyik igaz",
|
||||
"app_survey_description": "Egy kérdőív beágyazása a webalkalmazásába vagy webhelyére a válaszok gyűjtéséhez.",
|
||||
"assign": "= hozzárendelése",
|
||||
"audience": "Közönség",
|
||||
@@ -1451,6 +1431,7 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
|
||||
"follow_ups_new": "Új követés",
|
||||
"follow_ups_upgrade_button_text": "Magasabb csomagra váltás a követések engedélyezéséhez",
|
||||
"form_styling": "Űrlap stílusának beállítása",
|
||||
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
|
||||
"four_points": "4 pont",
|
||||
"heading": "Címsor",
|
||||
@@ -1540,7 +1521,7 @@
|
||||
"option_idx": "{choiceIndex}. lehetőség",
|
||||
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
"optional": "Választható",
|
||||
"options": "Beállítások*",
|
||||
"options": "Beállítások",
|
||||
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
|
||||
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
|
||||
"overwrite_global_waiting_time": "Egyéni várakozási időszak beállítása",
|
||||
@@ -1565,7 +1546,6 @@
|
||||
"question_deleted": "Kérdés törölve.",
|
||||
"question_duplicated": "Kérdés megkettőzve.",
|
||||
"question_id_updated": "Kérdésazonosító frissítve",
|
||||
"question_number": "{number}. kérdés",
|
||||
"question_used_in_logic_warning_text": "Ezen blokkból származó elemek egy logikai szabályban vannak használva, biztosan törölni szeretné?",
|
||||
"question_used_in_logic_warning_title": "Logikai következetlenség",
|
||||
"question_used_in_quota": "Ez a kérdés használatban van a(z) „{quotaName}” kvótában",
|
||||
@@ -1624,7 +1604,7 @@
|
||||
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
|
||||
"response_options": "Válasz beállításai",
|
||||
"roundness": "Kerekesség",
|
||||
"roundness_description": "Szabályozza a sarkok lekerekítését.",
|
||||
"roundness_description": "Annak vezérlése, hogy a kártya sarkai mennyire legyenek lekerekítve.",
|
||||
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
|
||||
"rows": "Sorok",
|
||||
"save_and_close": "Mentés és bezárás",
|
||||
@@ -1670,7 +1650,6 @@
|
||||
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
|
||||
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
|
||||
"survey_placement": "Kérdőív elhelyezése",
|
||||
"survey_styling": "Űrlap stílusának beállítása",
|
||||
"survey_trigger": "Kérdőív aktiválója",
|
||||
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
|
||||
"target_block_not_found": "A célblokk nem található",
|
||||
@@ -1759,9 +1738,9 @@
|
||||
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
|
||||
"waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.",
|
||||
"welcome_message": "Üdvözlő üzenet",
|
||||
"when": "Amikor",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Szűrő nélkül az összes felhasználója megkérdezhető.",
|
||||
"you_have_not_created_a_segment_yet": "Még nem hozott létre szakaszt",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Be kell állítania kettő vagy több nyelvet a munkaterületen a fordításokkal való munkához.",
|
||||
"your_description_here_recall_information_with": "Ide jön a leírás. Információk visszahívása a @ karakterrel.",
|
||||
"your_question_here_recall_information_with": "Ide jön a kérdés. Információk visszahívása a @ karakterrel.",
|
||||
"your_web_app": "Saját webalkalmazás",
|
||||
@@ -1969,7 +1948,6 @@
|
||||
"filtered_responses_excel": "Szűrt válaszok (Excel)",
|
||||
"generating_qr_code": "QR-kód előállítása",
|
||||
"impressions": "Benyomások",
|
||||
"impressions_identified_only": "Csak az azonosított kapcsolatok megjelenítései láthatók",
|
||||
"impressions_tooltip": "A kérdőív megtekintési alkalmainak száma.",
|
||||
"in_app": {
|
||||
"connection_description": "A kérdőív a webhelye azon felhasználóinak lesz megjelenítve, akik megfelelnek az alább felsorolt feltételeknek",
|
||||
@@ -2012,7 +1990,6 @@
|
||||
"last_quarter": "Elmúlt negyedév",
|
||||
"last_year": "Elmúlt év",
|
||||
"limit": "Korlát",
|
||||
"no_identified_impressions": "Nincsenek megjelenítések azonosított kapcsolatoktól",
|
||||
"no_responses_found": "Nem találhatók válaszok",
|
||||
"other_values_found": "Más értékek találhatók",
|
||||
"overall": "Összesen",
|
||||
@@ -2035,7 +2012,6 @@
|
||||
"starts": "Elkezdések",
|
||||
"starts_tooltip": "A kérdőív elkezdési alkalmainak száma.",
|
||||
"survey_reset_successfully": "A kérdőív sikeresen visszaállítva. {responseCount} válasz és {displayCount} megjelenítés lett törölve.",
|
||||
"survey_results": "{surveyName} eredményei",
|
||||
"this_month": "Ez a hónap",
|
||||
"this_quarter": "Ez a negyedév",
|
||||
"this_year": "Ez az év",
|
||||
@@ -2183,7 +2159,7 @@
|
||||
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
|
||||
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
|
||||
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
|
||||
"advanced_styling_field_input_height_description": "Szabályozza a beviteli mező minimális magasságát.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
|
||||
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
|
||||
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
|
||||
@@ -2192,8 +2168,6 @@
|
||||
"advanced_styling_field_input_text_description": "Kiszínezi a beviteli mezőkbe beírt szöveget.",
|
||||
"advanced_styling_field_option_bg": "Háttér",
|
||||
"advanced_styling_field_option_bg_description": "Kitölti a választási lehetőség elemeit.",
|
||||
"advanced_styling_field_option_border": "Szegély színe",
|
||||
"advanced_styling_field_option_border_description": "A rádiógomb és jelölőnégyzet opciók körvonalát határozza meg.",
|
||||
"advanced_styling_field_option_border_radius_description": "Lekerekíti a választási lehetőség sarkait.",
|
||||
"advanced_styling_field_option_font_size_description": "Átméretezi a választási lehetőség címkéjének szövegét.",
|
||||
"advanced_styling_field_option_label": "Címke színe",
|
||||
@@ -2403,16 +2377,6 @@
|
||||
"alignment_and_engagement_survey_question_4_headline": "Hogyan tudná javítani a vállalat a jövőképe és stratégiája összehangolását?",
|
||||
"alignment_and_engagement_survey_question_4_placeholder": "Írja be ide a válaszát…",
|
||||
"back": "Vissza",
|
||||
"block_1": "1. blokk",
|
||||
"block_10": "10. blokk",
|
||||
"block_2": "2. blokk",
|
||||
"block_3": "3. blokk",
|
||||
"block_4": "4. blokk",
|
||||
"block_5": "5. blokk",
|
||||
"block_6": "6. blokk",
|
||||
"block_7": "7. blokk",
|
||||
"block_8": "8. blokk",
|
||||
"block_9": "9. blokk",
|
||||
"book_interview": "Interjú foglalása",
|
||||
"build_product_roadmap_description": "A felhasználók által leginkább igényelt EGY dolog azonosítása és összeállítása.",
|
||||
"build_product_roadmap_name": "Termékútiterv összeállítása",
|
||||
@@ -2476,7 +2440,7 @@
|
||||
"career_development_survey_question_6_choice_2": "Igazgató",
|
||||
"career_development_survey_question_6_choice_3": "Vezető igazgató",
|
||||
"career_development_survey_question_6_choice_4": "Alelnök",
|
||||
"career_development_survey_question_6_choice_5": "Ügyvezető",
|
||||
"career_development_survey_question_6_choice_5": "Igazgató",
|
||||
"career_development_survey_question_6_choice_6": "Egyéb",
|
||||
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
|
||||
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
@@ -2620,6 +2584,7 @@
|
||||
"csat_survey_question_3_headline": "Jaj, bocsánat! Tehetünk valamit, amivel javíthatnánk az élményén?",
|
||||
"csat_survey_question_3_placeholder": "Írja be ide a válaszát…",
|
||||
"cta_description": "Információk megjelenítése és a felhasználók felkérése egy bizonyos művelet elvégzésére",
|
||||
"custom_survey_block_1_name": "1. blokk",
|
||||
"custom_survey_description": "Kérdőív létrehozása sablon nélkül.",
|
||||
"custom_survey_name": "Kezdés a semmiből",
|
||||
"custom_survey_question_1_headline": "Mit szeretne tudni?",
|
||||
@@ -2988,7 +2953,7 @@
|
||||
"onboarding_segmentation": "Beléptetés szakaszolása",
|
||||
"onboarding_segmentation_description": "További információk azzal kapcsolatban, hogy kik regisztráltak a termékére és miért.",
|
||||
"onboarding_segmentation_question_1_choice_1": "Alapító",
|
||||
"onboarding_segmentation_question_1_choice_2": "Ügyvezető",
|
||||
"onboarding_segmentation_question_1_choice_2": "Igazgató",
|
||||
"onboarding_segmentation_question_1_choice_3": "Termékmenedzser",
|
||||
"onboarding_segmentation_question_1_choice_4": "Terméktulajdonos",
|
||||
"onboarding_segmentation_question_1_choice_5": "Szoftvermérnök",
|
||||
@@ -3022,9 +2987,6 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nem, köszönöm!",
|
||||
"preview_survey_question_2_headline": "Szeretne naprakész maradni?",
|
||||
"preview_survey_question_2_subheader": "Ez egy példa a leírásra.",
|
||||
"preview_survey_question_open_text_headline": "Bármi egyéb, amit meg szeretne osztani?",
|
||||
"preview_survey_question_open_text_placeholder": "Írja be ide a válaszát…",
|
||||
"preview_survey_question_open_text_subheader": "A visszajelzése segít nekünk a fejlődésben.",
|
||||
"preview_survey_welcome_card_headline": "Üdvözöljük!",
|
||||
"prioritize_features_description": "A felhasználóknak leginkább és legkevésbé szükséges funkciók azonosítása.",
|
||||
"prioritize_features_name": "Funkciók rangsorolása",
|
||||
@@ -3059,7 +3021,7 @@
|
||||
"product_market_fit_superhuman_question_2_headline": "Mennyire lenne csalódott, ha többé nem használhatná a(z) $[projectName] projektet?",
|
||||
"product_market_fit_superhuman_question_2_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
"product_market_fit_superhuman_question_3_choice_1": "Alapító",
|
||||
"product_market_fit_superhuman_question_3_choice_2": "Ügyvezető",
|
||||
"product_market_fit_superhuman_question_3_choice_2": "Igazgató",
|
||||
"product_market_fit_superhuman_question_3_choice_3": "Termékmenedzser",
|
||||
"product_market_fit_superhuman_question_3_choice_4": "Terméktulajdonos",
|
||||
"product_market_fit_superhuman_question_3_choice_5": "Szoftvermérnök",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user